287 lines
17 KiB
Python
287 lines
17 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 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
|