168 lines
7.8 KiB
Python
168 lines
7.8 KiB
Python
# 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: <token>
|
|
این توکن API رفرش ندارد (اگر باطل شود باید دستی از پیشخان توکن تازه گرفت).
|
|
|
|
«روش قدیمی» — OAuth2 (.../api/setting-up)، در پارامتر URL:
|
|
?access_token=<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
|