Payment providers as classes (Strategy + factory); 10342 = monthly; health logs a failed Bazaar check

- payment/providers.py: ZarinpalProvider / BazaarProvider with one interface (check_plan, sku_for, payment_url,
  purchase_extras, discount_blocker, reconcile_user); provider_for / provider_for_method pick one; unknown fails.
  create_order, purchase view, discount redeem and reconcile_user_orders use it instead of scattered ifs.
- subscription.0015: Bazaar SKU 10342 on the one-month plan (owner: "probably the monthly one").
- config/health: the Bazaar check no longer swallows its exception without a log.
982 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 18:21:45 +03:30
parent 76b0ef976e
commit c0fa46be44
11 changed files with 246 additions and 66 deletions

View File

@@ -103,38 +103,24 @@ 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':
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این مورد هنوز در کافه‌بازار تعریف نشده است.'})
provider = 'bazaar'
bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else ''
# روشِ پرداخت (زرین‌پال/بازار) و تفاوت‌هایش از payment/providers.py؛ SKUِ بازار از پلن، نه از کلاینت.
from payment.providers import provider_for_method
provider = provider_for_method(v['paymentMethod'])
provider.check_plan(plan)
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')
data = {'orderId': str(order.id), 'paymentUrl': payment_url, **provider.purchase_extras(order, plan)}
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=bazaar_sku,
)
return response(order, None)
if plan.kind == 'pack':
# بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی
# خودِ سفارش داده می‌شوند (payment.services → subscription.services.grant_pack).
order, payment_url = 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=provider.key, bazaar_product_sku=provider.sku_for(plan),
)
return Response(PurchaseResponseSerializer({'orderId': str(order.id), 'paymentUrl': payment_url}).data)
return response(order, payment_url)
# Three-phase flow so create_order never runs inside a DB transaction/lock.
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
@@ -196,8 +182,8 @@ class PurchaseView(APIView):
idempotency_key=idempotency_key,
plan_id=plan.id,
plan_title=plan.title,
provider=provider,
bazaar_product_sku=bazaar_sku,
provider=provider.key,
bazaar_product_sku=provider.sku_for(plan),
)
except Exception:
# Guard by pk+status+plan_id+order_id+version so this cleanup only