Files
MathApp/payment/providers.py
Zareian c0fa46be44 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>
2026-10-12 18:21:45 +03:30

124 lines
6.2 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
روش‌های پرداخت، هرکدام یک کلاس با یک رابط (الگوی 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