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>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -26,26 +26,17 @@ class Plan(models.Model):
# برچسبِ بالای کارت: «محبوب‌ترین»، «بهترین ارزش». `popular` فقط بولین است
# و یک متن نمی‌دهد؛ این جایگزینش نیست، کنارش است.
badge = models.CharField(max_length=30, blank=True)
# چند سوال در روز از دستیار (۱۰ برای پلن‌های فعلی، subscription.0008). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینه‌ی API
# دارد (docs/ai-cost-and-guardrails.md)، پس این عدد مستقیم سودِ پلن را تعیین می‌کند.
# چند سوال در روز از دستیار (۴ برای پلن‌های فروشی، subscription.0017). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۷۰۰ تومان)
# هزینه‌ی 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 را فقط از اینجا می‌گیرد، هرگز از کلاینت: وگرنه توکنِ ارزان‌ترین محصول بسته‌ی گران را
# فعال می‌کرد. خالی یعنی این بسته در بازار فروخته نمی‌شود (درخواستش ۴۰۰ می‌گیرد).
# شناسه‌ی همین اشتراک در پیشخانِ کافه‌بازار (محصولِ «اشتراکی»). خریدِ بازار SKU را فقط از اینجا می‌گیرد،
# هرگز از کلاینت: وگرنه توکنِ ارزان‌ترین محصول اشتراکِ گران را فعال می‌کرد. خالی یعنی این اشتراک در بازار
# فروخته نمی‌شود (درخواستش ۴۰۰ می‌گیرد).
bazaar_sku = models.CharField(
max_length=100, blank=True, default='',
help_text='SKUِ محصولِ درون‌برنامه‌ای در پیشخانِ کافه‌بازار؛ خالی = در بازار فروخته نمی‌شود.')
@@ -86,45 +77,3 @@ class Subscription(models.Model):
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}'