Files
customerTicket/apps/payments/services.py
T
2026-10-06 12:30:28 +03:30

310 lines
19 KiB
Python

"""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 delete_notice(actor, notice):
# Receipt submission locks the same customer, then notice. Do not lock access
# links here: anonymous submission already holds its link before the customer.
actor = User.objects.select_related('staff_profile').get(pk=actor.pk)
User.objects.select_for_update().get(pk=notice.customer_id)
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
if not policies.may_delete_notice(actor, notice):
raise PermissionDenied
if notice.receipts.exists():
raise ValidationError('برای این اعلامیه فیش ثبت شده است؛ حذف آن امکان‌پذیر نیست، حتی اگر فیش رد شده باشد.')
if notice.deleted_at:
return
notice.deleted_at = notice.cancelled_at = timezone.now()
notice.save(update_fields=['deleted_at', 'cancelled_at', 'updated_at'])
from apps.notifications.models import NotificationJob
NotificationJob.objects.filter(payment_notification__notice=notice,
status__in=['PENDING', 'RETRYING', 'FAILED']).update(status='CANCELLED',
last_error='اعلامیه واریز حذف شده است.')
audit(actor, 'NOTICE_DELETED', notice, {'number': notice.number, 'customer_id': notice.customer_id,
'subject': notice.subject, 'amount': notice.amount})
@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