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:
2026-10-02 06:10:17 +03:30
parent e9d676ce68
commit 63cd1af6f7
18 changed files with 468 additions and 78 deletions

View 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),
]

View File

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

View File

@@ -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()

View File

@@ -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)