197 lines
7.6 KiB
Python
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}>"
|