- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
388 lines
19 KiB
Python
388 lines
19 KiB
Python
"""
|
||
کلاینت سرور-به-سرورِ پرداخت درونبرنامهای بازار (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 base64
|
||
import hashlib
|
||
import hmac
|
||
import json
|
||
import logging
|
||
import secrets
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone as dt_timezone
|
||
from urllib.parse import quote, 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
|
||
# فقط اشتراک: پایانِ دورهی پرداختشده (با هر تمدیدِ خودکار جلو میرود) و اینکه تمدید روشن است.
|
||
valid_until: 'datetime | None' = None
|
||
auto_renewing: bool | 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=_access_token_ttl(data))
|
||
logger.info('Bazaar API authorized; refresh token stored')
|
||
|
||
|
||
def _access_token_ttl(data: dict) -> int:
|
||
# سند ۳۶۰۰ میگوید ولی بازارِ واقعی ۲۰۲۶-۰۹-۲۶ `expires_in: 3600000` داد
|
||
# (احتمالاً میلیثانیه). کش را به یک ساعت سقف میزنیم تا ۴۱ روز توکنِ
|
||
# مرده نگه نداریم؛ روی 401/403 هم پاک میشود.
|
||
try:
|
||
seconds = int(data.get('expires_in', 3600))
|
||
except (TypeError, ValueError):
|
||
seconds = 3600
|
||
return max(min(seconds, 3600) - 60, 60)
|
||
|
||
|
||
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()
|
||
|
||
cache.set(ACCESS_TOKEN_CACHE_KEY, token, timeout=_access_token_ttl(data))
|
||
return token
|
||
|
||
|
||
def is_configured() -> bool:
|
||
"""روشِ جدید (توکنِ ثابتِ پیشخان) یا روشِ قدیمی (refresh tokenِ OAuth)."""
|
||
return bool(settings.BAZAAR_PISHKHAN_API_SECRET or get_refresh_token())
|
||
|
||
|
||
def _auth_headers() -> dict:
|
||
"""
|
||
روشِ جدیدِ بازار (سندِ «راه اندازی API (روش جدید)»، مهر ۱۴۰۳): یک توکنِ
|
||
ثابت از پیشخان ← «API پیشخان بازار» ← «دریافت توکن جدید»، در هدرِ
|
||
CAFEBAZAAR-PISHKHAN-API-SECRET؛ نه redirect دارد نه انقضا. اگر ست باشد
|
||
همان به کار میرود، وگرنه access tokenِ روشِ قدیمیِ OAuth.
|
||
"""
|
||
if settings.BAZAAR_PISHKHAN_API_SECRET:
|
||
return {'CAFEBAZAAR-PISHKHAN-API-SECRET': settings.BAZAAR_PISHKHAN_API_SECRET}
|
||
# بدونِ «Bearer»: با آن بازار 403 «Access token is not valid» میدهد، با
|
||
# توکنِ خام قبول میکند (آزمایش روی بازارِ واقعی، ۲۰۲۶-۰۹-۲۶)، هرچند
|
||
# token_typeِ خودش Bearer است.
|
||
return {'Authorization': _get_access_token()}
|
||
|
||
|
||
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 بازار گرفته.
|
||
|
||
آدرسها از سندِ خودِ بازار (خواندهشده ۲۰۲۶-۰۹-۲۶ از wp-jsonِ
|
||
developers.cafebazaar.ir؛ پیش از آن هر دو غلط بودند و هیچ خریدِ واقعیای
|
||
تأیید نمیشد):
|
||
تکخرید: api/validate/<pkg>/inapp/<sku>/purchases/<token>/
|
||
اشتراک: api/applications/<pkg>/subscriptions/<sku>/purchases/<token>
|
||
به گفتهی سند «تنها اگر error برابر not_found باشد خرید انجام نشده»؛ پس
|
||
فقط همان نامعتبر است و بقیهی خطاها (کلیدِ غلط، سقفِ روزانه، قطعی) ۵۰۳ اند
|
||
تا یک خریدِ واقعی بهخاطرِ خطای ما failed نشود.
|
||
|
||
is_subscription=None (پیشفرض): نوعِ محصول در پیشخانِ بازار به ما گفته نشده
|
||
(۲۰۲۶-۰۹-۳۰)، پس اول تکخرید پرسیده میشود و اگر بازار گفت «Product is not
|
||
found»، همان SKU بهعنوانِ اشتراک. با پاسخِ واقعیِ بازار روی SKU و توکنِ
|
||
ساختگی دیده شد؛ با خریدِ واقعی هنوز آزموده نشده.
|
||
"""
|
||
if not is_configured():
|
||
# 🔒 fail-closed: بدونِ کلیدِ بازار هیچوقت به گفتهی کلاینت اعتماد
|
||
# نمیکنیم (قبلاً هر purchaseTokenِ غیرخالی valid فرض میشد).
|
||
return BazaarPurchaseResult(
|
||
is_valid=False,
|
||
raw={'error': 'bazaar_credentials_not_configured'},
|
||
)
|
||
|
||
# token از کلاینت میآید: quote با safe='' تا `/` یا `..` در آن مسیرِ
|
||
# دیگری از API را با کلیدِ ما صدا نزند.
|
||
pkg = quote(settings.BAZAAR_PACKAGE_NAME, safe='')
|
||
sku = quote(product_sku, safe='')
|
||
token = quote(purchase_token, safe='')
|
||
|
||
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 as exc:
|
||
# فقط نوعِ خطا: متنِ خطا آدرس را دارد و توکنِ خریدِ کاربر بخشی از آن است.
|
||
logger.error('Bazaar verify-purchase request failed: error=%s', type(exc).__name__)
|
||
raise BazaarAPIError()
|
||
try:
|
||
return resp.status_code, resp.json()
|
||
except ValueError:
|
||
return resp.status_code, 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 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 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', status,
|
||
error, data.get('error_description') if isinstance(data, dict) else None)
|
||
raise BazaarAPIError()
|
||
|
||
if subscription:
|
||
# پاسخِ اشتراک purchaseState ندارد و اشتراکِ منقضی را هم برمیگرداند؛
|
||
# سند میگوید validUntil را با اکنون مقایسه کنید.
|
||
valid_until = data.get('validUntilTimestampMsec') or 0
|
||
is_valid = valid_until > timezone.now().timestamp() * 1000
|
||
return BazaarPurchaseResult(
|
||
is_valid=is_valid,
|
||
developer_payload=data.get('developerPayload', ''),
|
||
raw=data,
|
||
valid_until=datetime.fromtimestamp(valid_until / 1000, tz=dt_timezone.utc) if valid_until else None,
|
||
auto_renewing=data.get('autoRenewing'),
|
||
)
|
||
else:
|
||
purchase_state = data.get('purchaseState')
|
||
is_valid = purchase_state == 0 # ۰ یعنی خریداریشده؛ ۱ یعنی refund
|
||
return BazaarPurchaseResult(
|
||
is_valid=is_valid,
|
||
purchase_state=purchase_state,
|
||
consumption_state=data.get('consumptionState'),
|
||
developer_payload=data.get('developerPayload', ''),
|
||
raw=data,
|
||
)
|
||
|
||
|
||
def dynamic_price_configured() -> bool:
|
||
return bool(settings.BAZAAR_DYNAMIC_PRICE_KEY)
|
||
|
||
|
||
def _b64url(raw: bytes) -> str:
|
||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode()
|
||
|
||
|
||
def dynamic_price_token(*, sku: str, price_rial: int, ttl_seconds: int = 30 * 60) -> str:
|
||
"""
|
||
JWTِ «تخفیف پویا»ی بازار (سندِ dynamic-discount، خواندهشده ۲۰۲۶-۱۰-۱۲): HS256 با کلیدِ پیشخان،
|
||
payload = price (ریال)، package_name، sku، exp، nonce. اپ آن را در PurchaseRequest.dynamicPriceToken
|
||
میگذارد و بازار همین مبلغ را میگیرد؛ اگر از قیمتِ پیشخان بیشتر باشد خطای ۱۰ میدهد.
|
||
یکبارمصرف است (nonce). بیکتابخانه، چون فقط HS256 لازم است.
|
||
نسنجیده: خریدِ واقعی با این توکن (اپِ تازه هنوز ساخته نشده).
|
||
"""
|
||
key = settings.BAZAAR_DYNAMIC_PRICE_KEY
|
||
if not key:
|
||
raise ValueError('BAZAAR_DYNAMIC_PRICE_KEY is not set')
|
||
header = {'alg': 'HS256', 'typ': 'JWT'}
|
||
payload = {
|
||
'price': int(price_rial),
|
||
'package_name': settings.BAZAAR_PACKAGE_NAME,
|
||
'sku': sku,
|
||
'exp': int(time.time()) + ttl_seconds,
|
||
'nonce': secrets.token_hex(12),
|
||
}
|
||
signing_input = '.'.join(
|
||
_b64url(json.dumps(part, separators=(',', ':')).encode()) for part in (header, payload))
|
||
signature = hmac.new(key.encode(), signing_input.encode(), hashlib.sha256).digest()
|
||
return f'{signing_input}.{_b64url(signature)}'
|