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.0008). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینه‌ی API # دارد (docs/ai-cost-and-guardrails.md)، پس این عدد مستقیم سودِ پلن را تعیین می‌کند. # کاربرِ بدونِ اشتراک سقفِ AI_DAILY_QUOTA_FREE (settings) را دارد. ai_daily_quota = models.PositiveSmallIntegerField( default=15, help_text='سقفِ سوالِ روزانه از دستیارِ هوش مصنوعی برای مشترکانِ این پلن.') # از ۲۰۲۶-۱۰-۰۲ پلن دو نوع است. «بسته‌ی سوال» (pack) تعدادی سوال از دستیار می‌فروشد که # منقضی نمی‌شود، به‌علاوه‌ی چند روز قلبِ نامحدود؛ به Subscription دست نمی‌زند و سقفِ روزانه # نمی‌دهد. «اشتراک» (subscription) همان مدلِ قبلی است و برای مشترک‌های فعلی می‌ماند. KIND_CHOICES = [('pack', 'بسته‌ی سوال'), ('subscription', 'اشتراکِ زمانی')] kind = models.CharField(max_length=15, choices=KIND_CHOICES, default='subscription') question_credits = models.PositiveIntegerField( default=0, help_text='فقط بسته: چند سوال از دستیار به اعتبارِ خریدار اضافه می‌شود.') hearts_days = models.PositiveSmallIntegerField( default=0, 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}" class QuestionCredit(models.Model): """ دفترِ اعتبارِ سوال: هر خریدِ بسته یک ردیفِ مثبت، هر سوالی که با اعتبار پرسیده شد یک ردیفِ ۱−. موجودی = جمعِ delta. چرا دفتر و نه یک عددِ «موجودی»: با عدد، دو درخواستِ هم‌زمان می‌توانستند یک اعتبار را دو بار خرج کنند و هیچ راهی نبود بفهمیم هر سوال از کدام خرید آمده. کم کردن داخلِ همان تراکنشی است که ردیفِ کاربر را قفل می‌کند (ai_tutor.views، SQLite با IMMEDIATE). ردیفِ ۱− فقط وقتی پاک می‌شود که خودِ سوال پس گرفته شود: جوابِ ناموفق یا جوابی که از کش آمد و به API نرسید. اعتبار منقضی نمی‌شود (تصمیمِ صاحبِ پروژه). """ # «refund»: ردیفِ منفیِ بازپرداختِ سفارش (payment.services.refund_order)؛ فقط اعتبارِ خرج‌نشده را پس می‌گیرد. REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیه‌ی ادمین'), ('refund', 'بازپرداخت')] user_id = models.IntegerField(db_index=True) # ارجاع سبک به accounts.User delta = models.IntegerField() reason = models.CharField(max_length=10, choices=REASON_CHOICES) order_id = models.IntegerField(null=True, blank=True) # ارجاع سبک به payment.Order message_id = models.IntegerField(null=True, blank=True, db_index=True) # ارجاع سبک به ai_tutor.Message note = models.CharField(max_length=100, blank=True) created_at = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['-created_at'] constraints = [ # هر سفارش فقط یک بار اعتبار می‌دهد، حتی اگر تأییدِ پرداخت دو بار برسد. models.UniqueConstraint(fields=['order_id'], condition=models.Q(reason='purchase'), name='uniq_credit_per_paid_order'), ] def __str__(self): return f'credit-user{self.user_id}:{self.delta:+d} ({self.reason})' class HeartsPass(models.Model): """قلبِ نامحدودِ هدیه‌ی بسته‌ها تا expires_at؛ جدا از Subscription تا خریدِ بسته اشتراکِ کسی را عوض نکند.""" user_id = models.IntegerField(unique=True, db_index=True) # ارجاع سبک به accounts.User expires_at = models.DateTimeField() def __str__(self): return f'hearts-pass-user{self.user_id}-{self.expires_at:%Y-%m-%d}'