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

@@ -559,6 +559,8 @@ REST_FRAMEWORK = {
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره می‌بندد، نه این.
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
'bazaar_verify': '10/hour',
# شروعِ خریدِ بازار (امضای قیمت)؛ بی‌فراخوانیِ بیرونی، ولی هر بار یک توکنِ تازه.
'bazaar_start': '30/hour',
'bazaar_oauth': '20/hour',
'ai_ask': '20/hour',
'rag_ingest': '30/hour',
@@ -697,6 +699,7 @@ SPECTACULAR_SETTINGS = {
'ENUM_NAME_OVERRIDES': {
'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')],
'PlanKindEnum': [('pack', 'pack'), ('subscription', 'subscription')],
'BazaarProductTypeEnum': [('inapp', 'inapp'), ('subscription', 'subscription')],
},
'SWAGGER_UI_SETTINGS': {
'deepLinking': True,
@@ -884,7 +887,9 @@ INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
# گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره.
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
# این روش رو قبلاً تغییر داده.
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app')
# applicationIdِ APK و پکیجِ پیشخان (com.tadrisino.math.app روی سرور؛ ۲۰۲۶-۱۰-۱۲ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را
# زیرِ همین نام شناخت). پیش‌فرض همان است تا لپ‌تاپ و سرور یکی باشند.
BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'com.tadrisino.math.app')
BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
@@ -900,6 +905,11 @@ BAZAAR_REDIRECT_URI = (
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify'
)
# کلیدِ «تخفیف پویا» از پیشخانِ بازار (پرداخت درون‌برنامه‌ای ← تخفیف پویا). با آن سرور برای هر خرید
# یک JWTِ یک‌بارمصرف امضا می‌کند که قیمتِ همان سفارش (پس از کدِ تخفیف) را به بازار می‌گوید؛ پس قیمتِ
# ادمین ملاک است، نه قیمتِ پیشخان (که باید از آن کمتر نباشد). بی‌آن بازار قیمتِ پیشخان را می‌گیرد و
# کدِ تخفیف روی سفارشِ بازار ۴۰۰ است. فقط سمتِ سرور؛ هرگز در اپ.
BAZAAR_DYNAMIC_PRICE_KEY = os.environ.get('BAZAAR_DYNAMIC_PRICE_KEY', '').strip()
# چند ساعت لینکِ «اجازه»ی بازار باز می‌ماند؛ بعد از اتصالِ موفق خودش بسته می‌شود.
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))