""" کلاینت سرور-به-سرورِ پرداخت درون‌برنامه‌ای بازار (REST API v2)، طبق داکیومنت رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing جریان رسمی: ۱) یک‌بار (دستی، از پنل devapi توسعه‌دهنده) با CLIENT_ID/CLIENT_SECRET یک authorization code گرفته می‌شه و با اون REFRESH_TOKEN ساخته می‌شه — این مرحله در این فایل نیست چون یک‌بار و دستی‌ست، نه بخشی از API عمومی. ۲) بک‌اند با همون REFRESH_TOKEN، هر بار (یا هر بار که access_token منقضی شده) یک ACCESS_TOKEN تازه می‌گیره (POST /auth/token با grant_type=refresh_token). ۳) با همون access_token، وضعیت خرید/اشتراک از endpoint purchase/subscription خونده می‌شه (GET .../applications/{package}/inapp/{sku}/purchases/{token}/). ⚠️ کافه‌بازار روش صحت‌سنجی رو تغییر داده (نگاه کنید به developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api-new)؛ قبل از اتصال واقعی به production، دقیق‌ترین مسیر endpoint را از همون صفحه یا پنل devapi خودتون تایید کنید. 🔒 بدون BAZAAR_REFRESH_TOKEN (کلیدهای بازار ست نشده)، verify_purchase همیشه fail-closed عمل می‌کنه و invalid برمی‌گردونه؛ هیچ‌وقت به purchaseToken ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه. """ from dataclasses import dataclass import requests from django.conf import settings from django.core.cache import cache ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token' @dataclass class BazaarPurchaseResult: is_valid: bool purchase_state: int | None = None # ۰=خریداری‌شده، ۱=لغوشده (طبق سند بازار) consumption_state: int | None = None developer_payload: str = '' raw: dict | None = None def _get_access_token() -> str: """ ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک درخواست OAuth جدا نزنه؛ اگه کش نداشت یا منقضی شده بود، با REFRESH_TOKEN یکی تازه می‌گیره. """ cached = cache.get(ACCESS_TOKEN_CACHE_KEY) if cached: return cached if not settings.BAZAAR_REFRESH_TOKEN: # بدون REFRESH_TOKEN، امکان گرفتن access_token واقعی از بازار نیست؛ # قبلاً اینجا یک 'stub-access-token' برمی‌گشت که باعث می‌شد # verify_purchase هر purchaseToken غیرخالی رو valid فرض کنه # (باگ fail-open). این تابع نباید در این حالت صدا زده بشه — # verify_purchase قبل از فراخوانیِ این تابع، خودش fail-closed # برمی‌گرده — ولی برای دفاع در عمق، اینجا هم صراحتاً خطا می‌ده. raise RuntimeError('BAZAAR_REFRESH_TOKEN is not configured; cannot obtain a Bazaar access token.') resp = requests.post( f'{settings.BAZAAR_API_BASE}/auth/token/', data={ 'grant_type': 'refresh_token', 'client_id': settings.BAZAAR_CLIENT_ID, 'client_secret': settings.BAZAAR_CLIENT_SECRET, 'refresh_token': settings.BAZAAR_REFRESH_TOKEN, }, timeout=10, ) resp.raise_for_status() data = resp.json() token = data['access_token'] # طبق سند بازار access_token معمولاً ~۱ ساعت اعتباره؛ کمی زودتر منقضی # می‌کنیم تا لبه‌ی زمانی مشکل نسازه. cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=int(data.get('expires_in', 3600)) - 60) return token def verify_purchase(*, product_sku: str, purchase_token: str, is_subscription: bool = False) -> BazaarPurchaseResult: """ صحت‌سنجی purchaseToken که کلاینت اندروید بعد از خرید از SDK بازار گرفته. is_subscription=True برای اشتراک‌های دوره‌ای بازار (endpoint جدا از خرید تک‌محصولی)، طبق سند رسمی. """ if not settings.BAZAAR_REFRESH_TOKEN: # 🔒 fail-closed: بدون BAZAAR_REFRESH_TOKEN (کلید سرور-به-سرورِ # بازار)، امکان صحت‌سنجیِ واقعیِ purchaseToken با سرور بازار وجود # نداره. قبلاً اینجا هر purchaseToken غیرخالیِ ارسالی از کلاینت # مستقیماً valid فرض می‌شد (باگ fail-open) که یعنی یک کلاینت # می‌تونست با فرستادن هر مقدار غیرخالی، بدون خرید واقعی، سفارش رو # paid کنه. حالا در نبودِ کلیدهای بازار، هیچ‌وقت به گفته‌ی کلاینت # اعتماد نمی‌کنیم و صریحاً invalid برمی‌گردونیم. return BazaarPurchaseResult( is_valid=False, raw={'error': 'bazaar_credentials_not_configured'}, ) access_token = _get_access_token() kind = 'subscriptions' if is_subscription else 'purchases' url = ( f'{settings.BAZAAR_API_BASE}/api/applications/' f'{settings.BAZAAR_PACKAGE_NAME}/{kind}/{product_sku}/{purchase_token}/' ) resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10) if resp.status_code != 200: return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code}) data = resp.json() purchase_state = data.get('purchaseState') return BazaarPurchaseResult( is_valid=purchase_state == 0, # ۰ یعنی خریداری‌شده و معتبر purchase_state=purchase_state, consumption_state=data.get('consumptionState'), developer_payload=data.get('developerPayload', ''), raw=data, )