Files
MathApp/payment/bazaar.py
Zareian 8077ab4350 Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- 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>
2026-10-12 17:53:11 +03:30

388 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
کلاینت سرور-به-سرورِ پرداخت درون‌برنامه‌ای بازار (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)}'