Files
MathApp/subscription/models.py
Zareian 947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:38 +03:30

80 lines
5.3 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.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}"