"""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 from .deposit_identifier import normalize_deposit_identifier from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template 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) def save(self, *args, **kwargs): self.deposit_id = normalize_deposit_identifier(self.deposit_id) super().save(*args, **kwargs) 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() def save(self, *args, **kwargs): self.deposit_id = normalize_deposit_identifier(self.deposit_id) super().save(*args, **kwargs) 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) class PaymentReceiptAlertConfiguration(models.Model): """Independent, initially disabled destinations for customer receipt uploads.""" bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False) bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True) bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True) bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION, validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000) sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False) sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS, validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000) notify_revisions = models.BooleanField('اطلاع‌رسانی فیش اصلاح‌شده مشتری', default=True) updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True) class Meta: verbose_name = 'تنظیمات اطلاع‌رسانی فیش مشتری' verbose_name_plural = 'تنظیمات اطلاع‌رسانی فیش مشتری' permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')] constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')] def __str__(self): return 'اطلاع‌رسانی فیش مشتری' def save(self, *args, **kwargs): self.pk = 1 super().save(*args, **kwargs) def clean(self): from django.core.exceptions import ValidationError self.pk = 1 self.bale_token = self.bale_token.strip() self.bale_chat_id = self.bale_chat_id.strip() if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token): raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'}) if self.bale_enabled and (not self.bale_token or not self.bale_chat_id): raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.') class PaymentReceiptAlertRecipient(models.Model): configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients') name = models.CharField('نام مدیر', max_length=150) mobile = models.CharField('شماره موبایل مدیر', max_length=20) is_active = models.BooleanField('دریافت پیامک فعال', default=True) class Meta: verbose_name = 'مدیر دریافت‌کننده پیامک فیش' verbose_name_plural = 'مدیران دریافت‌کننده پیامک فیش' constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')] def __str__(self): return self.name def clean(self): from apps.accounts.models import normalize_mobile from django.core.exceptions import ValidationError from .validators import digits try: self.mobile = normalize_mobile(digits(self.mobile)) except ValueError: raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'}) def save(self, *args, **kwargs): self.clean() super().save(*args, **kwargs) class PaymentReceiptAlert(models.Model): job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert') revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts') recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT) recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True) recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True) bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True) context = models.JSONField() message_template = models.TextField('قالب هنگام ثبت') rendered_body = models.TextField('متن ارسال', blank=True) class Meta: verbose_name = 'سابقه اعلان فیش مشتری' verbose_name_plural = 'سوابق اعلان فیش مشتری'