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:
13
CLAUDE.md
13
CLAUDE.md
@@ -453,10 +453,12 @@ anyone who can read this repo can forge a token for any user.**
|
|||||||
|
|
||||||
## کافهبازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بکاند `8077ab4`، فرانت `fffdeac`)
|
## کافهبازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بکاند `8077ab4`، فرانت `fffdeac`)
|
||||||
|
|
||||||
- **محصولهای پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یکماهه، به گفتهی مالک)، `10343` (۹۰ روزه)،
|
- **محصولهای پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یکماهه، تأییدِ سینیور)، `10343` (۹۰ روزه)،
|
||||||
`10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلنها (`subscription.0014`، `0015`). **بستهها در بازار نیستند**
|
`10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلنها (`subscription.0014`، `0015`). بستهها دیگر فروخته نمیشوند. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم).
|
||||||
و کاشیِ بازارشان غیرفعال است. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم).
|
|
||||||
- **روشهای پرداخت** در `payment/providers.py` (Strategy + factory)؛ تأییدِ خودِ پرداخت (کالبک/verify) عمداً بیرونش.
|
- **روشهای پرداخت** در `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` سرور است (بکاپ:
|
- **مسیر** و تمدیدِ خودکار: `docs/operations.md` «فروش در کافهبازار». `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور است (بکاپ:
|
||||||
`.deploy-backups/env-before-bazaar-dynamic-20261012-175336`)؛ توکن فقط وقتی کدِ تخفیف قیمت را کم کرده میرود.
|
`.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`). بیرون از آن، بیاشتراکِ فعال، سوالهای درس،
|
- **فقط فصلِ اولِ هر پایه رایگان است** (`content.views.unit_is_free`). بیرون از آن، بیاشتراکِ فعال، سوالهای درس،
|
||||||
آزمونِ جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` میدهند؛ `/api/home` برای هر فصل
|
آزمونِ جزیره، ثبتِ جواب و پایانِ درس/جزیره ۴۰۲ `subscription_required` میدهند؛ `/api/home` برای هر فصل
|
||||||
`requiresSubscription` دارد. تست: `content/tests_paywall.py`.
|
`requiresSubscription` دارد. تست: `content/tests_paywall.py`.
|
||||||
- **اشتراک (`subscription.0013`):** یکماهه ۴۹۸، سهماهه ۷۹۸، ششماهه ۱٬۲۹۸ هزار تومان؛ همهی فصلها، قلبِ نامحدود و
|
- قیمتها و سقفِ دستیار از `subscription.0016` (بخشِ کافهبازار بالا). رایگان ۲ سوال در روز (`AI_DAILY_QUOTA_FREE`).
|
||||||
۳ سوال در روز از دستیار. **دستیار جدا فروخته میشود** (بستههای سوال روی فروش ماندند). رایگان ۲ سوال در روز
|
|
||||||
(`AI_DAILY_QUOTA_FREE`). حساب: بدترین حالتِ ششماهه با سقفِ ۳ ≈ ۲۹٪ِ سهمِ ماهانهی قیمت؛ با سقفِ ۸ ≈ ۷۸٪ بود.
|
|
||||||
- **اشتراک داخلِ اپِ بازار خریدنی نیست** (SKU ندارد، `PurchaseView` فقط بسته را از بازار میفروشد).
|
|
||||||
- حسابهای دمو اشتراک ندارند، پس از فصلِ دوم به بعد برایشان قفل است.
|
- حسابهای دمو اشتراک ندارند، پس از فصلِ دوم به بعد برایشان قفل است.
|
||||||
- از سرور، `git fetch` از Gitea در این روز کار کرد (بی `DEPLOY_BUNDLE`).
|
- از سرور، `git fetch` از Gitea در این روز کار کرد (بی `DEPLOY_BUNDLE`).
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ class DailyQuotaTests(TestCase):
|
|||||||
self.assertEqual([self.ask().status_code for _ in range(2)], [200, 200])
|
self.assertEqual([self.ask().status_code for _ in range(2)], [200, 200])
|
||||||
blocked = self.ask()
|
blocked = self.ask()
|
||||||
self.assertEqual(blocked.status_code, 429)
|
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):
|
def test_remaining_quota_follows_the_users_own_limit(self):
|
||||||
self.assertEqual(self.ask().json()['remainingQuota'], 1)
|
self.assertEqual(self.ask().json()['remainingQuota'], 1)
|
||||||
|
|||||||
@@ -360,9 +360,10 @@ class AskAIView(APIView):
|
|||||||
|
|
||||||
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
|
n = str(quota).translate(str.maketrans('0123456789', '۰۱۲۳۴۵۶۷۸۹'))
|
||||||
raise Throttled(detail=(
|
raise Throttled(detail=(
|
||||||
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. با خریدِ بستهی سوال همین الان ادامه بده.'
|
# بستهی سوال از subscription.0016 فروخته نمیشود؛ مشترک تا فردا صبر میکند، بیاشتراک اشتراک میخرد.
|
||||||
|
f'سقفِ امروزِ سوال از دستیار ({n} سوال) پر شد. فردا دوباره میتونی بپرسی.'
|
||||||
if has_active_subscription(user.id) else
|
if has_active_subscription(user.id) else
|
||||||
f'سوالهای رایگانِ امروز ({n} سوال) تمام شد. با خریدِ بستهی سوال همین الان ادامه بده.'
|
f'سوالهای رایگانِ امروز ({n} سوال) تمام شد. با خریدِ اشتراک هر روز سوالِ بیشتری بپرس.'
|
||||||
))
|
))
|
||||||
|
|
||||||
conversation_id = v.get('conversationId')
|
conversation_id = v.get('conversationId')
|
||||||
|
|||||||
@@ -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
|
.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 برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل. ردِ صافیِ سرور،
|
- **چه چیزی خرج میشود:** هر سوالی که به API برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل. ردِ صافیِ سرور،
|
||||||
جوابِ کششده و جوابی که ساخته نشد چیزی کم نمیکنند. `Message.charge` روی هر پیامِ کاربر میگوید
|
جوابِ کششده و جوابی که ساخته نشد چیزی کم نمیکنند. `Message.charge` روی هر پیامِ کاربر میگوید
|
||||||
سوال از کجا پرداخت شد (`free` / `credit` / `none`).
|
سوال از کجا پرداخت شد (`free` / `credit` / `none`).
|
||||||
|
|||||||
@@ -278,11 +278,12 @@ callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
|
|||||||
«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
|
«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
|
||||||
است و تستهایش در `payment/tests_refund.py`.
|
است و تستهایش در `payment/tests_refund.py`.
|
||||||
|
|
||||||
## فروش در کافهبازار (اشتراک و بسته)
|
## فروش در کافهبازار
|
||||||
|
|
||||||
اپِ اندروید (پوستهی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درونبرنامهایِ بازار میفروشد. در
|
اپِ اندروید (پوستهی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درونبرنامهایِ بازار میفروشد. در
|
||||||
برگهی پرداخت دو کاشی همیشه کنارِ هماند و کاربر هر کدام را بخواهد میزند: «کارتِ بانکی» (زرینپال، پیشفرض) و
|
برگهی پرداخت دو کاشی همیشه کنارِ هماند و کاربر هر کدام را بخواهد میزند: «کارتِ بانکی» (زرینپال، پیشفرض) و
|
||||||
«کافهبازار». در مرورگر، یا وقتی پلن SKU ندارد (الان همهی بستهها)، کاشیِ بازار غیرفعال است و دلیلش را میگوید. داخلِ اپ
|
«کافهبازار». در مرورگر، یا وقتی پلن SKU ندارد، کاشیِ بازار غیرفعال است و دلیلش را میگوید. فقط اشتراک فروخته میشود
|
||||||
|
(بستهها `on_sale=False`، `subscription.0016`)؛ مسیرِ بسته (`inapp`، consume) برای روزی که ادمین دوباره روشنش کند مانده. داخلِ اپ
|
||||||
زیرِ «کافهبازار» قیمتی میآید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمیدهد).
|
زیرِ «کافهبازار» قیمتی میآید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمیدهد).
|
||||||
|
|
||||||
تفاوتِ روشها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش
|
تفاوتِ روشها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش
|
||||||
@@ -290,9 +291,9 @@ callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
|
|||||||
|
|
||||||
| چه | کجا | الان |
|
| چه | کجا | الان |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید میکند)، بسته = «مصرفشدنی» | یکماهه `10342` (به گفتهی مالک)، سهماهه `10343`، ششماهه `10344`؛ بستهها هنوز تعریف نشدهاند |
|
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید میکند)، بسته = «مصرفشدنی» | یکماهه `10342`، سهماهه `10343`، ششماهه `10344` (تأییدِ سینیور) |
|
||||||
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از اینجا میگیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`)؛ بستهای که در پیشخان تعریف شد، SKUاش اینجا نوشته شود |
|
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از اینجا میگیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`) |
|
||||||
| قیمت | بازار قیمتِ پیشخان را میگیرد؛ ادمین باید با آن یکی باشد | داخلِ اپ قیمتِ بازار روی کاشی دیده میشود؛ برابریاش **سنجیده نشده** |
|
| قیمت | بازار قیمتِ پیشخان را میگیرد؛ ادمین باید با آن یکی باشد | ۵۹۸/۸۹۸/۱٬۳۹۸ هزار تومان در هر دو (`subscription.0016`، به گفتهی سینیور)؛ داخلِ اپ قیمتِ بازار روی کاشی دیده میشود |
|
||||||
| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار میکند |
|
| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار میکند |
|
||||||
| کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد |
|
| کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد |
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ from .models import Order
|
|||||||
@override_settings(INTERNAL_SERVICE_KEY='internal-test-key')
|
@override_settings(INTERNAL_SERVICE_KEY='internal-test-key')
|
||||||
class BazaarPackTests(TestCase):
|
class BazaarPackTests(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.user = User.objects.create(phone='09120007101')
|
self.user = User.objects.create(phone='09120007101')
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
@@ -73,6 +74,7 @@ class BazaarSkuComesFromTheOrderTests(TestCase):
|
|||||||
"""تأییدِ بازار SKU را فقط از سفارش میگیرد، نه از کلاینت (۲۰۲۶-۱۰-۰۳)."""
|
"""تأییدِ بازار SKU را فقط از سفارش میگیرد، نه از کلاینت (۲۰۲۶-۱۰-۰۳)."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.user = User.objects.create(phone='09120007102')
|
self.user = User.objects.create(phone='09120007102')
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
@@ -104,6 +106,7 @@ class BazaarPurchaseFromTheAppTests(TestCase):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.user = User.objects.create(phone='09120007103')
|
self.user = User.objects.create(phone='09120007103')
|
||||||
self.client = APIClient()
|
self.client = APIClient()
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ class BazaarSubscriptionTests(TestCase):
|
|||||||
|
|
||||||
def test_plan_without_sku_is_refused_for_bazaar(self):
|
def test_plan_without_sku_is_refused_for_bazaar(self):
|
||||||
pack = Plan.objects.get(kind='pack', question_credits=20)
|
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.assertEqual(self.buy(pack).status_code, 400)
|
||||||
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
|
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):
|
def test_pack_verify_asks_for_inapp(self):
|
||||||
pack = Plan.objects.get(kind='pack', question_credits=20)
|
pack = Plan.objects.get(kind='pack', question_credits=20)
|
||||||
pack.bazaar_sku = 'pack20'
|
pack.bazaar_sku = 'pack20'
|
||||||
|
pack.on_sale = True
|
||||||
pack.save()
|
pack.save()
|
||||||
body = self.buy(pack).json()
|
body = self.buy(pack).json()
|
||||||
self.assertEqual(body['bazaarProductType'], 'inapp')
|
self.assertEqual(body['bazaarProductType'], 'inapp')
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from .services import OrderStateError, apply_order_payment_result, cancel_pendin
|
|||||||
|
|
||||||
class _Base(TestCase):
|
class _Base(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.user = User.objects.create(phone='09120007401')
|
self.user = User.objects.create(phone='09120007401')
|
||||||
self.client_app = APIClient()
|
self.client_app = APIClient()
|
||||||
@@ -141,6 +142,7 @@ class CancelServiceTests(_Base):
|
|||||||
|
|
||||||
class OrderAdminTests(_Base):
|
class OrderAdminTests(_Base):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
super().setUp()
|
super().setUp()
|
||||||
staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only')
|
staff = get_user_model().objects.create_superuser('admin', 'a@example.com', 'pw-for-tests-only')
|
||||||
self.admin = self.client
|
self.admin = self.client
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ def client_for(user):
|
|||||||
|
|
||||||
class _Base(TestCase):
|
class _Base(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.user = User.objects.create(phone='09120009901')
|
self.user = User.objects.create(phone='09120009901')
|
||||||
self.app = client_for(self.user)
|
self.app = client_for(self.user)
|
||||||
@@ -46,6 +47,7 @@ class SupervisorCannotRunMoneyActionsTests(_Base):
|
|||||||
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بیpermissions برای هر بینندهی فهرست اجرا میشد."""
|
"""گروهِ Supervisor فقط view_order دارد؛ actionِ بیpermissions برای هر بینندهی فهرست اجرا میشد."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
super().setUp()
|
super().setUp()
|
||||||
call_command('setup_supervisor_group', verbosity=0)
|
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)
|
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')
|
@override_settings(BAZAAR_PISHKHAN_API_SECRET='x', BAZAAR_PACKAGE_NAME='com.example')
|
||||||
class BazaarSecondTokenOnPaidOrderTests(_Base):
|
class BazaarSecondTokenOnPaidOrderTests(_Base):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
super().setUp()
|
super().setUp()
|
||||||
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
|
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-pack20')
|
||||||
|
|
||||||
@@ -125,6 +128,7 @@ class BazaarRejectedVerifyDoesNotBurnTokenTests(_Base):
|
|||||||
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت میکرد و قیدِ یکتا همان خرید را برای همیشه میسوزاند."""
|
"""ردِ بازار (not_found) توکن را روی تراکنشِ «failed» ثبت میکرد و قیدِ یکتا همان خرید را برای همیشه میسوزاند."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
super().setUp()
|
super().setUp()
|
||||||
Plan.objects.filter(pk=self.pack.pk).update(bazaar_sku='sec-p20')
|
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()
|
self.big = Plan.objects.filter(kind='pack').exclude(pk=self.pack.pk).order_by('-price').first()
|
||||||
@@ -168,6 +172,7 @@ class OrderAdminMoneyFieldsReadOnlyTests(_Base):
|
|||||||
"""اجازهی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز میکرد: مبلغ و تعدادِ سوالِ سفارش ویرایششدنی بود."""
|
"""اجازهی change (لازمِ لغو/بازپرداخت) فرمِ سفارش را هم باز میکرد: مبلغ و تعدادِ سوالِ سفارش ویرایششدنی بود."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
super().setUp()
|
super().setUp()
|
||||||
from django.contrib.auth.models import Permission
|
from django.contrib.auth.models import Permission
|
||||||
|
|
||||||
|
|||||||
38
subscription/migrations/0016_subscriptions_only_2026_10.py
Normal file
38
subscription/migrations/0016_subscriptions_only_2026_10.py
Normal 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)]
|
||||||
@@ -500,17 +500,15 @@ class YearlyPlanSeedTests(TestCase):
|
|||||||
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
|
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
|
||||||
self.assertEqual(Plan.objects.filter(duration_days=30, on_sale=False).count(), 1)
|
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
|
from django.core.cache import cache
|
||||||
cache.clear()
|
cache.clear()
|
||||||
data = self.client.get('/api/subscription/plans').json()
|
data = self.client.get('/api/subscription/plans').json()
|
||||||
packs = [p for p in data['plans'] if p['kind'] == 'pack']
|
# subscription.0016: بستهها فروشی نیستند؛ قیمتها همان پیشخانِ بازار؛ سقفِ دستیار ۵/۳/۳.
|
||||||
subs = [p for p in data['plans'] if p['kind'] == 'subscription']
|
self.assertEqual({p['kind'] for p in data['plans']}, {'subscription'})
|
||||||
self.assertEqual([p['questionCredits'] for p in packs], [20, 60, 150])
|
self.assertEqual([(p['durationDays'], p['price'], p['aiDailyQuota'], p['bazaarSku']) for p in data['plans']],
|
||||||
self.assertEqual([p['heartsDays'] for p in packs], [7, 30, 60])
|
[(30, 598_000, 5, '10342'), (90, 898_000, 3, '10343'), (180, 1_398_000, 3, '10344')])
|
||||||
# subscription.0013: یک/سه/ششماهه با سقفِ ۳ سوال در روز.
|
self.assertTrue(all(p['features'] for p in data['plans']))
|
||||||
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)])
|
|
||||||
self.assertEqual(data['freeAiDailyQuota'], 2)
|
self.assertEqual(data['freeAiDailyQuota'], 2)
|
||||||
# پلنهای اشتراکی پاک نشدهاند؛ فقط فروخته نمیشوند.
|
# پلنهای اشتراکی پاک نشدهاند؛ فقط فروخته نمیشوند.
|
||||||
self.assertFalse(Plan.objects.get(title='اشتراک یکساله').on_sale)
|
self.assertFalse(Plan.objects.get(title='اشتراک یکساله').on_sale)
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ def _client(user):
|
|||||||
|
|
||||||
class _Base(TestCase):
|
class _Base(TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
cache.clear()
|
cache.clear()
|
||||||
self.user = User.objects.create(phone='09120005001')
|
self.user = User.objects.create(phone='09120005001')
|
||||||
self.client = _client(self.user)
|
self.client = _client(self.user)
|
||||||
@@ -115,7 +116,7 @@ class CreditSpendingTests(_Base):
|
|||||||
self.assertEqual(self.ask().json()['credits'], 0)
|
self.assertEqual(self.ask().json()['credits'], 0)
|
||||||
blocked = self.ask()
|
blocked = self.ask()
|
||||||
self.assertEqual(blocked.status_code, 429)
|
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))
|
charges = list(Message.objects.filter(role='user').order_by('id').values_list('charge', flat=True))
|
||||||
self.assertEqual(charges, ['free', 'credit', 'credit'])
|
self.assertEqual(charges, ['free', 'credit', 'credit'])
|
||||||
|
|
||||||
@@ -143,6 +144,7 @@ class HeartsAreRealTests(_Base):
|
|||||||
"""از ۲۰۲۶-۱۰-۰۲ قلبِ صفر در سرور جلوی جواب را میگیرد، و قلبِ هدیهی بسته آن را باز میکند."""
|
"""از ۲۰۲۶-۱۰-۰۲ قلبِ صفر در سرور جلوی جواب را میگیرد، و قلبِ هدیهی بسته آن را باز میکند."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
Plan.objects.filter(kind='pack').update(on_sale=True) # بستهها از ۲۰۲۶-۱۰-۱۲ فروشی نیستند (subscription.0016)؛ مسیرشان برای سفارشهای قبلی و ادمین میماند
|
||||||
super().setUp()
|
super().setUp()
|
||||||
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
from content.models import Choice, Grade, Lesson, Node, Question, Unit
|
||||||
from progress.models import UserHearts
|
from progress.models import UserHearts
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class PaidPackWithDeletedPlanTests(TestCase):
|
|||||||
app = APIClient()
|
app = APIClient()
|
||||||
app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
app.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||||
pack = Plan.objects.get(kind='pack', question_credits=20)
|
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)},
|
order = Order.objects.get(pk=app.post('/api/subscription/purchase', {'planId': str(pack.id)},
|
||||||
format='json').json()['orderId'])
|
format='json').json()['orderId'])
|
||||||
pack.delete()
|
pack.delete()
|
||||||
|
|||||||
Reference in New Issue
Block a user