from django.db import models class Plan(models.Model): """ ⚠️ اصلاح نسبت به سند اولیه: نمونه‌ی پاسخ GET /api/subscription/plans در سند Endpointهای API یک فیلد boolean به اسم `popular` داره («ماهانه» → false، «سالانه» → true) که در دیاگرام مدل‌ها (سند معماری داده) برای Plan اصلاً ذکر نشده. چون بدون این فیلد نمی‌شه دقیقاً همون response schema رو پیاده‌سازی کرد، اینجا اضافه شده — دقیقاً مثل فیکس is_correct روی content.Choice در فاز ۱. """ title = models.CharField(max_length=50) price = models.PositiveIntegerField() currency = models.CharField(max_length=10, default='IRT') discount_percent = models.PositiveSmallIntegerField(default=0) popular = models.BooleanField(default=False) # منبعِ صریحِ مدتِ اشتراک؛ بدونش activate_subscription مجبور بود مدت رو # از رویِ متنِ title (مثلاً کلمه‌ی «سالانه») حدس بزنه. duration_days = models.PositiveIntegerField(default=30) # کارتِ اشتراک زیرِ هر پلن فهرستِ امکانات نشان می‌دهد («درس‌های نامحدود»، # «دستیار هوشمند»…). تا امروز این فهرست در کدِ فرانت هاردکد بود، یعنی # عوض کردنِ یک بند به دیپلویِ تازه‌ی اپ نیاز داشت. features = models.JSONField(default=list, blank=True) # برچسبِ بالای کارت: «محبوب‌ترین»، «بهترین ارزش». `popular` فقط بولین است # و یک متن نمی‌دهد؛ این جایگزینش نیست، کنارش است. badge = models.CharField(max_length=30, blank=True) # چند سوال در روز از دستیار (۴ برای پلن‌های فروشی، subscription.0017). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۷۰۰ تومان) # هزینه‌ی API دارد (docs/ai-cost-and-guardrails.md «اشتراک‌ها»)، پس این عدد مستقیم سودِ پلن را تعیین می‌کند. # کاربرِ بدونِ اشتراک سقفِ AI_DAILY_QUOTA_FREE (settings) را دارد. ai_daily_quota = models.PositiveSmallIntegerField( default=15, help_text='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.') # پلنی که دیگر فروخته نمی‌شود ولی مشترک‌های فعلی‌اش هنوز به آن وصل‌اند. on_sale = models.BooleanField(default=True, help_text='در صفحه‌ی خرید نشان داده شود.') # شناسه‌ی همین اشتراک در پیشخانِ کافه‌بازار (محصولِ «اشتراکی»). خریدِ بازار SKU را فقط از اینجا می‌گیرد، # هرگز از کلاینت: وگرنه توکنِ ارزان‌ترین محصول اشتراکِ گران را فعال می‌کرد. خالی یعنی این اشتراک در بازار # فروخته نمی‌شود (درخواستش ۴۰۰ می‌گیرد). bazaar_sku = models.CharField( max_length=100, blank=True, default='', help_text='SKUِ محصولِ درون‌برنامه‌ای در پیشخانِ کافه‌بازار؛ خالی = در بازار فروخته نمی‌شود.') class Meta: constraints = [ models.UniqueConstraint(fields=['bazaar_sku'], condition=~models.Q(bazaar_sku=''), name='plan_bazaar_sku_unique_when_set'), ] def __str__(self): return self.title class Subscription(models.Model): """وضعیتِ pending برای فاصله‌ی بینِ ساختنِ Subscription (در purchase) و تاییدِ پرداخت (webhook)؛ فعال‌سازیِ واقعی توسطِ subscription.services.activate_subscription انجام می‌شه.""" STATUS_CHOICES = [ ('pending', 'در انتظار پرداخت'), ('active', 'فعال'), ('expired', 'منقضی'), ('canceled', 'لغوشده'), ] user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User plan = models.ForeignKey(Plan, on_delete=models.SET_NULL, null=True, related_name='subscriptions') status = models.CharField(max_length=15, choices=STATUS_CHOICES, default='pending') started_at = models.DateTimeField(auto_now_add=True) expires_at = models.DateTimeField(null=True, blank=True) order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order # چون Subscription فقط یک ردیف در ازای هر user_id داره، دو خریدِ هم‌زمانِ # همون کاربر می‌تونن به وضعیتِ میانیِ کاملاً یکسانی برسن (مقایسه‌ی # فیلدها کافی نیست برای CAS). version با UPDATEِ اتمیکِ # version=F('version')+1 (بدونِ select_for_update) هر نوشتنِ واقعی رو # از نوشتنِ هم‌زمانِ دیگری تمیز می‌ده. version = models.PositiveIntegerField(default=0) def __str__(self): return f"sub-user{self.user_id}-{self.status}"