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>
This commit is contained in:
2026-10-12 17:53:11 +03:30
parent 2e0753587c
commit 8077ab4350
20 changed files with 580 additions and 40 deletions

View File

@@ -22,10 +22,15 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api
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 timedelta
from datetime import datetime, timedelta, timezone as dt_timezone
from urllib.parse import quote, urlencode
import requests
@@ -56,6 +61,9 @@ class BazaarPurchaseResult:
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:
@@ -290,7 +298,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
def fetch(subscription: bool):
if subscription:
url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}'
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:
@@ -327,7 +335,13 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
# سند می‌گوید validUntil را با اکنون مقایسه کنید.
valid_until = data.get('validUntilTimestampMsec') or 0
is_valid = valid_until > timezone.now().timestamp() * 1000
purchase_state = None
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
@@ -338,3 +352,36 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
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)}'