diff --git a/CLAUDE.md b/CLAUDE.md index 4eb2038..e70026a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -409,7 +409,8 @@ anyone who can read this repo can forge a token for any user.** - **لحنِ دستیار از سن** (`tone_for`، پرامپت `tutor-v12-age-tone`): ۱۸+ در هر پایه لحنِ بزرگسال می‌گیرد؛ نمادگذاری از کتابِ پایه. کشِ جواب با لحن کلید می‌خورد. فقط یک مقایسه‌ی زنده (سوم، ۸ و ۴۵ سال). - **شناسه‌ی نمایشی** `User482917` (`accounts/public_id.py`، از id، بی‌ستون؛ ادمین با آن جستجو می‌کند). -- **قیمتِ موقتِ پلن‌ها** (`subscription.0007`): ماهانه ۹۹ و سالانه ۹۹۰ هزار تومان، هر دو ۵ سوال در روز. +- **قیمتِ پلن‌ها** (`subscription.0008`): ماهانه ۱۷۹ و سالانه ۱٬۷۹۰ هزار تومان، هر دو ۱۰ سوال در روز + (۰۰۰۷ ۹۹ هزار و ۵ سوال بود؛ ۵ در برابرِ ۳ِ رایگان بی‌فایده بود). حساب در گزارشِ هزینه‌ی گزارش-۲۶. - **حذفِ حساب** `POST /api/user/delete`: شماره‌دار با کدِ پیامکیِ تازه (`verify_phone_otp`، مشترک با ورود)، مهمان با «حذف»؛ سفارش‌ها و statuslog می‌مانند؛ دموها رد می‌شوند. «ورود با حسابِ دیگر» فقط خروج است؛ چند حساب روی یک دستگاه عمداً نگه داشته نمی‌شود. diff --git a/docs/ai-cost-and-guardrails.md b/docs/ai-cost-and-guardrails.md index 5e3ecfd..8238e01 100644 --- a/docs/ai-cost-and-guardrails.md +++ b/docs/ai-cost-and-guardrails.md @@ -39,7 +39,11 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa می‌گیرد. کد در `subscription.services.ai_daily_quota` است. - کاربرِ رایگانی که به سقف می‌رسد پیامِ «با خریدِ اشتراک…» می‌گیرد و اپ دکمه‌ی «دیدنِ اشتراک‌ها» را نشان می‌دهد. -- قیمتِ موقت (migrationِ `subscription.0007`): ماهانه ۹۹٬۰۰۰ و سالانه ۹۹۰٬۰۰۰ تومان، هر دو ۵ سوال در روز. +- قیمتِ فعلی (migrationِ `subscription.0008`): ماهانه ۱۷۹٬۰۰۰ و سالانه ۱٬۷۹۰٬۰۰۰ تومان، هر دو ۱۰ سوال در + روز. ۵ سوال (`0007`) در برابرِ ۳ِ رایگان تفاوتی حس نمی‌شد. با هزینه‌ی ~۶۰۰ تومان برای هر سوال و مصرفِ + یک‌سومِ سقف، ۱۰ سوال حدودِ ~۱۷۶ هزار تومان در ماه می‌خواهد تا دستیار حداکثر ۴۰٪ِ درآمدِ خالص (پس از ۱۵٪یِ + بازار) باشد؛ ۱۵ سوال ~۲۶۵ هزار. خرجِ کاربرهای رایگان به ازای هر مشترک با درصدِ تبدیل تغییر می‌کند: با ۵٪ + تبدیل و میانگینِ ۵ سوال در ماه برای هر رایگان، ~۵۷ هزار تومان؛ زیرِ ۵٪ از خرجِ خودِ مشترک بیشتر می‌شود. API پلن‌ها `aiDailyQuota` و `freeAiDailyQuota` می‌دهد و کارتِ پلن عدد را از همین می‌سازد. - انتخابِ عددها تصمیمِ محصولی است. جدولِ قیمت در برابرِ سقف در نسخه‌ی کاملِ گزارش آمده است. diff --git a/subscription/migrations/0008_paid_quota_ten_a_day.py b/subscription/migrations/0008_paid_quota_ten_a_day.py new file mode 100644 index 0000000..ba64a74 --- /dev/null +++ b/subscription/migrations/0008_paid_quota_ten_a_day.py @@ -0,0 +1,42 @@ +""" +سقفِ پولی ۱۰ سوال در روز، با قیمتی که هزینه‌اش را می‌پوشاند (۲۰۲۶-۱۰-۰۲). + +۵ سوال در روز (0007) در برابرِ ۳ِ رایگان تفاوتی حس نمی‌شد (صاحبِ پروژه). با ۱۰ سوال و هزینه‌ی +میانگینِ ~۶۰۰ تومان برای هر سوال، مشترکی که یک‌سومِ سقف را مصرف کند (~۱۰۰ سوال در ماه) حدودِ +۶۰ هزار تومان هزینه دارد. ماهانه‌ی ۱۷۹ هزار تومانی آن را زیرِ ۴۰٪ِ درآمدِ خالص نگه می‌دارد (۳۹٪ با +سهمِ ۱۵٪یِ بازار، ۳۴٪ با زرین‌پال)؛ سالانه‌ی ۱٬۷۹۰ هزار تومانی (قیمتِ ۱۰ ماه) ۴۷٪ / ۴۰٪. مشترکی که +هر روز سقف را پر کند (۳۰۰ سوال، ~۱۸۰ هزار تومان) زیان دارد؛ حسابِ کامل: گزارشِ هزینه در +گزارش-۲۶ و docs/ai-cost-and-guardrails.md. + +فقط پلن‌هایی را عوض می‌کند که هنوز همان قیمت و سقفِ 0007 را دارند، تا ویرایشِ ادمین بازنویسی نشود. +""" +from django.db import migrations + +CHANGES = [ + # (قیمتِ 0007، مدت) → مقدارِ تازه + ((99_000, 30), {'price': 179_000, 'ai_daily_quota': 10}), + ((990_000, 365), {'price': 1_790_000, 'ai_daily_quota': 10, 'discount_percent': 17}), +] + + +def apply(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + for (price, days), new in CHANGES: + Plan.objects.filter(price=price, duration_days=days, ai_daily_quota=5).update(**new) + + +def undo(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + for (price, days), new in CHANGES: + Plan.objects.filter(price=new['price'], duration_days=days).update(price=price, ai_daily_quota=5) + + +class Migration(migrations.Migration): + + dependencies = [ + ('subscription', '0007_plans_priced_by_ai_cost'), + ] + + operations = [ + migrations.RunPython(apply, undo), + ] diff --git a/subscription/models.py b/subscription/models.py index 3d585ba..7458b89 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -26,7 +26,7 @@ class Plan(models.Model): # برچسبِ بالای کارت: «محبوب‌ترین»، «بهترین ارزش». `popular` فقط بولین است # و یک متن نمی‌دهد؛ این جایگزینش نیست، کنارش است. badge = models.CharField(max_length=30, blank=True) - # چند سوال در روز از دستیار. هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینه‌ی API + # چند سوال در روز از دستیار (۱۰ برای پلن‌های فعلی، subscription.0008). هر سوال حدودِ ۰٫۲ سنتِ دلار (~۶۰۰ تومان) هزینه‌ی API # دارد (docs/ai-cost-and-guardrails.md)، پس این عدد مستقیم سودِ پلن را تعیین می‌کند. # کاربرِ بدونِ اشتراک سقفِ AI_DAILY_QUOTA_FREE (settings) را دارد. ai_daily_quota = models.PositiveSmallIntegerField( diff --git a/subscription/tests.py b/subscription/tests.py index 271fb2d..2e70793 100644 --- a/subscription/tests.py +++ b/subscription/tests.py @@ -552,13 +552,13 @@ class YearlyPlanSeedTests(TestCase): def test_the_yearly_plan_exists_after_migrations(self): plan = Plan.objects.get(title='اشتراک یک‌ساله') self.assertEqual(plan.duration_days, 365) - self.assertEqual((plan.price, plan.ai_daily_quota), (990_000, 5)) + self.assertEqual((plan.price, plan.ai_daily_quota), (1_790_000, 10)) 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)) + self.assertEqual((plan.price, plan.ai_daily_quota), (179_000, 10)) def test_repricing_leaves_an_admin_edited_yearly_plan_alone(self): import importlib @@ -566,6 +566,7 @@ class YearlyPlanSeedTests(TestCase): 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) + 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) @@ -575,7 +576,7 @@ class YearlyPlanSeedTests(TestCase): 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}) + self.assertEqual({p['aiDailyQuota'] for p in data['plans']}, {10}) def test_it_is_served_to_the_app(self): from django.core.cache import cache