Files
MathApp/subscription/models.py
Zareian 8077ab4350 Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
  response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
  code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
  and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
  the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 17:53:11 +03:30

131 lines
9.0 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='در صفحه‌ی خرید نشان داده شود.')
# شناسه‌ی همین پلن در پیشخانِ کافه‌بازار: بسته = محصولِ مصرف‌شدنی، اشتراک = محصولِ «اشتراکی». خریدِ بازار
# 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}'