let Bazaar verification find out whether a SKU is a one-time product or a subscription

Nobody has told us how the plans are defined in the Bazaar panel. The
verify view always asked the in-app endpoint, so a subscription SKU would
get "Product is not found" and a 503. verify_purchase now defaults to
is_subscription=None: in-app first, and on that exact answer the same SKU
is asked as a subscription. A wrong package name still raises at once.

Seen against the live Bazaar API with a fake SKU and token only; no real
purchase has gone through it yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 12:31:51 +03:30
parent 5f4d7b1ed4
commit e3abd675db
2 changed files with 93 additions and 19 deletions

View File

@@ -248,7 +248,15 @@ def _auth_headers() -> dict:
return {'Authorization': _get_access_token()}
def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult:
def _is_unknown_product(status: int, data) -> bool:
return (
status == 404 and isinstance(data, dict) and data.get('error') == 'invalid_value'
and 'product' in str(data.get('error_description', '')).lower()
)
def verify_purchase(*, product_sku: str, purchase_token: str,
is_subscription: bool | None = None) -> BazaarPurchaseResult:
"""
صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته.
@@ -260,6 +268,11 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
به گفته‌ی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
فقط همان نامعتبر است و بقیه‌ی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
تا یک خریدِ واقعی به‌خاطرِ خطای ما failed نشود.
is_subscription=None (پیش‌فرض): نوعِ محصول در پیشخانِ بازار به ما گفته نشده
(۲۰۲۶-۰۹-۳۰)، پس اول تک‌خرید پرسیده می‌شود و اگر بازار گفت «Product is not
found»، همان SKU به‌عنوانِ اشتراک. با پاسخِ واقعیِ بازار روی SKU و توکنِ
ساختگی دیده شد؛ با خریدِ واقعی هنوز آزموده نشده.
"""
if not is_configured():
# 🔒 fail-closed: بدونِ کلیدِ بازار هیچ‌وقت به گفته‌ی کلاینت اعتماد
@@ -274,35 +287,41 @@ def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: b
pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='')
sku = quote(product_sku, safe='')
token = quote(purchase_token, safe='')
if is_subscription:
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:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
except requests.RequestException:
logger.exception('Bazaar verify-purchase request failed')
raise BazaarAPIError()
def fetch(subscription: bool):
if subscription:
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:
resp = requests.get(url, headers=_auth_headers(), timeout=10)
except requests.RequestException:
logger.exception('Bazaar verify-purchase request failed')
raise BazaarAPIError()
try:
return resp.status_code, resp.json()
except ValueError:
return resp.status_code, None
try:
data = resp.json()
except ValueError:
data = None
subscription = bool(is_subscription)
status, data = fetch(subscription)
if is_subscription is None and _is_unknown_product(status, data):
subscription = True
status, data = fetch(True)
error = data.get('error') if isinstance(data, dict) else None
if resp.status_code == 404 and error == 'not_found':
if status == 404 and error == 'not_found':
return BazaarPurchaseResult(is_valid=False, raw=data)
# 404 با invalid_value («Package name is invalid» / «Product is not found»)
# یعنی تنظیمِ ما غلط است، نه خریدِ کاربر؛ مثلِ بقیه‌ی خطاها ۵۰۳.
if resp.status_code != 200 or not isinstance(data, dict):
if resp.status_code in (401, 403):
if status != 200 or not isinstance(data, dict):
if status in (401, 403):
cache.delete(ACCESS_TOKEN_CACHE_KEY)
logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', resp.status_code,
logger.error('Bazaar verify-purchase failed: status=%s error=%s desc=%s', status,
error, data.get('error_description') if isinstance(data, dict) else None)
raise BazaarAPIError()
if is_subscription:
if subscription:
# پاسخِ اشتراک purchaseState ندارد و اشتراکِ منقضی را هم برمی‌گرداند؛
# سند می‌گوید validUntil را با اکنون مقایسه کنید.
valid_until = data.get('validUntilTimestampMsec') or 0

View File

@@ -199,3 +199,58 @@ class BazaarValidateAgainstDocsTests(TestCase):
)
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)
@override_settings(
BAZAAR_PISHKHAN_API_SECRET='pishkhan-secret', BAZAAR_REFRESH_TOKEN='',
BAZAAR_PACKAGE_NAME='ir.example.app',
BAZAAR_API_BASE='https://pardakht.cafebazaar.ir/devapi/v2',
)
class BazaarProductTypeAutoDetectTests(TestCase):
"""نوعِ محصول (تک‌خرید/اشتراک) به ما گفته نشده؛ کد خودش امتحان می‌کند."""
def setUp(self):
cache.clear()
def _responses(self, mock_get, *pairs):
responses = []
for status, body in pairs:
r = type('R', (), {})()
r.status_code = status
r.json = (lambda b: (lambda: b))(body)
responses.append(r)
mock_get.side_effect = responses
@patch('payment.bazaar.requests.get')
def test_inapp_product_is_checked_once(self, mock_get):
self._responses(mock_get, (200, {'purchaseState': 0}))
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='t').is_valid)
self.assertEqual(mock_get.call_count, 1)
self.assertIn('/inapp/', mock_get.call_args.args[0])
@patch('payment.bazaar.requests.get')
def test_unknown_inapp_product_falls_back_to_subscription(self, mock_get):
future = timezone.now().timestamp() * 1000 + 86_400_000
self._responses(
mock_get,
(404, {'error': 'invalid_value', 'error_description': 'Product is not found.'}),
(200, {'validUntilTimestampMsec': future}),
)
self.assertTrue(bazaar.verify_purchase(product_sku='monthly', purchase_token='t').is_valid)
self.assertIn('/subscriptions/monthly/purchases/t', mock_get.call_args.args[0])
@patch('payment.bazaar.requests.get')
def test_forged_token_on_subscription_is_invalid(self, mock_get):
self._responses(
mock_get,
(404, {'error': 'invalid_value', 'error_description': 'Product is not found.'}),
(404, {'error': 'not_found'}),
)
self.assertFalse(bazaar.verify_purchase(product_sku='monthly', purchase_token='forged').is_valid)
@patch('payment.bazaar.requests.get')
def test_wrong_package_does_not_fall_back(self, mock_get):
self._responses(mock_get, (404, {'error': 'invalid_value', 'error_description': 'Package name is invalid.'}))
with self.assertRaises(bazaar.BazaarAPIError):
bazaar.verify_purchase(product_sku='monthly', purchase_token='t')
self.assertEqual(mock_get.call_count, 1)