Grades 4 and 5, Bazaar purchase from the app, pack SKUs

- rag: MATH_4 / MATH_5 textbooks (TOC and page offsets read from the PDFs)
- content: grades 4 and 5 in the seed files: 7 units / 28 nodes each,
  204 + 194 questions (every node >= 4), all re-solved blind once
- subscription: Plan.bazaar_sku (admin); POST /api/subscription/purchase
  accepts paymentMethod=bazaar for packs, rejects unknown methods (was ignored)
- payment: idempotency key cannot switch provider; discount codes refused
  on Bazaar orders (Bazaar charges the fixed SKU price)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 20:01:44 +03:30
parent ab5e53730a
commit 8cb326ba94
15 changed files with 16699 additions and 3897 deletions

View File

@@ -435,6 +435,27 @@ preferred setup, because there is then no key to leak. **Never let the server ru
`django-insecure-…` fallback from `config/settings.py`: that string is committed here, so
anyone who can read this repo can forge a token for any user.**
## وضعیت ۲۰۲۶-۱۰-۰۳ (عصر) — پایه‌ی چهارم و پنجم، خریدِ بازار از اپ، بهبودِ UI (هنوز روی سرور نیست)
- **چهارم و پنجم** (درخواستِ دکتر: «از سوم به بعد»): `MATH_4`/`MATH_5` در `rag/services/textbooks.py`
(فهرست و offset از خودِ PDF؛ پنجم چاپِ ۱۴۰۵ با offset صفر). ingest با gpt-4.1-mini ≈ ۰٫۷ دلار
(۵۹۰ + ۵۹۴ chunk)، verify ≈ ۰٫۲؛ هر دو روی Qdrant. هر پایه ۷ فصل و ۲۸ جزیره؛ **۲۰۴ و ۱۹۴ سوال**
با subagentِ Sonnet (حداکثر ۸ نامزد برای هر جزیره، نه همه‌ی ۵۷۳ جفت)، و هر جزیره دستِ‌کم ۴ سوال.
**همه‌ی ۳۹۸ سوال یک بار کور دوباره حل شدند** (subagentِ دیگر، بی‌دیدنِ کلید): ۳ مورد واقعاً غلط/مبهم
بود و اصلاح شد (یکی کلیدِ غلط: «عدد ششمِ ۱۲۰، ۲۴۰، ۳۶۰» = ۷۲۰ نه ۶۰۰). دو جزیره‌ی تقارنِ پنجم در
کتاب فقط رسم روی شطرنجی دارند؛ ۱۲ سوالِ بی‌شکل برایشان نوشته شد. `seed_content` روی DBِ خالی آزموده شد.
⚠️ `fill_lesson_texts` هم‌زمان با `rag_verify_solutions` روی SQLite «database is locked» داد و verify
را نیمه‌کاره گذاشت (بی‌صدا تا شمردنِ رأی‌ها)؛ دو فرمانِ نویسنده را هم‌زمان اجرا نکنید.
- **خریدِ بازار از مسیرِ کاربر:** تا امروز سفارشِ بازار فقط با کلیدِ داخلی ساخته می‌شد و `paymentMethod`
هر مقداری را بی‌صدا نادیده می‌گرفت. حالا `Plan.bazaar_sku` (ادمین، `subscription.0012`) و
`POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته)؛ کدِ تخفیف روی سفارشِ بازار
۴۰۰، Idempotency-Key با روشِ پرداختِ دیگر ۴۰۹. فرانت: `src/lib/bazaar.ts` (پل به اپِ اندروید) و
`android/` (پوسته‌ی WebView + Poolakey) — **هرگز build نشده** (این ماشین Android SDK ندارد). از سنیور
لازم: اسمِ پکیج، کلیدِ RSA، SKUِ بسته‌ها در پیشخان. شرح: `docs/operations.md` «فروشِ بسته در کافه‌بازار».
- **UI** (فرانت `f8cf86e`): رنگِ متنِ کم‌رنگ ‎#727299 (کنتراست ۲٫۶ → ۴٫۵)، برچسبِ تب ۱۱px، عنوانِ فصل
در نقشه دوخطی، کاهشِ حرکت، حلقه‌ی فوکوس، بی pull-to-refresh، لرزشِ کوتاهِ جواب. با اسکرین‌شاتِ
قبل/بعد وارسی شد. manifestِ PWA عمداً اضافه نشد (رفتارِ برگشت از زرین‌پال در حالتِ standalone آزموده نشده).
## وضعیت ۲۰۲۶-۱۰-۰۲ — تصمیم‌های سینیور پس از ارائه
- **بخشِ والدین از ریشه حذف شد.** اپِ `family` دیگر وجود ندارد. جدول‌هایش، مجوزهای ادمینش و ردش در

View File

@@ -27,6 +27,9 @@ from rag.services.config import build_collection_name
from rag.services.textbooks import TEXTBOOKS, get_textbook
GRADE_STYLE = {
# چهارم/پنجم (۲۰۲۶-۱۰-۰۳): رنگ‌های گرمِ روشن، کنارِ سبزِ ششم، تا سه پایه‌ی دبستان یک خانواده باشند.
'چهارم': ('grade-4', '#FFB300'),
'پنجم': ('grade-5', '#FF7043'),
'ششم': ('grade-6', '#4CAF50'),
'هفتم': ('grade-7', '#2196F3'),
'هشتم': ('grade-8', '#9C27B0'),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -19,7 +19,8 @@
| سوم، ششم، هفتم | روی Qdrant هست (ساخته‌ی تیم دیگر) — PDF نداریم جز هفتم |
| هشتم، نهم، دهم | ingest شده؛ بازخوانیِ صفحه‌های شکل‌دار مانده |
| یازدهم، دوازدهم | در نوبتِ ingest (۱۴۰۵/۰۶/۲۸) |
| اول، دوم، چهارم، پنجم | PDF موجود است، خارج از اولویتِ فعلی |
| چهارم، پنجم | ingest شد ۲۰۲۶-۱۰-۰۳ (درخواستِ دکتر: «از سوم به بعد»)؛ پنجم چاپِ ۱۴۰۵ است |
| اول، دوم | PDF موجود است، خارج از اولویتِ فعلی |
⚠️ کتابِ ریاضیِ یازدهم و دوازدهم چند رشته دارد (تجربی، ریاضی‌فیزیک، انسانی).
نامِ فایل و `Textbook.grade` باید رشته را مشخص کند، وگرنه دو کتابِ متفاوت

View File

@@ -128,6 +128,10 @@ class RedeemDiscountView(APIView):
from payment.models import Order
# سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
# بازار قیمتِ ثابتِ SKU را می‌گیرد، هرچه مبلغِ سفارش باشد؛ تخفیف روی سفارشِ بازار فقط
# کد را می‌سوزاند و کاربر همان قیمتِ کامل را می‌پرداخت.
if order.provider == 'bazaar':
raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافه‌بازار اعمال نمی‌شود.'})
try:
with transaction.atomic():

View File

@@ -197,6 +197,23 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
«پرداخت‌ها و فاکتورها» می‌بیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
است و تست‌هایش در `payment/tests_refund.py`.
## فروشِ بسته در کافه‌بازار
از ۲۰۲۶-۱۰-۰۳. اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) بسته‌ها را با
پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد؛ در مرورگرِ معمولی همه‌چیز مثلِ قبل از زرین‌پال است.
1. در پیشخانِ بازار برای هر بسته یک **محصولِ مصرف‌شدنی** با همان قیمت بسازید.
2. شناسه‌ی آن (SKU) را در `/admin/subscription/plan/` در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU
را فقط از همین‌جا می‌گیرد، نه از اپ؛ بسته‌ی بی‌SKU داخلِ اپ «به‌زودی در بازار» نشان داده می‌شود و
درخواستش ۴۰۰ است. یک SKU روی دو بسته نمی‌نشیند.
3. کلیدِ RSAِ عمومیِ اپ (از پیشخان) در `android/app/build.gradle.kts` می‌رود، نه در `.env`.
مسیر: `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته) سفارش می‌سازد و
`bazaarSku` برمی‌گرداند؛ اپ با Poolakey می‌خرد (`payload` = شماره‌ی سفارش)؛ `POST /api/payment/bazaar/verify`
توکن را از خودِ بازار می‌سنجد و اعتبار می‌دهد. کدِ تخفیف روی سفارشِ بازار رد می‌شود (بازار قیمتِ
ثابتِ محصول را می‌گیرد). تست‌ها: `payment/tests_bazaar_pack.py`. **هنوز هیچ خریدِ واقعی‌ای از بازار
انجام نشده.**
## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)
تا وقتی `KAVENEGAR_API_KEY` ست نشده، با `DEBUG=0` هیچ‌کس نمی‌تواند با شماره

View File

@@ -25,7 +25,8 @@ class IdempotencyKeyConflict(APIException):
def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currency: str,
subscription_id: int | None, plan_id: int | None = None) -> None:
subscription_id: int | None, plan_id: int | None = None,
provider: str | None = None) -> None:
if order.user_id != user_id:
raise IdempotencyKeyConflict(
'این Idempotency-Key متعلق به کاربر دیگری است و نمی‌تواند استفاده شود.'
@@ -35,7 +36,8 @@ def _ensure_idempotent_match(order: Order, *, user_id: int, amount: int, currenc
discounted = order.amount < amount and order.status == 'pending'
if ((order.amount != amount and not discounted) or order.currency != currency
or order.subscription_id != subscription_id
or (plan_id is not None and order.plan_id not in (None, plan_id))):
or (plan_id is not None and order.plan_id not in (None, plan_id))
or (provider is not None and order.provider != provider)):
raise IdempotencyKeyConflict(
'این Idempotency-Key قبلاً با پارامترهای متفاوتی استفاده شده است.'
)
@@ -70,7 +72,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
if existing is not None:
_ensure_idempotent_match(
existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id, plan_id=plan_id,
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
)
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
return existing, payment_url
@@ -97,7 +99,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
raise
_ensure_idempotent_match(
order, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id, plan_id=plan_id,
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
)
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً

View File

@@ -94,3 +94,79 @@ class BazaarSkuComesFromTheOrderTests(TestCase):
verify.assert_not_called()
order.refresh_from_db()
self.assertEqual(order.status, 'pending')
class BazaarPurchaseFromTheAppTests(TestCase):
"""
خریدِ بازار از مسیرِ خودِ کاربر: `POST /api/subscription/purchase` با
`paymentMethod: "bazaar"` (۲۰۲۶-۱۰-۰۳). پیش از آن تنها راهِ ساختِ سفارشِ بازار کلیدِ
داخلی بود و `paymentMethod` هر مقداری را می‌پذیرفت و نادیده می‌گرفت.
"""
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007103', is_guest=False)
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.pack = Plan.objects.get(kind='pack', question_credits=20)
self.pack.bazaar_sku = 'tadrisino_pack_20'
self.pack.save()
def tearDown(self):
cache.clear()
def buy(self, plan, method='bazaar', **headers):
return self.client.post('/api/subscription/purchase',
{'planId': str(plan.id), 'paymentMethod': method}, format='json', **headers)
def test_bazaar_order_carries_the_plans_sku_and_no_payment_url(self):
response = self.buy(self.pack)
self.assertEqual(response.status_code, 200, response.content)
body = response.json()
self.assertIsNone(body['paymentUrl'])
self.assertEqual(body['bazaarSku'], 'tadrisino_pack_20')
order = Order.objects.get(pk=body['orderId'])
self.assertEqual((order.provider, order.bazaar_product_sku, order.plan_id),
('bazaar', 'tadrisino_pack_20', self.pack.id))
def test_whole_flow_gives_the_pack_credits(self):
order_id = self.buy(self.pack).json()['orderId']
with patch('payment.bazaar.verify_purchase',
return_value=BazaarPurchaseResult(is_valid=True, purchase_state=0)) as verify:
response = self.client.post('/api/payment/bazaar/verify',
{'orderId': order_id, 'purchaseToken': 'tok-app-1'}, format='json')
self.assertEqual(response.json()['status'], 'paid')
self.assertEqual(verify.call_args.kwargs['product_sku'], 'tadrisino_pack_20')
self.assertEqual(credit_balance(self.user.id), 20)
def test_pack_without_a_sku_is_refused_not_sent_to_zarinpal(self):
other = Plan.objects.get(kind='pack', question_credits=60)
response = self.buy(other)
self.assertEqual(response.status_code, 400)
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
def test_unknown_payment_method_is_a_400(self):
self.assertEqual(self.buy(self.pack, method='paypal').status_code, 400)
def test_zarinpal_is_still_the_default(self):
response = self.client.post('/api/subscription/purchase', {'planId': str(self.pack.id)}, format='json')
self.assertEqual(response.status_code, 200)
self.assertIn('/pay', response.json()['paymentUrl'])
self.assertEqual(Order.objects.get(pk=response.json()['orderId']).provider, 'gateway')
def test_same_idempotency_key_cannot_switch_payment_method(self):
self.assertEqual(self.buy(self.pack, HTTP_IDEMPOTENCY_KEY='k-1').status_code, 200)
self.assertEqual(self.buy(self.pack, method='zarinpal', HTTP_IDEMPOTENCY_KEY='k-1').status_code, 409)
def test_discount_code_is_refused_on_a_bazaar_order(self):
from discount.models import DiscountCode
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
order_id = self.buy(self.pack).json()['orderId']
response = self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json')
self.assertEqual(response.status_code, 400)
self.assertEqual(Order.objects.get(pk=order_id).amount, self.pack.price)
def test_plans_list_exposes_the_sku(self):
plans = self.client.get('/api/subscription/plans').json()['plans']
self.assertEqual(next(p for p in plans if p['id'] == str(self.pack.id))['bazaarSku'], 'tadrisino_pack_20')

View File

@@ -95,6 +95,112 @@ MATH_3 = Textbook(
),
)
# چهارم و پنجم: درخواستِ دکتر (۲۰۲۶-۱۰-۰۳) که «از سوم به بعد» همه‌ی پایه‌ها باشند.
# فهرست و offset از خودِ PDFها خوانده شد: در چهارم PDF ۸ = صفحه‌ی چاپیِ ۲، در
# پنجم (چاپِ تازه‌ی ۱۴۰۵) شماره‌ی PDF همان شماره‌ی چاپی است. «حلِ مسئله»ی آغازِ
# هر فصل جزءِ درسِ اول حساب می‌شود (`_sectioned` درسِ اول را تا ابتدای فصل
# عقب می‌کشد) و «مرور فصل» جزءِ درسِ آخر.
MATH_4 = Textbook(
subject='ریاضی', grade='چهارم', grade_label='چهارم دبستان',
filename='ریاضی چهارم.pdf', page_offset=6,
chapters=_ch(
(1, 'فصل ۱: اعداد و الگوها', 1, 23, (
(1, 'عددنویسی', 4),
(2, 'الگوها', 8),
(3, 'ماشین ورودی ـ خروجی', 12),
(4, 'معرفی میلیون', 16),
)),
(2, 'فصل ۲: کسر', 24, 45, (
(1, 'شناخت کسرها', 26),
(2, 'جمع و تفریق کسرها', 30),
(3, 'تساوی کسرها', 34),
(4, 'ضرب عدد در کسر', 38),
)),
(3, 'فصل ۳: ضرب و تقسیم', 46, 75, (
(1, 'ضرب دو عدد دورقمی', 48),
(2, 'محاسبهٔ حاصل‌ضرب', 52),
(3, 'محاسبه‌های تقریبی', 56),
(4, 'تقسیم و بخش‌پذیری', 60),
(5, 'تقسیم بر عددهای یک‌رقمی', 64),
(6, 'تقسیم بر عددهای دورقمی', 68),
)),
(4, 'فصل ۴: اندازه‌گیری', 76, 97, (
(1, 'زاویه', 78),
(2, 'اندازه‌گیری زاویه', 82),
(3, 'اندازه‌گیری زمان', 86),
(4, 'اندازه‌گیری طول', 90),
)),
(5, 'فصل ۵: عدد مخلوط و عدد اعشاری', 98, 119, (
(1, 'عدد مخلوط', 100),
(2, 'عدد اعشاری', 104),
(3, 'جمع و تفریق اعشاری', 108),
(4, 'ارزش مکانی عددهای اعشاری', 112),
)),
(6, 'فصل ۶: شکل‌های هندسی', 120, 141, (
(1, 'عمود و موازی', 122),
(2, 'چهارضلعی‌ها', 126),
(3, 'مساحت متوازی‌الاضلاع و مثلث', 130),
(4, 'محیط و مساحت', 134),
)),
(7, 'فصل ۷: آمار و احتمال', 142, 154, (
(1, 'نمودار خط شکسته', 144),
(2, 'احتمال', 148),
)),
),
)
# شروعِ هر فصل یک صفحه پیش از درسِ اولش گرفته شد (صفحه‌ی جداکننده)؛ فقط برای
# فصل ۱ با چشم دیده شد (PDF ۱۱). جداکننده‌ی بقیه اگر دو صفحه باشد، یک صفحه‌ی
# تصویریِ بی‌مسئله به درسِ آخرِ فصلِ قبل می‌افتد. ۲۰۷–۲۰۸ پشتِ جلد است.
MATH_5 = Textbook(
subject='ریاضی', grade='پنجم', grade_label='پنجم دبستان',
filename='ریاضی پنجم.pdf', page_offset=0,
chapters=_ch(
(1, 'فصل ۱: عددنویسی و الگوها', 11, 38, (
(1, 'یادآوری عددنویسی و محاسبات عددی', 12),
(2, 'معرفی میلیارد', 18),
(3, 'جمع و تفریق عددهای مرکب', 25),
(4, 'الگوها', 30),
)),
(2, 'فصل ۲: کسر', 39, 76, (
(1, 'کسرهای بزرگ‌تر از واحد', 40),
(2, 'جمع و تفریق عددهای مخلوط', 46),
(3, 'ضرب کسرها', 53),
(4, 'تقسیم کسرها', 59),
(5, 'ضرب عددهای مخلوط', 66),
)),
(3, 'فصل ۳: نسبت، تناسب و درصد', 77, 102, (
(1, 'نسبت', 78),
(2, 'نسبت‌های مساوی', 83),
(3, 'تناسب', 88),
(4, 'درصد', 93),
)),
(4, 'فصل ۴: تقارن و چندضلعی‌ها', 103, 128, (
(1, 'تقارن محوری', 104),
(2, 'تقارن مرکزی', 110),
(3, 'زاویه و نیمساز', 113),
(4, 'چندضلعی‌ها و مجموع زاویه‌های آن‌ها', 118),
)),
(5, 'فصل ۵: عددهای اعشاری', 129, 156, (
(1, 'عددهای اعشاری', 130),
(2, 'جمع عددهای اعشاری', 139),
(3, 'تفریق عددهای اعشاری', 143),
(4, 'ضرب عددهای اعشاری', 147),
)),
(6, 'فصل ۶: اندازه‌گیری', 157, 184, (
(1, 'مساحت لوزی و ذوزنقه', 158),
(2, 'محیط دایره', 165),
(3, 'حجم', 170),
(4, 'گنجایش', 177),
)),
(7, 'فصل ۷: آمار و احتمال', 185, 206, (
(1, 'جمع‌آوری و نمایش داده‌ها', 186),
(2, 'میانگین', 193),
(3, 'احتمال', 197),
)),
),
)
# هشتم تنها کتابی‌ست که هم PDF دارد و هم روی Qdrant نیست — یعنی فصل‌بندی و
# درس‌هایش کاملاً از فهرستِ خودِ کتاب می‌آید و ingest دقیقاً همین رشته‌ها را
# می‌نویسد. «مرور فصل» جزء آخرین درسِ هر فصل حساب شده است.
@@ -365,7 +471,7 @@ CALCULUS_12 = Textbook(
TEXTBOOKS: dict[str, Textbook] = {
b.key: b for b in (MATH_3, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10,
b.key: b for b in (MATH_3, MATH_4, MATH_5, MATH_6, MATH_7, MATH_8, MATH_9, MATH_10,
CALCULUS_11, CALCULUS_12)
}

View File

@@ -5,7 +5,8 @@ from .models import Plan, Subscription
@admin.register(Plan)
class PlanAdmin(admin.ModelAdmin):
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular')
list_display = ('id', 'title', 'price', 'currency', 'duration_days', 'ai_daily_quota', 'discount_percent', 'popular',
'bazaar_sku')
list_editable = ('ai_daily_quota',)

View File

@@ -0,0 +1,22 @@
# Generated by Django 5.2.17 on 2026-10-03 12:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('subscription', '0011_questioncredit_refund_reason'),
]
operations = [
migrations.AddField(
model_name='plan',
name='bazaar_sku',
field=models.CharField(blank=True, default='', help_text='SKUِ محصولِ درون\u200cبرنامه\u200cای در پیشخانِ کافه\u200cبازار؛ خالی = در بازار فروخته نمی\u200cشود.', max_length=100),
),
migrations.AddConstraint(
model_name='plan',
constraint=models.UniqueConstraint(condition=models.Q(('bazaar_sku', ''), _negated=True), fields=('bazaar_sku',), name='plan_bazaar_sku_unique_when_set'),
),
]

View File

@@ -43,6 +43,18 @@ class Plan(models.Model):
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
# پلنی که دیگر فروخته نمی‌شود ولی مشترک‌های فعلی‌اش هنوز به آن وصل‌اند.
on_sale = models.BooleanField(default=True, help_text='در صفحه‌ی خرید نشان داده شود.')
# شناسه‌ی همین بسته در پیشخانِ کافه‌بازار (محصولِ درون‌برنامه‌ای، مصرف‌شدنی). خریدِ بازار
# SKU را فقط از اینجا می‌گیرد، هرگز از کلاینت: وگرنه توکنِ ارزان‌ترین محصول بسته‌ی گران را
# فعال می‌کرد. خالی یعنی این بسته در بازار فروخته نمی‌شود (درخواستش ۴۰۰ می‌گیرد).
bazaar_sku = models.CharField(
max_length=100, blank=True, default='',
help_text='SKUِ محصولِ درون‌برنامه‌ای در پیشخانِ کافه‌بازار؛ خالی = در بازار فروخته نمی‌شود.')
class Meta:
constraints = [
models.UniqueConstraint(fields=['bazaar_sku'], condition=~models.Q(bazaar_sku=''),
name='plan_bazaar_sku_unique_when_set'),
]
def __str__(self):
return self.title

View File

@@ -15,6 +15,8 @@ class PlanSerializer(serializers.Serializer):
kind = serializers.ChoiceField(choices=['pack', 'subscription'])
questionCredits = serializers.IntegerField(help_text='بسته: سوال‌هایی که به اعتبار اضافه می‌شود (منقضی نمی‌شود).')
heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.')
bazaarSku = serializers.CharField(
allow_blank=True, help_text='SKUِ همین بسته در کافه‌بازار؛ خالی یعنی در بازار خریدنی نیست.')
class PlansListResponseSerializer(serializers.Serializer):
@@ -27,12 +29,17 @@ class PurchaseRequestSerializer(serializers.Serializer):
# هدر Idempotency-Key هم روی همین درخواست لازمه (بخش ۴.۳ سند API)؛
# از request.headers خونده می‌شه، نه از بدنه.
planId = serializers.CharField()
paymentMethod = serializers.CharField(required=False, default='zarinpal')
# تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته می‌شد، پس «bazaar» بی‌صدا به زرین‌پال می‌رفت.
paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal')
class PurchaseResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
paymentUrl = serializers.CharField()
paymentUrl = serializers.CharField(
allow_null=True, help_text='زرین‌پال: آدرسی که کاربر به آن برود. بازار: null.')
bazaarSku = serializers.CharField(
required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey می‌خرد؛ '
'developerPayload همان orderId باشد.')
class SubscriptionStatusResponseSerializer(serializers.Serializer):

View File

@@ -5,6 +5,7 @@ from django.db.models import F
from django.shortcuts import get_object_or_404
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle
@@ -69,6 +70,7 @@ class PlansListView(APIView):
'kind': p.kind,
'questionCredits': p.question_credits,
'heartsDays': p.hearts_days,
'bazaarSku': p.bazaar_sku,
}
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
],
@@ -119,6 +121,21 @@ class PurchaseView(APIView):
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
if v['paymentMethod'] == 'bazaar':
# فقط بسته: اشتراک‌ها دیگر فروخته نمی‌شوند و مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز
# با خریدِ واقعی آزموده نشده. SKU از پلن، نه از کلاینت.
if plan.kind != 'pack':
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'})
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این بسته هنوز در کافه‌بازار تعریف نشده است.'})
order, _ = create_order(
user_id=user.id, amount=plan.price, currency=plan.currency,
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
provider='bazaar', bazaar_product_sku=plan.bazaar_sku,
)
return Response(PurchaseResponseSerializer(
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
if plan.kind == 'pack':
# بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی
# خودِ سفارش داده می‌شوند (payment.services → subscription.services.grant_pack).