Ilo/apps/payments/models.py
2026-05-02 20:01:30 +03:30

197 lines
7.6 KiB
Python

from django.conf import settings
from django.db import models
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
class OrderStatus(models.TextChoices):
PENDING = "pending", _("Pending")
PAID = "paid", _("Paid")
FAILED = "failed", _("Failed")
CANCELLED = "cancelled", _("Cancelled")
class Gateway(models.TextChoices):
CONSOLE = "console", _("Console (dev)")
ZARINPAL = "zarinpal", _("ZarinPal")
IDPAY = "idpay", _("IDPay")
FREE = "free", _("Free (auto)")
class PromoCode(models.Model):
code = models.CharField(_("code"), max_length=32, unique=True)
discount_percent = models.PositiveSmallIntegerField(
_("discount %"), null=True, blank=True,
help_text=_("Percentage off (1-100). Mutually exclusive with discount_amount_toman."),
)
discount_amount_toman = models.PositiveIntegerField(
_("discount amount (Toman)"), null=True, blank=True,
help_text=_("Fixed amount off. Mutually exclusive with discount_percent."),
)
course = models.ForeignKey(
"courses.Course", on_delete=models.CASCADE, related_name="promo_codes",
null=True, blank=True,
help_text=_("If set, code only applies to this course; otherwise applies to any course."),
)
max_uses = models.PositiveIntegerField(
_("max uses"), null=True, blank=True,
help_text=_("Total uses across all users. Null = unlimited."),
)
used_count = models.PositiveIntegerField(_("used count"), default=0)
valid_from = models.DateTimeField(_("valid from"), null=True, blank=True)
valid_until = models.DateTimeField(_("valid until"), null=True, blank=True)
is_active = models.BooleanField(_("active"), default=True)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
class Meta:
verbose_name = _("promo code")
verbose_name_plural = _("promo codes")
ordering = ["-created_at"]
indexes = [models.Index(fields=["code"])]
def __str__(self):
return self.code
def save(self, *args, **kwargs):
if self.code:
self.code = self.code.strip().upper()
super().save(*args, **kwargs)
def is_valid_now(self) -> bool:
if not self.is_active:
return False
now = timezone.now()
if self.valid_from and now < self.valid_from:
return False
if self.valid_until and now >= self.valid_until:
return False
if self.max_uses is not None and self.used_count >= self.max_uses:
return False
if (self.discount_percent or 0) <= 0 and (self.discount_amount_toman or 0) <= 0:
return False
return True
def applies_to_course(self, course) -> bool:
return self.course_id is None or self.course_id == course.id
def discount_for(self, base_amount_toman: int) -> int:
"""Return the Toman discount this code yields on `base_amount_toman`."""
if self.discount_percent:
return min(base_amount_toman, (base_amount_toman * int(self.discount_percent)) // 100)
if self.discount_amount_toman:
return min(base_amount_toman, int(self.discount_amount_toman))
return 0
class Order(models.Model):
student = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="orders",
limit_choices_to={"role": "student"},
)
course = models.ForeignKey(
"courses.Course",
on_delete=models.PROTECT,
related_name="orders",
)
# Money — snapshotted at order creation so price/commission changes don't affect old orders.
base_amount_toman = models.PositiveIntegerField(
_("base amount (Toman)"), default=0,
help_text=_("Course price at order time, before any discount."),
)
discount_amount_toman = models.PositiveIntegerField(
_("discount applied (Toman)"), default=0,
)
amount_toman = models.PositiveIntegerField(
_("charged amount (Toman)"),
help_text=_("base_amount - discount; what the buyer actually pays."),
)
ilo_fee_toman = models.PositiveIntegerField(_("platform fee (Toman)"), default=0)
instructor_share_toman = models.PositiveIntegerField(_("instructor share (Toman)"), default=0)
commission_percent_snapshot = models.PositiveSmallIntegerField(
_("commission % at order time"), default=0
)
promo_code = models.ForeignKey(
PromoCode, on_delete=models.SET_NULL, related_name="orders",
null=True, blank=True,
)
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name="referred_orders",
null=True, blank=True,
help_text=_("The user whose referral_code was applied at checkout."),
)
gateway = models.CharField(_("gateway"), max_length=16, choices=Gateway.choices)
authority = models.CharField(_("authority"), max_length=100, blank=True, db_index=True)
ref_id = models.CharField(_("ref ID"), max_length=64, blank=True)
status = models.CharField(
_("status"), max_length=16, choices=OrderStatus.choices, default=OrderStatus.PENDING
)
created_at = models.DateTimeField(_("created at"), auto_now_add=True)
paid_at = models.DateTimeField(_("paid at"), null=True, blank=True)
failed_at = models.DateTimeField(_("failed at"), null=True, blank=True)
last_error = models.CharField(_("last error"), max_length=255, blank=True)
class Meta:
verbose_name = _("order")
verbose_name_plural = _("orders")
ordering = ["-created_at"]
indexes = [
models.Index(fields=["student", "-created_at"]),
models.Index(fields=["course", "-created_at"]),
models.Index(fields=["status"]),
]
def __str__(self):
return f"Order#{self.pk} {self.student_id}{self.course_id} {self.status}"
@property
def is_paid(self) -> bool:
return self.status == OrderStatus.PAID
def mark_paid(self, ref_id: str = ""):
self.status = OrderStatus.PAID
self.paid_at = timezone.now()
if ref_id:
self.ref_id = ref_id
self.save(update_fields=["status", "paid_at", "ref_id"])
def mark_failed(self, error: str = ""):
self.status = OrderStatus.FAILED
self.failed_at = timezone.now()
if error:
self.last_error = error[:255]
self.save(update_fields=["status", "failed_at", "last_error"])
class Referral(models.Model):
"""Settled referral attribution — created when a paid order had a referrer."""
referrer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="referrals_given",
)
buyer = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name="referrals_received",
)
order = models.OneToOneField(Order, on_delete=models.CASCADE, related_name="referral")
course = models.ForeignKey("courses.Course", on_delete=models.PROTECT, related_name="referrals")
buyer_discount_toman = models.PositiveIntegerField(_("buyer discount (Toman)"), default=0)
referrer_credit_toman = models.PositiveIntegerField(_("referrer credit (Toman)"), default=0)
settled_at = models.DateTimeField(_("settled at"), auto_now_add=True)
class Meta:
verbose_name = _("referral")
verbose_name_plural = _("referrals")
ordering = ["-settled_at"]
indexes = [
models.Index(fields=["referrer", "-settled_at"]),
models.Index(fields=["buyer", "-settled_at"]),
]
def __str__(self):
return f"Referral<{self.referrer_id}{self.buyer_id} order#{self.order_id}>"