# apps/billing/providers/bazaar.py """ تایید خرید کافه‌بازار — سمت سرور، طبق مستندات رسمی کافه‌بازار (developers.cafebazaar.ir/fa/guidelines/in-app-billing). این تنها API ای است که اجازه دارد جم واقعا اضافه کند؛ کلاینت هرچقدر هم بگوید «پرداخت کردم»، تا این تابع True برنگرداند هیچ جمی اضافه نمی‌شود. GET https://pardakht.cafebazaar.ir/devapi/v2/api/validate/{package_name} /inapp/{product_id}/purchases/{purchase_token}/ پاسخ موفق شامل purchaseState=0 (خریداری‌شده، نه refund‌شده) است. دو روش احراز هویت (هر دو طبق مستندات رسمی): «روش جدید» — توکن ثابتِ پیشخان (.../api/setting-up-new)، در هدر: CAFEBAZAAR-PISHKHAN-API-SECRET: این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت). «روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL: ?access_token= با client_id/client_secret/refresh_token قابل تمدید خودکار است — دقیقا همین رفرش خودکار اینجا با auto_refresh_access_token_if_needed پیاده شده. تشخیص این‌که کدام روش فعال است صرفا از روی BazaarSettings.access_token_ expires_at: خالی=روش جدید (هدر)، پرشده=روش قدیمی/OAuth (پارامتر URL، با رفرش خودکار پیش از انقضا). همه‌چیز از BazaarSettings (پنل ادمین) خوانده می‌شود، نه .env — چون باید بدون ری‌دیپلوی قابل‌تعویض باشد. """ import logging import requests from django.utils import timezone from apps.billing.models import BazaarSettings logger = logging.getLogger(__name__) REQUEST_TIMEOUT_SECONDS = 15 DEVAPI_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/api" OAUTH_BASE_URL = "https://pardakht.cafebazaar.ir/devapi/v2/auth" PISHKHAN_TOKEN_HEADER = "CAFEBAZAAR-PISHKHAN-API-SECRET" # پیش از این فاصله تا انقضا، توکن OAuth را تمدید می‌کنیم تا وسط یک درخواست # واقعی منقضی نشود (access_token های کافه‌بازار معمولا ۳۶۰۰ ثانیه عمر دارند). REFRESH_SAFETY_MARGIN_SECONDS = 120 class BazaarAuthError(Exception): """احراز هویت با کافه‌بازار ممکن نیست (تنظیمات ناقص یا رفرش ناموفق).""" def build_authorize_url(redirect_uri: str) -> str: """لینک شروع فلوی OAuth (روش قدیمی) — کاربر باید یک‌بار در مرورگر باز و تایید کند. redirect_uri باید دقیقا همانی باشد که موقع ساخت Client در پیشخان ثبت شده.""" settings_obj = BazaarSettings.get_solo() if not settings_obj.client_id: raise BazaarAuthError("Client ID در تنظیمات کافه‌بازار خالی است.") return ( f"{OAUTH_BASE_URL}/authorize/" f"?response_type=code&access_type=offline" f"&redirect_uri={redirect_uri}&client_id={settings_obj.client_id}" ) def exchange_code_for_tokens(code: str, redirect_uri: str) -> None: """گام آخر فلوی OAuth — code گرفته‌شده از ریدایرکت (یا دستی کپی‌شده) را به access_token/refresh_token تبدیل و در BazaarSettings ذخیره می‌کند.""" settings_obj = BazaarSettings.get_solo() if not settings_obj.client_id or not settings_obj.client_secret: raise BazaarAuthError("Client ID یا Client Secret در تنظیمات کافه‌بازار خالی است.") response = requests.post( f"{OAUTH_BASE_URL}/token/", data={ "grant_type": "authorization_code", "code": code, "client_id": settings_obj.client_id, "client_secret": settings_obj.client_secret, "redirect_uri": redirect_uri, }, timeout=REQUEST_TIMEOUT_SECONDS, ) if not response.ok: raise BazaarAuthError(f"تبادل کد ناموفق بود: {response.status_code} {response.text}") _save_token_response(settings_obj, response.json()) def _refresh_access_token(settings_obj: BazaarSettings) -> None: if not settings_obj.client_id or not settings_obj.client_secret or not settings_obj.refresh_token: raise BazaarAuthError( "برای تمدید خودکار access_token باید client_id/client_secret/refresh_token هر سه ست باشند." ) response = requests.post( f"{OAUTH_BASE_URL}/token/", data={ "grant_type": "refresh_token", "client_id": settings_obj.client_id, "client_secret": settings_obj.client_secret, "refresh_token": settings_obj.refresh_token, }, timeout=REQUEST_TIMEOUT_SECONDS, ) if not response.ok: raise BazaarAuthError(f"تمدید access_token ناموفق بود: {response.status_code} {response.text}") _save_token_response(settings_obj, response.json()) def _save_token_response(settings_obj: BazaarSettings, body: dict) -> None: settings_obj.access_token = body["access_token"] # رفرش‌گرنت گاهی refresh_token جدید برنمی‌گرداند — قبلی را نگه می‌داریم. if body.get("refresh_token"): settings_obj.refresh_token = body["refresh_token"] expires_in = body.get("expires_in", 3600) settings_obj.access_token_expires_at = timezone.now() + timezone.timedelta(seconds=expires_in) settings_obj.save( update_fields=["access_token", "refresh_token", "access_token_expires_at", "updated_at"] ) def _auth_kwargs_for_request(settings_obj: BazaarSettings) -> dict: """توکنِ سالم برای یک درخواست devapi را برمی‌گرداند — هدر یا پارامتر URL، بسته به این‌که توکن ثابتِ پیشخان است یا OAuth (و اگر OAuth و نزدیک انقضاست، همین‌جا اول تمدیدش می‌کند).""" is_oauth_managed = settings_obj.access_token_expires_at is not None if is_oauth_managed: margin = timezone.timedelta(seconds=REFRESH_SAFETY_MARGIN_SECONDS) if timezone.now() >= settings_obj.access_token_expires_at - margin: _refresh_access_token(settings_obj) return {"params": {"access_token": settings_obj.access_token}} return {"headers": {PISHKHAN_TOKEN_HEADER: settings_obj.access_token}} def verify_purchase(product_id: str, purchase_token: str) -> bool: settings_obj = BazaarSettings.get_solo() if not settings_obj.access_token or not settings_obj.package_name: logger.error("BazaarSettings.access_token یا package_name در پنل ادمین تنظیم نشده") return False url = f"{DEVAPI_BASE_URL}/validate/{settings_obj.package_name}/inapp/{product_id}/purchases/{purchase_token}/" try: auth_kwargs = _auth_kwargs_for_request(settings_obj) except BazaarAuthError as exc: logger.error(f"❌ احراز هویت کافه‌بازار ناموفق: {exc}") return False try: response = requests.get(url, timeout=REQUEST_TIMEOUT_SECONDS, **auth_kwargs) response.raise_for_status() body = response.json() purchase_state = body.get("purchaseState") is_valid = purchase_state == 0 if not is_valid: logger.warning(f"❌ خرید کافه‌بازار نامعتبر: {body}") return is_valid except (requests.RequestException, ValueError) as exc: logger.error(f"❌ خطا در تایید خرید کافه‌بازار ({product_id}/{purchase_token}): {exc}") return False