"""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