Files
MathApp/subscription/models.py
Zareian 58c8e6d014 question packs instead of monthly/yearly plans; hearts are real
- packs (20 / 60 / 150 questions for 45,000 / 119,000 / 259,000 toman) never expire and
  give 7 / 30 / 60 days of unlimited hearts; old subscription plans stay for existing
  subscribers but are no longer on sale
- credit ledger (subscription.QuestionCredit): +N per paid pack (once per order), -1 per
  tutor question that reaches the API; the free daily questions are spent first; questions
  stopped by the server filter, cached answers and failed answers cost nothing
- unlimited hearts from packs live in HeartsPass, so buying a pack never rewrites a
  running subscription
- Message.charge records how each question was paid; GET /api/ai/quota and the ask
  responses report free-left-today and credits
- HEARTS_ENFORCED now defaults to on: zero hearts stops answering until refill or a pack

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 11:41:39 +03:30

118 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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='در صفحه‌ی خرید نشان داده شود.')
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 نرسید. اعتبار منقضی نمی‌شود (تصمیمِ صاحبِ پروژه).
"""
REASON_CHOICES = [('purchase', 'خرید'), ('question', 'سوال'), ('grant', 'هدیه‌ی ادمین')]
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}'