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

123
payment/providers.py Normal file
View File

@@ -0,0 +1,123 @@
"""
روش‌های پرداخت، هرکدام یک کلاس با یک رابط (الگوی Strategy)، و `provider_for`/`provider_for_method` که از روی
`Order.provider` یا `paymentMethod`ِ API کلاسِ درست را برمی‌گردانند (Factory). ۲۰۲۶-۱۰-۱۲.
پیش از این، تفاوتِ زرین‌پال و بازار با `if provider == 'bazaar'` در چند فایل پخش بود (ساختِ سفارش، کدِ تخفیف،
بازسنجی، صفحه‌ی خرید)؛ افزودنِ روشِ سوم یعنی پیدا کردنِ همه‌ی آن‌ها. حالا روشِ تازه یک زیرکلاس است و یک خط در
`PROVIDERS`. تأییدِ خود پرداخت (کال‌بکِ زرین‌پال، verifyِ بازار) عمداً بیرون مانده: دو مسیرِ HTTPِ کاملاً جدا با
ورودی‌های متفاوت‌اند و در یک رابط فقط شکلِ مشترکِ ساختگی پیدا می‌کردند.
روشِ ناشناخته خطا می‌دهد (KeyError/ValidationError)، نه پیش‌فرضِ زرین‌پال: سفارشی که روشش را نمی‌شناسیم نباید
بی‌صدا به درگاهِ دیگری برود.
"""
from rest_framework.exceptions import ValidationError
class PaymentProvider:
#: مقدارِ `Order.provider`
key: str
#: مقدارِ `paymentMethod` در `POST /api/subscription/purchase`
method: str
def check_plan(self, plan) -> None:
"""اگر این پلن با این روش فروختنی نیست، ValidationError."""
def sku_for(self, plan) -> str:
"""شناسه‌ی محصول که روی سفارش می‌نشیند (فقط بازار)."""
return ''
def payment_url(self, order) -> str | None:
"""آدرسی که کاربر برای پرداخت به آن می‌رود؛ None یعنی پرداخت در اپ است، نه در مرورگر."""
raise NotImplementedError
def purchase_extras(self, order, plan) -> dict:
"""فیلدهای اضافه‌ی پاسخِ خرید برای این روش."""
return {}
def discount_blocker(self, order) -> str | None:
"""زیرِ قفلِ سفارش صدا زده می‌شود. متنِ خطا اگر کدِ تخفیف دیگر روی این سفارش نمی‌نشیند."""
return None
def reconcile_user(self, user_id: int) -> int:
"""پرداخت‌هایی که تأییدشان نرسیده یا تمدیدشان دیده نشده را از خودِ درگاه می‌پرسد. خروجی: شمارِ تازه‌ها."""
return 0
class ZarinpalProvider(PaymentProvider):
key = 'gateway'
method = 'zarinpal'
def payment_url(self, order) -> str:
from .services import call_payment_gateway
return call_payment_gateway(order)
def discount_blocker(self, order) -> str | None:
# بعد از رفتن به درگاه، authorityِ زرین‌پال با مبلغِ قبلی گرفته شده و دوباره استفاده می‌شود؛ تخفیف در این
# لحظه یعنی کاربر مبلغِ قبلی را می‌پردازد ولی verify با مبلغِ جدید رد می‌شود.
if order.gateway_authority:
return 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'
return None
def reconcile_user(self, user_id: int) -> int:
from django.utils import timezone
from .models import Order
from .services import reconcile_pending_order
since = timezone.now() - timezone.timedelta(days=2)
return sum(reconcile_pending_order(order) for order in Order.objects.filter(
user_id=user_id, status='pending', provider=self.key, created_at__gte=since,
).exclude(gateway_authority='')[:3])
class BazaarProvider(PaymentProvider):
key = 'bazaar'
method = 'bazaar'
def check_plan(self, plan) -> None:
# SKU فقط از پلن، هرگز از کلاینت. بی‌SKU یعنی این مورد در پیشخانِ بازار تعریف نشده.
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این مورد هنوز در کافه‌بازار تعریف نشده است.'})
def sku_for(self, plan) -> str:
return plan.bazaar_sku
def payment_url(self, order) -> None:
# اپِ اندروید با Poolakey می‌خرد و توکن را به /api/payment/bazaar/verify می‌فرستد.
return None
def purchase_extras(self, order, plan) -> dict:
return {'bazaarSku': order.bazaar_product_sku,
'bazaarProductType': 'subscription' if plan.kind == 'subscription' else 'inapp'}
def discount_blocker(self, order) -> str | None:
from . import bazaar
# بی‌«تخفیف پویا» بازار قیمتِ ثابتِ پیشخان را می‌گیرد؛ تخفیف آن‌وقت فقط کد را می‌سوزاند.
if not bazaar.dynamic_price_configured():
return 'کدِ تخفیف روی خرید از کافه‌بازار اعمال نمی‌شود.'
# قیمت در JWT امضا شده و خرید در بازار شروع شده است (BazaarStartPurchaseView).
if order.bazaar_price_rial is not None:
return 'کدِ تخفیف را پیش از رفتن به صفحه‌ی خریدِ بازار وارد کنید.'
return None
def reconcile_user(self, user_id: int) -> int:
from .services import sync_bazaar_renewals
return sync_bazaar_renewals(user_id)
PROVIDERS = (ZarinpalProvider(), BazaarProvider())
_BY_KEY = {p.key: p for p in PROVIDERS}
_BY_METHOD = {p.method: p for p in PROVIDERS}
def provider_for(key: str) -> PaymentProvider:
"""از `Order.provider`. مقدارِ ناشناخته KeyError است (داده‌ی خراب، نه ورودیِ کاربر)."""
return _BY_KEY[key]
def provider_for_method(method: str) -> PaymentProvider:
"""از `paymentMethod`ِ درخواست. ناشناخته ۴۰۰."""
try:
return _BY_METHOD[method]
except KeyError:
raise ValidationError({'paymentMethod': f'روشِ پرداختِ ناشناخته: {method}'}) from None