Files
customerTicket/apps/payments/models.py
T

328 lines
17 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
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()
depositor_name = models.CharField('نام واریزکننده', max_length=150)
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 = 'سوابق اعلان فیش مشتری'