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:
@@ -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}'
|
||||
|
||||
Reference in New Issue
Block a user