diff --git a/CLAUDE.md b/CLAUDE.md index 76e1794..90712c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -453,13 +453,15 @@ anyone who can read this repo can forge a token for any user.** ## کافه‌بازار: اشتراک، تخفیفِ پویا، تمدیدِ خودکار — روی سرور از ۲۰۲۶-۱۰-۱۲ ۱۷:۵۴ (بک‌اند `8077ab4`، فرانت `fffdeac`) -- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342`، `10343` (اشتراکِ ۹۰ روزه)، `10344` (۱۸۰ روزه)، - زیرِ `com.tadrisino.math.app`. سه‌ماهه و شش‌ماهه SKU گرفتند (`subscription.0014`)؛ `10342` نامعلوم است و به هیچ پلنی وصل - نیست. هیچ بسته‌ای در بازار نیست. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم). +- **محصول‌های پیشخان** (پرسشِ API با توکنِ ساختگی، ۱۰۰۰۰–۱۰۶۹۹): فقط `10342` (یک‌ماهه، به گفته‌ی مالک)، `10343` (۹۰ روزه)، + `10344` (۱۸۰ روزه)، زیرِ `com.tadrisino.math.app`؛ هر سه روی پلن‌ها (`subscription.0014`، `0015`). **بسته‌ها در بازار نیستند** + و کاشیِ بازارشان غیرفعال است. اتصالِ سرور به بازار از راهِ OAuthِ حسابِ شرکت است (کلیدِ پیشخان نداریم). +- **روش‌های پرداخت** در `payment/providers.py` (Strategy + factory)؛ تأییدِ خودِ پرداخت (کال‌بک/verify) عمداً بیرونش. - **مسیر** و تمدیدِ خودکار: `docs/operations.md` «فروش در کافه‌بازار». `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور است (بکاپ: `.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» را به اپ داد (سفارشِ ۱۴ بعد لغو شد). **نسنجیده:** هیچ خرید، JWT یا تمدیدِ واقعی در بازار؛ برابریِ قیمتِ پیشخان با ادمین. diff --git a/config/health.py b/config/health.py index c33190f..c9681f4 100644 --- a/config/health.py +++ b/config/health.py @@ -12,6 +12,7 @@ GET /api/health — «آیا این سرور واقعاً سالم است؟» عمداً هیچ سکرتی برنمی‌گرداند: فقط بله/خیرِ «تنظیم شده» و شمارشِ محتوا. """ +import logging import os from datetime import timedelta @@ -25,6 +26,8 @@ from rest_framework.response import Response from rest_framework.throttling import ScopedRateThrottle from rest_framework.views import APIView +logger = logging.getLogger(__name__) + def _check_database() -> dict: try: @@ -129,7 +132,8 @@ def _bazaar_configured() -> bool: try: from payment.bazaar import is_configured return is_configured() - except Exception: + except Exception: # noqa: BLE001 — سلامت نباید با خطای خواندنِ اعتبارِ بازار ۵۰۰ بدهد؛ ولی دیده شود + logger.exception('health: بررسیِ تنظیمِ بازار شکست خورد') return False diff --git a/discount/views.py b/discount/views.py index 578a086..6e6d83a 100644 --- a/discount/views.py +++ b/discount/views.py @@ -91,12 +91,6 @@ class RedeemDiscountView(APIView): from payment.models import Order # سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران). 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: with transaction.atomic(): @@ -117,15 +111,12 @@ class RedeemDiscountView(APIView): if order_locked.status != 'pending': raise ValidationError({'code': 'این سفارش دیگر قابل اعمال تخفیف نیست.'}) - # بعد از رفتن به درگاه، authorityِ زرین‌پال با مبلغِ قبلی گرفته شده و - # دوباره استفاده می‌شود؛ تخفیف در این لحظه یعنی کاربر مبلغِ قبلی را - # می‌پردازد ولی verify با مبلغِ جدید رد می‌شود — پول کم می‌شود و - # اشتراکی فعال نمی‌شود. - if order_locked.gateway_authority: - raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'}) - # همان قاعده برای بازار: قیمت در JWT امضا شده و خرید در بازار شروع شده است. - if order_locked.bazaar_price_rial is not None: - raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به صفحه‌ی خریدِ بازار وارد کنید.'}) + # هر روشِ پرداخت خودش می‌داند کی دیگر تخفیف نمی‌نشیند (زرین‌پال: بعد از رفتن به درگاه؛ بازار: + # بی‌کلیدِ «تخفیف پویا» یا بعد از امضای قیمت). payment/providers.py. + from payment.providers import provider_for + blocker = provider_for(order_locked.provider).discount_blocker(order_locked) + if blocker: + raise ValidationError({'code': blocker}) # یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند. if Redemption.objects.filter(order_id=order_locked.id).exists(): diff --git a/docs/operations.md b/docs/operations.md index 13377aa..803e896 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -281,14 +281,18 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U ## فروش در کافه‌بازار (اشتراک و بسته) اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در -برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند: «کارتِ بانکی» (زرین‌پال) و «کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد، -کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. +برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند و کاربر هر کدام را بخواهد می‌زند: «کارتِ بانکی» (زرین‌پال، پیش‌فرض) و +«کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد (الان همه‌ی بسته‌ها)، کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. داخلِ اپ +زیرِ «کافه‌بازار» قیمتی می‌آید که خودِ بازار برای آن SKU دارد (Poolakey روی گوشی؛ API سرور قیمت نمی‌دهد). + +تفاوتِ روش‌ها در یک جاست: `payment/providers.py` (هر روش یک کلاس، `provider_for`/`provider_for_method` انتخابش +می‌کنند). روشِ تازه = یک زیرکلاس و یک خط در `PROVIDERS`. | چه | کجا | الان | |---|---|---| -| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | سه‌ماهه `10343`، شش‌ماهه `10344`؛ `10342` هم هست و معلوم نیست چیست | -| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | دو اشتراکِ بالا (migrationِ `subscription.0014`) | -| قیمت | قیمتِ پیشخان باید با قیمتِ ادمین یکی باشد | **سنجیده نشده** | +| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | یک‌ماهه `10342` (به گفته‌ی مالک)، سه‌ماهه `10343`، شش‌ماهه `10344`؛ بسته‌ها هنوز تعریف نشده‌اند | +| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | سه اشتراکِ بالا (`subscription.0014`، `0015`)؛ بسته‌ای که در پیشخان تعریف شد، SKUاش این‌جا نوشته شود | +| قیمت | بازار قیمتِ پیشخان را می‌گیرد؛ ادمین باید با آن یکی باشد | داخلِ اپ قیمتِ بازار روی کاشی دیده می‌شود؛ برابری‌اش **سنجیده نشده** | | کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار می‌کند | | کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد | diff --git a/payment/providers.py b/payment/providers.py new file mode 100644 index 0000000..beacd34 --- /dev/null +++ b/payment/providers.py @@ -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 diff --git a/payment/services.py b/payment/services.py index 5f60a91..4a4aee5 100644 --- a/payment/services.py +++ b/payment/services.py @@ -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) # تا این فاصله پیش از پایانِ دوره، از بازار پرسیده می‌شود که تمدید شده یا نه. بازار روزِ آخر تمدید می‌کند. diff --git a/payment/tests_bazaar_subscription.py b/payment/tests_bazaar_subscription.py index a1358f0..806600e 100644 --- a/payment/tests_bazaar_subscription.py +++ b/payment/tests_bazaar_subscription.py @@ -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') diff --git a/subscription/migrations/0015_bazaar_sku_monthly.py b/subscription/migrations/0015_bazaar_sku_monthly.py new file mode 100644 index 0000000..1d779a5 --- /dev/null +++ b/subscription/migrations/0015_bazaar_sku_monthly.py @@ -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)] diff --git a/subscription/serializers.py b/subscription/serializers.py index 3c1a88e..be94945 100644 --- a/subscription/serializers.py +++ b/subscription/serializers.py @@ -30,7 +30,8 @@ class PurchaseRequestSerializer(serializers.Serializer): # از request.headers خونده می‌شه، نه از بدنه. planId = serializers.CharField() # تا ۲۰۲۶-۱۰-۰۳ هر مقداری پذیرفته و نادیده گرفته می‌شد، پس «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): diff --git a/subscription/services.py b/subscription/services.py index 0bd0d17..e73c01d 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -41,8 +41,10 @@ def has_subscription_access(user_id: int) -> bool: """ if has_active_subscription(user_id): 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): diff --git a/subscription/views.py b/subscription/views.py index 3aac2d5..25d52a3 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -103,38 +103,24 @@ class PurchaseView(APIView): plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True) idempotency_key = request.headers.get('Idempotency-Key') - # بازار: SKU از پلن، نه از کلاینت. بسته محصولِ مصرف‌شدنیِ بازار است و اشتراک محصولِ «اشتراکی» - # (از ۲۰۲۶-۱۰-۱۲؛ تمدیدِ خودکارش در payment.services.sync_bazaar_renewals). خریدِ واقعی هنوز آزموده نشده. - provider = 'gateway' - if v['paymentMethod'] == 'bazaar': - if not plan.bazaar_sku: - raise ValidationError({'planId': 'این مورد هنوز در کافه‌بازار تعریف نشده است.'}) - provider = 'bazaar' - bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else '' + # روشِ پرداخت (زرین‌پال/بازار) و تفاوت‌هایش از payment/providers.py؛ SKUِ بازار از پلن، نه از کلاینت. + from payment.providers import provider_for_method + provider = provider_for_method(v['paymentMethod']) + provider.check_plan(plan) def response(order, payment_url): - data = {'orderId': str(order.id), 'paymentUrl': payment_url} - if provider == 'bazaar': - data.update(bazaarSku=order.bazaar_product_sku, - bazaarProductType='subscription' if plan.kind == 'subscription' else 'inapp') + data = {'orderId': str(order.id), 'paymentUrl': payment_url, **provider.purchase_extras(order, plan)} 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': # بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی # خودِ سفارش داده می‌شوند (payment.services → subscription.services.grant_pack). order, payment_url = 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=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. # (create_order itself no longer calls Zarinpal — PaymentRedirectView @@ -196,8 +182,8 @@ class PurchaseView(APIView): idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title, - provider=provider, - bazaar_product_sku=bazaar_sku, + provider=provider.key, + bazaar_product_sku=provider.sku_for(plan), ) except Exception: # Guard by pk+status+plan_id+order_id+version so this cleanup only