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