236 lines
12 KiB
Python
236 lines
12 KiB
Python
"""Financial records are independent of tickets; issued instructions and evidence are snapshots."""
|
|
import uuid
|
|
from pathlib import Path
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from apps.core.models import PublicIdModel, TimeStampedModel
|
|
|
|
MAX_AMOUNT = 9_007_199_254_740_991
|
|
|
|
|
|
class BankInstitution(models.Model):
|
|
code = models.SlugField(unique=True)
|
|
name = models.CharField(max_length=100)
|
|
is_active = models.BooleanField(default=True)
|
|
historical = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
ordering = ['historical', 'name']
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class CompanyBankAccount(PublicIdModel, TimeStampedModel):
|
|
name = models.CharField('نام نمایشی حساب', max_length=120)
|
|
bank = models.ForeignKey(BankInstitution, on_delete=models.PROTECT, verbose_name='بانک')
|
|
holder = models.CharField('صاحب حساب', max_length=150)
|
|
account_number = models.CharField('شماره حساب', max_length=40, blank=True)
|
|
iban = models.CharField('شبا', max_length=26, blank=True)
|
|
card_number = models.CharField('شماره کارت', max_length=16, blank=True)
|
|
requires_deposit_id = models.BooleanField('شناسه واریز الزامی', default=False)
|
|
is_active = models.BooleanField('فعال کلی', default=True)
|
|
available_to_staff = models.BooleanField('قابل انتخاب کارشناسان', default=True)
|
|
available_to_customers = models.BooleanField('قابل انتخاب مشتریان', default=True)
|
|
sort_order = models.PositiveIntegerField(default=0)
|
|
|
|
class Meta:
|
|
ordering = ['sort_order', 'name', 'pk']
|
|
permissions = [('manage_company_bankaccounts', 'Manage company bank accounts')]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
def snapshot(self):
|
|
return {key: getattr(self, key) for key in ('name', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id')} | {'bank': self.bank.name, 'id': str(self.public_id)}
|
|
|
|
|
|
class PaymentAccessConfiguration(models.Model):
|
|
singleton = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
|
|
open_view = models.BooleanField('مشاهده برای همه کارشناسان فعال', default=True)
|
|
open_create = models.BooleanField('ایجاد برای همه کارشناسان فعال', default=True)
|
|
|
|
class Meta:
|
|
permissions = [('manage_payment_access', 'Manage payment access')]
|
|
|
|
|
|
class PaymentApprovalPolicy(TimeStampedModel):
|
|
name = models.CharField('عنوان سیاست', max_length=100)
|
|
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, blank=True, on_delete=models.PROTECT, verbose_name='واحد (خالی: عمومی)')
|
|
reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_review_policies', verbose_name='افراد الزامی تأیید')
|
|
seniors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_senior_policies', blank=True, verbose_name='تأییدکنندگان ارشد')
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(fields=['unit'], condition=models.Q(is_active=True, unit__isnull=False), name='pay_active_unit_policy'),
|
|
models.UniqueConstraint(fields=['is_active'], condition=models.Q(is_active=True, unit__isnull=True), name='pay_active_global_policy'),
|
|
]
|
|
permissions = [('manage_payment_approval_policy', 'Manage payment approval policy')]
|
|
|
|
def __str__(self):
|
|
return self.name
|
|
|
|
|
|
class PaymentNotice(PublicIdModel, TimeStampedModel):
|
|
issuance_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
|
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_notices')
|
|
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_payment_notices')
|
|
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
|
|
account = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
|
|
account_snapshot = models.JSONField()
|
|
subject = models.CharField(max_length=200)
|
|
amount = models.BigIntegerField()
|
|
deposit_id = models.CharField(max_length=100, blank=True)
|
|
invoice_number = models.CharField(max_length=100, blank=True)
|
|
description = models.TextField(blank=True)
|
|
cancelled_at = models.DateTimeField(null=True, blank=True)
|
|
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at', '-pk']
|
|
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
|
|
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['owning_unit', '-created_at'])]
|
|
|
|
@property
|
|
def number(self):
|
|
return f'PN-{self.public_id.hex[:12].upper()}'
|
|
|
|
|
|
class PaymentReceipt(PublicIdModel, TimeStampedModel):
|
|
class Status(models.TextChoices):
|
|
PENDING = 'PENDING', 'در انتظار بررسی'
|
|
PARTIAL = 'PARTIAL', 'تأیید ناقص'
|
|
APPROVED = 'APPROVED', 'تأیید شده'
|
|
REJECTED = 'REJECTED', 'رد شده'
|
|
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_receipts')
|
|
submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_payment_receipts')
|
|
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT, related_name='receipts')
|
|
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
|
|
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING, db_index=True)
|
|
submission_key = models.UUIDField(unique=True)
|
|
current_version = models.PositiveIntegerField(default=1)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at', '-pk']
|
|
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['status', '-created_at'])]
|
|
permissions = [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')]
|
|
|
|
@property
|
|
def number(self):
|
|
return f'PR-{self.public_id.hex[:12].upper()}'
|
|
|
|
|
|
def receipt_path(instance, filename):
|
|
return f'payments/{instance.created_at:%Y/%m}/{uuid.uuid4().hex}{Path(filename).suffix.lower()}'
|
|
|
|
|
|
class PaymentReceiptRevision(models.Model):
|
|
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
|
|
version = models.PositiveIntegerField()
|
|
amount = models.BigIntegerField()
|
|
paid_date = models.DateField()
|
|
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
|
|
account_snapshot = models.JSONField()
|
|
origin_bank = models.ForeignKey(BankInstitution, null=True, blank=True, on_delete=models.PROTECT)
|
|
origin_other = models.CharField(max_length=100, blank=True)
|
|
deposit_id = models.CharField(max_length=100, blank=True)
|
|
tracking_number = models.CharField(max_length=100, blank=True)
|
|
description = models.TextField(blank=True)
|
|
destination_mismatch = models.BooleanField(default=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
image = models.FileField(upload_to=receipt_path, max_length=500)
|
|
mime_type = models.CharField(max_length=100)
|
|
sha256 = models.CharField(max_length=64)
|
|
original_name = models.CharField(max_length=255)
|
|
file_size = models.PositiveBigIntegerField()
|
|
|
|
class Meta:
|
|
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
|
|
|
|
|
|
class ReceiptApprovalCycle(models.Model):
|
|
revision = models.OneToOneField(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='cycle')
|
|
policy_snapshot = models.JSONField(default=dict)
|
|
status = models.CharField(max_length=12, choices=PaymentReceipt.Status.choices, default=PaymentReceipt.Status.PENDING)
|
|
finalized_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)
|
|
finalized_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
|
|
class ReceiptApprovalAssignment(models.Model):
|
|
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='assignments')
|
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
|
is_senior = models.BooleanField(default=False)
|
|
|
|
class Meta:
|
|
constraints = [models.UniqueConstraint(fields=['cycle', 'user', 'is_senior'], name='pay_cycle_assignment')]
|
|
|
|
|
|
class ReceiptDecision(models.Model):
|
|
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='decisions')
|
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
|
action = models.CharField(max_length=20, choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')])
|
|
reason = models.TextField(blank=True)
|
|
actor_name = models.CharField(max_length=150)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
|
|
class Meta:
|
|
constraints = [models.UniqueConstraint(fields=['cycle', 'user'], name='pay_one_vote_per_user')]
|
|
|
|
|
|
class PaymentAuditEvent(models.Model):
|
|
actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
|
|
actor_name = models.CharField(max_length=150)
|
|
action = models.CharField(max_length=50)
|
|
object_type = models.CharField(max_length=50)
|
|
object_id = models.CharField(max_length=64)
|
|
details = models.JSONField(default=dict)
|
|
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at', '-pk']
|
|
permissions = [('view_payment_audit', 'View financial audit')]
|
|
|
|
|
|
class PaymentAccessLink(models.Model):
|
|
"""Revocable capability, separate from customer login and ticket magic links."""
|
|
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
|
|
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT)
|
|
receipt = models.ForeignKey(PaymentReceipt, null=True, blank=True, on_delete=models.PROTECT)
|
|
token_hash = models.CharField(max_length=64, unique=True, editable=False)
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
expires_at = models.DateTimeField()
|
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
|
submitted_receipts = models.ManyToManyField(PaymentReceipt, blank=True, related_name='access_links')
|
|
view_count = models.PositiveIntegerField(default=0)
|
|
last_used_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
ordering = ['-created_at']
|
|
constraints = [models.CheckConstraint(
|
|
condition=models.Q(notice__isnull=False) | models.Q(receipt__isnull=False),
|
|
name='payment_link_has_target')]
|
|
verbose_name = 'لینک موقت پرداخت'
|
|
verbose_name_plural = 'لینکهای موقت پرداخت'
|
|
|
|
|
|
class PaymentNotification(models.Model):
|
|
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='payment_notification')
|
|
notice = models.ForeignKey(PaymentNotice, null=True, on_delete=models.PROTECT)
|
|
cycle = models.ForeignKey(ReceiptApprovalCycle, null=True, on_delete=models.PROTECT)
|
|
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
|
|
context = models.JSONField()
|
|
rendered_body = models.TextField(blank=True)
|
|
template_snapshot = models.JSONField(default=dict)
|
|
access_link = models.ForeignKey(PaymentAccessLink, null=True, blank=True, on_delete=models.PROTECT)
|
|
|
|
|
|
class PaymentNotificationAttempt(models.Model):
|
|
notification = models.ForeignKey(PaymentNotification, on_delete=models.PROTECT, related_name='attempt_log')
|
|
started_at = models.DateTimeField(auto_now_add=True)
|
|
finished_at = models.DateTimeField(null=True)
|
|
success = models.BooleanField(null=True)
|
|
provider_code = models.CharField(max_length=50, blank=True)
|
|
provider_id = models.CharField(max_length=500, blank=True)
|
|
error = models.TextField(blank=True)
|