Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync

- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
  response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
  code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
  and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
  the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 17:53:11 +03:30
parent 2e0753587c
commit 8077ab4350
20 changed files with 580 additions and 40 deletions

View File

@@ -22,10 +22,15 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api
fail-closed عمل می‌کنه و invalid برمی‌گردونه؛ هیچ‌وقت به purchaseToken
ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه.
"""
import base64
import hashlib
import hmac
import json
import logging
import secrets
import time
from dataclasses import dataclass
from datetime import timedelta
from datetime import datetime, timedelta, timezone as dt_timezone
from urllib.parse import quote, urlencode
import requests
@@ -56,6 +61,9 @@ class BazaarPurchaseResult:
consumption_state: int | None = None
developer_payload: str = ''
raw: dict | None = None
# فقط اشتراک: پایانِ دوره‌ی پرداخت‌شده (با هر تمدیدِ خودکار جلو می‌رود) و این‌که تمدید روشن است.
valid_until: 'datetime | None' = None
auto_renewing: bool | None = None
def _authorize_window() -> timedelta:
@@ -290,7 +298,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
def fetch(subscription: bool):
if subscription:
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}'
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}/'
else:
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
try:
@@ -327,7 +335,13 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
# سند می‌گوید validUntil را با اکنون مقایسه کنید.
valid_until = data.get('validUntilTimestampMsec') or 0
is_valid = valid_until > timezone.now().timestamp() * 1000
purchase_state = None
return BazaarPurchaseResult(
is_valid=is_valid,
developer_payload=data.get('developerPayload', ''),
raw=data,
valid_until=datetime.fromtimestamp(valid_until / 1000, tz=dt_timezone.utc) if valid_until else None,
auto_renewing=data.get('autoRenewing'),
)
else:
purchase_state = data.get('purchaseState')
is_valid = purchase_state == 0 # ۰ یعنی خریداری‌شده؛ ۱ یعنی refund
@@ -338,3 +352,36 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
developer_payload=data.get('developerPayload', ''),
raw=data,
)
def dynamic_price_configured() -> bool:
return bool(settings.BAZAAR_DYNAMIC_PRICE_KEY)
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
def dynamic_price_token(*, sku: str, price_rial: int, ttl_seconds: int = 30 * 60) -> str:
"""
JWTِ «تخفیف پویا»ی بازار (سندِ dynamic-discount، خوانده‌شده ۲۰۲۶-۱۰-۱۲): HS256 با کلیدِ پیشخان،
payload = price (ریال)، package_name، sku، exp، nonce. اپ آن را در PurchaseRequest.dynamicPriceToken
می‌گذارد و بازار همین مبلغ را می‌گیرد؛ اگر از قیمتِ پیشخان بیشتر باشد خطای ۱۰ می‌دهد.
یک‌بارمصرف است (nonce). بی‌کتابخانه، چون فقط HS256 لازم است.
نسنجیده: خریدِ واقعی با این توکن (اپِ تازه هنوز ساخته نشده).
"""
key = settings.BAZAAR_DYNAMIC_PRICE_KEY
if not key:
raise ValueError('BAZAAR_DYNAMIC_PRICE_KEY is not set')
header = {'alg': 'HS256', 'typ': 'JWT'}
payload = {
'price': int(price_rial),
'package_name': settings.BAZAAR_PACKAGE_NAME,
'sku': sku,
'exp': int(time.time()) + ttl_seconds,
'nonce': secrets.token_hex(12),
}
signing_input = '.'.join(
_b64url(json.dumps(part, separators=(',', ':')).encode()) for part in (header, payload))
signature = hmac.new(key.encode(), signing_input.encode(), hashlib.sha256).digest()
return f'{signing_input}.{_b64url(signature)}'

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.2.17 on 2026-10-12 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payment', '0011_order_plan_snapshot'),
]
operations = [
migrations.AddField(
model_name='order',
name='bazaar_price_rial',
field=models.PositiveIntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='order',
name='bazaar_valid_until',
field=models.DateTimeField(blank=True, null=True),
),
]

View File

@@ -44,6 +44,12 @@ class Order(models.Model):
# بازار می‌گیره و برای verify به بک‌اند می‌فرسته.
bazaar_product_sku = models.CharField(max_length=100, blank=True)
bazaar_purchase_token = models.CharField(max_length=300, blank=True)
# مبلغی (ریال) که در JWTِ «تخفیف پویا» برای همین سفارش امضا شد؛ بعد از آن کدِ تخفیف روی سفارش
# نمی‌نشیند (همان قاعده‌ی gateway_authority). null یعنی بازار قیمتِ پیشخان را گرفته/می‌گیرد.
bazaar_price_rial = models.PositiveIntegerField(null=True, blank=True)
# فقط اشتراکِ بازار: پایانِ دوره‌ای که همین سفارش پولش را داده، از پاسخِ بازار. تمدیدِ خودکار با
# جلو رفتنِ این عدد در بازار دیده می‌شود (payment.services.sync_bazaar_renewals).
bazaar_valid_until = models.DateTimeField(null=True, blank=True)
# فقط وقتی provider == 'gateway' (زرین‌پال) پر می‌شه: Authorityِ برگشتی از
# PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ
# ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرین‌پال

View File

@@ -25,6 +25,17 @@ class BazaarVerifyRequestSerializer(serializers.Serializer):
purchaseToken = serializers.CharField()
class BazaarStartResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
sku = serializers.CharField(help_text='productIdِ PurchaseRequestِ Poolakey.')
productType = serializers.ChoiceField(
choices=['inapp', 'subscription'],
help_text='inapp: purchaseProduct و بعد از تأییدِ سرور consume. subscription: subscribeProduct، بی‌consume.')
amount = serializers.IntegerField(help_text='مبلغِ سفارش (تومان)، پس از کدِ تخفیف.')
dynamicPriceToken = serializers.CharField(
allow_null=True, help_text='JWTِ «تخفیف پویا» برای PurchaseRequest.dynamicPriceToken؛ null یعنی قیمتِ پیشخان.')
class BazaarVerifyResponseSerializer(serializers.Serializer):
orderId = serializers.CharField()
status = serializers.CharField()

View File

@@ -4,6 +4,7 @@
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
import hashlib
import logging
from django.conf import settings
@@ -281,12 +282,97 @@ def reconcile_pending_order(order: Order) -> bool:
def reconcile_user_orders(user_id: int) -> None:
"""سفارش‌های pendingِ اخیرِ یک کاربر؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود."""
"""سفارش‌های pendingِ اخیرِ یک کاربر و تمدیدِ اشتراکِ بازار؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود."""
since = timezone.now() - timezone.timedelta(days=2)
for order in Order.objects.filter(
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
).exclude(gateway_authority='')[:3]:
reconcile_pending_order(order)
sync_bazaar_renewals(user_id)
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده می‌شود که تمدید شده یا نه. بازار روزِ آخر تمدید می‌کند.
BAZAAR_RENEWAL_LOOKAHEAD = timezone.timedelta(days=1)
def sync_bazaar_renewals(user_id: int) -> int:
"""
اشتراکِ بازار خودکار تمدید می‌شود و بازار به ما خبر نمی‌دهد (webhook ندارد). پس وقتی دوره‌ی آخرِ
پرداخت‌شده‌ی کاربر (`bazaar_valid_until`ِ آخرین سفارشِ همان توکن) نزدیکِ پایان یا گذشته است، همان توکن
دوباره از بازار پرسیده می‌شود؛ اگر `validUntil` جلو رفته، برای هر دوره‌ی تازه یک سفارشِ «paid» ساخته و
همان مسیرِ پرداخت (activate_subscription، اعلان، پورسانت) اجرا می‌شود. هر توکن ساعتی یک بار.
مبلغِ سفارشِ تمدید = قیمتِ فعلیِ پلن در ادمین؛ بازار تمدید را با قیمتِ پیشخان می‌گیرد و آن را به ما
نمی‌گوید. روزها مثلِ هر خریدِ دیگر روی پایانِ فعلی اضافه می‌شوند، پس کاربری که چند روز بعد از تمدید
می‌آید همان چند روز را بیشتر دارد. نسنجیده: تمدیدِ واقعی در بازار. خروجی: تعدادِ دوره‌های تازه.
"""
from django.core.cache import cache
from . import bazaar
now = timezone.now()
latest_by_token = {}
for order in Order.objects.filter(
user_id=user_id, provider='bazaar', status='paid', subscription_id__isnull=False,
bazaar_valid_until__isnull=False,
).exclude(bazaar_purchase_token='').order_by('bazaar_valid_until'):
latest_by_token[order.bazaar_purchase_token] = order
added = 0
for token, last in latest_by_token.items():
if last.bazaar_valid_until > now + BAZAAR_RENEWAL_LOOKAHEAD:
continue
token_key = hashlib.sha256(token.encode()).hexdigest()[:32]
if not cache.add(f'payment:bazaar-renewal:{token_key}', 1, 3600):
continue
try:
result = bazaar.verify_purchase(
product_sku=last.bazaar_product_sku, purchase_token=token, is_subscription=True)
except bazaar.BazaarAPIError: # خودِ bazaar لاگ کرده؛ ساعتِ بعد
continue
except Exception: # noqa: BLE001 — صفحه‌ی وضعیت نباید با خطای بازار ۵۰۰ بدهد؛ ولی دیده شود
logger.exception('bazaar renewal: سنجشِ تمدیدِ سفارشِ %s شکست خورد', last.id)
continue
if result.valid_until is None or result.valid_until <= last.bazaar_valid_until + timezone.timedelta(hours=12):
continue
added += _record_bazaar_renewal(last, token, token_key, result.valid_until)
return added
def _record_bazaar_renewal(last: Order, token: str, token_key: str, valid_until) -> int:
from subscription.models import Plan
plan = Plan.objects.filter(pk=last.plan_id).first() if last.plan_id else None
days = last.duration_days or (plan.duration_days if plan else None)
if not days:
logger.error('bazaar renewal: سفارشِ %s مدت ندارد؛ تمدید ثبت نشد', last.id)
return 0
gap = (valid_until - last.bazaar_valid_until).total_seconds() / 86400
periods = max(1, round(gap / days))
if periods > 1:
logger.warning('bazaar renewal: %s دوره‌ی تمدید برای سفارشِ %s یک‌جا ثبت می‌شود', periods, last.id)
msec = int(valid_until.timestamp() * 1000)
added = 0
for i in range(periods):
period_end = valid_until - timezone.timedelta(days=days * (periods - 1 - i))
price = plan.price if plan else last.list_price or last.amount
order, created = Order.objects.get_or_create(
idempotency_key=f'bazaar-renew-{token_key}-{msec}-{i}',
defaults=dict(
user_id=last.user_id, subscription_id=last.subscription_id, plan_id=last.plan_id,
duration_days=days, amount=price, list_price=price,
plan_title=(plan.title if plan else last.plan_title)[:50], currency=last.currency,
status='pending', provider='bazaar', bazaar_product_sku=last.bazaar_product_sku,
bazaar_purchase_token=token, bazaar_valid_until=period_end,
),
)
if not created and order.status != 'pending':
continue
if apply_order_payment_result(
order=order, gateway='bazaar', gateway_ref_id=f'bazaar-renew-{token_key}-{msec}-{i}', # توکن تا ۳۰۰ نویسه است؛ ref هم ۳۰۰
new_status='paid', event='bazaar_renewal',
):
added += 1
return added
class OrderStateError(Exception):

View File

@@ -195,7 +195,7 @@ class BazaarValidateAgainstDocsTests(TestCase):
self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
self.assertEqual(
mock_get.call_args.args[0],
'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t',
'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t/',
)
self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000})
self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)

View File

@@ -0,0 +1,203 @@
"""
اشتراکِ کافه‌بازار، «تخفیف پویا» و تمدیدِ خودکار (۲۰۲۶-۱۰-۱۲).
بازار با `bazaar.verify_purchase`ِ ساختگی آزموده می‌شود؛ خریدِ واقعی، تمدیدِ واقعی و پذیرفتنِ JWT
از سوی بازار هیچ‌کدام آزموده نشده‌اند (اپِ اندرویدِ تازه هنوز ساخته نشده).
"""
import base64
import hashlib
import hmac
import json
from unittest.mock import patch
from django.core.cache import cache
from django.test import TestCase, override_settings
from django.utils import timezone
from rest_framework.test import APIClient
from accounts.authentication import issue_access_token
from accounts.models import User
from discount.models import DiscountCode
from subscription.models import Plan, Subscription
from .bazaar import BazaarPurchaseResult
from .models import Order
def _result(days: float, valid=True):
return BazaarPurchaseResult(is_valid=valid, valid_until=timezone.now() + timezone.timedelta(days=days),
auto_renewing=True)
class BazaarSubscriptionTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007301')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
def tearDown(self):
cache.clear()
def buy(self, plan, key='k1'):
return self.client.post('/api/subscription/purchase', {'planId': str(plan.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY=key)
def verify(self, order_id, result):
with patch('payment.bazaar.verify_purchase', return_value=result) as mock:
response = self.client.post('/api/payment/bazaar/verify',
{'orderId': order_id, 'purchaseToken': 'sub-token-1'}, format='json')
return response, mock
def test_migration_put_the_panel_skus_on_three_and_six_months(self):
self.assertEqual(self.three.bazaar_sku, '10343')
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=180).bazaar_sku, '10344')
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=30).bazaar_sku, '')
def test_plan_without_sku_is_refused_for_bazaar(self):
monthly = Plan.objects.get(kind='subscription', on_sale=True, duration_days=30)
self.assertEqual(self.buy(monthly).status_code, 400)
def test_subscription_bought_in_bazaar_activates_for_the_plan(self):
response = self.buy(self.three)
self.assertEqual(response.status_code, 200, response.content)
body = response.json()
self.assertEqual((body['bazaarSku'], body['bazaarProductType'], body['paymentUrl']), ('10343', 'subscription', None))
order = Order.objects.get(pk=body['orderId'])
self.assertEqual((order.provider, order.plan_id), ('bazaar', self.three.id))
self.assertIsNotNone(order.subscription_id)
response, mock = self.verify(body['orderId'], _result(90))
self.assertEqual(response.json()['status'], 'paid')
self.assertTrue(mock.call_args.kwargs['is_subscription'])
sub = Subscription.objects.get(user_id=self.user.id)
self.assertEqual(sub.status, 'active')
self.assertAlmostEqual((sub.expires_at - timezone.now()).days, 89, delta=1)
self.assertIsNotNone(Order.objects.get(pk=body['orderId']).bazaar_valid_until)
def test_pack_verify_asks_for_inapp(self):
pack = Plan.objects.get(kind='pack', question_credits=20)
pack.bazaar_sku = 'pack20'
pack.save()
body = self.buy(pack).json()
self.assertEqual(body['bazaarProductType'], 'inapp')
_, mock = self.verify(body['orderId'], BazaarPurchaseResult(is_valid=True, purchase_state=0))
self.assertFalse(mock.call_args.kwargs['is_subscription'])
def _paid_subscription(self):
order_id = self.buy(self.three).json()['orderId']
self.verify(order_id, _result(90))
return Order.objects.get(pk=order_id)
def test_auto_renewal_in_bazaar_extends_the_subscription_once(self):
order = self._paid_subscription()
# دوره‌ی پرداخت‌شده تمام شده؛ بازار تمدید کرده و validUntil نود روز جلو رفته.
order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1)
order.save(update_fields=['bazaar_valid_until'])
before = Subscription.objects.get(user_id=self.user.id).expires_at
with patch('payment.bazaar.verify_purchase', return_value=_result(90)) as mock:
self.client.get('/api/subscription/status')
cache.clear() # سقفِ ساعتی را کنار بگذار: ثبتِ دوباره هم نباید دوره‌ی دوم بسازد
self.client.get('/api/subscription/status')
self.assertTrue(mock.call_args.kwargs['is_subscription'])
renewals = Order.objects.filter(user_id=self.user.id, status='paid').exclude(pk=order.pk)
self.assertEqual(renewals.count(), 1)
self.assertEqual(renewals.get().amount, self.three.price)
after = Subscription.objects.get(user_id=self.user.id).expires_at
self.assertAlmostEqual((after - before).days, 90, delta=1)
def test_paywall_picks_up_a_renewal_without_opening_the_store(self):
from subscription.services import has_active_subscription, has_subscription_access
order = self._paid_subscription()
past = timezone.now() - timezone.timedelta(hours=1)
Order.objects.filter(pk=order.pk).update(bazaar_valid_until=past)
Subscription.objects.filter(user_id=self.user.id).update(expires_at=past)
self.assertFalse(has_active_subscription(self.user.id))
with patch('payment.bazaar.verify_purchase', return_value=_result(90)):
self.assertTrue(has_subscription_access(self.user.id))
def test_no_renewal_when_bazaar_did_not_move_the_period(self):
order = self._paid_subscription()
order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1)
order.save(update_fields=['bazaar_valid_until'])
with patch('payment.bazaar.verify_purchase', return_value=_result(-1 / 24, valid=False)):
self.client.get('/api/subscription/status')
self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), 1)
def test_bazaar_is_not_asked_while_the_period_is_far_from_its_end(self):
self._paid_subscription()
with patch('payment.bazaar.verify_purchase') as mock:
self.client.get('/api/subscription/status')
mock.assert_not_called()
def _decode(token: str, key: str) -> dict:
head, body, sig = token.split('.')
expected = base64.urlsafe_b64encode(
hmac.new(key.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()).rstrip(b'=').decode()
assert hmac.compare_digest(sig, expected), 'bad signature'
return json.loads(base64.urlsafe_b64decode(body + '=' * (-len(body) % 4)))
class BazaarDynamicPriceTests(TestCase):
def setUp(self):
cache.clear()
self.user = User.objects.create(phone='09120007302')
self.client = APIClient()
self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}')
self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10)
def tearDown(self):
cache.clear()
def order(self):
return self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'bazaar'},
format='json', HTTP_IDEMPOTENCY_KEY='dp1').json()['orderId']
def start(self, order_id):
return self.client.post(f'/api/payment/bazaar/orders/{order_id}/start', format='json')
def redeem(self, order_id):
return self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json')
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='')
def test_without_the_key_discount_is_refused_and_no_token(self):
order_id = self.order()
self.assertEqual(self.redeem(order_id).status_code, 400)
body = self.start(order_id).json()
self.assertIsNone(body['dynamicPriceToken'])
self.assertEqual((body['sku'], body['productType']), ('10343', 'subscription'))
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key', BAZAAR_PACKAGE_NAME='com.tadrisino.math.app')
def test_discounted_price_is_signed_for_bazaar(self):
order_id = self.order()
self.assertEqual(self.redeem(order_id).status_code, 200)
body = self.start(order_id).json()
claims = _decode(body['dynamicPriceToken'], 'panel-key')
self.assertEqual(claims['price'], self.three.price // 2 * 10) # تومان → ریال
self.assertEqual((claims['sku'], claims['package_name']), ('10343', 'com.tadrisino.math.app'))
self.assertGreater(claims['exp'], timezone.now().timestamp())
self.assertTrue(claims['nonce'])
self.assertEqual(Order.objects.get(pk=order_id).bazaar_price_rial, claims['price'])
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key')
def test_full_price_sends_no_token(self):
# توکن روی بازارِ قدیمی‌تر از ۱۳.۳ خطا می‌دهد؛ بی‌تخفیف لازمش نداریم.
self.assertIsNone(self.start(self.order()).json()['dynamicPriceToken'])
@override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key')
def test_no_discount_after_the_purchase_started(self):
order_id = self.order()
self.start(order_id)
self.assertEqual(self.redeem(order_id).status_code, 400)
self.assertEqual(Order.objects.get(pk=order_id).amount, self.three.price)
def test_start_is_only_for_the_owner_and_pending_orders(self):
order_id = self.order()
other = APIClient()
other.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(User.objects.create(phone="09120007303").id)}')
self.assertEqual(other.post(f'/api/payment/bazaar/orders/{order_id}/start').status_code, 404)
Order.objects.filter(pk=order_id).update(status='paid')
self.assertEqual(self.start(order_id).status_code, 400)

View File

@@ -1,6 +1,7 @@
from django.urls import path
from .views import (
BazaarStartPurchaseView,
BazaarVerifyPurchaseView,
CreateOrderView,
InvoiceListView,
@@ -13,6 +14,8 @@ urlpatterns = [
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
path('payment/bazaar/orders/<int:order_id>/start', BazaarStartPurchaseView.as_view(),
name='payment-bazaar-start'),
path('payment/invoices', InvoiceListView.as_view(), name='payment-invoices'),
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),

View File

@@ -20,6 +20,7 @@ from .models import Order
from .permissions import HasInternalServiceKey
from .serializers import (
InvoiceListResponseSerializer,
BazaarStartResponseSerializer,
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
@@ -339,9 +340,12 @@ class BazaarVerifyPurchaseView(APIView):
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
# نوعِ محصول از سفارش: سفارشِ اشتراک subscription_id دارد (PurchaseView)، بسته ندارد. پیش‌تر نوع
# حدس زده می‌شد (اول تک‌خرید، بعد اشتراک).
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
purchase_token=v['purchaseToken'],
is_subscription=bool(order_lookup.subscription_id),
)
with transaction.atomic():
@@ -358,7 +362,8 @@ class BazaarVerifyPurchaseView(APIView):
# می‌کرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمی‌شد (بازتولید شد).
if result.is_valid:
order.bazaar_purchase_token = v['purchaseToken']
order.save(update_fields=['bazaar_purchase_token'])
order.bazaar_valid_until = result.valid_until
order.save(update_fields=['bazaar_purchase_token', 'bazaar_valid_until'])
apply_order_payment_result(
order=order,
@@ -391,6 +396,63 @@ class BazaarVerifyPurchaseView(APIView):
status=status.HTTP_409_CONFLICT)
class BazaarStartPurchaseView(APIView):
"""
POST /api/payment/bazaar/orders/{id}/start — آنچه اپِ اندروید برای شروعِ خرید در بازار لازم دارد.
پس از ساخت سفارش (`POST /api/subscription/purchase` با `paymentMethod: bazaar`) و کدِ تخفیفِ احتمالی
(`POST /api/discount/redeem`) صدا زده می‌شود. اگر کدِ تخفیف مبلغ را کم کرده، `dynamicPriceToken` همان
مبلغ را (به ریال) برای بازار امضا می‌کند؛ بی‌تخفیف null است و بازار قیمتِ پیشخان را می‌گیرد (عمداً: توکن
روی بازارِ قدیمی‌تر از ۱۳.۳ خطا می‌دهد). بعد از این، کدِ تخفیفِ تازه روی سفارش نمی‌نشیند.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'bazaar_start'
throttle_classes = [ScopedRateThrottle]
@extend_schema(request=None, responses=BazaarStartResponseSerializer, tags=['Payment (payment)'])
def post(self, request, order_id):
with transaction.atomic():
order = get_object_or_404(
Order.objects.select_for_update(), pk=order_id, user_id=request.user.id, provider='bazaar')
if order.status != 'pending':
raise ValidationError({'orderId': 'این سفارش دیگر در انتظارِ پرداخت نیست.'})
if not order.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
token = None
full_price = order.list_price if order.list_price is not None else order.amount
if order.amount < full_price:
if not bazaar.dynamic_price_configured():
# redeem بی‌کلید تخفیف را روی سفارشِ بازار نمی‌گذارد؛ رسیدن به این‌جا یعنی کلید بعد از آن
# از .env رفته. بی‌توکن بازار قیمتِ کامل را می‌گرفت.
logger.error('bazaar start: سفارشِ %s تخفیف دارد ولی BAZAAR_DYNAMIC_PRICE_KEY نیست', order.id)
raise bazaar.BazaarAPIError()
price_rial = _to_rial(order.amount, order.currency)
token = bazaar.dynamic_price_token(sku=order.bazaar_product_sku, price_rial=price_rial)
order.bazaar_price_rial = price_rial
order.save(update_fields=['bazaar_price_rial'])
elif order.bazaar_price_rial is None:
# علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است.
order.bazaar_price_rial = _to_rial(order.amount, order.currency)
order.save(update_fields=['bazaar_price_rial'])
data = {
'orderId': str(order.id),
'sku': order.bazaar_product_sku,
'productType': 'subscription' if order.subscription_id else 'inapp',
'amount': order.amount,
'dynamicPriceToken': token,
}
return Response(BazaarStartResponseSerializer(data).data)
def _to_rial(amount: int, currency: str) -> int:
if currency == 'IRT':
return amount * 10
if currency == 'IRR':
return amount
raise ValueError(f'unknown currency {currency!r}')
class OrderDetailView(APIView):
"""GET /api/payment/orders/{id} — وضعیت سفارش"""