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

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