Subscriptions only: Bazaar panel prices (598/898/1398k), assistant quota 5/3/3 from a cost calculation, packs off sale

- subscription.0016: prices equal to the Bazaar panel (senior), ai_daily_quota 5/3/3, card features; packs on_sale=False
  (one paid pack order, user 26, keeps its credits; pack paths stay for that order and for admin).
- Quota math in docs/ai-cost-and-guardrails.md: ~77% of a Bazaar sale reaches us; worst case (full quota every day)
  the assistant costs 23/27/35% of it.
- Assistant quota message points to the subscription, not packs.
982 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 19:57:16 +03:30
parent c0fa46be44
commit 9c3adc3e60
13 changed files with 95 additions and 40 deletions

View File

@@ -453,10 +453,12 @@ anyone who can read this repo can forge a token for any user.**
## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`)
- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یک‌ماهه، به گفته‌ی مالک)، `10343` (۹۰ روزه)،
`10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلن‌ها (`subscription.0014`، `0015`). **بسته‌ها در بازار نیستند**
و کاشیِ بازارشان غیرفعال است. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم).
- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یک‌ماهه، تأییدِ سینیور)، `10343` (۹۰ روزه)،
`10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلن‌ها (`subscription.0014`، `0015`). بسته‌ها دیگر فروخته نمی‌شوند. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم).
- **روش‌های پرداخت** در `payment/providers.py` (Strategy + factory)؛ تأییدِ خودِ پرداخت (کال‌بک/verify) عمداً بیرونش.
- **فقط اشتراک** (`subscription.0016`، سینیور): یک/سه/شش‌ماهه ۵۹۸/۸۹۸/۱٬۳۹۸ هزار تومان (= پیشخانِ بازار به گفته‌ی
سینیور)، سقفِ دستیار ۵/۳/۳ در روز؛ حساب در `docs/ai-cost-and-guardrails.md` «اشتراک‌ها». بسته‌ها `on_sale=False`
(کاربرِ ۲۶ یک بسته‌ی پرداخت‌شده و ۲۰ سوال اعتبار دارد). هر کارتِ خرید مزیت‌ها و سقفِ دستیار را خودش می‌گوید.
- **مسیر** و تمدیدِ خودکار: `docs/operations.md` «فروش در کافه‌بازار». `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور است (بکاپ:
`.deploy-backups/env-before-bazaar-dynamic-20261012-175336`)؛ توکن فقط وقتی کدِ تخفیف قیمت را کم کرده می‌رود.
- **فرانت:** دو کاشیِ «کارتِ بانکی» (پیش‌فرض) و «کافه‌بازار» همیشه کنارِ هم؛ بازار جایی که ممکن نیست غیرفعال با دلیل.
@@ -573,10 +575,7 @@ anyone who can read this repo can forge a token for any user.**
- **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بی‌اشتراکِ فعال، سوال‌های درس،
آزمونِ جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` می‌دهند؛ `/api/home` برای هر فصل
`requiresSubscription` دارد. تست: `content/tests_paywall.py`.
- **اشتراک (`subscription.0013`):** یک‌ماهه ۴۹۸، سه‌ماهه ۷۹۸، شش‌ماهه ۱٬۲۹۸ هزار تومان؛ همه‌ی فصل‌ها، قلبِ نامحدود و
۳ سوال در روز از دستیار. **دستیار جدا فروخته می‌شود** (بسته‌های سوال روی فروش ماندند). رایگان ۲ سوال در روز
(`AI_DAILY_QUOTA_FREE`). حساب: بدترین حالتِ شش‌ماهه با سقفِ ۳ ≈ ۲۹٪ِ سهمِ ماهانه‌ی قیمت؛ با سقفِ ۸ ≈ ۷۸٪ بود.
- **اشتراک داخلِ اپِ بازار خریدنی نیست** (SKU ندارد، `PurchaseView` فقط بسته را از بازار می‌فروشد).
- قیمت‌ها و سقفِ دستیار از `subscription.0016` (بخشِ کافه‌بازار بالا). رایگان ۲ سوال در روز (`AI_DAILY_QUOTA_FREE`).
- حساب‌های دمو اشتراک ندارند، پس از فصلِ دوم به بعد برایشان قفل است.
- از سرور، `git fetch` از Gitea در این روز کار کرد (بی `DEPLOY_BUNDLE`).

View File

@@ -30,7 +30,7 @@ class DailyQuotaTests(TestCase):
self.assertEqual([self.ask().status_code for _ in range(2)], [200, 200])
blocked = self.ask()
self.assertEqual(blocked.status_code, 429)
self.assertIn('بسته', blocked.json()['detail'])
self.assertIn('اشتراک', blocked.json()['detail']) # بسته از subscription.0016 فروخته نمی‌شود
def test_remaining_quota_follows_the_users_own_limit(self):
self.assertEqual(self.ask().json()['remainingQuota'], 1)

View File

@@ -360,9 +360,10 @@ class AskAIView(APIView):
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
raise Throttled(detail=(
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. با خریدِ بسته‌ی سوال همین الان ادامه بده.'
# بسته‌ی سوال از subscription.0016 فروخته نمی‌شود؛ مشترک تا فردا صبر می‌کند، بی‌اشتراک اشتراک می‌خرد.
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. فردا دوباره می‌تونی بپرسی.'
if has_active_subscription(user.id) else
f'سوال‌های رایگانِ امروز ({n} سوال) تمام شد. با خریدِ بسته‌ی سوال همین الان ادامه بده.'
f'سوال‌های رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری بپرس.'
))
conversation_id = v.get('conversationId')

View File

@@ -33,27 +33,30 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa
.annotate(p=Sum('prompt_tokens'), c=Sum('completion_tokens')) # سنت = (0.4p + 1.6c) / 1e4
```
## بسته‌ی سوال و سقفِ روزانه (از ۲۰۲۶-۱۰-۰۲)
## اشتراک‌ها و سقفِ روزانه‌ی دستیار
اشتراکِ ماهانه/سالانه کنار رفت و **بسته‌ی سوال** جایش آمد (`subscription.0009`، `subscription.0010`):
فقط اشتراک فروخته می‌شود (`subscription.0016`، تصمیمِ سینیور). قیمت‌ها همان قیمتِ پیشخانِ بازار است و کاربر در هر دو
راهِ پرداخت همین را می‌بیند. بسته‌های سوال `on_sale=False` هستند؛ اعتبارِ کسی که قبلاً خریده (کاربرِ ۲۶، ۲۰ سوال)
همچنان خرج می‌شود.
| بسته | قیمت (تومان) | هدیه | سود اگر کامل خرج شود | حاشیه‌ی سود |
**فرض‌ها:** هر سوال ~۷۰۰ تومان (دستِ بالا، بخشِ بالا). از هر خریدِ بازار اول ۱۰٪ ارزشِ افزوده کم می‌شود، از باقی ۸۵٪
سهمِ ماست (زیرِ یک میلیارد تومان فروشِ سالانه؛ بالاترش ۷۰٪)، و ۱۲۰ تومان کارمزد (سندِ «محاسبه سهم توسعه‌دهنده در
بازار»). یعنی **~۷۷٪ِ قیمت** می‌رسد. زرین‌پال بی‌سهمِ بازار است (~۹۰٪، کارمزدش را نسنجیده‌ایم)، پس بازار بدترین حالت است.
| اشتراک | قیمت (تومان) | خالصِ بازار در روز | سقفِ دستیار | هزینه‌ی دستیار اگر هر روز کلِ سقف را بپرسد |
|---|---|---|---|---|
| ۲۰ سوال | ۴۵٬۰۰۰ | ۷ روز قلبِ نامحدود | ۲۴٬۰۰۰ | ۶۳٪ |
| ۶۰ سوال | ۱۱۹٬۰۰۰ | ۳۰ روز | ۵۹٬۰۰۰ | ۵۸٪ |
| ۱۵۰ سوال | ۲۵۹٬۰۰۰ | ۶۰ روز | ۱۱۵٬۰۰۰ | ۵۲٪ |
| یک‌ماهه | ۵۹۸٬۰۰۰ | ~۱۵٬۴۰۰ | ۵ در روز | ۳٬۵۰۰ در روز، ۲۳٪ |
| سه‌ماهه | ۸۹۸٬۰۰۰ | ~۷٬۷۰۰ | ۳ در روز | ۲٬۱۰۰ در روز، ۲۷٪ |
| شش‌ماهه | ۱٬۳۹۸٬۰۰۰ | ~۶٬۰۰۰ | ۳ در روز | ۲٬۱۰۰ در روز، ۳۵٪ |
با ۷۰۰ تومان برای هر سوال (دستِ بالا) و پس از ۱۵٪یِ کافه‌بازار؛ اگر همه دبیرستانی باشند ۴۸ تا ۶۰٪. چرا بسته: همه هر روز نمی‌آیند، ۱۷۹ هزار تومان در ماه برای خیلی‌ها زیاد
بود، و مشترکِ پرکار زیان می‌ساخت. با بسته هر سوال جدا سود دارد. مسیرِ قیمت‌ها پیش از این:
- سالانه ۲۹۹ هزار تومان، با ۱۵ سوال در روز برای همه.
- ماهانه/سالانه ۹۹ و ۹۹۰ هزار تومان، با ۵ سوال در روز.
- ماهانه/سالانه ۱۷۹ و ۱٬۷۹۰ هزار تومان، با ۱۰ سوال در روز.
پس حتی اگر هر مشترک هر روز تا آخرِ سقف بپرسد، دستِ‌کم ۶۵٪ِ درآمدِ بازار می‌ماند (هزینه‌ی سرور جدا). مصرفِ واقعی کمتر
است: روی سرور (فقط دمو و آزمونگرها، ۲۲ روز‌ـکاربر) میانه‌ی روزهایی که کسی پرسید ۳ سوال بود، و همه هر روز نمی‌آیند؛ با
کاربرِ واقعی دوباره بسنجید (پرس‌وجوی بالا). هر ردیف با دو برابر شدنِ سقف تقریباً دو برابر می‌شود: شش‌ماهه با ۵ در روز ۵۸٪.
سقف در ادمین (`Plan.ai_daily_quota`) عوض می‌شود و کارتِ خرید آن را از همان عدد نشان می‌دهد.
پلن‌های اشتراکی پاک نشدند (`on_sale=False`) تا مشترکِ فعلی سقفش را نگه دارد.
- **ترتیبِ خرج:** اول سقفِ روزانه (بی‌اشتراک `AI_DAILY_QUOTA_FREE`، پیش‌فرض ۲؛ مشترک `Plan.ai_daily_quota`،
برای یک/سه/شش‌ماهه ۳)، بعد اعتبارِ بسته. اشتراک محتوا و قلب می‌فروشد؛ سوالِ بیشتر از دستیار فقط با بسته.
بدترین حالتِ مشترکِ شش‌ماهه با سقفِ ۳: ~۶۳ هزار تومان در ماه، ۲۹٪ِ سهمِ ماهانه‌ی قیمت.
- **ترتیبِ خرج:** اول سقفِ روزانه (بی‌اشتراک `AI_DAILY_QUOTA_FREE`، پیش‌فرض ۲؛ مشترک `Plan.ai_daily_quota`)، بعد
اعتبارِ بسته‌ی قدیمی اگر مانده. سوالِ رایگانِ بی‌اشتراک‌ها هزینه‌ی خالص است: ۲ × ۷۰۰ = ۱٬۴۰۰ تومان برای هر کاربرِ
فعال در روز.
- **چه چیزی خرج می‌شود:** هر سوالی که به API برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل. ردِ صافیِ سرور،
جوابِ کش‌شده و جوابی که ساخته نشد چیزی کم نمی‌کنند. `Message.charge` روی هر پیامِ کاربر می‌گوید
سوال از کجا پرداخت شد (`free` / `credit` / `none`).

View File

@@ -278,11 +278,12 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
«پرداخت‌ها و فاکتورها» می‌بیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
است و تست‌هایش در `payment/tests_refund.py`.
## فروش در کافه‌بازار (اشتراک و بسته)
## فروش در کافه‌بازار
اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در
برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند و کاربر هر کدام را بخواهد می‌زند: «کارتِ بانکی» (زرین‌پال، پیش‌فرض) و
«کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد (الان همه‌ی بسته‌ها)، کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. داخلِ اپ
«کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد، کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. فقط اشتراک فروخته می‌شود
(بسته‌ها `on_sale=False`، `subscription.0016`)؛ مسیرِ بسته (`inapp`، consume) برای روزی که ادمین دوباره روشنش کند مانده. داخلِ اپ
زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمی‌دهد).
تفاوتِ روش‌ها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش
@@ -290,9 +291,9 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
| چه | کجا | الان |
|---|---|---|
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | یک‌ماهه `10342` (به گفته‌ی مالک)، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ها هنوز تعریف نشده‌اند |
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`)؛ بسته‌ای که در پیشخان تعریف شد، SKUاش این‌جا نوشته شود |
| قیمت | بازار قیمتِ پیشخان را می‌گیرد؛ ادمین باید با آن یکی باشد | داخلِ اپ قیمتِ بازار روی کاشی دیده می‌شود؛ برابری‌اش **سنجیده نشده** |
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344` (تأییدِ سینیور) |
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`) |
| قیمت | بازار قیمتِ پیشخان را می‌گیرد؛ ادمین باید با آن یکی باشد | ۵۹۸/۸۹۸/۱٬۳۹۸ هزار تومان در هر دو (`subscription.0016`، به گفته‌ی سینیور)؛ داخلِ اپ قیمتِ بازار روی کاشی دیده می‌شود |
| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار می‌کند |
| کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد |

View File

@@ -25,6 +25,7 @@ from .models import Order
@override_settings(INTERNAL_SERVICE_KEY='internal-test-key')
class BazaarPackTests(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007101')
self.client = APIClient()
@@ -73,6 +74,7 @@ class BazaarSkuComesFromTheOrderTests(TestCase):
"""تأییدِ بازار SKU را فقط از سفارش می‌گیرد، نه از کلاینت (۲۰۲۶-۱۰-۰۳)."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007102')
self.client = APIClient()
@@ -104,6 +106,7 @@ class BazaarPurchaseFromTheAppTests(TestCase):
"""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007103')
self.client = APIClient()

View File

@@ -59,6 +59,7 @@ class BazaarSubscriptionTests(TestCase):
def test_plan_without_sku_is_refused_for_bazaar(self):
pack = Plan.objects.get(kind='pack', question_credits=20)
Plan.objects.filter(pk=pack.pk).update(on_sale=True) # بسته‌ها از subscription.0016 فروشی نیستند
self.assertEqual(self.buy(pack).status_code, 400)
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
@@ -82,6 +83,7 @@ class BazaarSubscriptionTests(TestCase):
def test_pack_verify_asks_for_inapp(self):
pack = Plan.objects.get(kind='pack', question_credits=20)
pack.bazaar_sku = 'pack20'
pack.on_sale = True
pack.save()
body = self.buy(pack).json()
self.assertEqual(body['bazaarProductType'], 'inapp')

View File

@@ -26,6 +26,7 @@ from .services import OrderStateError, apply_order_payment_result, cancel_pendin
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120007401')
self.client_app = APIClient()
@@ -141,6 +142,7 @@ class CancelServiceTests(_Base):
class OrderAdminTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only')
self.admin = self.client

View File

@@ -29,6 +29,7 @@ def client_for(user):
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120009901')
self.app = client_for(self.user)
@@ -46,6 +47,7 @@ class SupervisorCannotRunMoneyActionsTests(_Base):
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بی‌permissions برای هر بیننده‌ی فهرست اجرا می‌شد."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
call_command('setup_supervisor_group', verbosity=0)
staff = get_user_model().objects.create_user('sup', 'sup@example.com', 'pw-for-tests-only', is_staff=True)
@@ -93,6 +95,7 @@ class PlanEditedBetweenOrderAndPaymentTests(_Base):
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
class BazaarSecondTokenOnPaidOrderTests(_Base):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
@@ -125,6 +128,7 @@ class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت می‌کرد و قیدِ یکتا همان خرید را برای همیشه می‌سوزاند."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-p20')
self.big = Plan.objects.filter(kind='pack').exclude(pk=self.pack.pk).order_by('-price').first()
@@ -168,6 +172,7 @@ class OrderAdminMoneyFieldsReadOnlyTests(_Base):
"""اجازه‌ی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز می‌کرد: مبلغ و تعدادِ سوالِ سفارش ویرایش‌شدنی بود."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
from django.contrib.auth.models import Permission

View File

@@ -0,0 +1,38 @@
"""
فقط اشتراک (تصمیمِ سینیور، ۲۰۲۶-۱۰-۱۲): بسته‌های سوال از فروش برمی‌دارند، قیمت‌ها همان قیمتِ پیشخانِ بازار.
- قیمت: یک‌ماهه ۵۹۸، سه‌ماهه ۸۹۸، شش‌ماهه ۱٬۳۹۸ هزار تومان (به گفته‌ی سینیور همان قیمتِ پیشخان؛ ما پیشخان را ندیده‌ایم).
- سقفِ دستیار در روز: یک‌ماهه ۵، سه‌ماهه و شش‌ماهه ۳. حساب (docs/ai-cost-and-guardrails.md «اشتراک‌ها»): هر سوال ~۷۰۰ تومان
(دستِ بالا)، و از هر خریدِ بازار پس از ارزشِ افزوده و سهمِ ۱۵٪ِ بازار ~۷۷٪ می‌رسد. اگر کسی هر روز کلِ سقف را بپرسد، هزینه‌ی
دستیار ۲۳٪، ۲۷٪ و ۳۵٪ِ آن می‌شود. ادمین می‌تواند عوضش کند.
- مزیت‌های کارت: دو خطِ ثابت؛ خطِ دستیار را فرانت از `aiDailyQuota` می‌سازد تا با سقفِ واقعی جدا نیفتد.
- بسته‌ها on_sale=False، نه حذف: یک خریدِ واقعی (کاربرِ ۲۶) و اعتبارِ مانده‌اش به آن‌ها بسته است و بازپرداخت از سفارش می‌خواند.
پلن‌ها با مدت پیدا می‌شوند (idها بین دیتابیس‌ها فرق دارند)؛ اگر دقیقاً یکی نباشد، migration می‌شکند.
"""
from django.core.cache import cache
from django.db import migrations
PLANS = {30: (598000, 5), 90: (898000, 3), 180: (1398000, 3)}
FEATURES = ['همه‌ی فصل‌ها و جزیره‌ها باز', 'قلبِ نامحدود']
def forward(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
for days, (price, quota) in PLANS.items():
plans = list(Plan.objects.filter(kind='subscription', on_sale=True, duration_days=days))
if len(plans) != 1:
raise RuntimeError(f'باید دقیقاً یک اشتراکِ فروشیِ {days} روزه باشد، {len(plans)} تا پیدا شد.')
plan = plans[0]
plan.price = price
plan.ai_daily_quota = quota
plan.features = FEATURES
plan.save(update_fields=['price', 'ai_daily_quota', 'features'])
Plan.objects.filter(kind='pack').update(on_sale=False)
cache.delete('subscription:plans:v3') # migration سیگنالِ post_save را نمی‌زند (0014)
class Migration(migrations.Migration):
dependencies = [('subscription', '0015_bazaar_sku_monthly')]
# بازگشت ندارد: قیمت و سقفِ قبلی تصمیمِ کهنه‌اند، نه حالتی که بخواهیم به آن برگردیم.
operations = [migrations.RunPython(forward, migrations.RunPython.noop)]

View File

@@ -500,17 +500,15 @@ class YearlyPlanSeedTests(TestCase):
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
self.assertEqual(Plan.objects.filter(duration_days=30, on_sale=False).count(), 1)
def test_packs_and_new_subscriptions_are_on_sale(self):
def test_only_subscriptions_are_on_sale(self):
from django.core.cache import cache
cache.clear()
data = self.client.get('/api/subscription/plans').json()
packs = [p for p in data['plans'] if p['kind'] == 'pack']
subs = [p for p in data['plans'] if p['kind'] == 'subscription']
self.assertEqual([p['questionCredits'] for p in packs], [20, 60, 150])
self.assertEqual([p['heartsDays'] for p in packs], [7, 30, 60])
# subscription.0013: یک/سه/شش‌ماهه با سقفِ ۳ سوال در روز.
self.assertEqual([(p['durationDays'], p['price'], p['aiDailyQuota']) for p in subs],
[(30, 498_000, 3), (90, 798_000, 3), (180, 1_298_000, 3)])
# subscription.0016: بسته‌ها فروشی نیستند؛ قیمت‌ها همان پیشخانِ بازار؛ سقفِ دستیار ۵/۳/۳.
self.assertEqual({p['kind'] for p in data['plans']}, {'subscription'})
self.assertEqual([(p['durationDays'], p['price'], p['aiDailyQuota'], p['bazaarSku']) for p in data['plans']],
[(30, 598_000, 5, '10342'), (90, 898_000, 3, '10343'), (180, 1_398_000, 3, '10344')])
self.assertTrue(all(p['features'] for p in data['plans']))
self.assertEqual(data['freeAiDailyQuota'], 2)
# پلن‌های اشتراکی پاک نشده‌اند؛ فقط فروخته نمی‌شوند.
self.assertFalse(Plan.objects.get(title='اشتراک یک‌ساله').on_sale)

View File

@@ -31,6 +31,7 @@ def _client(user):
class _Base(TestCase):
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
cache.clear()
self.user = User.objects.create(phone='09120005001')
self.client = _client(self.user)
@@ -115,7 +116,7 @@ class CreditSpendingTests(_Base):
self.assertEqual(self.ask().json()['credits'], 0)
blocked = self.ask()
self.assertEqual(blocked.status_code, 429)
self.assertIn('بسته', blocked.json()['detail'])
self.assertIn('اشتراک', blocked.json()['detail']) # بسته از subscription.0016 فروخته نمی‌شود
charges = list(Message.objects.filter(role='user').order_by('id').values_list('charge', flat=True))
self.assertEqual(charges, ['free', 'credit', 'credit'])
@@ -143,6 +144,7 @@ class HeartsAreRealTests(_Base):
"""از ۲۰۲۶-۱۰-۰۲ قلبِ صفر در سرور جلوی جواب را می‌گیرد، و قلبِ هدیه‌ی بسته آن را باز می‌کند."""
def setUp(self):
Plan.objects.filter(kind='pack').update(on_sale=True) # بسته‌ها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارش‌های قبلی و ادمین می‌ماند
super().setUp()
from content.models import Choice, Grade, Lesson, Node, Question, Unit
from progress.models import UserHearts

View File

@@ -24,6 +24,7 @@ class PaidPackWithDeletedPlanTests(TestCase):
app = APIClient()
app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
pack = Plan.objects.get(kind='pack', question_credits=20)
Plan.objects.filter(pk=pack.pk).update(on_sale=True) # بسته‌ها از subscription.0016 فروشی نیستند
order = Order.objects.get(pk=app.post('/api/subscription/purchase', {'planId': str(pack.id)},
format='json').json()['orderId'])
pack.delete()