add payment_link and banks
This commit is contained in:
1 parent
2ae79b1ca4
commit
de3135b588
100 files changed
+6167
-47
No files matched your search
@@ -0,0 +1,286 @@
|
||||
"""All business writes go through these atomic, permission-checked operations."""
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core import serializers
|
||||
import json
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
from apps.accounts.models import User
|
||||
from apps.tickets.policies import TicketPolicy
|
||||
from . import policies, validators
|
||||
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
|
||||
PaymentApprovalPolicy, ReceiptApprovalCycle, ReceiptApprovalAssignment,
|
||||
ReceiptDecision, PaymentAuditEvent)
|
||||
|
||||
|
||||
def actor_name(actor):
|
||||
profile = getattr(actor, 'staff_profile', None)
|
||||
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
|
||||
|
||||
|
||||
def audit(actor, action, obj, details=None):
|
||||
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
|
||||
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
|
||||
|
||||
|
||||
def configuration_snapshot(obj):
|
||||
"""Serialize only the financial configuration passed here, including flags and M2M IDs."""
|
||||
return json.loads(serializers.serialize('json', [obj]))[0]['fields']
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def issue_notice(actor, data):
|
||||
unit = TicketPolicy.primary_unit(actor)
|
||||
if not policies.can_create(actor, unit):
|
||||
raise PermissionDenied
|
||||
# Keep lock order consistent with receipt submission: customer, then account.
|
||||
customer = User.objects.select_for_update().get(pk=data['customer'].pk, account_type='CUSTOMER', is_active=True)
|
||||
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
|
||||
if not account.is_active or not account.available_to_staff:
|
||||
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
|
||||
deposit_id = validators.digits(data.get('deposit_id', ''))
|
||||
if account.requires_deposit_id and not deposit_id:
|
||||
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
|
||||
key = data.get('submission_key')
|
||||
if key:
|
||||
existing = PaymentNotice.objects.filter(issuance_key=key).first()
|
||||
if existing:
|
||||
if existing.created_by_id != actor.pk or existing.customer_id != customer.pk:
|
||||
raise PermissionDenied
|
||||
expected = {'account_id': account.pk, 'amount': validators.amount(data['amount']),
|
||||
'deposit_id': deposit_id, 'subject': data['subject'],
|
||||
'invoice_number': data.get('invoice_number', ''), 'description': data.get('description', '')}
|
||||
if any(getattr(existing, field) != value for field, value in expected.items()):
|
||||
raise ValidationError('این اعلامیه قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
|
||||
return existing
|
||||
notice = PaymentNotice.objects.create(customer=customer, created_by=actor, owning_unit=unit, account=account,
|
||||
account_snapshot=account.snapshot(), amount=validators.amount(data['amount']), deposit_id=deposit_id,
|
||||
subject=data['subject'], invoice_number=data.get('invoice_number', ''), description=data.get('description', ''),
|
||||
**({'issuance_key': key} if key else {}))
|
||||
audit(actor, 'NOTICE_ISSUED', notice, {'account': account.snapshot(), 'amount': str(notice.amount), 'deposit_id': deposit_id})
|
||||
from .notifications import queue_notice
|
||||
queue_notice(notice)
|
||||
return notice
|
||||
|
||||
|
||||
def policy_for(unit):
|
||||
policy = PaymentApprovalPolicy.objects.filter(is_active=True, unit=unit).first() if unit else None
|
||||
return policy or PaymentApprovalPolicy.objects.filter(is_active=True, unit__isnull=True).first()
|
||||
|
||||
|
||||
def assign_cycle(revision, actor):
|
||||
policy = policy_for(revision.receipt.owning_unit)
|
||||
reviewer_ids = list(policy.reviewers.values_list('pk', flat=True)) if policy else []
|
||||
senior_ids = list(policy.seniors.values_list('pk', flat=True)) if policy else []
|
||||
selected = User.objects.filter(pk__in=set(reviewer_ids + senior_ids))
|
||||
if policy and (not reviewer_ids or any(not policies.is_staff(user) for user in selected)):
|
||||
raise ValidationError('سیاست تأیید معتبر نیست؛ مدیر باید افراد فعال تأییدکننده را تعیین کند.')
|
||||
cycle = ReceiptApprovalCycle.objects.create(revision=revision, policy_snapshot={
|
||||
'policy_id': policy.pk if policy else None, 'name': policy.name if policy else 'هنوز تعیین نشده',
|
||||
'method': 'ALL_SELECTED', 'reviewers': reviewer_ids, 'seniors': senior_ids})
|
||||
ReceiptApprovalAssignment.objects.bulk_create([
|
||||
ReceiptApprovalAssignment(cycle=cycle, user_id=pk, is_senior=senior)
|
||||
for senior, ids in [(False, reviewer_ids), (True, senior_ids)] for pk in ids])
|
||||
audit(actor, 'CYCLE_STARTED', revision.receipt, {'version': revision.version, 'policy': cycle.policy_snapshot})
|
||||
return cycle
|
||||
|
||||
|
||||
def receipt_metadata(data):
|
||||
return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
|
||||
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
|
||||
'deposit_id': validators.digits(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
|
||||
'description': data.get('description', '')}
|
||||
|
||||
|
||||
def validate_destination(account, notice):
|
||||
if not account.is_active:
|
||||
raise ValidationError('این حساب غیرفعال شده است؛ برای ثبت پرداخت قبلی با کارشناس هماهنگ کنید.')
|
||||
if not account.available_to_customers and (not notice or notice.account_id != account.pk):
|
||||
raise ValidationError('حساب مقصد برای این واریز قابل انتخاب نیست.')
|
||||
|
||||
|
||||
def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
# Same-customer lock serializes retries of a submission nonce before inserting it.
|
||||
User.objects.select_for_update().get(pk=customer.pk)
|
||||
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
|
||||
if existing:
|
||||
revision = existing.revisions.get(version=1)
|
||||
if existing.customer_id != customer.pk or existing.submitted_by_id != actor.pk:
|
||||
raise PermissionDenied
|
||||
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
|
||||
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
|
||||
existing.notice_id != getattr(data.get('notice_id'), 'pk', None)):
|
||||
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
|
||||
return existing
|
||||
notice = data.get('notice_id')
|
||||
if notice:
|
||||
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk, customer=customer)
|
||||
if notice.cancelled_at:
|
||||
raise ValidationError('اعلامیه لغو شده است؛ واریز مستقل را انتخاب کنید.')
|
||||
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
|
||||
validate_destination(account, notice)
|
||||
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
|
||||
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
|
||||
fields = receipt_metadata(data)
|
||||
fields['destination'] = account
|
||||
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
|
||||
revision = PaymentReceiptRevision(receipt=receipt, version=1,
|
||||
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
|
||||
destination_mismatch=bool(notice and notice.account_id != account.pk), image=data['receipt_image'], **file_info, **fields)
|
||||
try:
|
||||
revision.save()
|
||||
finally:
|
||||
if revision.image and revision.image._committed:
|
||||
created_files.append((revision.image.storage, revision.image.name))
|
||||
assign_cycle(revision, actor)
|
||||
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
|
||||
return receipt
|
||||
|
||||
|
||||
def submit_receipt(actor, customer, data, *, access_link=None):
|
||||
if not actor.is_active or customer.account_type != 'CUSTOMER' or not customer.is_active:
|
||||
raise PermissionDenied
|
||||
if actor.pk != customer.pk and not policies.can_create(actor):
|
||||
raise PermissionDenied
|
||||
if actor.account_type == 'CUSTOMER' and actor.pk != customer.pk:
|
||||
raise PermissionDenied
|
||||
data = dict(data)
|
||||
data['paid_amount'] = validators.amount(data['paid_amount'])
|
||||
if data['paid_date'] > timezone.localdate():
|
||||
raise ValidationError('تاریخ واریز نمیتواند بعد از امروز باشد.')
|
||||
file_info = validators.image(data['receipt_image'])
|
||||
# File storage is not transactional. Remove only the file created by this failed write.
|
||||
created_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
if access_link:
|
||||
from .access import validate_link_submission
|
||||
validate_link_submission(access_link, customer, data)
|
||||
result = _submit_receipt(actor, customer, data, file_info, created_files)
|
||||
if access_link:
|
||||
access_link.submitted_receipts.add(result)
|
||||
return result
|
||||
except Exception:
|
||||
for storage, name in created_files:
|
||||
storage.delete(name)
|
||||
raise
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def decide_receipt(actor, receipt_id, version, action, reason=''):
|
||||
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
|
||||
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
|
||||
# Never authorize a financial vote using a stale User/profile/permission cache.
|
||||
actor = User.objects.select_for_update().get(pk=actor.pk)
|
||||
if receipt.current_version != version:
|
||||
raise ValidationError('اطلاعات تغییر کرده است؛ نسخه تازه را دوباره بررسی کنید.')
|
||||
revision = receipt.revisions.get(version=version)
|
||||
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
|
||||
if action not in {'APPROVE', 'REJECT', 'SENIOR_APPROVE'}:
|
||||
raise ValidationError('نوع تصمیم معتبر نیست.')
|
||||
allowed = policies.can_decide(actor, receipt, cycle, senior=action == 'SENIOR_APPROVE')
|
||||
# Assigned seniors may also reject with a reason; all permission, visibility,
|
||||
# own-submission and assignment checks remain in can_decide.
|
||||
if not allowed and action == 'REJECT':
|
||||
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
|
||||
if not allowed:
|
||||
raise PermissionDenied
|
||||
reason = reason.strip()
|
||||
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
|
||||
raise ValidationError('دلیل این تصمیم را بنویسید.')
|
||||
old = cycle.decisions.filter(user=actor).first()
|
||||
if old:
|
||||
if old.action == action and old.reason == reason:
|
||||
return receipt
|
||||
raise ValidationError('تصمیم شما ثبت شده است؛ تغییر آن نیازمند چرخه جدید است.')
|
||||
if receipt.status not in {'PENDING', 'PARTIAL'}:
|
||||
raise ValidationError('بررسی این فیش پایان یافته است.')
|
||||
if not cycle.assignments.filter(is_senior=False).exists():
|
||||
raise ValidationError('مدیر باید ابتدا افراد تأییدکننده را تعیین کند.')
|
||||
if revision.destination_mismatch and action != 'REJECT' and not reason:
|
||||
raise ValidationError('حساب مقصد با اعلامیه متفاوت است؛ نتیجه بررسی مغایرت را بنویسید.')
|
||||
ReceiptDecision.objects.create(cycle=cycle, user=actor, action=action, reason=reason, actor_name=actor_name(actor))
|
||||
if action == 'REJECT':
|
||||
status = 'REJECTED'
|
||||
elif action == 'SENIOR_APPROVE':
|
||||
status = 'APPROVED'
|
||||
else:
|
||||
required = set(cycle.assignments.filter(is_senior=False).values_list('user_id', flat=True))
|
||||
voted = set(cycle.decisions.filter(action='APPROVE').values_list('user_id', flat=True))
|
||||
status = 'APPROVED' if required <= voted else 'PARTIAL'
|
||||
cycle.status = receipt.status = status
|
||||
if status in {'APPROVED', 'REJECTED'}:
|
||||
cycle.finalized_by = actor
|
||||
cycle.finalized_at = timezone.now()
|
||||
cycle.save(update_fields=['status', 'finalized_by', 'finalized_at'])
|
||||
receipt.save(update_fields=['status', 'updated_at'])
|
||||
audit(actor, action, receipt, {'version': version, 'reason': reason, 'status': status, 'cycle': cycle.pk})
|
||||
if status == 'APPROVED':
|
||||
from .notifications import queue_approval
|
||||
queue_approval(receipt, cycle)
|
||||
return receipt
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def bind_unassigned_cycle(actor, receipt):
|
||||
"""An empty cycle cannot accept votes. An authorized manager can bind a newly configured policy."""
|
||||
if not policies.has_permission(actor, 'manage_payment_approval_policy'):
|
||||
raise PermissionDenied
|
||||
receipt = PaymentReceipt.objects.select_for_update().get(pk=receipt.pk)
|
||||
revision = receipt.revisions.get(version=receipt.current_version)
|
||||
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
|
||||
if cycle.assignments.exists() or cycle.decisions.exists() or receipt.status != 'PENDING':
|
||||
raise ValidationError('سیاست این چرخه قبلاً ثبت شده است و قابل بازنویسی نیست.')
|
||||
policy = policy_for(receipt.owning_unit)
|
||||
if not policy or not policy.reviewers.exists():
|
||||
raise ValidationError('ابتدا یک سیاست فعال با افراد تأییدکننده ایجاد کنید.')
|
||||
users = list(policy.reviewers.all()) + list(policy.seniors.all())
|
||||
if any(not policies.is_staff(user) for user in users):
|
||||
raise ValidationError('همه تأییدکنندگان باید فعال باشند.')
|
||||
cycle.policy_snapshot = {'policy_id': policy.pk, 'name': policy.name, 'method': 'ALL_SELECTED', 'reviewers': list(policy.reviewers.values_list('pk', flat=True)), 'seniors': list(policy.seniors.values_list('pk', flat=True))}
|
||||
cycle.save(update_fields=['policy_snapshot'])
|
||||
ReceiptApprovalAssignment.objects.bulk_create([ReceiptApprovalAssignment(cycle=cycle, user=user, is_senior=senior) for senior, selected in [(False, policy.reviewers.all()), (True, policy.seniors.all())] for user in selected])
|
||||
audit(actor, 'EMPTY_CYCLE_ASSIGNED', receipt, cycle.policy_snapshot)
|
||||
|
||||
|
||||
def revise_receipt(actor, receipt_id, version, data):
|
||||
data = dict(data)
|
||||
data['paid_amount'] = validators.amount(data['paid_amount'])
|
||||
if data['paid_date'] > timezone.localdate():
|
||||
raise ValidationError('تاریخ واریز نمیتواند بعد از امروز باشد.')
|
||||
file_info = validators.image(data['receipt_image'])
|
||||
created_files = []
|
||||
try:
|
||||
with transaction.atomic():
|
||||
receipt = PaymentReceipt.objects.select_for_update().get(public_id=receipt_id)
|
||||
if actor.pk != receipt.customer_id and not policies.has_permission(actor,'submit_customer_receipt',receipt.owning_unit):
|
||||
raise PermissionDenied
|
||||
if not actor.is_active or receipt.status != 'REJECTED' or receipt.current_version != version:
|
||||
raise ValidationError('فقط نسخه جاری فیش ردشده قابل اصلاح است.')
|
||||
# Link identity is fixed for a revision; changing notice requires a distinct receipt.
|
||||
if getattr(data.get('notice_id'),'pk',None) != receipt.notice_id:
|
||||
raise ValidationError('ارتباط اعلامیه این فیش قابل تغییر نیست.')
|
||||
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
|
||||
validate_destination(account,receipt.notice)
|
||||
fields=receipt_metadata(data)
|
||||
fields['destination']=account
|
||||
notice = receipt.notice
|
||||
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
|
||||
new_version=version+1
|
||||
revision=PaymentReceiptRevision(receipt=receipt,version=new_version,
|
||||
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
|
||||
image=data['receipt_image'],destination_mismatch=bool(receipt.notice and receipt.notice.account_id!=account.pk),**file_info,**fields)
|
||||
try:
|
||||
revision.save()
|
||||
finally:
|
||||
if revision.image and revision.image._committed:
|
||||
created_files.append((revision.image.storage,revision.image.name))
|
||||
assign_cycle(revision,actor)
|
||||
receipt.current_version=new_version
|
||||
receipt.status='PENDING'
|
||||
receipt.save(update_fields=['current_version','status','updated_at'])
|
||||
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
|
||||
return receipt
|
||||
except Exception:
|
||||
for storage,name in created_files:
|
||||
storage.delete(name)
|
||||
raise
|
||||
Reference in new issue
Block a user