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

@@ -453,13 +453,15 @@ anyone who can read this repo can forge a token for any user.**
## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`) ## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`)
- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342`، `10343` (اشتراکِ ۹۰ روزه)، `10344` (۱۸۰ روزه)، - **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یک‌ماهه، به گفته‌ی مالک)، `10343` (۹۰ روزه)،
زیرِ `com.tadrisino.math.app`. سه‌ماهه و شش‌ماهه SKU گرفتند (`subscription.0014`)؛ `10342` نامعلوم است و به هیچ پلنی وصل `10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلن‌ها (`subscription.0014`، `0015`). **بسته‌ها در بازار نیستند**
نیست. هیچ بسته‌ای در بازار نیست. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم). و کاشیِ بازارشان غیرفعال است. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم).
- **روش‌های پرداخت** در `payment/providers.py` (Strategy + factory)؛ تأییدِ خودِ پرداخت (کال‌بک/verify) عمداً بیرونش.
- **مسیر** و تمدیدِ خودکار: `docs/operations.md` «فروش در کافه‌بازار». `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور است (بکاپ: - **مسیر** و تمدیدِ خودکار: `docs/operations.md` «فروش در کافه‌بازار». `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور است (بکاپ:
`.deploy-backups/env-before-bazaar-dynamic-20261012-175336`)؛ توکن فقط وقتی کدِ تخفیف قیمت را کم کرده می‌رود. `.deploy-backups/env-before-bazaar-dynamic-20261012-175336`)؛ توکن فقط وقتی کدِ تخفیف قیمت را کم کرده می‌رود.
- **فرانت:** دو کاشیِ «کارتِ بانکی» و «کافه‌بازار» همیشه کنارِ هم؛ بازار جایی که ممکن نیست غیرفعال با دلیل. پلِ تازه - **فرانت:** دو کاشیِ «کارتِ بانکی» (پیش‌فرض) و «کافه‌بازار» همیشه کنارِ هم؛ بازار جایی که ممکن نیست غیرفعال با دلیل.
`bazaarBuy`؛ کلیدِ RSA و `versionCode` ۲ در `android/`. `compileDebugKotlin` سبز؛ **APK ساخته نشده**. داخلِ اپ قیمتِ پیشخان از Poolakey (`bazaarPrices`) روی کاشیِ بازار. پلِ `bazaarBuy`؛ کلیدِ RSA و `versionCode` ۲ در
`android/`. `compileDebugKotlin` سبز؛ **APK ساخته نشده**.
- **سنجیده:** ۹۷۹ تست؛ روی سایتِ زنده با دموی ۱۴ و پلِ اندرویدِ ساختگی در مرورگر: کاشی‌ها، و سرور SKUِ `10343`/«subscription» - **سنجیده:** ۹۷۹ تست؛ روی سایتِ زنده با دموی ۱۴ و پلِ اندرویدِ ساختگی در مرورگر: کاشی‌ها، و سرور SKUِ `10343`/«subscription»
را به اپ داد (سفارشِ ۱۴ بعد لغو شد). **نسنجیده:** هیچ خرید، JWT یا تمدیدِ واقعی در بازار؛ برابریِ قیمتِ پیشخان با ادمین. را به اپ داد (سفارشِ ۱۴ بعد لغو شد). **نسنجیده:** هیچ خرید، JWT یا تمدیدِ واقعی در بازار؛ برابریِ قیمتِ پیشخان با ادمین.

View File

@@ -12,6 +12,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟»
عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا. عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا.
""" """
import logging
import os import os
from datetime import timedelta from datetime import timedelta
@@ -25,6 +26,8 @@ from rest_framework.response import Response
from rest_framework.throttling import ScopedRateThrottle from rest_framework.throttling import ScopedRateThrottle
from rest_framework.views import APIView from rest_framework.views import APIView
logger = logging.getLogger(__name__)
def _check_database() -> dict: def _check_database() -> dict:
try: try:
@@ -129,7 +132,8 @@ def _bazaar_configured() -> bool:
try: try:
from payment.bazaar import is_configured from payment.bazaar import is_configured
return is_configured() return is_configured()
except Exception: except Exception: # noqa: BLE001 — سلامت نباید با خطای خواندنِ اعتبارِ بازار ۵۰۰ بدهد؛ ولی دیده شود
logger.exception('health: بررسیِ تنظیمِ بازار شکست خورد')
return False return False

View File

@@ -91,12 +91,6 @@ class RedeemDiscountView(APIView):
from payment.models import Order from payment.models import Order
# سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران). # سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران).
order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) order = get_object_or_404(Order, pk=order_id, user_id=request.user.id)
# بازار بی‌«تخفیف پویا» قیمتِ ثابتِ پیشخان را می‌گیرد؛ تخفیف آن‌وقت فقط کد را می‌سوزاند. با کلید،
# مبلغِ کم‌شده در JWT به بازار می‌رود (payment.views.BazaarStartPurchaseView).
if order.provider == 'bazaar':
from payment import bazaar
if not bazaar.dynamic_price_configured():
raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافه‌بازار اعمال نمی‌شود.'})
try: try:
with transaction.atomic(): with transaction.atomic():
@@ -117,15 +111,12 @@ class RedeemDiscountView(APIView):
if order_locked.status != 'pending': if order_locked.status != 'pending':
raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'}) raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'})
# بعد از رفتن به درگاه، authorityِ زرین‌پال با مبلغِ قبلی گرفته شده و # هر روشِ پرداخت خودش می‌داند کی دیگر تخفیف نمی‌نشیند (زرین‌پال: بعد از رفتن به درگاه؛ بازار:
# دوباره استفاده می‌شود؛ تخفیف در این لحظه یعنی کاربر مبلغِ قبلی را # بی‌کلیدِ «تخفیف پویا» یا بعد از امضای قیمت). payment/providers.py.
# می‌پردازد ولی verify با مبلغِ جدید رد می‌شود — پول کم می‌شود و from payment.providers import provider_for
# اشتراکی فعال نمی‌شود. blocker = provider_for(order_locked.provider).discount_blocker(order_locked)
if order_locked.gateway_authority: if blocker:
raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'}) raise ValidationError({'code': blocker})
# همان قاعده برای بازار: قیمت در JWT امضا شده و خرید در بازار شروع شده است.
if order_locked.bazaar_price_rial is not None:
raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به صفحه‌ی خریدِ بازار وارد کنید.'})
# یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند. # یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند.
if Redemption.objects.filter(order_id=order_locked.id).exists(): if Redemption.objects.filter(order_id=order_locked.id).exists():

View File

@@ -281,14 +281,18 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
## فروش در کافه‌بازار (اشتراک و بسته) ## فروش در کافه‌بازار (اشتراک و بسته)
اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در
برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند: «کارتِ بانکی» (زرین‌پال) و «کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد، برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند و کاربر هر کدام را بخواهد می‌زند: «کارتِ بانکی» (زرین‌پال، پیش‌فرض) و
کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. «کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد (الان همه‌ی بسته‌ها)، کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. داخلِ اپ
زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمی‌دهد).
تفاوتِ روش‌ها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش
می‌کنند). روشِ تازه = یک زیرکلاس و یک خط در `PROVIDERS`.
| چه | کجا | الان | | چه | کجا | الان |
|---|---|---| |---|---|---|
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | سه‌ماهه `10343`، شش‌ماهه `10344`؛ `10342` هم هست و معلوم نیست چیست | | محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | یک‌ماهه `10342` (به گفته‌ی مالک)، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ها هنوز تعریف نشده‌اند |
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | دو اشتراکِ بالا (migrationِ `subscription.0014`) | | SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`)؛ بسته‌ای که در پیشخان تعریف شد، SKUاش این‌جا نوشته شود |
| قیمت | قیمتِ پیشخان باید با قیمتِ ادمین یکی باشد | **سنجیده نشده** | | قیمت | بازار قیمتِ پیشخان را می‌گیرد؛ ادمین باید با آن یکی باشد | داخلِ اپ قیمتِ بازار روی کاشی دیده می‌شود؛ برابری‌اش **سنجیده نشده** |
| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار می‌کند | | کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار می‌کند |
| کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد | | کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد |

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

View File

@@ -18,6 +18,7 @@ from rest_framework.exceptions import APIException
from statuslog.signals import status_changed from statuslog.signals import status_changed
from .models import Order, Transaction from .models import Order, Transaction
from .providers import PROVIDERS, provider_for
logger = logging.getLogger(__name__) 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, existing, user_id=user_id, amount=amount, currency=currency,
subscription_id=subscription_id, plan_id=plan_id, provider=provider, 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 return existing, payment_url
from subscription.models import Plan 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, subscription_id=subscription_id, plan_id=plan_id, provider=provider,
) )
# برای provider='bazaar' هیچ paymentUrlی وجود ندارد: کلاینت اندروید مستقیماً # بازار paymentUrl ندارد (اپ با Poolakey می‌خرد)؛ زرین‌پال آدرسِ PaymentRedirectView را دارد (providers.py).
# با SDK بازار خرید را انجام می‌دهد و purchaseToken را به verify می‌فرستد. payment_url = provider_for(order.provider).payment_url(order)
payment_url = None if order.provider == 'bazaar' else call_payment_gateway(order)
return order, payment_url return order, payment_url
@@ -282,13 +282,9 @@ def reconcile_pending_order(order: Order) -> bool:
def reconcile_user_orders(user_id: int) -> None: def reconcile_user_orders(user_id: int) -> None:
"""سفارش‌های pendingِ اخیرِ یک کاربر و تمدیدِ اشتراکِ بازار؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود.""" """هر روشِ پرداخت آنچه از این کاربر جا مانده را از درگاهِ خودش می‌پرسد (providers.py)؛ از صفحه‌ی وضعیتِ اشتراک."""
since = timezone.now() - timezone.timedelta(days=2) for provider in PROVIDERS:
for order in Order.objects.filter( provider.reconcile_user(user_id)
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)
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده می‌شود که تمدید شده یا نه. بازار روزِ آخر تمدید می‌کند. # تا این فاصله پیش از پایانِ دوره، از بازار پرسیده می‌شود که تمدید شده یا نه. بازار روزِ آخر تمدید می‌کند.

View File

@@ -53,11 +53,14 @@ class BazaarSubscriptionTests(TestCase):
def test_migration_put_the_panel_skus_on_three_and_six_months(self): def test_migration_put_the_panel_skus_on_three_and_six_months(self):
self.assertEqual(self.three.bazaar_sku, '10343') 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=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): def test_plan_without_sku_is_refused_for_bazaar(self):
monthly = Plan.objects.get(kind='subscription', on_sale=True, duration_days=30) pack = Plan.objects.get(kind='pack', question_credits=20)
self.assertEqual(self.buy(monthly).status_code, 400) 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): def test_subscription_bought_in_bazaar_activates_for_the_plan(self):
response = self.buy(self.three) 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) self.assertEqual(other.post(f'/api/payment/bazaar/orders/{order_id}/start').status_code, 404)
Order.objects.filter(pk=order_id).update(status='paid') Order.objects.filter(pk=order_id).update(status='paid')
self.assertEqual(self.start(order_id).status_code, 400) 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')

View File

@@ -0,0 +1,32 @@
"""
`10342` → اشتراکِ یک‌ماهه (۲۰۲۶-۱۰-۱۲، به گفته‌ی مالک «احتمالاً همان یک‌ماهه»؛ پیشخان را خودمان ندیده‌ایم).
بازار آن را زیرِ com.tadrisino.math.app می‌شناسد؛ نوعش (اشتراکی یا تک‌خرید) را API بدونِ خریدِ واقعی نمی‌گوید.
اگر اشتباه بود: «Bazaar sku» همان پلن را در ادمین خالی کنید. همان قاعده‌ی 0014: دقیقاً یک پلنِ فروشیِ ۳۰ روزه.
"""
from django.core.cache import cache
from django.db import migrations
SKU = '10342'
def forward(apps, schema_editor):
Plan = apps.get_model('subscription', 'Plan')
if Plan.objects.filter(bazaar_sku=SKU).exists():
return
plans = list(Plan.objects.filter(kind='subscription', on_sale=True, duration_days=30))
if len(plans) != 1:
raise RuntimeError(f'SKUِ بازار {SKU}: باید دقیقاً یک اشتراکِ فروشیِ ۳۰ روزه باشد، {len(plans)} تا پیدا شد.')
if not plans[0].bazaar_sku:
plans[0].bazaar_sku = SKU
plans[0].save(update_fields=['bazaar_sku'])
cache.delete('subscription:plans:v3') # مثلِ 0014: سیگنالِ post_save در migration نمی‌آید
def backward(apps, schema_editor):
apps.get_model('subscription', 'Plan').objects.filter(bazaar_sku=SKU).update(bazaar_sku='')
cache.delete('subscription:plans:v3')
class Migration(migrations.Migration):
dependencies = [('subscription', '0014_bazaar_skus_2026_10')]
operations = [migrations.RunPython(forward, backward)]

View File

@@ -30,7 +30,8 @@ class PurchaseRequestSerializer(serializers.Serializer):
# از request.headers خونده می‌شه، نه از بدنه. # از request.headers خونده می‌شه، نه از بدنه.
planId = serializers.CharField() planId = serializers.CharField()
# تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته می‌شد، پس «bazaar» بی‌صدا به زرین‌پال می‌رفت. # تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته می‌شد، پس «bazaar» بی‌صدا به زرین‌پال می‌رفت.
paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal') paymentMethod = serializers.ChoiceField(choices=['zarinpal', 'bazaar'], required=False, default='zarinpal',
help_text='payment/providers.py: هر مقدار یک کلاسِ PaymentProvider.')
class PurchaseResponseSerializer(serializers.Serializer): class PurchaseResponseSerializer(serializers.Serializer):

View File

@@ -41,8 +41,10 @@ def has_subscription_access(user_id: int) -> bool:
""" """
if has_active_subscription(user_id): if has_active_subscription(user_id):
return True return True
from payment.services import sync_bazaar_renewals # فقط بازار، نه همه‌ی روش‌ها: بازسنجیِ زرین‌پال در این مسیرِ پرترافیک برای هر بی‌اشتراکی با سفارشِ نیمه‌کاره
return bool(sync_bazaar_renewals(user_id)) and has_active_subscription(user_id) # یک فراخوانیِ شبکه می‌شد؛ آن را صفحه‌ی وضعیت و کال‌بک می‌کنند.
from payment.providers import provider_for
return bool(provider_for('bazaar').reconcile_user(user_id)) and has_active_subscription(user_id)
def hearts_pass_until(user_id: int): def hearts_pass_until(user_id: int):

View File

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