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`)
|
||||
|
||||
- **محصولهای پیشخان** (پرسشِ 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`).
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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` | گذاشته شد |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
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.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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user