public user IDs, plans priced by tutor cost, age-based tutor tone, self-service account deletion
- profile publicId ("User482917", accounts/public_id.py); admin shows and searches it
- plans API reports aiDailyQuota per plan and freeAiDailyQuota; migration 0007 sets the
temporary prices (monthly 99,000 / yearly 990,000 toman, 5 tutor questions a day) unless
an admin already edited the yearly plan
- tutor tone follows the learner's age (18+ gets an adult tone at any grade); notation
still follows the grade's book; answer cache keyed by tone (prompt tutor-v12-age-tone)
- POST /api/user/delete: phone users confirm with a fresh SMS code (same checks as login,
now shared in verify_phone_otp), guests type "حذف"; payment records are kept, demo
accounts refused
- delete_users --skip-paid
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
66
subscription/migrations/0007_plans_priced_by_ai_cost.py
Normal file
66
subscription/migrations/0007_plans_priced_by_ai_cost.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
قیمتِ موقتِ پلنها بر اساسِ هزینهی دستیار (مهرِ ۱۴۰۵).
|
||||
|
||||
هر سوال از دستیار حدودِ ۶۰۰ تومان هزینهی API دارد (docs/ai-cost-and-guardrails.md).
|
||||
اشتراکِ سالانهی ۲۹۹ هزار تومانی (۰۰۰۵) فقط ~۴۰ سوال در ماه را پوشش میداد، در حالی که
|
||||
مشترک تا ۴۵۰ سوال در ماه میتوانست بپرسد. این قیمتها را صاحبِ پروژه «موقت و معقول»
|
||||
خواست، تا تصمیمِ نهاییِ مدیرعامل:
|
||||
|
||||
- ماهانه ۹۹٬۰۰۰ تومان، سالانه ۹۹۰٬۰۰۰ تومان (قیمتِ ۱۰ ماه، دو ماه هدیه)، هر دو ۵ سوال در روز.
|
||||
- کاربرِ معمولی (یکسومِ سقف، ~۵۰ سوال در ماه) ~۳۰ هزار تومان هزینه دارد: ~۳۶٪ِ درآمدِ خالصِ
|
||||
ماهانه پس از سهمِ ۱۵٪یِ بازار، و ~۴۳٪ در سالانه. کاربری که هر روز سقف را پر کند (۱۵۰ سوال،
|
||||
~۹۰ هزار تومان) در پلنِ ماهانه از درآمدش بیشتر خرج میکند؛ این سقف با درآمدِ واقعی بازبینی شود.
|
||||
|
||||
ویژگیها فقط چیزی است که کد واقعاً به مشترک میدهد. درسها، آزمونها و گزارشِ والدین برای همه
|
||||
رایگاناند و اینجا نیامدهاند. سقفِ سوال را خودِ اپ از ai_daily_quota میسازد و در features نیست.
|
||||
|
||||
قاعده: پلنِ سالانه فقط وقتی عوض میشود که هنوز همان قیمتِ پیشفرضِ ۲۹۹ هزار تومانِ ۰۰۰۵ را
|
||||
دارد، یعنی کسی در ادمین ویرایشش نکرده. پلنِ ماهانه فقط وقتی ساخته میشود که هیچ پلنِ ۳۰ روزهای
|
||||
وجود ندارد.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
YEARLY_TITLE = 'اشتراک یکساله'
|
||||
YEARLY = {
|
||||
'price': 990_000,
|
||||
'discount_percent': 17,
|
||||
'popular': True,
|
||||
'badge': 'دو ماه هدیه',
|
||||
'ai_daily_quota': 5,
|
||||
'features': ['قلبِ نامحدود؛ هیچوقت وسطِ تمرین متوقف نمیشی', 'دوازده ماه، به قیمتِ ده ماه'],
|
||||
}
|
||||
MONTHLY = {
|
||||
'title': 'اشتراک ماهانه',
|
||||
'price': 99_000,
|
||||
'currency': 'IRT',
|
||||
'discount_percent': 0,
|
||||
'popular': False,
|
||||
'duration_days': 30,
|
||||
'badge': '',
|
||||
'ai_daily_quota': 5,
|
||||
'features': ['قلبِ نامحدود؛ هیچوقت وسطِ تمرین متوقف نمیشی', 'هر وقت خواستی تمدید کن'],
|
||||
}
|
||||
|
||||
|
||||
def apply_prices(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
Plan.objects.filter(title=YEARLY_TITLE, price=299_000).update(**YEARLY)
|
||||
if not Plan.objects.filter(duration_days=30).exists():
|
||||
Plan.objects.create(**MONTHLY)
|
||||
|
||||
|
||||
def undo(apps, schema_editor):
|
||||
Plan = apps.get_model('subscription', 'Plan')
|
||||
Plan.objects.filter(title=MONTHLY['title'], subscriptions__isnull=True).delete()
|
||||
Plan.objects.filter(title=YEARLY_TITLE, price=YEARLY['price']).update(price=299_000, ai_daily_quota=15)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('subscription', '0006_plan_ai_daily_quota'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply_prices, undo),
|
||||
]
|
||||
@@ -11,10 +11,13 @@ class PlanSerializer(serializers.Serializer):
|
||||
durationDays = serializers.IntegerField(required=False)
|
||||
features = serializers.ListField(child=serializers.CharField())
|
||||
badge = serializers.CharField(allow_blank=True)
|
||||
aiDailyQuota = serializers.IntegerField(help_text='سوال در روز از دستیار برای مشترکِ این پلن.')
|
||||
|
||||
|
||||
class PlansListResponseSerializer(serializers.Serializer):
|
||||
plans = PlanSerializer(many=True)
|
||||
# تا کارتِ پلن بتواند بگوید «۵ سوال در روز (رایگان: ۳)» بیآنکه عدد در فرانت هاردکد شود.
|
||||
freeAiDailyQuota = serializers.IntegerField()
|
||||
|
||||
|
||||
class PurchaseRequestSerializer(serializers.Serializer):
|
||||
|
||||
@@ -547,15 +547,36 @@ class PlanCardContractTests(TestCase):
|
||||
|
||||
|
||||
class YearlyPlanSeedTests(TestCase):
|
||||
"""migrationِ 0005: سرورِ بیپلن یک پلنِ یکساله میگیرد."""
|
||||
"""migrationهای 0005 و 0007: سرورِ بیپلن پلنِ سالانه و ماهانه با قیمتِ ۱۴۰۵-۰۷ میگیرد."""
|
||||
|
||||
def test_the_yearly_plan_exists_after_migrations(self):
|
||||
plan = Plan.objects.get(title='اشتراک یکساله')
|
||||
self.assertEqual(plan.duration_days, 365)
|
||||
self.assertEqual(plan.price, 299_000)
|
||||
self.assertEqual((plan.price, plan.ai_daily_quota), (990_000, 5))
|
||||
self.assertEqual(plan.currency, 'IRT')
|
||||
self.assertTrue(plan.features)
|
||||
|
||||
def test_a_monthly_plan_is_created_next_to_it(self):
|
||||
plan = Plan.objects.get(duration_days=30)
|
||||
self.assertEqual((plan.price, plan.ai_daily_quota), (99_000, 5))
|
||||
|
||||
def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self):
|
||||
import importlib
|
||||
from django.apps import apps as global_apps
|
||||
reprice = importlib.import_module('subscription.migrations.0007_plans_priced_by_ai_cost')
|
||||
Plan.objects.filter(title='اشتراک یکساله').update(price=450_000, ai_daily_quota=9)
|
||||
reprice.apply_prices(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)
|
||||
|
||||
def test_plans_api_reports_each_quota_and_the_free_one(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
data = self.client.get('/api/subscription/plans').json()
|
||||
self.assertEqual(data['freeAiDailyQuota'], 3)
|
||||
self.assertEqual({p['aiDailyQuota'] for p in data['plans']}, {5})
|
||||
|
||||
def test_it_is_served_to_the_app(self):
|
||||
from django.core.cache import cache
|
||||
cache.clear()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import transaction
|
||||
from django.db.models import F
|
||||
@@ -42,7 +43,7 @@ class GuestAwarePurchaseThrottle(ScopedRateThrottle):
|
||||
|
||||
# پلنها تقریباً هیچوقت عوض نمیشوند و روی صفحهی اشتراک هر بار خوانده میشوند.
|
||||
# با هر save/delete روی Plan کش باطل میشود (subscription/receivers.py).
|
||||
PLANS_CACHE_KEY = 'subscription:plans:v1'
|
||||
PLANS_CACHE_KEY = 'subscription:plans:v2' # v2: aiDailyQuota و freeAiDailyQuota
|
||||
PLANS_CACHE_TTL = 3600
|
||||
|
||||
|
||||
@@ -64,9 +65,11 @@ class PlansListView(APIView):
|
||||
'durationDays': p.duration_days,
|
||||
'features': p.features or [],
|
||||
'badge': p.badge,
|
||||
'aiDailyQuota': p.ai_daily_quota,
|
||||
}
|
||||
for p in Plan.objects.all()
|
||||
]
|
||||
for p in Plan.objects.order_by('duration_days', 'price')
|
||||
],
|
||||
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
|
||||
}
|
||||
|
||||
data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL)
|
||||
|
||||
Reference in New Issue
Block a user