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

@@ -103,20 +103,29 @@ class PurchaseView(APIView):
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
# بازار: SKU از پلن، نه از کلاینت. بسته محصولِ مصرف‌شدنیِ بازار است و اشتراک محصولِ «اشتراکی»
# (از ۲۰۲۶-۱۰-۱۲؛ تمدیدِ خودکارش در payment.services.sync_bazaar_renewals). خریدِ واقعی هنوز آزموده نشده.
provider = 'gateway'
if v['paymentMethod'] == 'bazaar':
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
# اشتراک فعلاً فقط از زرین‌پال فروخته می‌شود. SKU از پلن، نه از کلاینت.
if plan.kind != 'pack':
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'})
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این بسته هنوز در کافه‌بازار تعریف نشده است.'})
raise ValidationError({'planId': 'این مورد هنوز در کافه‌بازار تعریف نشده است.'})
provider = 'bazaar'
bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else ''
def response(order, payment_url):
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
if provider == 'bazaar':
data.update(bazaarSku=order.bazaar_product_sku,
bazaarProductType='subscription' if plan.kind == 'subscription' else 'inapp')
return Response(PurchaseResponseSerializer(data).data)
if provider == 'bazaar' and plan.kind == 'pack':
order, _ = create_order(
user_id=user.id, amount=plan.price, currency=plan.currency,
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
provider='bazaar', bazaar_product_sku=plan.bazaar_sku,
provider='bazaar', bazaar_product_sku=bazaar_sku,
)
return Response(PurchaseResponseSerializer(
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
return response(order, None)
if plan.kind == 'pack':
# بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی
@@ -187,6 +196,8 @@ class PurchaseView(APIView):
idempotency_key=idempotency_key,
plan_id=plan.id,
plan_title=plan.title,
provider=provider,
bazaar_product_sku=bazaar_sku,
)
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only
@@ -212,8 +223,7 @@ class PurchaseView(APIView):
subscription.order_id = order.id
subscription.save(update_fields=['order_id'])
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
return Response(PurchaseResponseSerializer(data).data)
return response(order, payment_url)
class SubscriptionStatusView(APIView):