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

@@ -4,6 +4,7 @@
جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
پیاده‌سازی شده، نه یک HTTP call به خودمون.
"""
import hashlib
import logging
from django.conf import settings
@@ -281,12 +282,97 @@ def reconcile_pending_order(order: Order) -> bool:
def reconcile_user_orders(user_id: int) -> None:
"""سفارش‌های pendingِ اخیرِ یک کاربر؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود."""
"""سفارش‌های pendingِ اخیرِ یک کاربر و تمدیدِ اشتراکِ بازار؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود."""
since = timezone.now() - timezone.timedelta(days=2)
for order in Order.objects.filter(
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
).exclude(gateway_authority='')[:3]:
reconcile_pending_order(order)
sync_bazaar_renewals(user_id)
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده می‌شود که تمدید شده یا نه. بازار روزِ آخر تمدید می‌کند.
BAZAAR_RENEWAL_LOOKAHEAD = timezone.timedelta(days=1)
def sync_bazaar_renewals(user_id: int) -> int:
"""
اشتراکِ بازار خودکار تمدید می‌شود و بازار به ما خبر نمی‌دهد (webhook ندارد). پس وقتی دوره‌ی آخرِ
پرداخت‌شده‌ی کاربر (`bazaar_valid_until`ِ آخرین سفارشِ همان توکن) نزدیکِ پایان یا گذشته است، همان توکن
دوباره از بازار پرسیده می‌شود؛ اگر `validUntil` جلو رفته، برای هر دوره‌ی تازه یک سفارشِ «paid» ساخته و
همان مسیرِ پرداخت (activate_subscription، اعلان، پورسانت) اجرا می‌شود. هر توکن ساعتی یک بار.
مبلغِ سفارشِ تمدید = قیمتِ فعلیِ پلن در ادمین؛ بازار تمدید را با قیمتِ پیشخان می‌گیرد و آن را به ما
نمی‌گوید. روزها مثلِ هر خریدِ دیگر روی پایانِ فعلی اضافه می‌شوند، پس کاربری که چند روز بعد از تمدید
می‌آید همان چند روز را بیشتر دارد. نسنجیده: تمدیدِ واقعی در بازار. خروجی: تعدادِ دوره‌های تازه.
"""
from django.core.cache import cache
from . import bazaar
now = timezone.now()
latest_by_token = {}
for order in Order.objects.filter(
user_id=user_id, provider='bazaar', status='paid', subscription_id__isnull=False,
bazaar_valid_until__isnull=False,
).exclude(bazaar_purchase_token='').order_by('bazaar_valid_until'):
latest_by_token[order.bazaar_purchase_token] = order
added = 0
for token, last in latest_by_token.items():
if last.bazaar_valid_until > now + BAZAAR_RENEWAL_LOOKAHEAD:
continue
token_key = hashlib.sha256(token.encode()).hexdigest()[:32]
if not cache.add(f'payment:bazaar-renewal:{token_key}', 1, 3600):
continue
try:
result = bazaar.verify_purchase(
product_sku=last.bazaar_product_sku, purchase_token=token, is_subscription=True)
except bazaar.BazaarAPIError: # خودِ bazaar لاگ کرده؛ ساعتِ بعد
continue
except Exception: # noqa: BLE001 — صفحه‌ی وضعیت نباید با خطای بازار ۵۰۰ بدهد؛ ولی دیده شود
logger.exception('bazaar renewal: سنجشِ تمدیدِ سفارشِ %s شکست خورد', last.id)
continue
if result.valid_until is None or result.valid_until <= last.bazaar_valid_until + timezone.timedelta(hours=12):
continue
added += _record_bazaar_renewal(last, token, token_key, result.valid_until)
return added
def _record_bazaar_renewal(last: Order, token: str, token_key: str, valid_until) -> int:
from subscription.models import Plan
plan = Plan.objects.filter(pk=last.plan_id).first() if last.plan_id else None
days = last.duration_days or (plan.duration_days if plan else None)
if not days:
logger.error('bazaar renewal: سفارشِ %s مدت ندارد؛ تمدید ثبت نشد', last.id)
return 0
gap = (valid_until - last.bazaar_valid_until).total_seconds() / 86400
periods = max(1, round(gap / days))
if periods > 1:
logger.warning('bazaar renewal: %s دوره‌ی تمدید برای سفارشِ %s یک‌جا ثبت می‌شود', periods, last.id)
msec = int(valid_until.timestamp() * 1000)
added = 0
for i in range(periods):
period_end = valid_until - timezone.timedelta(days=days * (periods - 1 - i))
price = plan.price if plan else last.list_price or last.amount
order, created = Order.objects.get_or_create(
idempotency_key=f'bazaar-renew-{token_key}-{msec}-{i}',
defaults=dict(
user_id=last.user_id, subscription_id=last.subscription_id, plan_id=last.plan_id,
duration_days=days, amount=price, list_price=price,
plan_title=(plan.title if plan else last.plan_title)[:50], currency=last.currency,
status='pending', provider='bazaar', bazaar_product_sku=last.bazaar_product_sku,
bazaar_purchase_token=token, bazaar_valid_until=period_end,
),
)
if not created and order.status != 'pending':
continue
if apply_order_payment_result(
order=order, gateway='bazaar', gateway_ref_id=f'bazaar-renew-{token_key}-{msec}-{i}', # توکن تا ۳۰۰ نویسه است؛ ref هم ۳۰۰
new_status='paid', event='bazaar_renewal',
):
added += 1
return added
class OrderStateError(Exception):