""" کلاینت سرور-به-سرورِ پرداخت درون‌برنامه‌ای بازار (REST API v2)، طبق داکیومنت رسمی: developers.cafebazaar.ir/fa/guidelines/in-app-billing جریان رسمی: ۱) یک‌بار: صاحبِ حسابِ بازار لینکِ start_authorization را باز می‌کند و «اجازه» می‌زند؛ بازار با `?code=` به Redirect URI (همان `/api/payment/bazaar/verify`، GET) برمی‌گردد و complete_authorization آن را با REFRESH_TOKEN عوض می‌کند و در BazaarCredential نگه می‌دارد. ۲) بک‌اند با همون 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 ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه. """ import logging import secrets from dataclasses import dataclass from datetime import timedelta from urllib.parse import urlencode import requests from django.conf import settings from django.core.cache import cache from django.utils import timezone from rest_framework.exceptions import APIException logger = logging.getLogger(__name__) ACCESS_TOKEN_CACHE_KEY = 'bazaar_iap_access_token' class BazaarAPIError(APIException): """خطای موقتِ سرویسِ بازار (شبکه/HTTP/پاسخِ نامعتبر). عمداً is_valid=False برنمی‌گردونیم چون نبودِ پاسخِ قابل‌اعتماد به‌معنیِ خریدِ نامعتبر نیست — ممکنه یک خریدِ واقعاً معتبر رو غلط failed کنه.""" status_code = 503 default_detail = 'در حال حاضر امکان تایید خرید با سرور بازار وجود ندارد؛ لطفاً بعداً دوباره تلاش کنید.' default_code = 'bazaar_api_unavailable' @dataclass class BazaarPurchaseResult: is_valid: bool purchase_state: int | None = None # ۰=خریداری‌شده، ۱=لغوشده (طبق سند بازار) consumption_state: int | None = None developer_payload: str = '' raw: dict | None = None def _authorize_window() -> timedelta: # پنجره‌ی اتصال: از ساختِ لینک تا برگشتن از بازار. پیش‌فرضِ سه روز چون # صاحبِ حسابِ بازار آدمِ دیگری است و ممکن است یکی دو روز طول بکشد # (تصمیمِ صاحبِ پروژه، ۲۰۲۶-۰۹-۲۶). بعد از اتصالِ موفق پنجره خودش بسته # می‌شود، پس بلندی‌اش فقط تا همان لحظه در معرض است. return timedelta(hours=settings.BAZAAR_AUTHORIZE_WINDOW_HOURS) def get_refresh_token() -> str: """مقدارِ `.env` اگر باشد، وگرنه آن‌چه بعد از «اجازه» در دیتابیس ذخیره شد.""" if settings.BAZAAR_REFRESH_TOKEN: return settings.BAZAAR_REFRESH_TOKEN from .models import BazaarCredential cred = BazaarCredential.objects.filter(pk=1).first() return cred.refresh_token if cred else '' def start_authorization() -> str: """ پنجره‌ی اتصال را باز می‌کند و لینکی برمی‌گرداند که صاحبِ حسابِ بازار باید باز کند و «اجازه» بزند. بازار بعدش به BAZAAR_REDIRECT_URI (همان verify) با `?code=` برمی‌گردد. ⚠️ شکلِ این آدرس از روندِ شناخته‌شده‌ی devapi v2 است؛ سندِ فعلیِ بازار (صفحه‌ی JS) خوانده نشد. این‌که بازار `state` را برمی‌گرداند یا نه هم دیده نشده — برای همین complete_authorization نبودِ state را تحمل می‌کند. """ from .models import BazaarCredential state = secrets.token_urlsafe(24) BazaarCredential.objects.update_or_create( pk=1, defaults={'pending_state': state, 'pending_since': timezone.now()}, ) query = urlencode({ 'response_type': 'code', 'access_type': 'offline', 'redirect_uri': settings.BAZAAR_REDIRECT_URI, 'client_id': settings.BAZAAR_CLIENT_ID, 'state': state, }) return f'{settings.BAZAAR_API_BASE}/auth/authorize/?{query}' class BazaarAuthorizationError(Exception): """پیامش مستقیم به کسی که در مرورگر است نشان داده می‌شود؛ رازی در آن نیست.""" def complete_authorization(*, code: str, state: str = '') -> None: """ codeی که بازار به Redirect URI فرستاد را با refresh token عوض و ذخیره می‌کند. فقط داخلِ پنجره‌ی باز کار می‌کند؛ اگر بازار state را برگرداند باید با همانی که ساختیم یکی باشد. پنجره فقط با اتصالِ موفق بسته می‌شود، تا یک درخواستِ خراب (یا عمدی با codeِ بی‌معنی) پنجره‌ی چندروزه را نسوزاند؛ تلاش‌ها با throttleِ `bazaar_oauth` محدودند. """ from django.db import transaction from .models import BazaarCredential if not (settings.BAZAAR_CLIENT_ID and settings.BAZAAR_CLIENT_SECRET): raise BazaarAuthorizationError('کلیدهای بازار (CLIENT_ID/SECRET) روی سرور تنظیم نشده‌اند.') cred = BazaarCredential.objects.filter(pk=1).first() if ( not cred or not cred.pending_since or timezone.now() - cred.pending_since > _authorize_window() ): raise BazaarAuthorizationError('پنجره‌ی اتصال باز نیست؛ لینکِ تازه بسازید.') if state and not secrets.compare_digest(state, cred.pending_state): raise BazaarAuthorizationError('state با لینکی که ساخته شد یکی نیست.') opened_at = cred.pending_since try: resp = requests.post( f'{settings.BAZAAR_API_BASE}/auth/token/', data={ 'grant_type': 'authorization_code', 'code': code, 'client_id': settings.BAZAAR_CLIENT_ID, 'client_secret': settings.BAZAAR_CLIENT_SECRET, 'redirect_uri': settings.BAZAAR_REDIRECT_URI, }, timeout=10, ) except requests.RequestException: logger.exception('Bazaar code exchange failed') raise BazaarAuthorizationError('بازار در دسترس نبود؛ دوباره لینک بسازید و امتحان کنید.') try: data = resp.json() except ValueError: data = {} refresh_token = data.get('refresh_token') if isinstance(data, dict) else None if resp.status_code != 200 or not refresh_token: # بدنه را لاگ نمی‌کنیم: ممکن است توکن داشته باشد. logger.error('Bazaar code exchange rejected: status=%s keys=%s', resp.status_code, sorted(data) if isinstance(data, dict) else '?') raise BazaarAuthorizationError(f'بازار code را نپذیرفت (HTTP {resp.status_code}).') # بستنِ پنجره شرطی است: از دو درخواستِ هم‌زمان فقط اولی ذخیره می‌کند. with transaction.atomic(): stored = BazaarCredential.objects.filter(pk=1, pending_since=opened_at).update( refresh_token=refresh_token, authorized_at=timezone.now(), pending_state='', pending_since=None, ) if not stored: raise BazaarAuthorizationError('این لینک همین الان یک بار استفاده شد.') cache.delete(ACCESS_TOKEN_CACHE_KEY) if data.get('access_token'): cache.set(ACCESS_TOKEN_CACHE_KEY, data['access_token'], timeout=max(int(data.get('expires_in', 3600)) - 60, 60)) logger.info('Bazaar API authorized; refresh token stored') def _get_access_token() -> str: """ ACCESS_TOKEN رو کش می‌کنه (توی cache framework جنگو) تا هر verify یک درخواست OAuth جدا نزنه؛ اگه کش نداشت یا منقضی شده بود، با REFRESH_TOKEN یکی تازه می‌گیره. """ cached = cache.get(ACCESS_TOKEN_CACHE_KEY) if cached: return cached refresh_token = get_refresh_token() if not 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.') try: 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': refresh_token, }, timeout=10, ) resp.raise_for_status() except requests.RequestException: logger.exception('Bazaar access-token request failed') raise BazaarAPIError() try: data = resp.json() token = data['access_token'] except (ValueError, KeyError, TypeError): logger.error('Bazaar access-token response malformed: status=%s', resp.status_code) raise BazaarAPIError() # طبق سند بازار 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 get_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}/' ) try: resp = requests.get(url, headers={'Authorization': f'Bearer {access_token}'}, timeout=10) except requests.RequestException: logger.exception('Bazaar verify-purchase request failed') raise BazaarAPIError() if resp.status_code != 200: return BazaarPurchaseResult(is_valid=False, raw={'status_code': resp.status_code}) try: data = resp.json() except ValueError: logger.error('Bazaar verify-purchase response malformed: status=%s', resp.status_code) raise BazaarAPIError() 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, )