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:
123
payment/providers.py
Normal file
123
payment/providers.py
Normal 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
|
||||
@@ -18,6 +18,7 @@ from rest_framework.exceptions import APIException
|
||||
from statuslog.signals import status_changed
|
||||
|
||||
from .models import Order, Transaction
|
||||
from .providers import PROVIDERS, provider_for
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -102,7 +103,7 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
existing, user_id=user_id, amount=amount, currency=currency,
|
||||
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
||||
)
|
||||
payment_url = None if existing.provider == 'bazaar' else call_payment_gateway(existing)
|
||||
payment_url = provider_for(existing.provider).payment_url(existing)
|
||||
return existing, payment_url
|
||||
|
||||
from subscription.models import Plan
|
||||
@@ -136,9 +137,8 @@ def create_order(*, user_id: int, amount: int, currency: str = 'IRT',
|
||||
subscription_id=subscription_id, plan_id=plan_id, provider=provider,
|
||||
)
|
||||
|
||||
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً
|
||||
# با SDK بازار خرید را انجام میدهد و purchaseToken را به verify میفرستد.
|
||||
payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order)
|
||||
# بازار paymentUrl ندارد (اپ با Poolakey میخرد)؛ زرینپال آدرسِ PaymentRedirectView را دارد (providers.py).
|
||||
payment_url = provider_for(order.provider).payment_url(order)
|
||||
return order, payment_url
|
||||
|
||||
|
||||
@@ -282,13 +282,9 @@ def reconcile_pending_order(order: Order) -> bool:
|
||||
|
||||
|
||||
def reconcile_user_orders(user_id: int) -> None:
|
||||
"""سفارشهای 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)
|
||||
"""هر روشِ پرداخت آنچه از این کاربر جا مانده را از درگاهِ خودش میپرسد (providers.py)؛ از صفحهی وضعیتِ اشتراک."""
|
||||
for provider in PROVIDERS:
|
||||
provider.reconcile_user(user_id)
|
||||
|
||||
|
||||
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده میشود که تمدید شده یا نه. بازار روزِ آخر تمدید میکند.
|
||||
|
||||
@@ -53,11 +53,14 @@ class BazaarSubscriptionTests(TestCase):
|
||||
def test_migration_put_the_panel_skus_on_three_and_six_months(self):
|
||||
self.assertEqual(self.three.bazaar_sku, '10343')
|
||||
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=180).bazaar_sku, '10344')
|
||||
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=30).bazaar_sku, '')
|
||||
self.assertEqual(Plan.objects.get(kind='subscription', on_sale=True, duration_days=30).bazaar_sku, '10342')
|
||||
# بستهها هنوز در پیشخان نیستند.
|
||||
self.assertFalse(Plan.objects.filter(kind='pack').exclude(bazaar_sku='').exists())
|
||||
|
||||
def test_plan_without_sku_is_refused_for_bazaar(self):
|
||||
monthly = Plan.objects.get(kind='subscription', on_sale=True, duration_days=30)
|
||||
self.assertEqual(self.buy(monthly).status_code, 400)
|
||||
pack = Plan.objects.get(kind='pack', question_credits=20)
|
||||
self.assertEqual(self.buy(pack).status_code, 400)
|
||||
self.assertFalse(Order.objects.filter(user_id=self.user.id).exists())
|
||||
|
||||
def test_subscription_bought_in_bazaar_activates_for_the_plan(self):
|
||||
response = self.buy(self.three)
|
||||
@@ -201,3 +204,39 @@ class BazaarDynamicPriceTests(TestCase):
|
||||
self.assertEqual(other.post(f'/api/payment/bazaar/orders/{order_id}/start').status_code, 404)
|
||||
Order.objects.filter(pk=order_id).update(status='paid')
|
||||
self.assertEqual(self.start(order_id).status_code, 400)
|
||||
|
||||
|
||||
class PaymentProviderFactoryTests(TestCase):
|
||||
"""payment/providers.py: هر روش یک کلاس؛ روشِ ناشناخته خطا، نه پیشفرضِ زرینپال."""
|
||||
|
||||
def test_each_method_and_order_value_maps_to_its_class(self):
|
||||
from .providers import BazaarProvider, ZarinpalProvider, provider_for, provider_for_method
|
||||
self.assertIsInstance(provider_for_method('zarinpal'), ZarinpalProvider)
|
||||
self.assertIsInstance(provider_for_method('bazaar'), BazaarProvider)
|
||||
self.assertIsInstance(provider_for('gateway'), ZarinpalProvider)
|
||||
self.assertIsInstance(provider_for('bazaar'), BazaarProvider)
|
||||
# هر مقدارِ مدل یک کلاس دارد؛ مقدارِ تازه در مدل بیکلاس این تست را میشکند.
|
||||
self.assertEqual({k for k, _ in Order.PROVIDER_CHOICES}, {'gateway', 'bazaar'})
|
||||
for key, _ in Order.PROVIDER_CHOICES:
|
||||
provider_for(key)
|
||||
|
||||
def test_unknown_method_fails_loudly(self):
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from .providers import provider_for, provider_for_method
|
||||
with self.assertRaises(ValidationError):
|
||||
provider_for_method('paypal')
|
||||
with self.assertRaises(KeyError):
|
||||
provider_for('paypal')
|
||||
|
||||
def test_zarinpal_order_gets_a_pay_link_and_bazaar_order_none(self):
|
||||
user = User.objects.create(phone='09120007304')
|
||||
client = APIClient()
|
||||
client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(user.id)}')
|
||||
three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90)
|
||||
z = client.post('/api/subscription/purchase', {'planId': str(three.id)}, format='json').json()
|
||||
self.assertIn('/api/payment/orders/', z['paymentUrl'])
|
||||
self.assertNotIn('bazaarSku', z)
|
||||
b = client.post('/api/subscription/purchase', {'planId': str(three.id), 'paymentMethod': 'bazaar'},
|
||||
format='json', HTTP_IDEMPOTENCY_KEY='fb').json()
|
||||
self.assertIsNone(b['paymentUrl'])
|
||||
self.assertEqual(b['bazaarSku'], '10343')
|
||||
|
||||
Reference in New Issue
Block a user