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:
12
CLAUDE.md
12
CLAUDE.md
@@ -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 یا تمدیدِ واقعی در بازار؛ برابریِ قیمتِ پیشخان با ادمین.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
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 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)
|
|
||||||
|
|
||||||
|
|
||||||
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده میشود که تمدید شده یا نه. بازار روزِ آخر تمدید میکند.
|
# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده میشود که تمدید شده یا نه. بازار روزِ آخر تمدید میکند.
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
32
subscription/migrations/0015_bazaar_sku_monthly.py
Normal file
32
subscription/migrations/0015_bazaar_sku_monthly.py
Normal 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)]
|
||||||
@@ -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):
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user