diff --git a/CLAUDE.md b/CLAUDE.md index 90712c5..dd6eb67 100644 --- a/CLAUDE.md +++ b/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`). diff --git a/ai_tutor/tests_quota.py b/ai_tutor/tests_quota.py index 802e8cc..32996bb 100644 --- a/ai_tutor/tests_quota.py +++ b/ai_tutor/tests_quota.py @@ -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) diff --git a/ai_tutor/views.py b/ai_tutor/views.py index 6964cd0..d0e7b25 100644 --- a/ai_tutor/views.py +++ b/ai_tutor/views.py @@ -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') diff --git a/docs/ai-cost-and-guardrails.md b/docs/ai-cost-and-guardrails.md index 71495fb..66e7bef 100644 --- a/docs/ai-cost-and-guardrails.md +++ b/docs/ai-cost-and-guardrails.md @@ -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`). diff --git a/docs/operations.md b/docs/operations.md index 803e896..b3e7783 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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` | گذاشته شد | diff --git a/payment/tests_bazaar_pack.py b/payment/tests_bazaar_pack.py index 533ce8c..110e25e 100644 --- a/payment/tests_bazaar_pack.py +++ b/payment/tests_bazaar_pack.py @@ -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() diff --git a/payment/tests_bazaar_subscription.py b/payment/tests_bazaar_subscription.py index 806600e..a700ee3 100644 --- a/payment/tests_bazaar_subscription.py +++ b/payment/tests_bazaar_subscription.py @@ -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') diff --git a/payment/tests_refund.py b/payment/tests_refund.py index 536defe..11bd4e7 100644 --- a/payment/tests_refund.py +++ b/payment/tests_refund.py @@ -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 diff --git a/payment/tests_security_review.py b/payment/tests_security_review.py index 423bb82..0bd5253 100644 --- a/payment/tests_security_review.py +++ b/payment/tests_security_review.py @@ -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 diff --git a/subscription/migrations/0016_subscriptions_only_2026_10.py b/subscription/migrations/0016_subscriptions_only_2026_10.py new file mode 100644 index 0000000..e240681 --- /dev/null +++ b/subscription/migrations/0016_subscriptions_only_2026_10.py @@ -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)] diff --git a/subscription/tests.py b/subscription/tests.py index 16430e6..592e38d 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -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) diff --git a/subscription/tests_packs.py b/subscription/tests_packs.py index c6b0328..412483d 100644 --- a/subscription/tests_packs.py +++ b/subscription/tests_packs.py @@ -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 diff --git a/subscription/tests_security_review.py b/subscription/tests_security_review.py index 47096b9..85a0283 100644 --- a/subscription/tests_security_review.py +++ b/subscription/tests_security_review.py @@ -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()