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:
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