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}>"