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:
@@ -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ی وجود ندارد: کلاینت اندروید مستقیماً
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user