Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only

- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 20:33:38 +03:30
parent 9c3adc3e60
commit 947f946909
39 changed files with 398 additions and 1174 deletions

View File

@@ -105,7 +105,7 @@
| `progress` | امتیاز، سکه، قلب، زنجیره، جدولِ رتبه، همراه‌ها | `UserStats UserHearts StreakRecord ActiveDay LeaderboardEntry Character` |
| `ai_tutor` | گفتگو با دستیار، سهمیه، بازخوردِ 👍/👎 | `Conversation Message MessageFeedback AskIdempotency` |
| `rag` | خواندنِ کتاب، Qdrant، ساختِ پرامپت، شکل‌ها | `LessonChunk LessonChunkImage IngestedPage` |
| `subscription` | پلن‌ها (بسته‌ی سوال، اشتراکِ قدیمی)، دفترِ اعتبارِ سوال، قلبِ نامحدودِ بسته | `Plan Subscription QuestionCredit HeartsPass` |
| `subscription` | پلن‌های اشتراک و اشتراکِ هر کاربر | `Plan Subscription` |
| `payment` | سفارش، تراکنش، زرین‌پال، بازار، لغو و بازپرداخت از ادمین | `Order Transaction BazaarCredential` |
| `discount` | کدِ تخفیف و پیشنهادها | `DiscountCode Redemption Offer UserOffer` |
| `affiliate` | معرفی به دوستان و پورسانت | `AffiliateAccount Conversion` |
@@ -314,24 +314,24 @@ with transaction.atomic():
سه نکته‌ی طراحی: (۱) تماس با GapGPT **بیرون از تراکنشِ دیتابیس** است، وگرنه ۳۰ ثانیه انتظار دیتابیس را قفل می‌کرد؛ (۲) اگر GapGPT خطا داد، سهمیه‌ی رزروشده پس داده می‌شود و کاربر ۵۰۳ می‌گیرد، نه ۵۰۰؛ (۳) اولین پیامِ هر گفتگو کش می‌شود — سؤالِ تکراری از کش ۰٫۰۱ ثانیه‌ای برمی‌گردد در برابرِ ~۱۱ ثانیه.
### ۶-۴ خریدِ بسته‌ی سوال با زرین‌پال
### ۶-۴ خریدِ اشتراک (زرین‌پال یا کافه‌بازار)
![سناریوی خرید بسته‌ی سوال](images/technical-handbook/scenario-subscription-payment.png)
![سناریوی خرید اشتراک](images/technical-handbook/scenario-subscription-payment.png)
*کد: `subscription/views.py` · `payment/services.py` · `subscription/services.py → grant_pack` · `payment/views.py` ( `PaymentRedirectView`، `ZarinpalCallbackView` ) · `payment/zarinpal.py` · تست‌ها: `payment/tests_audit_fixes.py` · `subscription/tests_packs.py` · `payment/tests_refund.py`*
*کد: `subscription/views.py` · `payment/providers.py` · `payment/services.py` · `subscription/services.py → activate_subscription` · `payment/views.py` ( `PaymentRedirectView`، `ZarinpalCallbackView`، `BazaarStartPurchaseView`، `BazaarVerifyPurchaseView` ) · `payment/zarinpal.py` · `payment/bazaar.py` · تست‌ها: `payment/tests_audit_fixes.py` · `payment/tests_bazaar_subscription.py` · `payment/tests_refund.py`*
از ۱۰ مهر ۱۴۰۵ آنچه فروخته می‌شود **بسته‌ی سوال** است، نه اشتراکِ ماهانه/سالانه: ۲۰ / ۶۰ / ۱۵۰ سوال از دستیار به ۴۵ / ۱۱۹ / ۲۵۹ هزار تومان، بی‌انقضا، با ۷ / ۳۰ / ۶۰ روز قلبِ نامحدودِ هدیه. پرداختِ تأییدشده یک ردیفِ مثبت در دفترِ `QuestionCredit` و روزهای `HeartsPass` می‌سازد و به اشتراکِ کسی دست نمی‌زند. پلن‌های اشتراکیِ قبلی پاک نشدند ( `on_sale=False` ) تا مشترکِ فعلی سقفش را نگه دارد.
فقط **اشتراک** فروخته می‌شود: یک / سه / شش‌ماهه به ۵۹۸ / ۸۹۸ / ۱٬۳۹۸ هزار تومان، هر سه با همه‌ی فصل‌ها، قلبِ نامحدود و ۴ سوال در روز از دستیار. کاربر در برگه‌ی پرداخت بینِ «کارتِ بانکی» (زرین‌پال، پیش‌فرض) و «کافه‌بازار» (فقط داخلِ اپِ اندروید) انتخاب می‌کند. پرداختِ تأییدشده روزهای همان پلن را از پایانِ اشتراکِ فعلی اضافه می‌کند. تفاوتِ دو راهِ پرداخت در `payment/providers.py` است (هر راه یک کلاس).
> #### چرا این‌قدر پیچیده؟
>
> - **ساختِ سفارش به زرین‌پال وابسته نیست:** اگر زرین‌پال قطع باشد، سفارش ساخته می‌شود و فقط رفتن به درگاه عقب می‌افتد.
> - **برگشتِ کاربر هیچ چیزی را ثابت نمی‌کند:** هر کس می‌تواند آدرسِ برگشت را با `Status=OK` بسازد. فقط تأییدِ سرور-به-سرور سفارش را «پرداخت‌شده» می‌کند.
> - **پرداختِ جاافتاده بازسنجی می‌شود:** اگر زرین‌پال موقعِ برگشت جواب نداد، هر بار که اپ وضعیتِ خرید را می‌خواند سفارش دوباره سنجیده می‌شود (و دستورِ `payment_reconcile` ).
> - **پلن روی خودِ سفارش است** ؛ کدِ تخفیفِ ۱۰۰٪ بی‌درگاه تمام می‌کند؛ تأییدِ تکراری اعتبارِ دوباره نمی‌دهد (قیدِ یکتا روی سفارش).
> - **کافه‌بازار:** سفارشِ بازار با `planId` ساخته می‌شود ( `POST /api/payment/orders` )؛ تا ۱۰ مهر این فیلد نبود و خریدِ بسته از بازار هیچ اعتباری نمی‌داد (در بازبینی پیدا و با تست بسته شد).
> - **لغو و بازپرداخت از پنلِ ادمین:** دو action روی سفارش‌ها؛ بازپرداخت پس از صفحه‌ی تأیید، اعتبارِ خرج‌نشده و روزهای قلب یا اشتراکِ همان سفارش را پس می‌گیرد. خودِ پول از پنلِ درگاه برگردانده می‌شود ( `docs/operations.md` ).
> - **پلن و مدت روی خودِ سفارش است** ؛ کدِ تخفیفِ ۱۰۰٪ بی‌درگاه تمام می‌کند؛ تأییدِ تکراری روزِ دوباره نمی‌دهد (قیدِ یکتا روی تراکنش).
> - **کافه‌بازار:** اپ با Poolakey اشتراک می‌خرد و سرور توکن را از خودِ بازار می‌سنجد؛ بازار تمدیدِ خودکار را خبر نمی‌دهد، پس سرور نزدیکِ پایانِ دوره خودش می‌پرسد ( `sync_bazaar_renewals` ). کدِ تخفیف با «تخفیف پویا»ی بازار به قیمت می‌رسد.
> - **لغو و بازپرداخت از پنلِ ادمین:** دو action روی سفارش‌ها؛ بازپرداخت پس از صفحه‌ی تأیید روزهای اشتراکِ همان سفارش را پس می‌گیرد. خودِ پول از پنلِ درگاه برگردانده می‌شود ( `docs/operations.md` ).
>
> آزموده‌نشده زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده.
> آزموده‌نشده زرین‌پال درخواستِ پرداخت را با کدِ ۱۰۰ می‌پذیرد، ولی یک پرداختِ واقعی تا انتها هنوز انجام نشده؛ خریدِ بازار منتظرِ ساختِ اپِ اندروید است.
### ۶-۵ گزارشِ والدین
@@ -386,11 +386,9 @@ with transaction.atomic():
- `IngestedPage collection, page`
**subscription · payment پول**
- `Plan kind (pack/subscription), price, question_credits, hearts_days, on_sale, ai_daily_quota`
- `Plan price, duration_days, on_sale, ai_daily_quota, bazaar_sku`
- `Subscription user_id, → Plan, status, expires_at`
- `QuestionCredit user_id, delta (+خرید / −سوال / −بازپرداخت), order_id, message_id`
- `HeartsPass user_id, expires_at قلبِ نامحدودِ بسته`
- `Order user_id, plan_id, amount, list_price, plan_title, status (… canceled, refunded), gateway_authority`
- `Order user_id, plan_id, subscription_id, amount, list_price, plan_title, duration_days, status (… canceled, refunded), provider, gateway_authority, bazaar_purchase_token, bazaar_valid_until`
- `Transaction → Order · gateway_ref_id, status, paid_at`
- `BazaarCredential توکنِ اتصال به بازار`
@@ -419,7 +417,7 @@ with transaction.atomic():
|---|---|
| یکتا: `UserAnswer(user_id, question, idempotency_key)` | ثبتِ دوباره‌ی یک جواب با تکرارِ درخواست |
| یکتا: `Redemption(کاربر، کد)` | استفاده‌ی دوباره از یک کدِ تخفیف |
| یکتا: `QuestionCredit(order_id)` برای ردیفِ خرید | اعتبارِ دوباره وقتی تأییدِ پرداخت دو بار برسد |
| یکتا: `Transaction(gateway, gateway_ref_id)` | روزِ دوباره وقتی تأییدِ پرداخت دو بار برسد، یا یک توکنِ خرید برای دو سفارش |
| یکتا: `CampaignDelivery(کمپین، کاربر، کانال)` | پیامِ تکراری وقتی کمپین دو بار هم‌زمان اجرا شود |
| یکتا: `LessonChunk(collection, qdrant_point_id)` و `IngestedPage(collection, page)` | تکه‌ی تکراری در Qdrant وقتی خواندنِ کتاب از سر گرفته می‌شود |
@@ -476,16 +474,16 @@ with transaction.atomic():
| POST | `/api/characters/select` | ورود | انتخابِ همراه (فقط بازشده) |
| **دستیار — `ai_tutor` و `rag`** | | | |
| POST | `/api/ai/ask` | ورود | پرسش؛ JSON یا استریم |
| GET | `/api/ai/quota` | ورود | سوال‌های رایگانِ باقیِ امروز و اعتبارِ بسته |
| GET | `/api/ai/quota` | ورود | سوال‌های باقیِ امروز |
| GET | `/api/ai/conversations/{id}/messages` | ورود | تاریخچه‌ی گفتگو |
| POST | `/api/ai/messages/{id}/feedback` | ورود | 👍/👎 روی یک جواب |
| POST | `/api/rag/ingest/text` · `/pdf` | داخلی | افزودنِ متن یا PDF به Qdrant |
| GET | `/api/rag/chunks` | داخلی | مرورِ تکه‌های ذخیره‌شده |
| **بسته، پرداخت، تخفیف** | | | |
| GET | `/api/subscription/plans` | عمومی | بسته‌های سوالِ در حالِ فروش |
| GET | `/api/subscription/plans` | عمومی | اشتراک‌های در حالِ فروش |
| POST | `/api/subscription/purchase` | ورود | شروعِ خرید؛ سفارش و لینکِ پرداخت |
| GET | `/api/subscription/status` | ورود | اعتبارِ سوال، قلبِ نامحدود تا کِی، اشتراکِ قدیمی (و بازسنجیِ پرداختِ جاافتاده) |
| POST | `/api/payment/orders` | داخلی | ساختِ سفارش (سفارشِ بازار، با `planId` ِ بسته) |
| POST | `/api/payment/bazaar/orders/{id}/start` | ورود | شروعِ خریدِ بازار (SKU و قیمتِ امضاشده) |
| GET | `/api/payment/orders/{id}` | ورود | وضعیتِ سفارش |
| GET | `/api/payment/invoices` | ورود | پرداخت‌ها و فاکتورهای کاربر (شماره‌ی فاکتور، تخفیف، کدِ پیگیری) |
| GET | `/api/payment/orders/{id}/pay` | عمومی | رفتن به درگاهِ زرین‌پال |
@@ -700,7 +698,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| انتخابِ مدلِ جواب: ۱۰۲ مسئله‌ی تصحیح‌شده‌ی دستی | `gpt-4.1-mini` : ۹۵–۹۶ درست · `gpt-4o-mini` : ۸۵–۹۰ |
| شکل‌ها: ۶۴ مسئله‌ی تصادفیِ کتاب، وارسیِ چشمیِ تک‌تک | ۴۰ درست، ۱۲ قابلِ قبول، ۷ غلط، ۵ بی‌شکل — حدودِ یکی از پنج هنوز ایراد دارد؛ برای همین شکل همیشه کنارِ حلِ متنی می‌آید |
| سرعت | جوابِ کامل با شکل ~۳۰ ثانیه؛ اندازه‌گیری شد که در خودِ GapGPT است («فقط بگو سلام» = ۷ ثانیه) |
| سقف‌ها | ۲ سؤالِ رایگان در روز (مشترک ۳)، بعد از اعتبارِ بسته؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
| سقف‌ها | ۲ سؤالِ رایگان در روز (مشترک ۴)؛ ۲۰ در ساعت؛ مهمان‌ها بر اساسِ IP (تا با ساختِ حسابِ مهمانِ تازه دور زده نشود) |
### ۹-۷ فقط ریاضی، و چه چیزی هزینه دارد
@@ -708,7 +706,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| قاعده‌ی خرج | |
|---|---|
| ترتیب | اول سقفِ روزانه (۲ رایگان، مشترک ۳)، بعد اعتبارِ بسته ( `Message.charge` : `free` / `credit` / `none` ) |
| ترتیب | فقط سقفِ روزانه (۲ رایگان، مشترک ۴؛ `Message.charge` : `free` / `none` ) |
| خرج می‌شود | هر سوالی که به مدل برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل |
| خرج نمی‌شود | ردِ صافیِ سرور، جوابِ کش‌شده، جوابی که ساخته نشد، و تکرارِ درخواست با همان `Idempotency-Key` |
| هم‌زمانی | دو سوالِ هم‌زمان با یک اعتبار فقط یک جواب می‌گیرند؛ با دو thread واقعی آزموده می‌شود ( `ai_tutor/tests_credit_concurrency.py` ) |
@@ -788,7 +786,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
![صفحه‌ی قلب‌هات تموم شد](images/technical-handbook/screenshot-06-no-hearts.jpg)
*شکلِ ۱۱-۳ — صفحه‌ی «قلب‌هات تموم شد»: شمارشِ معکوسِ زنده تا قلبِ بعدی، قلبی که کم‌کم پر می‌شود، ساعتِ پر شدنِ همه، پیشنهادِ بسته‌ها و «تا اون موقع از پی بپرس» (پرسیدن قلب خرج نمی‌کند). وقتی وقتش رسید، صفحه خودش از سرور می‌پرسد و «ادامه بده» را نشان می‌دهد. فرانت: `src/components/NoHeartsScreen.tsx` .*
*شکلِ ۱۱-۳ — صفحه‌ی «قلب‌هات تموم شد»: شمارشِ معکوسِ زنده تا قلبِ بعدی، قلبی که کم‌کم پر می‌شود، ساعتِ پر شدنِ همه، پیشنهادِ اشتراک و «تا اون موقع از پی بپرس» (پرسیدن قلب خرج نمی‌کند). وقتی وقتش رسید، صفحه خودش از سرور می‌پرسد و «ادامه بده» را نشان می‌دهد. فرانت: `src/components/NoHeartsScreen.tsx` .*
## ۱۲. امنیت و احرازِ هویت
@@ -810,7 +808,7 @@ if (Math.sign(y) !== Math.sign(py)) { const r = bisect(f, px, x); if (Math.abs(f
| دسترسی به داده‌ی دیگران | شناسه‌ی کاربر همیشه از توکن خوانده می‌شود، هرگز از ورودی؛ هر کاربر فقط داده‌ی خودش را می‌بیند (گزارشِ والدین هم در حسابِ خودِ دانش‌آموز است). | همه‌ی `views.py` ها |
| سقفِ نرخ | روی همه‌ی نقاطِ حساس (ورود، دستیار، خرید، کدِ تخفیف، جواب…)؛ مهمان بر اساسِ IPِ واقعی ( `NUM_PROXIES=2` : آروان + nginx). | `config/settings.py` |
| پیامک و هزینه‌اش | شماره به `09XXXXXXXXX` نرمال می‌شود؛ فاصله بینِ دو درخواست، ۱۰ در روز برای هر شماره، ۳۰۰ در روز برای کلِ سامانه (ضدِ SMS pumping) — شمارش در دیتابیس، نه حافظه‌ی موقت. | `accounts/phone.py` · `tests_otp_limits.py` |
| پرداخت | فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. اعتبارِ بسته دفتری است و هر سفارش فقط یک بار اعتبار می‌دهد؛ وضعیتِ سفارش در ادمین فقط با actionِ لغو/بازپرداخت عوض می‌شود. | `payment/` · `subscription/` |
| پرداخت | فقط تأییدِ سرور-به-سرور؛ برگشتِ جعلی اثری ندارد؛ webhookِ عمومیِ ناامن حذف شد؛ هر درگاهی بی‌کلید «بسته» می‌ماند (fail-closed)، نه موفقیتِ ساختگی. هر سفارش فقط یک بار روز می‌دهد (قیدِ یکتا روی تراکنش)؛ وضعیتِ سفارش در ادمین فقط با actionِ لغو/بازپرداخت عوض می‌شود. | `payment/` · `subscription/` |
| تقلبِ امتیاز | امتیاز فقط برای جوابِ ثبت‌شده؛ جایزه فقط بارِ اول. | `content/views.py` |
| درخواستِ تکراری و هم‌زمانی | Idempotency-Key در شش نقطه؛ `transaction.atomic` ؛ قیدِ یکتا در دیتابیس؛ SQLite در حالتِ `IMMEDIATE` تا قفل‌ها واقعاً قفل کنند. | `config/settings.py` |
| خطا و لاگ | هر خطا JSONِ یکدست بی‌جزئیاتِ داخلی؛ لاگ بدونِ بدنه، هدر، توکن یا رمز. | `config/exceptions.py` · `middleware.py` |
@@ -967,8 +965,8 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
| پیامکِ ورود | کار می‌کند | کدِ واقعی به گوشی رسید (الگوی SMSVerify)؛ هر پیامک هزینه دارد |
| دستیار + شکل | کار می‌کند | کُند (~۳۰ ثانیه، از سمتِ GapGPT)؛ ~یک شکل از پنج ایراد دارد |
| زرین‌پال | نیمه | درخواستِ پرداخت پذیرفته می‌شود؛ پرداختِ واقعی تا انتها آزموده نشده |
| کافه‌بازار | منتظر | اتصالِ API برقرار؛ منتظرِ تعریفِ بسته‌ها در پنلِ بازار (محصولِ مصرفی) و اپِ اندرویدی |
| بسته‌ی سوال و قلب | زنده | سه بسته روی سایت؛ قلبِ صفر بازی را متوقف می‌کند |
| کافه‌بازار | منتظرِ اپ | اتصالِ API برقرار؛ سه اشتراک در پیشخان (۱۰۳۴۲–۱۰۳۴۴) و روی پلن‌ها؛ منتظرِ ساختِ اپِ اندروید و یک خریدِ واقعی |
| اشتراک و قلب | زنده | سه اشتراک روی سایت؛ قلبِ صفر بازی را متوقف می‌کند |
| پیامِ گروهی | پایه | ذخیره و هدف‌گیری هست؛ زمان‌بندیِ خودکار نه |
| بکاپِ خودکار | ندارد | دستی پیش از هر انتشار |
@@ -978,8 +976,8 @@ fetch ← `npm ci` ← بررسیِ نوع‌ها ← build در `dist.next` ←
|---|---|
| عوض کردنِ کلیدِ امضا | کد آماده؛ همه یک بار از حساب بیرون می‌افتند — زمانش را باید تعیین کرد. |
| پاک‌سازیِ داده‌ی دمو | حساب‌های آزمایشیِ شماره‌دار ۱۰ مهر پاک شدند (با بکاپ)؛ ۷ شماره‌ی نمایشی و حساب‌هایشان عمداً مانده‌اند. |
| قیمتِ بسته‌ها و سهمیه‌ی رایگان | بعد از دو هفته مصرفِ واقعی بازبینی شود؛ سود در `docs/ai-cost-and-guardrails.md` حساب شده. |
| سیاستِ بازپرداخت | الان فقط اعتبارِ خرج‌نشده پس گرفته می‌شود؛ پول از پنلِ درگاه. |
| سقفِ دستیار و سهمیه‌ی رایگان | بعد از دو هفته مصرفِ واقعی بازبینی شود؛ سود در `docs/ai-cost-and-guardrails.md` حساب شده. |
| سیاستِ بازپرداخت | الان روزهای اشتراکِ همان سفارش پس گرفته می‌شود؛ پول از پنلِ درگاه. |
| Redis و Postgres | هماهنگی با ادمینِ سرور؛ هر دو روی همان ماشین موجودند. |
| پورتِ ۸۰ و دسترسیِ SSH | کارِ ادمینِ سرور. |
| پیشرفت با هر نمره | در حالِ حاضر جزیره با هر نمره‌ای تمام می‌شود؛ اگر حدِ قبولی لازم است، یک تصمیمِ آموزشی است. |
@@ -1050,7 +1048,7 @@ npm run build # type-check + build در dist/
| `docs/security-before-production.md` | ۱۸ موردِ امنیتیِ باز با خطر و راهِ بستن |
| `docs/content-authoring.md` | ویرایشِ محتوا و ساختِ انبوهِ سؤال |
| `docs/extraction-playbook.md` | خواندنِ کتاب با مدلِ بینایی: هزینه و درس‌ها |
| `docs/ai-cost-and-guardrails.md` | هزینه‌ی هر سوالِ دستیار، بسته‌ها و قاعده‌ی خرج، محافظِ «فقط ریاضی» |
| `docs/ai-cost-and-guardrails.md` | هزینه‌ی هر سوالِ دستیار، سودِ هر اشتراک و قاعده‌ی خرج، محافظِ «فقط ریاضی» |
| `docs/model-prices.md` | دفترِ قیمتِ اندازه‌گیری‌شده‌ی مدل‌ها، با تاریخ؛ اگر قیمت عوض شد ردیف اضافه شود |
| `data/textbooks/README.md` | افزودنِ کتابِ تازه |
| `/api/docs/` | قراردادِ زنده و قابلِ امتحانِ API |