175 lines
8.1 KiB
Python
175 lines
8.1 KiB
Python
from django.db import models
|
|
from django.db.models import Q, Count
|
|
from django.core.exceptions import ValidationError
|
|
from django.conf import settings
|
|
from django.utils import timezone
|
|
|
|
from core.models import BaseModel
|
|
from apps.events.models import Event
|
|
|
|
from ninja.errors import HttpError
|
|
|
|
User = settings.AUTH_USER_MODEL
|
|
|
|
|
|
class DiscountCode(BaseModel):
|
|
class Type(models.TextChoices):
|
|
PERCENT = "percent", "Percent"
|
|
FIXED = "fixed", "Fixed (IRR)"
|
|
|
|
code = models.CharField(max_length=64, unique=True)
|
|
type = models.CharField(max_length=10, choices=Type.choices, default=Type.PERCENT)
|
|
value = models.PositiveIntegerField()
|
|
max_discount = models.PositiveIntegerField(null=True, blank=True)
|
|
is_active = models.BooleanField(default=True)
|
|
starts_at = models.DateTimeField(null=True, blank=True)
|
|
ends_at = models.DateTimeField(null=True, blank=True)
|
|
usage_limit_total = models.PositiveIntegerField(null=True, blank=True)
|
|
usage_limit_per_user = models.PositiveIntegerField(null=True, blank=True)
|
|
min_amount = models.PositiveIntegerField(null=True, blank=True)
|
|
applicable_events = models.ManyToManyField(Event, blank=True, related_name="discount_codes")
|
|
|
|
def __str__(self):
|
|
return f"{self.code} ({self.get_type_display()} {self.value})"
|
|
|
|
def calculate_discount(self, event: Event, user: User):
|
|
if not event.price:
|
|
return (0, 0)
|
|
|
|
if not self.is_active:
|
|
raise HttpError(400, "کد تخفیف نامعتبر یا غیرفعال است.")
|
|
|
|
n = timezone.now()
|
|
if self.starts_at and n < self.starts_at:
|
|
raise HttpError(400, "کد تخفیف هنوز فعال نشده است.")
|
|
if self.ends_at and n > self.ends_at:
|
|
raise HttpError(400, "کد تخفیف منقضی شده است.")
|
|
|
|
if self.applicable_events.exists() and not self.applicable_events.filter(pk=event.pk).exists():
|
|
raise HttpError(400, "کد تخفیف برای این رویداد قابل استفاده نیست.")
|
|
|
|
if self.min_amount and event.price < self.min_amount:
|
|
raise HttpError(400, "مبلغ سفارش کمتر از حداقل لازم برای این کد است.")
|
|
|
|
used_qs = Payment.objects.filter(discount_code=self, status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING])
|
|
if self.usage_limit_total is not None and used_qs.count() >= self.usage_limit_total:
|
|
raise HttpError(400, "حداکثر تعداد استفاده از این کد تخفیف تکمیل شده است.")
|
|
|
|
used_by_user = used_qs.filter(user=user).count()
|
|
if self.usage_limit_per_user is not None and used_by_user >= self.usage_limit_per_user:
|
|
raise HttpError(400, "شما حداکثر تعداد مجاز استفاده از این کد تخفیف را مصرف کردهاید.")
|
|
|
|
if self.type == DiscountCode.Type.FIXED:
|
|
disc = min(self.value, event.price)
|
|
else:
|
|
disc = (event.price * self.value) // 100
|
|
if self.max_discount:
|
|
disc = min(disc, self.max_discount)
|
|
|
|
final_amount = max(event.price - disc, 0)
|
|
if 0 < final_amount < 10_000:
|
|
raise HttpError(400, "با این تخفیف مبلغ قابل پرداخت به کمتر از ۱۰٬۰۰۰ ریال میرسد.")
|
|
|
|
return (final_amount, disc)
|
|
|
|
|
|
class Payment(BaseModel):
|
|
class OrderStatusChoices(models.IntegerChoices):
|
|
INIT = 0, "Initiated"
|
|
PENDING = 1, "Pending"
|
|
PAID = 2, "Paid"
|
|
FAILED = 3, "Failed"
|
|
CANCELED = 4, "Canceled"
|
|
|
|
class RefundStatusChoices(models.TextChoices):
|
|
NONE = "none", "No refund required"
|
|
PENDING = "pending", "Refund pending"
|
|
SUCCEEDED = "succeeded", "Refund completed"
|
|
MANUAL_REVIEW = "manual_review", "Refund needs review"
|
|
|
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False)
|
|
event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False)
|
|
|
|
base_amount = models.PositiveIntegerField(editable=False)
|
|
discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, editable=False, related_name="payments")
|
|
discount_amount = models.PositiveIntegerField(default=0, editable=False)
|
|
amount = models.PositiveIntegerField(editable=False)
|
|
|
|
registration = models.ForeignKey(
|
|
"events.Registration",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="payments",
|
|
editable=False,
|
|
)
|
|
idempotency_key = models.UUIDField(null=True, blank=True, editable=False)
|
|
authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False)
|
|
status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False)
|
|
ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False)
|
|
card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False)
|
|
card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False)
|
|
verified_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
gateway_paid_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
verification_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
|
|
verification_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
verification_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
verification_lease_until = models.DateTimeField(null=True, blank=True, editable=False)
|
|
verification_dispatch_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
verification_manual_review_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
refund_status = models.CharField(
|
|
max_length=20,
|
|
choices=RefundStatusChoices.choices,
|
|
default=RefundStatusChoices.NONE,
|
|
editable=False,
|
|
)
|
|
refund_attempts = models.PositiveSmallIntegerField(default=0, editable=False)
|
|
refund_last_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
refund_next_attempt_at = models.DateTimeField(null=True, blank=True, editable=False)
|
|
refund_reference = models.CharField(max_length=64, null=True, blank=True, editable=False)
|
|
refund_last_error = models.CharField(max_length=255, blank=True, default='', editable=False)
|
|
|
|
class Meta:
|
|
constraints = [
|
|
models.UniqueConstraint(
|
|
fields=["user", "event", "idempotency_key"],
|
|
condition=Q(idempotency_key__isnull=False),
|
|
name="payment_user_event_idempotency_key_uniq",
|
|
),
|
|
]
|
|
|
|
def clean(self):
|
|
if self.discount_amount and self.amount + self.discount_amount != self.base_amount:
|
|
raise ValidationError({"amount": "amount + discount_amount must equal base_amount"})
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.full_clean()
|
|
return super().save(*args, **kwargs)
|
|
|
|
@property
|
|
def status_label(self):
|
|
"""Human-readable label for the payment status."""
|
|
return self.get_status_display()
|
|
|
|
@property
|
|
def verification_state(self):
|
|
"""Server-owned verification state for callback and support screens."""
|
|
if self.status == self.OrderStatusChoices.PAID or self.verified_at is not None:
|
|
return "verified"
|
|
if self.status == self.OrderStatusChoices.FAILED:
|
|
return "failed"
|
|
if self.status == self.OrderStatusChoices.CANCELED:
|
|
return "canceled"
|
|
if self.verification_manual_review_at is not None or not self.authority:
|
|
return "manual_review"
|
|
|
|
now = timezone.now()
|
|
if self.verification_lease_until and self.verification_lease_until > now:
|
|
return "checking"
|
|
if self.verification_next_attempt_at:
|
|
return "retry_scheduled"
|
|
return "awaiting_verification"
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email}:{self.event} - {self.get_status_display()}"
|