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:
@@ -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)}'
|
||||
|
||||
Reference in New Issue
Block a user