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

@@ -35,46 +35,38 @@ Message.objects.filter(role='assistant', created_at__gte=since).values('conversa
## اشتراک‌ها و سقفِ روزانه‌ی دستیار
فقط اشتراک فروخته می‌شود (`subscription.0016`، تصمیمِ سینیور). قیمت‌ها همان قیمتِ پیشخانِ بازار است و کاربر در هر دو
راهِ پرداخت همین را می‌بیند. بسته‌های سوال `on_sale=False` هستند؛ اعتبارِ کسی که قبلاً خریده (کاربرِ ۲۶، ۲۰ سوال)
همچنان خرج می‌شود.
فقط اشتراک فروخته می‌شود؛ بسته‌ی سوال از ریشه حذف شد (`subscription.0016`، `0017`، `0018`). قیمت‌ها همان قیمتِ پیشخانِ
بازار است (به گفته‌ی سینیور) و هر سه **۴ سوال در روز** از دستیار دارند (`Plan.ai_daily_quota`، از ادمین عوض‌شدنی).
**فرض‌ها:** هر سوال ~۷۰۰ تومان (دستِ بالا، بخشِ بالا). از هر خریدِ بازار اول ۱۰٪ ارزشِ افزوده کم می‌شود، از باقی ۸۵٪
سهمِ ماست (زیرِ یک میلیارد تومان فروشِ سالانه؛ بالاترش ۷۰٪)، و ۱۲۰ تومان کارمزد (سندِ «محاسبه سهم توسعه‌دهنده در
بازار»). یعنی **~۷۷٪ِ قیمت** می‌رسد. زرین‌پال بی‌سهمِ بازار است (~۹۰٪، کارمزدش را نسنجیده‌ایم)، پس بازار بدترین حالت است.
**فرض‌ها:**
- هر سوالِ دستیار ~۷۰۰ تومان (دستِ بالا؛ بخشِ بالا).
- **بازار:** ۱۰٪ ارزشِ افزوده از قیمت کم می‌شود، از باقی ۸۵٪ سهمِ ماست (زیرِ یک میلیارد تومان فروشِ سالانه؛ بالاترش
۷۰٪)، و کارمزدِ تراکنش (سندِ «محاسبه سهم توسعه‌دهنده در بازار»). یعنی ~۷۷٪ِ قیمت می‌رسد.
- **زرین‌پال:** همان ۱۰٪ ارزشِ افزوده و ~۱٪ کارمزد. کارمزدِ واقعیِ قراردادِ زرین‌پالِ شرکت را **نسنجیده‌ایم**.
- بدترین حالت: مشترک هر روزِ دوره کلِ ۴ سوال را بپرسد. هزینه‌ی سرور حساب نشده (ثابت است، نه به ازای کاربر).
| اشتراک | قیمت (تومان) | خالصِ بازار در روز | سقفِ دستیار | هزینه‌ی دستیار اگر هر روز کلِ سقف را بپرسد |
|---|---|---|---|---|
| یک‌ماهه | ۵۹۸٬۰۰۰ | ~۱۵٬۴۰۰ | ۵ در روز | ۳٬۵۰۰ در روز، ۲۳٪ |
| سه‌ماهه | ۸۹۸٬۰۰۰ | ~۷٬۷۰۰ | ۳ در روز | ۲٬۱۰۰ در روز، ۲۷٪ |
| شش‌ماهه | ۱٬۳۹۸٬۰۰۰ | ~۶٬۰۰۰ | ۳ در روز | ۲٬۱۰۰ در روز، ۳۵٪ |
| اشتراک | قیمت (تومان) | راهِ پرداخت | به ما می‌رسد | هزینه‌ی دستیار در بدترین حالت | سهمِ دستیار | سودِ بدترین حالت |
|---|---|---|---|---|---|---|
| یک‌ماهه | ۵۹۸٬۰۰۰ | بازار | ۴۶۲٬۰۰۰ | ۸۴٬۰۰۰ | ۱۸٪ | ۳۷۸٬۰۰۰ |
| یک‌ماهه | ۵۹۸٬۰۰۰ | زرین‌پال | ۵۳۸٬۰۰۰ | ۸۴٬۰۰۰ | ۱۶٪ | ۴۵۴٬۰۰۰ |
| سه‌ماهه | ۸۹۸٬۰۰۰ | بازار | ۶۹۴٬۰۰۰ | ۲۵۲٬۰۰۰ | ۳۶٪ | ۴۴۲٬۰۰۰ |
| سه‌ماهه | ۸۹۸٬۰۰۰ | زرین‌پال | ۸۰۸٬۰۰۰ | ۲۵۲٬۰۰۰ | ۳۱٪ | ۵۵۶٬۰۰۰ |
| شش‌ماهه | ۱٬۳۹۸٬۰۰۰ | بازار | ۱٬۰۸۰٬۰۰۰ | ۵۰۴٬۰۰۰ | ۴۷٪ | ۵۷۶٬۰۰۰ |
| شش‌ماهه | ۱٬۳۹۸٬۰۰۰ | زرین‌پال | ۱٬۲۵۸٬۰۰۰ | ۵۰۴٬۰۰۰ | ۴۰٪ | ۷۵۴٬۰۰۰ |
پس حتی اگر هر مشترک هر روز تا آخرِ سقف بپرسد، دستِ‌کم ۶۵٪ِ درآمدِ بازار می‌ماند (هزینه‌ی سرور جدا). مصرفِ واقعی کمتر
است: روی سرور (فقط دمو و آزمونگرها، ۲۲ روز‌ـکاربر) میانه‌ی روزهایی که کسی پرسید ۳ سوال بود، و همه هر روز نمی‌آیند؛ با
کاربرِ واقعی دوباره بسنجید (پرس‌وجوی بالا). هر ردیف با دو برابر شدنِ سقف تقریباً دو برابر می‌شود: شش‌ماهه با ۵ در روز ۵۸٪.
سقف در ادمین (`Plan.ai_daily_quota`) عوض می‌شود و کارتِ خرید آن را از همان عدد نشان می‌دهد.
**کاربرِ رایگان:** فصلِ اولِ هر کتاب و `AI_DAILY_QUOTA_FREE` (۲) سوال در روز. درآمد ندارد، پس هزینه‌اش خالص است: ۱٬۴۰۰
تومان برای هر روزی که می‌آید و هر دو سوال را می‌پرسد (۴۲٬۰۰۰ تومان اگر یک ماه هر روز بیاید). سقفِ کلِ سامانه
(`AI_GLOBAL_DAILY_MAX`، ۳٬۰۰۰ سوال در روز) بیشترین خرجِ روزانه‌ی دستیار را ~۲٫۱ میلیون تومان نگه می‌دارد.
- **ترتیبِ خرج:** اول سقفِ روزانه (بی‌اشتراک `AI_DAILY_QUOTA_FREE`، پیش‌فرض ۲؛ مشترک `Plan.ai_daily_quota`)، بعد
اعتبارِ بسته‌ی قدیمی اگر مانده. سوالِ رایگانِ بی‌اشتراک‌ها هزینه‌ی خالص است: ۲ × ۷۰۰ = ۱٬۴۰۰ تومان برای هر کاربرِ
فعال در روز.
مصرفِ واقعی کمتر از بدترین حالت است چون همه هر روز نمی‌آیند؛ روی سرور (فقط دمو و آزمونگرها) میانه‌ی روزهایی که کسی
پرسید ۳ سوال بود. با کاربرِ واقعی دوباره بسنجید (پرس‌وجوی بالا). سهمِ دستیار با سقف خطی بالا می‌رود: شش‌ماهه با ۳ در روز
۳۵٪ و با ۵ در روز ۵۸٪ِ درآمدِ بازار است.
- **ترتیبِ خرج:** فقط سقفِ روزانه (بی‌اشتراک `AI_DAILY_QUOTA_FREE`؛ مشترک `Plan.ai_daily_quota`؛ اشتراکِ هدیه‌ی بی‌پلن
`DEFAULT_SUBSCRIBER_AI_QUOTA` = ۴). سوالِ بیشتر فروخته نمی‌شود.
- **چه چیزی خرج می‌شود:** هر سوالی که به API برسد، از جمله ردِ «غیرِ ریاضی» توسطِ مدل. ردِ صافیِ سرور،
جوابِ کش‌شده و جوابی که ساخته نشد چیزی کم نمی‌کنند. `Message.charge` روی هر پیامِ کاربر می‌گوید
سوال از کجا پرداخت شد (`free` / `credit` / `none`).
- **دفترِ اعتبار** `subscription.QuestionCredit`: هر خرید یک ردیفِ مثبت دارد (هر سفارش فقط یک بار، با
قیدِ یکتا) و هر سوال یک ردیفِ ۱−. کم شدن داخلِ همان تراکنشی است که ردیفِ کاربر را قفل می‌کند. دو
سوالِ هم‌زمان با یک اعتبار، و یک Idempotency-Key که هم‌زمان دو بار برسد، از ۲۰۲۶-۱۰-۰۲ با دو thread
واقعی آزموده می‌شوند (`ai_tutor/tests_credit_concurrency.py`): یک جواب و یک اعتبار. بدونِ
`transaction_mode: IMMEDIATE`ِ SQLite همین تست ۵۰۰ می‌گیرد. روی Postgres سنجیده نشده.
- **قلبِ نامحدودِ بسته** در `HeartsPass` است، جدا از `Subscription`، تا خریدِ بسته اشتراکِ کسی را عوض
نکند. از پایانِ کارتِ قبلی ادامه پیدا می‌کند. `HEARTS_ENFORCED` حالا پیش‌فرض روشن است، پس قلبِ صفر
واقعاً جلوی جواب را می‌گیرد، و پایانِ درس یا جزیره‌ی بی‌جواب را هم (`content/tests_hearts_gate.py`).
- **لغو و بازپرداخت** از پنلِ ادمین (`/admin/payment/order/`): بازپرداخت فقط اعتبارِ خرج‌نشده‌ی همان
بسته و روزهای قلبش را پس می‌گیرد؛ پول از پنلِ درگاه برگردانده می‌شود. روند در `docs/operations.md`.
- اپ موجودی را از `GET /api/ai/quota` می‌خواند و بعد از هر جواب از `remainingQuota` و `credits`ِ خودِ
جواب. صفحه‌ی خرید `GET /api/subscription/status` (`questionCredits`، `unlimitedHeartsUntil`) را می‌خواند.
- **کافه‌بازار:** هر بسته باید در پنلِ بازار «محصولِ مصرفی» تعریف شود و اپِ اندروید بعد از تأیید
consume کند. این هنوز انجام نشده است. سفارشِ بازارِ یک بسته باید با `planId` ساخته شود
(`POST /api/payment/orders`)؛ تا ۲۰۲۶-۱۰-۰۲ این فیلد وجود نداشت و پرداختِ بسته از بازار هیچ اعتباری نمی‌داد.
جوابِ کش‌شده و جوابی که ساخته نشد چیزی کم نمی‌کنند. `Message.charge` روی هر پیامِ کاربر می‌گوید سوال از سهمیه بود
(`free`) یا نه (`none`).
## محافظِ دستیار (`rag/services/guardrails.py`)

View File

@@ -256,7 +256,7 @@ temperature و با `OPENAI_CHAT_REASONING_EFFORT` صدا زده می‌شوند
## قلب‌ها، مثلِ دولینگو
هر پاسخِ غلط یک قلب کم می‌کند (اشتراکِ فعال یا روزهای قلبِ بسته‌ی سوال = قلبِ نامحدود). **هر ۵ ساعت یک
هر پاسخِ غلط یک قلب کم می‌کند (اشتراکِ فعال = قلبِ نامحدود). **هر ۵ ساعت یک
قلب برمی‌گردد** (`HEART_REFILL_INTERVAL`) و **۲۴ ساعت بعد از اولین قلبی که از
حالتِ پر کم شد، همه یک‌جا پر می‌شوند** (`HEART_FULL_REFILL_AFTER`، فیلدِ
`full_refill_at`). `GET /api/user/hearts` هر دو زمان را می‌دهد: `nextRefillAt`

View File

@@ -68,5 +68,5 @@
این تنها عددِ قابلِ اتکاست، چون `hard_limit_usd` موجودی نیست.
۳. **ردیفِ تازه اضافه کنید**، با تاریخ، مدل، عدد و مدرک. ردیفِ قدیمی را نگه دارید. اگر قیمتِ
دستیار عوض شد، قیمتِ بسته‌ها (`subscription.0010`) را هم دوباره بسنجید: سهمِ هزینه از درآمدِ هر
بسته در `docs/ai-cost-and-guardrails.md` با همین عددها حساب شده است.
دستیار عوض شد، سقفِ روزانه‌ی اشتراک‌ها (`Plan.ai_daily_quota`) را هم دوباره بسنجید: سهمِ هزینه از درآمدِ هر
اشتراک در `docs/ai-cost-and-guardrails.md` با همین عددها حساب شده است.

View File

@@ -267,14 +267,14 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
- **«لغوِ سفارشِ در انتظار»**: برای سفارشی که هنوز پرداخت نشده. پولی جابه‌جا نشده است. اگر کاربر
همان لحظه در درگاه پرداخت کرده باشد، تأییدِ زرین‌پال سفارش را دوباره «پرداخت‌شده» می‌کند و او چیزش را می‌گیرد.
- **«بازپرداخت»**: برای سفارشِ پرداخت‌شده. اول یک صفحه‌ی تأیید می‌آید. بعد از تأیید، سفارش
«بازپرداخت‌شده» می‌شود و هر چه داده بود تا جایی که هنوز مانده پس گرفته می‌شود: سوال‌های
خرج‌نشده‌ی همان بسته (موجودی منفی نمی‌شود)، روزهای قلبِ نامحدودِ همان بسته، یا روزهای همان اشتراک.
«بازپرداخت‌شده» می‌شود و روزهای اشتراکِ همان سفارش از پایانِ اشتراک کم می‌شود (اگر چیزی نماند، اشتراک
«لغوشده» می‌شود).
پورسانتِ معرف هم برمی‌گردد. پیامِ بالای صفحه می‌گوید دقیقاً چه چیزی پس گرفته شد.
3. **خودِ پول را این دکمه برنمی‌گرداند.** پول را در پنلِ زرین‌پال برگردانید. خریدِ کافه‌بازار هم از
سمتِ خودِ بازار برگردانده می‌شود.
وضعیتِ سفارش در فرمِ ادمین فقط‌خواندنی است. اگر وضعیت را با دست از «پرداخت‌شده» به «ناموفق» عوض
می‌کردید، اعتبار و اشتراکی که سفارش داده بود سرِ جایش می‌ماند. کاربر بازپرداخت را در صفحه‌ی
می‌کردید، اشتراکی که سفارش داده بود سرِ جایش می‌ماند. کاربر بازپرداخت را در صفحه‌ی
«پرداخت‌ها و فاکتورها» می‌بیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
است و تست‌هایش در `payment/tests_refund.py`.
@@ -283,7 +283,7 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در
برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند و کاربر هر کدام را بخواهد می‌زند: «کارتِ بانکی» (زرین‌پال، پیش‌فرض) و
«کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد، کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. فقط اشتراک فروخته می‌شود
(بسته‌ها `on_sale=False`، `subscription.0016`)؛ مسیرِ بسته (`inapp`، consume) برای روزی که ادمین دوباره روشنش کند مانده. داخلِ اپ
(بسته‌ی سوال از ریشه حذف شد، `subscription.0017`/`0018`). داخلِ اپ
زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمی‌دهد).
تفاوتِ روش‌ها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش
@@ -291,7 +291,7 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
| چه | کجا | الان |
|---|---|---|
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344` (تأییدِ سینیور) |
| محصول در پیشخانِ بازار | محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند) | یک‌ماهه `10342`، سه‌ماهه `10343`، شش‌ماهه `10344` (تأییدِ سینیور) |
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`) |
| قیمت | بازار قیمتِ پیشخان را می‌گیرد؛ ادمین باید با آن یکی باشد | ۵۹۸/۸۹۸/۱٬۳۹۸ هزار تومان در هر دو (`subscription.0016`، به گفته‌ی سینیور)؛ داخلِ اپ قیمتِ بازار روی کاشی دیده می‌شود |
| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار می‌کند |
@@ -299,13 +299,12 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
مسیر:
1. `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` → سفارش، `bazaarSku`، `bazaarProductType`.
1. `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` → سفارش و `bazaarSku`.
2. (اختیاری) `POST /api/discount/redeem` روی همان سفارش. فقط با کلیدِ «تخفیف پویا»، وگرنه ۴۰۰.
3. `POST /api/payment/bazaar/orders/{id}/start` → SKU، نوع، و اگر تخفیف خورده `dynamicPriceToken` (JWTِ HS256 با مبلغ
3. `POST /api/payment/bazaar/orders/{id}/start` → SKU، و اگر تخفیف خورده `dynamicPriceToken` (JWTِ HS256 با مبلغ
به ریال). بی‌تخفیف توکن نمی‌رود چون بازارِ قدیمی‌تر از ۱۳.۳ با آن خطا می‌دهد. از این لحظه کدِ تخفیف نمی‌نشیند.
4. اپ با Poolakey می‌خرد (`subscribeProduct` یا `purchaseProduct`، `payload` = شماره‌ی سفارش).
5. `POST /api/payment/bazaar/verify` توکن را از خودِ بازار می‌سنجد (نوعِ محصول از سفارش) و اشتراک را فعال یا
اعتبار را اضافه می‌کند. فقط بسته بعد از آن consume می‌شود.
4. اپ با Poolakey اشتراک می‌خرد (`subscribeProduct`، `payload` = شماره‌ی سفارش).
5. `POST /api/payment/bazaar/verify` توکن را از خودِ بازار می‌سنجد و اشتراک را فعال می‌کند. اشتراک consume نمی‌شود.
**تمدیدِ خودکار:** بازار به ما خبر نمی‌دهد. وقتی دوره‌ی پرداخت‌شده‌ی کاربر (`Order.bazaar_valid_until`) کمتر از یک روز
مانده یا گذشته، با باز شدنِ وضعیتِ اشتراک (`GET /api/subscription/status`) همان توکن از بازار پرسیده می‌شود (هر توکن
@@ -313,7 +312,7 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
(`payment.services.sync_bazaar_renewals`). مبلغِ آن سفارش قیمتِ فعلیِ ادمین است، چون بازار مبلغِ تمدید را نمی‌گوید.
اگر بازار در دسترس نباشد، کاربرِ تمدیدشده تا تلاشِ ساعتِ بعد پشتِ دیوار می‌ماند. لغوِ تمدید از خودِ بازار است.
تست‌ها: `payment/tests_bazaar_subscription.py`، `payment/tests_bazaar_pack.py`، `payment/tests_bazaar_oauth.py`.
تست‌ها: `payment/tests_bazaar_subscription.py`، `payment/tests_bazaar_oauth.py`.
**هیچ خریدِ واقعی‌ای از بازار انجام نشده**؛ نه پذیرفتنِ JWT از سوی بازار و نه تمدیدِ واقعی.
## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)

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 |