Paywall: first unit of each grade free; 1/3/6-month subscriptions; AI sold separately
- content: unit_is_free/has_unit_access; questions, node quiz, answer and lesson/node complete return 402 subscription_required outside the first unit without an active subscription. /api/home units carry requiresSubscription. - subscription.0013: monthly 498k, quarterly 798k, six-month 1,298k toman, unlimited hearts, 3 AI questions/day; packs stay on sale for more questions. - AI_DAILY_QUOTA_FREE default 3 -> 2. - /api/subscription/status adds planTitle, planDurationDays, aiDailyQuota. - tests: content/tests_paywall.py; existing unlock-order tests get a subscription. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
44
subscription/migrations/0013_monthly_plans_2026_10.py
Normal file
44
subscription/migrations/0013_monthly_plans_2026_10.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""
|
||||
اشتراکِ زمانی برمیگردد (تصمیمِ سینیور و مدیرعامل، ۲۰۲۶-۱۰-۱۰): فصلِ اولِ هر پایه رایگان است و
|
||||
بقیهی فصلها با اشتراک باز میشوند (content.views.unit_is_free). بستههای سوال کنارِ اشتراک میمانند.
|
||||
|
||||
قیمتها از سینیور: یکماهه ۴۹۸، سهماهه ۷۹۸، ششماهه ۱٬۲۹۸ هزار تومان.
|
||||
|
||||
اشتراک «محتوا و قلب» میفروشد، نه دستیار (تصمیمِ مالک، ۲۰۲۶-۱۰-۱۰): سقفِ دستیار برای مشترک ۳ سوال در
|
||||
روز است و بیشتر از آن با بستهی سوال خریده میشود. حساب: هر سوال دستِ بالا ~۷۰۰ تومان
|
||||
(docs/ai-cost-and-guardrails.md). با سقفِ ۸، مشترکِ ششماههای که هر روز سقف را پر کند ~۱۶۸ هزار تومان در
|
||||
ماه هزینه داشت، یعنی ۷۸٪ِ سهمِ ماهانهی قیمت (۲۱۶ هزار)، و با ۱۵٪ِ کافهبازار ~۷٪ میماند. با سقفِ ۳ همین
|
||||
بدترین حالت ~۶۳ هزار تومان است: ۲۹٪ِ ششماهه، ۲۴٪ِ سهماهه، ۱۳٪ِ یکماهه. این بدترین حالت است، نه
|
||||
میانگین؛ مصرفِ واقعی سنجیده نشده. سقف از ادمین عوض میشود (Plan.ai_daily_quota).
|
||||
|
||||
پلنهای اشتراکیِ قدیمی (ماهانه/سالانه) دست نمیخورند و on_sale=False میمانند.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
FEATURES = ['همهی فصلها و جزیرهها باز', 'قلبِ نامحدود', '۳ سوال در روز از دستیارِ هوشمند']
|
||||
PLANS = [
|
||||
{'title': 'یکماهه', 'price': 498_000, 'duration_days': 30, 'badge': '', 'popular': False},
|
||||
{'title': 'سهماهه', 'price': 798_000, 'duration_days': 90, 'badge': 'محبوبترین', 'popular': True},
|
||||
{'title': 'ششماهه', 'price': 1_298_000, 'duration_days': 180, 'badge': 'بهصرفهترین', 'popular': False},
|
||||
]
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
# بستهها هم «محبوبترین» داشتند؛ دو کارتِ «محبوبترین» در یک صفحه معنا ندارد.
|
||||
Plan.objects.filter(kind='pack').update(popular=False)
|
||||
for plan in PLANS:
|
||||
if not Plan.objects.filter(kind='subscription', on_sale=True, duration_days=plan['duration_days']).exists():
|
||||
Plan.objects.create(kind='subscription', currency='IRT', discount_percent=0, ai_daily_quota=3,
|
||||
features=FEATURES, on_sale=True, question_credits=0, hearts_days=0, **plan)
|
||||
|
||||
|
||||
def undo(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
Plan.objects.filter(kind='subscription', on_sale=True, subscriptions__isnull=True,
|
||||
title__in=[p['title'] for p in PLANS]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('subscription', '0012_plan_bazaar_sku')]
|
||||
operations = [migrations.RunPython(seed, undo)]
|
||||
@@ -43,7 +43,10 @@ class PurchaseResponseSerializer(serializers.Serializer):
|
||||
|
||||
|
||||
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
||||
active = serializers.BooleanField(help_text='اشتراکِ زمانی (ماهانه/سالانهی قدیمی)؛ نه بسته.')
|
||||
active = serializers.BooleanField(help_text='اشتراکِ زمانی فعال (یک/سه/ششماهه)؛ نه بسته.')
|
||||
planTitle = serializers.CharField(allow_null=True, help_text='نامِ پلنِ اشتراکِ فعال؛ null بیاشتراک.')
|
||||
planDurationDays = serializers.IntegerField(allow_null=True, help_text='طولِ دورهی پلنِ فعال به روز.')
|
||||
aiDailyQuota = serializers.IntegerField(help_text='سقفِ سوالِ روزانهی همین کاربر از دستیار.')
|
||||
planId = serializers.CharField(allow_null=True)
|
||||
expiresAt = serializers.DateTimeField(allow_null=True)
|
||||
questionCredits = serializers.IntegerField(help_text='سوالهای خریدهشدهی باقیمانده (منقضی نمیشوند).')
|
||||
|
||||
@@ -486,7 +486,7 @@ class YearlyPlanSeedTests(TestCase):
|
||||
self.assertTrue(plan.features)
|
||||
|
||||
def test_a_monthly_plan_is_created_next_to_it(self):
|
||||
plan = Plan.objects.get(duration_days=30)
|
||||
plan = Plan.objects.get(duration_days=30, on_sale=False)
|
||||
self.assertEqual((plan.price, plan.ai_daily_quota), (179_000, 10))
|
||||
|
||||
def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self):
|
||||
@@ -498,16 +498,20 @@ class YearlyPlanSeedTests(TestCase):
|
||||
importlib.import_module('subscription.migrations.0008_paid_quota_ten_a_day').apply(global_apps, None)
|
||||
plan = Plan.objects.get(title='اشتراک یکساله')
|
||||
self.assertEqual((plan.price, plan.ai_daily_quota), (450_000, 9))
|
||||
self.assertEqual(Plan.objects.filter(duration_days=30).count(), 1)
|
||||
self.assertEqual(Plan.objects.filter(duration_days=30, on_sale=False).count(), 1)
|
||||
|
||||
def test_only_question_packs_are_on_sale(self):
|
||||
def test_packs_and_new_subscriptions_are_on_sale(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
data = self.client.get('/api/subscription/plans').json()
|
||||
self.assertEqual({p['kind'] for p in data['plans']}, {'pack'})
|
||||
self.assertEqual([p['questionCredits'] for p in data['plans']], [20, 60, 150])
|
||||
self.assertEqual([p['heartsDays'] for p in data['plans']], [7, 30, 60])
|
||||
self.assertEqual(data['freeAiDailyQuota'], 3)
|
||||
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)])
|
||||
self.assertEqual(data['freeAiDailyQuota'], 2)
|
||||
# پلنهای اشتراکی پاک نشدهاند؛ فقط فروخته نمیشوند.
|
||||
self.assertFalse(Plan.objects.get(title='اشتراک یکساله').on_sale)
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ from .serializers import (
|
||||
PurchaseResponseSerializer,
|
||||
SubscriptionStatusResponseSerializer,
|
||||
)
|
||||
from .services import credit_balance, has_active_subscription, hearts_pass_until
|
||||
from .services import ai_daily_quota, credit_balance, has_active_subscription, hearts_pass_until
|
||||
|
||||
|
||||
# پلنها تقریباً هیچوقت عوض نمیشوند و روی صفحهی اشتراک هر بار خوانده میشوند.
|
||||
@@ -104,8 +104,8 @@ class PurchaseView(APIView):
|
||||
idempotency_key = request.headers.get('Idempotency-Key')
|
||||
|
||||
if v['paymentMethod'] == 'bazaar':
|
||||
# فقط بسته: اشتراکها دیگر فروخته نمیشوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
|
||||
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
|
||||
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
|
||||
# اشتراک فعلاً فقط از زرینپال فروخته میشود. SKU از پلن، نه از کلاینت.
|
||||
if plan.kind != 'pack':
|
||||
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بستههای سوال است.'})
|
||||
if not plan.bazaar_sku:
|
||||
@@ -229,11 +229,17 @@ class SubscriptionStatusView(APIView):
|
||||
def get(self, request):
|
||||
# پرداختی که تأییدش در کالبک گم شده، اینجا دوباره سنجیده میشود.
|
||||
reconcile_user_orders(request.user.id)
|
||||
subscription = Subscription.objects.filter(user_id=request.user.id).first()
|
||||
subscription = Subscription.objects.select_related('plan').filter(user_id=request.user.id).first()
|
||||
active = has_active_subscription(request.user.id)
|
||||
data = {
|
||||
# وضعیتِ «active» با گذشتِ تاریخ خودبهخود عوض نمیشود؛ همان شرطی که
|
||||
# قلبِ نامحدود را میدهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده میشد.
|
||||
'active': has_active_subscription(request.user.id),
|
||||
'active': active,
|
||||
'planTitle': subscription.plan.title if active and subscription.plan else None,
|
||||
# طولِ دورهی پلن، برای نوارِ «چقدر مانده» در پروفایل. تمدیدِ زودتر از موعد روزها را روی هم
|
||||
# میگذارد، پس روزِ مانده میتواند از این بیشتر باشد؛ فرانت نوار را در ۱۰۰٪ میبندد.
|
||||
'planDurationDays': subscription.plan.duration_days if active and subscription.plan else None,
|
||||
'aiDailyQuota': ai_daily_quota(request.user.id),
|
||||
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
|
||||
'expiresAt': subscription.expires_at if subscription else None,
|
||||
'questionCredits': credit_balance(request.user.id),
|
||||
|
||||
Reference in New Issue
Block a user