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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user