- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind / question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the company owner's test account (user 26). - Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4). - Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType, verify refuses an order without a subscription. A paid order with no subscription now logs ERROR. - Removed POST /api/payment/orders (internal; no caller). Its permission tests stay. - Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted. - docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current. 949 tests green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
123 lines
6.1 KiB
Python
123 lines
6.1 KiB
Python
"""
|
||
روشهای پرداخت، هرکدام یک کلاس با یک رابط (الگوی 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}
|
||
|
||
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
|