diff --git a/.env.example b/.env.example index 6426d3d..64b50a3 100644 --- a/.env.example +++ b/.env.example @@ -111,7 +111,9 @@ BAZAAR_PISHKHAN_API_SECRET= BAZAAR_CLIENT_ID= BAZAAR_CLIENT_SECRET= BAZAAR_REFRESH_TOKEN= -# BAZAAR_PACKAGE_NAME=ir.tadrisino.app +# BAZAAR_PACKAGE_NAME=com.tadrisino.math.app +# کلیدِ «تخفیف پویا» از پیشخان (سمتِ سرور؛ قیمتِ ادمین و کدِ تخفیف را به بازار می‌گوید). +BAZAAR_DYNAMIC_PRICE_KEY= # ═══════════════════════════════════════════════════════════════════════ # ۶) اختیاری — پیش‌فرض‌ها معمولاً کافی‌اند diff --git a/config/health.py b/config/health.py index 7ac2449..c33190f 100644 --- a/config/health.py +++ b/config/health.py @@ -118,6 +118,7 @@ def _check_integrations() -> dict: 'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')), 'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')), 'bazaar': _bazaar_configured(), + 'bazaarDynamicPrice': bool(getattr(settings, 'BAZAAR_DYNAMIC_PRICE_KEY', '')), 'aiProvider': _ai_provider_state(), 'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''), 'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None, diff --git a/config/settings.py b/config/settings.py index 5815e7b..69d77db 100644 --- a/config/settings.py +++ b/config/settings.py @@ -559,6 +559,8 @@ REST_FRAMEWORK = { # را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره می‌بندد، نه این. 'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'), 'bazaar_verify': '10/hour', + # شروعِ خریدِ بازار (امضای قیمت)؛ بی‌فراخوانیِ بیرونی، ولی هر بار یک توکنِ تازه. + 'bazaar_start': '30/hour', 'bazaar_oauth': '20/hour', 'ai_ask': '20/hour', 'rag_ingest': '30/hour', @@ -697,6 +699,7 @@ SPECTACULAR_SETTINGS = { 'ENUM_NAME_OVERRIDES': { 'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')], 'PlanKindEnum': [('pack', 'pack'), ('subscription', 'subscription')], + 'BazaarProductTypeEnum': [('inapp', 'inapp'), ('subscription', 'subscription')], }, 'SWAGGER_UI_SETTINGS': { 'deepLinking': True, @@ -884,7 +887,9 @@ INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '') # گرفته می‌شه، بعد بک‌اند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه می‌گیره. # قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار # این روش رو قبلاً تغییر داده. -BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'ir.tadrisino.app') +# applicationIdِ APK و پکیجِ پیشخان (com.tadrisino.math.app روی سرور؛ ۲۰۲۶-۱۰-۱۲ بازار محصول‌های ۱۰۳۴۲–۱۰۳۴۴ را +# زیرِ همین نام شناخت). پیش‌فرض همان است تا لپ‌تاپ و سرور یکی باشند. +BAZAAR_PACKAGE_NAME = os.environ.get('BAZAAR_PACKAGE_NAME', 'com.tadrisino.math.app') BAZAAR_CLIENT_ID = os.environ.get('BAZAAR_CLIENT_ID', '') BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '') BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '') @@ -900,6 +905,11 @@ BAZAAR_REDIRECT_URI = ( os.environ.get('BAZAAR_REDIRECT_URI', '').strip() or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify' ) +# کلیدِ «تخفیف پویا» از پیشخانِ بازار (پرداخت درون‌برنامه‌ای ← تخفیف پویا). با آن سرور برای هر خرید +# یک JWTِ یک‌بارمصرف امضا می‌کند که قیمتِ همان سفارش (پس از کدِ تخفیف) را به بازار می‌گوید؛ پس قیمتِ +# ادمین ملاک است، نه قیمتِ پیشخان (که باید از آن کمتر نباشد). بی‌آن بازار قیمتِ پیشخان را می‌گیرد و +# کدِ تخفیف روی سفارشِ بازار ۴۰۰ است. فقط سمتِ سرور؛ هرگز در اپ. +BAZAAR_DYNAMIC_PRICE_KEY = os.environ.get('BAZAAR_DYNAMIC_PRICE_KEY', '').strip() # چند ساعت لینکِ «اجازه»ی بازار باز می‌ماند؛ بعد از اتصالِ موفق خودش بسته می‌شود. BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72')) diff --git a/content/views.py b/content/views.py index 53453a7..b62f63a 100644 --- a/content/views.py +++ b/content/views.py @@ -330,10 +330,10 @@ def unit_is_free(unit_id: int, grade_id: int) -> bool: def has_unit_access(user_id, unit_id: int, grade_id: int) -> bool: - from subscription.services import has_active_subscription + from subscription.services import has_subscription_access if unit_is_free(unit_id, grade_id): return True - return user_id is not None and has_active_subscription(user_id) + return user_id is not None and has_subscription_access(user_id) def subscription_required_response() -> Response: @@ -468,8 +468,8 @@ class HomeView(APIView): ).values_list('node_id', flat=True) ) - from subscription.services import has_active_subscription - subscribed = user_id is not None and has_active_subscription(user_id) + from subscription.services import has_subscription_access + subscribed = user_id is not None and has_subscription_access(user_id) unlock_all = user_has_all_nodes_unlocked(user_id) units_payload = [] for index, unit in enumerate(tree): diff --git a/discount/views.py b/discount/views.py index 56227f2..578a086 100644 --- a/discount/views.py +++ b/discount/views.py @@ -91,10 +91,12 @@ class RedeemDiscountView(APIView): from payment.models import Order # سفارش باید متعلق به همان کاربر احرازهویت‌شده باشد (جلوگیری از اعمال تخفیف روی سفارش دیگران). order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) - # بازار قیمتِ ثابتِ SKU را می‌گیرد، هرچه مبلغِ سفارش باشد؛ تخفیف روی سفارشِ بازار فقط - # کد را می‌سوزاند و کاربر همان قیمتِ کامل را می‌پرداخت. + # بازار بی‌«تخفیف پویا» قیمتِ ثابتِ پیشخان را می‌گیرد؛ تخفیف آن‌وقت فقط کد را می‌سوزاند. با کلید، + # مبلغِ کم‌شده در JWT به بازار می‌رود (payment.views.BazaarStartPurchaseView). if order.provider == 'bazaar': - raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافه‌بازار اعمال نمی‌شود.'}) + from payment import bazaar + if not bazaar.dynamic_price_configured(): + raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافه‌بازار اعمال نمی‌شود.'}) try: with transaction.atomic(): @@ -121,6 +123,9 @@ class RedeemDiscountView(APIView): # اشتراکی فعال نمی‌شود. if order_locked.gateway_authority: raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'}) + # همان قاعده برای بازار: قیمت در JWT امضا شده و خرید در بازار شروع شده است. + if order_locked.bazaar_price_rial is not None: + raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به صفحه‌ی خریدِ بازار وارد کنید.'}) # یک کد برای هر سفارش: کدهای درصدی روی هم جمع می‌شدند. if Redemption.objects.filter(order_id=order_locked.id).exists(): diff --git a/docs/operations.md b/docs/operations.md index 53147c4..13377aa 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -278,22 +278,38 @@ callbackِ زرین‌پال، برگشت از پرداخت، `BAZAAR_REDIRECT_U «پرداخت‌ها و فاکتورها» می‌بیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`) است و تست‌هایش در `payment/tests_refund.py`. -## فروشِ بسته در کافه‌بازار +## فروش در کافه‌بازار (اشتراک و بسته) -از ۲۰۲۶-۱۰-۰۳. اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) بسته‌ها را با -پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد؛ در مرورگرِ معمولی همه‌چیز مثلِ قبل از زرین‌پال است. +اپِ اندروید (پوسته‌ی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درون‌برنامه‌ایِ بازار می‌فروشد. در +برگه‌ی پرداخت دو کاشی همیشه کنارِ هم‌اند: «کارتِ بانکی» (زرین‌پال) و «کافه‌بازار». در مرورگر، یا وقتی پلن SKU ندارد، +کاشیِ بازار غیرفعال است و دلیلش را می‌گوید. -1. در پیشخانِ بازار برای هر بسته یک **محصولِ مصرف‌شدنی** با همان قیمت بسازید. -2. شناسه‌ی آن (SKU) را در `/admin/subscription/plan/` در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU - را فقط از همین‌جا می‌گیرد، نه از اپ؛ بسته‌ی بی‌SKU داخلِ اپ «به‌زودی در بازار» نشان داده می‌شود و - درخواستش ۴۰۰ است. یک SKU روی دو بسته نمی‌نشیند. -3. کلیدِ RSAِ عمومیِ اپ (از پیشخان) در `android/app/build.gradle.kts` می‌رود، نه در `.env`. +| چه | کجا | الان | +|---|---|---| +| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید می‌کند)، بسته = «مصرف‌شدنی» | سه‌ماهه `10343`، شش‌ماهه `10344`؛ `10342` هم هست و معلوم نیست چیست | +| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از این‌جا می‌گیرد) | دو اشتراکِ بالا (migrationِ `subscription.0014`) | +| قیمت | قیمتِ پیشخان باید با قیمتِ ادمین یکی باشد | **سنجیده نشده** | +| کلیدِ «تخفیف پویا» | `BAZAAR_DYNAMIC_PRICE_KEY` در `.env` سرور | با آن کدِ تخفیف روی خریدِ بازار هم کار می‌کند | +| کلیدِ RSAِ عمومی | `android/app/build.gradle.kts` | گذاشته شد | -مسیر: `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` (فقط بسته) سفارش می‌سازد و -`bazaarSku` برمی‌گرداند؛ اپ با Poolakey می‌خرد (`payload` = شماره‌ی سفارش)؛ `POST /api/payment/bazaar/verify` -توکن را از خودِ بازار می‌سنجد و اعتبار می‌دهد. کدِ تخفیف روی سفارشِ بازار رد می‌شود (بازار قیمتِ -ثابتِ محصول را می‌گیرد). تست‌ها: `payment/tests_bazaar_pack.py`. **هنوز هیچ خریدِ واقعی‌ای از بازار -انجام نشده.** +مسیر: + +1. `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` → سفارش، `bazaarSku`، `bazaarProductType`. +2. (اختیاری) `POST /api/discount/redeem` روی همان سفارش. فقط با کلیدِ «تخفیف پویا»، وگرنه ۴۰۰. +3. `POST /api/payment/bazaar/orders/{id}/start` → SKU، نوع، و اگر تخفیف خورده `dynamicPriceToken` (JWTِ HS256 با مبلغ + به ریال). بی‌تخفیف توکن نمی‌رود چون بازارِ قدیمی‌تر از ۱۳.۳ با آن خطا می‌دهد. از این لحظه کدِ تخفیف نمی‌نشیند. +4. اپ با Poolakey می‌خرد (`subscribeProduct` یا `purchaseProduct`، `payload` = شماره‌ی سفارش). +5. `POST /api/payment/bazaar/verify` توکن را از خودِ بازار می‌سنجد (نوعِ محصول از سفارش) و اشتراک را فعال یا + اعتبار را اضافه می‌کند. فقط بسته بعد از آن consume می‌شود. + +**تمدیدِ خودکار:** بازار به ما خبر نمی‌دهد. وقتی دوره‌ی پرداخت‌شده‌ی کاربر (`Order.bazaar_valid_until`) کمتر از یک روز +مانده یا گذشته، با باز شدنِ وضعیتِ اشتراک (`GET /api/subscription/status`) همان توکن از بازار پرسیده می‌شود (هر توکن +ساعتی یک بار)، و همین‌طور وقتی کاربرِ بی‌اشتراکِ فعال خانه یا فصلِ پولی را باز می‌کند (`has_subscription_access`). اگر `validUntil` جلو رفته، برای هر دوره یک سفارشِ «پرداخت‌شده» ساخته می‌شود و روزها اضافه می‌شوند +(`payment.services.sync_bazaar_renewals`). مبلغِ آن سفارش قیمتِ فعلیِ ادمین است، چون بازار مبلغِ تمدید را نمی‌گوید. +اگر بازار در دسترس نباشد، کاربرِ تمدیدشده تا تلاشِ ساعتِ بعد پشتِ دیوار می‌ماند. لغوِ تمدید از خودِ بازار است. + +تست‌ها: `payment/tests_bazaar_subscription.py`، `payment/tests_bazaar_pack.py`، `payment/tests_bazaar_oauth.py`. +**هیچ خریدِ واقعی‌ای از بازار انجام نشده**؛ نه پذیرفتنِ JWT از سوی بازار و نه تمدیدِ واقعی. ## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک) diff --git a/payment/bazaar.py b/payment/bazaar.py index 269cf06..8b7f89e 100644 --- a/payment/bazaar.py +++ b/payment/bazaar.py @@ -22,10 +22,15 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api fail-closed عمل می‌کنه و invalid برمی‌گردونه؛ هیچ‌وقت به purchaseToken ارسالیِ کلاینت، بدون صحت‌سنجیِ واقعی با سرور بازار، اعتماد نمی‌شه. """ +import base64 +import hashlib +import hmac +import json import logging import secrets +import time from dataclasses import dataclass -from datetime import timedelta +from datetime import datetime, timedelta, timezone as dt_timezone from urllib.parse import quote, urlencode import requests @@ -56,6 +61,9 @@ class BazaarPurchaseResult: consumption_state: int | None = None developer_payload: str = '' raw: dict | None = None + # فقط اشتراک: پایانِ دوره‌ی پرداخت‌شده (با هر تمدیدِ خودکار جلو می‌رود) و این‌که تمدید روشن است. + valid_until: 'datetime | None' = None + auto_renewing: bool | None = None def _authorize_window() -> timedelta: @@ -290,7 +298,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str, def fetch(subscription: bool): if subscription: - url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}' + url = f'{settings.BAZAAR_API_BASE}/api/applications/{pkg}/subscriptions/{sku}/purchases/{token}/' else: url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/' try: @@ -327,7 +335,13 @@ def verify_purchase(*, product_sku: str, purchase_token: str, # سند می‌گوید validUntil را با اکنون مقایسه کنید. valid_until = data.get('validUntilTimestampMsec') or 0 is_valid = valid_until > timezone.now().timestamp() * 1000 - purchase_state = None + return BazaarPurchaseResult( + is_valid=is_valid, + developer_payload=data.get('developerPayload', ''), + raw=data, + valid_until=datetime.fromtimestamp(valid_until / 1000, tz=dt_timezone.utc) if valid_until else None, + auto_renewing=data.get('autoRenewing'), + ) else: purchase_state = data.get('purchaseState') is_valid = purchase_state == 0 # ۰ یعنی خریداری‌شده؛ ۱ یعنی refund @@ -338,3 +352,36 @@ def verify_purchase(*, product_sku: str, purchase_token: str, developer_payload=data.get('developerPayload', ''), raw=data, ) + + +def dynamic_price_configured() -> bool: + return bool(settings.BAZAAR_DYNAMIC_PRICE_KEY) + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b'=').decode() + + +def dynamic_price_token(*, sku: str, price_rial: int, ttl_seconds: int = 30 * 60) -> str: + """ + JWTِ «تخفیف پویا»ی بازار (سندِ dynamic-discount، خوانده‌شده ۲۰۲۶-۱۰-۱۲): HS256 با کلیدِ پیشخان، + payload = price (ریال)، package_name، sku، exp، nonce. اپ آن را در PurchaseRequest.dynamicPriceToken + می‌گذارد و بازار همین مبلغ را می‌گیرد؛ اگر از قیمتِ پیشخان بیشتر باشد خطای ۱۰ می‌دهد. + یک‌بارمصرف است (nonce). بی‌کتابخانه، چون فقط HS256 لازم است. + نسنجیده: خریدِ واقعی با این توکن (اپِ تازه هنوز ساخته نشده). + """ + key = settings.BAZAAR_DYNAMIC_PRICE_KEY + if not key: + raise ValueError('BAZAAR_DYNAMIC_PRICE_KEY is not set') + header = {'alg': 'HS256', 'typ': 'JWT'} + payload = { + 'price': int(price_rial), + 'package_name': settings.BAZAAR_PACKAGE_NAME, + 'sku': sku, + 'exp': int(time.time()) + ttl_seconds, + 'nonce': secrets.token_hex(12), + } + signing_input = '.'.join( + _b64url(json.dumps(part, separators=(',', ':')).encode()) for part in (header, payload)) + signature = hmac.new(key.encode(), signing_input.encode(), hashlib.sha256).digest() + return f'{signing_input}.{_b64url(signature)}' diff --git a/payment/migrations/0012_bazaar_subscription.py b/payment/migrations/0012_bazaar_subscription.py new file mode 100644 index 0000000..8d9d149 --- /dev/null +++ b/payment/migrations/0012_bazaar_subscription.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.17 on 2026-10-12 14:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('payment', '0011_order_plan_snapshot'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='bazaar_price_rial', + field=models.PositiveIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='order', + name='bazaar_valid_until', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/payment/models.py b/payment/models.py index 63e10eb..695c67d 100644 --- a/payment/models.py +++ b/payment/models.py @@ -44,6 +44,12 @@ class Order(models.Model): # بازار می‌گیره و برای verify به بک‌اند می‌فرسته. bazaar_product_sku = models.CharField(max_length=100, blank=True) bazaar_purchase_token = models.CharField(max_length=300, blank=True) + # مبلغی (ریال) که در JWTِ «تخفیف پویا» برای همین سفارش امضا شد؛ بعد از آن کدِ تخفیف روی سفارش + # نمی‌نشیند (همان قاعده‌ی gateway_authority). null یعنی بازار قیمتِ پیشخان را گرفته/می‌گیرد. + bazaar_price_rial = models.PositiveIntegerField(null=True, blank=True) + # فقط اشتراکِ بازار: پایانِ دوره‌ای که همین سفارش پولش را داده، از پاسخِ بازار. تمدیدِ خودکار با + # جلو رفتنِ این عدد در بازار دیده می‌شود (payment.services.sync_bazaar_renewals). + bazaar_valid_until = models.DateTimeField(null=True, blank=True) # فقط وقتی provider == 'gateway' (زرین‌پال) پر می‌شه: Authorityِ برگشتی از # PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ # ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرین‌پال diff --git a/payment/serializers.py b/payment/serializers.py index e3b6378..9570cdd 100644 --- a/payment/serializers.py +++ b/payment/serializers.py @@ -25,6 +25,17 @@ class BazaarVerifyRequestSerializer(serializers.Serializer): purchaseToken = serializers.CharField() +class BazaarStartResponseSerializer(serializers.Serializer): + orderId = serializers.CharField() + sku = serializers.CharField(help_text='productIdِ PurchaseRequestِ Poolakey.') + productType = serializers.ChoiceField( + choices=['inapp', 'subscription'], + help_text='inapp: purchaseProduct و بعد از تأییدِ سرور consume. subscription: subscribeProduct، بی‌consume.') + amount = serializers.IntegerField(help_text='مبلغِ سفارش (تومان)، پس از کدِ تخفیف.') + dynamicPriceToken = serializers.CharField( + allow_null=True, help_text='JWTِ «تخفیف پویا» برای PurchaseRequest.dynamicPriceToken؛ null یعنی قیمتِ پیشخان.') + + class BazaarVerifyResponseSerializer(serializers.Serializer): orderId = serializers.CharField() status = serializers.CharField() diff --git a/payment/services.py b/payment/services.py index e82feef..5f60a91 100644 --- a/payment/services.py +++ b/payment/services.py @@ -4,6 +4,7 @@ جنگویی‌ه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم پیاده‌سازی شده، نه یک HTTP call به خودمون. """ +import hashlib import logging from django.conf import settings @@ -281,12 +282,97 @@ def reconcile_pending_order(order: Order) -> bool: def reconcile_user_orders(user_id: int) -> None: - """سفارش‌های pendingِ اخیرِ یک کاربر؛ از صفحه‌ی وضعیتِ اشتراک صدا زده می‌شود.""" + """سفارش‌های 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) + + +# تا این فاصله پیش از پایانِ دوره، از بازار پرسیده می‌شود که تمدید شده یا نه. بازار روزِ آخر تمدید می‌کند. +BAZAAR_RENEWAL_LOOKAHEAD = timezone.timedelta(days=1) + + +def sync_bazaar_renewals(user_id: int) -> int: + """ + اشتراکِ بازار خودکار تمدید می‌شود و بازار به ما خبر نمی‌دهد (webhook ندارد). پس وقتی دوره‌ی آخرِ + پرداخت‌شده‌ی کاربر (`bazaar_valid_until`ِ آخرین سفارشِ همان توکن) نزدیکِ پایان یا گذشته است، همان توکن + دوباره از بازار پرسیده می‌شود؛ اگر `validUntil` جلو رفته، برای هر دوره‌ی تازه یک سفارشِ «paid» ساخته و + همان مسیرِ پرداخت (activate_subscription، اعلان، پورسانت) اجرا می‌شود. هر توکن ساعتی یک بار. + + مبلغِ سفارشِ تمدید = قیمتِ فعلیِ پلن در ادمین؛ بازار تمدید را با قیمتِ پیشخان می‌گیرد و آن را به ما + نمی‌گوید. روزها مثلِ هر خریدِ دیگر روی پایانِ فعلی اضافه می‌شوند، پس کاربری که چند روز بعد از تمدید + می‌آید همان چند روز را بیشتر دارد. نسنجیده: تمدیدِ واقعی در بازار. خروجی: تعدادِ دوره‌های تازه. + """ + from django.core.cache import cache + + from . import bazaar + + now = timezone.now() + latest_by_token = {} + for order in Order.objects.filter( + user_id=user_id, provider='bazaar', status='paid', subscription_id__isnull=False, + bazaar_valid_until__isnull=False, + ).exclude(bazaar_purchase_token='').order_by('bazaar_valid_until'): + latest_by_token[order.bazaar_purchase_token] = order + added = 0 + for token, last in latest_by_token.items(): + if last.bazaar_valid_until > now + BAZAAR_RENEWAL_LOOKAHEAD: + continue + token_key = hashlib.sha256(token.encode()).hexdigest()[:32] + if not cache.add(f'payment:bazaar-renewal:{token_key}', 1, 3600): + continue + try: + result = bazaar.verify_purchase( + product_sku=last.bazaar_product_sku, purchase_token=token, is_subscription=True) + except bazaar.BazaarAPIError: # خودِ bazaar لاگ کرده؛ ساعتِ بعد + continue + except Exception: # noqa: BLE001 — صفحه‌ی وضعیت نباید با خطای بازار ۵۰۰ بدهد؛ ولی دیده شود + logger.exception('bazaar renewal: سنجشِ تمدیدِ سفارشِ %s شکست خورد', last.id) + continue + if result.valid_until is None or result.valid_until <= last.bazaar_valid_until + timezone.timedelta(hours=12): + continue + added += _record_bazaar_renewal(last, token, token_key, result.valid_until) + return added + + +def _record_bazaar_renewal(last: Order, token: str, token_key: str, valid_until) -> int: + from subscription.models import Plan + + plan = Plan.objects.filter(pk=last.plan_id).first() if last.plan_id else None + days = last.duration_days or (plan.duration_days if plan else None) + if not days: + logger.error('bazaar renewal: سفارشِ %s مدت ندارد؛ تمدید ثبت نشد', last.id) + return 0 + gap = (valid_until - last.bazaar_valid_until).total_seconds() / 86400 + periods = max(1, round(gap / days)) + if periods > 1: + logger.warning('bazaar renewal: %s دوره‌ی تمدید برای سفارشِ %s یک‌جا ثبت می‌شود', periods, last.id) + msec = int(valid_until.timestamp() * 1000) + added = 0 + for i in range(periods): + period_end = valid_until - timezone.timedelta(days=days * (periods - 1 - i)) + price = plan.price if plan else last.list_price or last.amount + order, created = Order.objects.get_or_create( + idempotency_key=f'bazaar-renew-{token_key}-{msec}-{i}', + defaults=dict( + user_id=last.user_id, subscription_id=last.subscription_id, plan_id=last.plan_id, + duration_days=days, amount=price, list_price=price, + plan_title=(plan.title if plan else last.plan_title)[:50], currency=last.currency, + status='pending', provider='bazaar', bazaar_product_sku=last.bazaar_product_sku, + bazaar_purchase_token=token, bazaar_valid_until=period_end, + ), + ) + if not created and order.status != 'pending': + continue + if apply_order_payment_result( + order=order, gateway='bazaar', gateway_ref_id=f'bazaar-renew-{token_key}-{msec}-{i}', # توکن تا ۳۰۰ نویسه است؛ ref هم ۳۰۰ + new_status='paid', event='bazaar_renewal', + ): + added += 1 + return added class OrderStateError(Exception): diff --git a/payment/tests_bazaar_oauth.py b/payment/tests_bazaar_oauth.py index f968e6e..3475219 100644 --- a/payment/tests_bazaar_oauth.py +++ b/payment/tests_bazaar_oauth.py @@ -195,7 +195,7 @@ class BazaarValidateAgainstDocsTests(TestCase): self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid) self.assertEqual( mock_get.call_args.args[0], - 'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t', + 'https://pardakht.cafebazaar.ir/devapi/v2/api/applications/ir.example.app/subscriptions/sub/purchases/t/', ) self._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000}) self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid) diff --git a/payment/tests_bazaar_subscription.py b/payment/tests_bazaar_subscription.py new file mode 100644 index 0000000..a1358f0 --- /dev/null +++ b/payment/tests_bazaar_subscription.py @@ -0,0 +1,203 @@ +""" +اشتراکِ کافه‌بازار، «تخفیف پویا» و تمدیدِ خودکار (۲۰۲۶-۱۰-۱۲). + +بازار با `bazaar.verify_purchase`ِ ساختگی آزموده می‌شود؛ خریدِ واقعی، تمدیدِ واقعی و پذیرفتنِ JWT +از سوی بازار هیچ‌کدام آزموده نشده‌اند (اپِ اندرویدِ تازه هنوز ساخته نشده). +""" +import base64 +import hashlib +import hmac +import json +from unittest.mock import patch + +from django.core.cache import cache +from django.test import TestCase, override_settings +from django.utils import timezone +from rest_framework.test import APIClient + +from accounts.authentication import issue_access_token +from accounts.models import User +from discount.models import DiscountCode +from subscription.models import Plan, Subscription + +from .bazaar import BazaarPurchaseResult +from .models import Order + + +def _result(days: float, valid=True): + return BazaarPurchaseResult(is_valid=valid, valid_until=timezone.now() + timezone.timedelta(days=days), + auto_renewing=True) + + +class BazaarSubscriptionTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create(phone='09120007301') + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') + self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90) + + def tearDown(self): + cache.clear() + + def buy(self, plan, key='k1'): + return self.client.post('/api/subscription/purchase', {'planId': str(plan.id), 'paymentMethod': 'bazaar'}, + format='json', HTTP_IDEMPOTENCY_KEY=key) + + def verify(self, order_id, result): + with patch('payment.bazaar.verify_purchase', return_value=result) as mock: + response = self.client.post('/api/payment/bazaar/verify', + {'orderId': order_id, 'purchaseToken': 'sub-token-1'}, format='json') + return response, mock + + 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, '') + + 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) + + def test_subscription_bought_in_bazaar_activates_for_the_plan(self): + response = self.buy(self.three) + self.assertEqual(response.status_code, 200, response.content) + body = response.json() + self.assertEqual((body['bazaarSku'], body['bazaarProductType'], body['paymentUrl']), ('10343', 'subscription', None)) + order = Order.objects.get(pk=body['orderId']) + self.assertEqual((order.provider, order.plan_id), ('bazaar', self.three.id)) + self.assertIsNotNone(order.subscription_id) + + response, mock = self.verify(body['orderId'], _result(90)) + self.assertEqual(response.json()['status'], 'paid') + self.assertTrue(mock.call_args.kwargs['is_subscription']) + sub = Subscription.objects.get(user_id=self.user.id) + self.assertEqual(sub.status, 'active') + self.assertAlmostEqual((sub.expires_at - timezone.now()).days, 89, delta=1) + self.assertIsNotNone(Order.objects.get(pk=body['orderId']).bazaar_valid_until) + + def test_pack_verify_asks_for_inapp(self): + pack = Plan.objects.get(kind='pack', question_credits=20) + pack.bazaar_sku = 'pack20' + pack.save() + body = self.buy(pack).json() + self.assertEqual(body['bazaarProductType'], 'inapp') + _, mock = self.verify(body['orderId'], BazaarPurchaseResult(is_valid=True, purchase_state=0)) + self.assertFalse(mock.call_args.kwargs['is_subscription']) + + def _paid_subscription(self): + order_id = self.buy(self.three).json()['orderId'] + self.verify(order_id, _result(90)) + return Order.objects.get(pk=order_id) + + def test_auto_renewal_in_bazaar_extends_the_subscription_once(self): + order = self._paid_subscription() + # دوره‌ی پرداخت‌شده تمام شده؛ بازار تمدید کرده و validUntil نود روز جلو رفته. + order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1) + order.save(update_fields=['bazaar_valid_until']) + before = Subscription.objects.get(user_id=self.user.id).expires_at + with patch('payment.bazaar.verify_purchase', return_value=_result(90)) as mock: + self.client.get('/api/subscription/status') + cache.clear() # سقفِ ساعتی را کنار بگذار: ثبتِ دوباره هم نباید دوره‌ی دوم بسازد + self.client.get('/api/subscription/status') + self.assertTrue(mock.call_args.kwargs['is_subscription']) + renewals = Order.objects.filter(user_id=self.user.id, status='paid').exclude(pk=order.pk) + self.assertEqual(renewals.count(), 1) + self.assertEqual(renewals.get().amount, self.three.price) + after = Subscription.objects.get(user_id=self.user.id).expires_at + self.assertAlmostEqual((after - before).days, 90, delta=1) + + def test_paywall_picks_up_a_renewal_without_opening_the_store(self): + from subscription.services import has_active_subscription, has_subscription_access + order = self._paid_subscription() + past = timezone.now() - timezone.timedelta(hours=1) + Order.objects.filter(pk=order.pk).update(bazaar_valid_until=past) + Subscription.objects.filter(user_id=self.user.id).update(expires_at=past) + self.assertFalse(has_active_subscription(self.user.id)) + with patch('payment.bazaar.verify_purchase', return_value=_result(90)): + self.assertTrue(has_subscription_access(self.user.id)) + + def test_no_renewal_when_bazaar_did_not_move_the_period(self): + order = self._paid_subscription() + order.bazaar_valid_until = timezone.now() - timezone.timedelta(hours=1) + order.save(update_fields=['bazaar_valid_until']) + with patch('payment.bazaar.verify_purchase', return_value=_result(-1 / 24, valid=False)): + self.client.get('/api/subscription/status') + self.assertEqual(Order.objects.filter(user_id=self.user.id).count(), 1) + + def test_bazaar_is_not_asked_while_the_period_is_far_from_its_end(self): + self._paid_subscription() + with patch('payment.bazaar.verify_purchase') as mock: + self.client.get('/api/subscription/status') + mock.assert_not_called() + + +def _decode(token: str, key: str) -> dict: + head, body, sig = token.split('.') + expected = base64.urlsafe_b64encode( + hmac.new(key.encode(), f'{head}.{body}'.encode(), hashlib.sha256).digest()).rstrip(b'=').decode() + assert hmac.compare_digest(sig, expected), 'bad signature' + return json.loads(base64.urlsafe_b64decode(body + '=' * (-len(body) % 4))) + + +class BazaarDynamicPriceTests(TestCase): + def setUp(self): + cache.clear() + self.user = User.objects.create(phone='09120007302') + self.client = APIClient() + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(self.user.id)}') + self.three = Plan.objects.get(kind='subscription', on_sale=True, duration_days=90) + DiscountCode.objects.create(code='HALF', type='percent', value=50, max_uses=10) + + def tearDown(self): + cache.clear() + + def order(self): + return self.client.post('/api/subscription/purchase', {'planId': str(self.three.id), 'paymentMethod': 'bazaar'}, + format='json', HTTP_IDEMPOTENCY_KEY='dp1').json()['orderId'] + + def start(self, order_id): + return self.client.post(f'/api/payment/bazaar/orders/{order_id}/start', format='json') + + def redeem(self, order_id): + return self.client.post('/api/discount/redeem', {'code': 'HALF', 'orderId': order_id}, format='json') + + @override_settings(BAZAAR_DYNAMIC_PRICE_KEY='') + def test_without_the_key_discount_is_refused_and_no_token(self): + order_id = self.order() + self.assertEqual(self.redeem(order_id).status_code, 400) + body = self.start(order_id).json() + self.assertIsNone(body['dynamicPriceToken']) + self.assertEqual((body['sku'], body['productType']), ('10343', 'subscription')) + + @override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key', BAZAAR_PACKAGE_NAME='com.tadrisino.math.app') + def test_discounted_price_is_signed_for_bazaar(self): + order_id = self.order() + self.assertEqual(self.redeem(order_id).status_code, 200) + body = self.start(order_id).json() + claims = _decode(body['dynamicPriceToken'], 'panel-key') + self.assertEqual(claims['price'], self.three.price // 2 * 10) # تومان → ریال + self.assertEqual((claims['sku'], claims['package_name']), ('10343', 'com.tadrisino.math.app')) + self.assertGreater(claims['exp'], timezone.now().timestamp()) + self.assertTrue(claims['nonce']) + self.assertEqual(Order.objects.get(pk=order_id).bazaar_price_rial, claims['price']) + + @override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key') + def test_full_price_sends_no_token(self): + # توکن روی بازارِ قدیمی‌تر از ۱۳.۳ خطا می‌دهد؛ بی‌تخفیف لازمش نداریم. + self.assertIsNone(self.start(self.order()).json()['dynamicPriceToken']) + + @override_settings(BAZAAR_DYNAMIC_PRICE_KEY='panel-key') + def test_no_discount_after_the_purchase_started(self): + order_id = self.order() + self.start(order_id) + self.assertEqual(self.redeem(order_id).status_code, 400) + self.assertEqual(Order.objects.get(pk=order_id).amount, self.three.price) + + def test_start_is_only_for_the_owner_and_pending_orders(self): + order_id = self.order() + other = APIClient() + other.credentials(HTTP_AUTHORIZATION=f'Bearer {issue_access_token(User.objects.create(phone="09120007303").id)}') + 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) diff --git a/payment/urls.py b/payment/urls.py index a808483..67eedd1 100644 --- a/payment/urls.py +++ b/payment/urls.py @@ -1,6 +1,7 @@ from django.urls import path from .views import ( + BazaarStartPurchaseView, BazaarVerifyPurchaseView, CreateOrderView, InvoiceListView, @@ -13,6 +14,8 @@ urlpatterns = [ path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'), path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'), path('payment/bazaar/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'), + path('payment/bazaar/orders//start', BazaarStartPurchaseView.as_view(), + name='payment-bazaar-start'), path('payment/invoices', InvoiceListView.as_view(), name='payment-invoices'), path('payment/orders/', OrderDetailView.as_view(), name='payment-order-detail'), path('payment/orders//pay', PaymentRedirectView.as_view(), name='payment-order-pay'), diff --git a/payment/views.py b/payment/views.py index 10cbed3..5f61398 100644 --- a/payment/views.py +++ b/payment/views.py @@ -20,6 +20,7 @@ from .models import Order from .permissions import HasInternalServiceKey from .serializers import ( InvoiceListResponseSerializer, + BazaarStartResponseSerializer, BazaarVerifyRequestSerializer, BazaarVerifyResponseSerializer, CreateOrderRequestSerializer, @@ -339,9 +340,12 @@ class BazaarVerifyPurchaseView(APIView): raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'}) if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']): return self._second_purchase_response() + # نوعِ محصول از سفارش: سفارشِ اشتراک subscription_id دارد (PurchaseView)، بسته ندارد. پیش‌تر نوع + # حدس زده می‌شد (اول تک‌خرید، بعد اشتراک). result = bazaar.verify_purchase( product_sku=order_lookup.bazaar_product_sku, purchase_token=v['purchaseToken'], + is_subscription=bool(order_lookup.subscription_id), ) with transaction.atomic(): @@ -358,7 +362,8 @@ class BazaarVerifyPurchaseView(APIView): # می‌کرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمی‌شد (بازتولید شد). if result.is_valid: order.bazaar_purchase_token = v['purchaseToken'] - order.save(update_fields=['bazaar_purchase_token']) + order.bazaar_valid_until = result.valid_until + order.save(update_fields=['bazaar_purchase_token', 'bazaar_valid_until']) apply_order_payment_result( order=order, @@ -391,6 +396,63 @@ class BazaarVerifyPurchaseView(APIView): status=status.HTTP_409_CONFLICT) +class BazaarStartPurchaseView(APIView): + """ + POST /api/payment/bazaar/orders/{id}/start — آنچه اپِ اندروید برای شروعِ خرید در بازار لازم دارد. + + پس از ساخت سفارش (`POST /api/subscription/purchase` با `paymentMethod: bazaar`) و کدِ تخفیفِ احتمالی + (`POST /api/discount/redeem`) صدا زده می‌شود. اگر کدِ تخفیف مبلغ را کم کرده، `dynamicPriceToken` همان + مبلغ را (به ریال) برای بازار امضا می‌کند؛ بی‌تخفیف null است و بازار قیمتِ پیشخان را می‌گیرد (عمداً: توکن + روی بازارِ قدیمی‌تر از ۱۳.۳ خطا می‌دهد). بعد از این، کدِ تخفیفِ تازه روی سفارش نمی‌نشیند. + """ + + permission_classes = [IsAuthenticated] + throttle_scope = 'bazaar_start' + throttle_classes = [ScopedRateThrottle] + + @extend_schema(request=None, responses=BazaarStartResponseSerializer, tags=['Payment (payment)']) + def post(self, request, order_id): + with transaction.atomic(): + order = get_object_or_404( + Order.objects.select_for_update(), pk=order_id, user_id=request.user.id, provider='bazaar') + if order.status != 'pending': + raise ValidationError({'orderId': 'این سفارش دیگر در انتظارِ پرداخت نیست.'}) + if not order.bazaar_product_sku: + raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'}) + token = None + full_price = order.list_price if order.list_price is not None else order.amount + if order.amount < full_price: + if not bazaar.dynamic_price_configured(): + # redeem بی‌کلید تخفیف را روی سفارشِ بازار نمی‌گذارد؛ رسیدن به این‌جا یعنی کلید بعد از آن + # از .env رفته. بی‌توکن بازار قیمتِ کامل را می‌گرفت. + logger.error('bazaar start: سفارشِ %s تخفیف دارد ولی BAZAAR_DYNAMIC_PRICE_KEY نیست', order.id) + raise bazaar.BazaarAPIError() + price_rial = _to_rial(order.amount, order.currency) + token = bazaar.dynamic_price_token(sku=order.bazaar_product_sku, price_rial=price_rial) + order.bazaar_price_rial = price_rial + order.save(update_fields=['bazaar_price_rial']) + elif order.bazaar_price_rial is None: + # علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است. + order.bazaar_price_rial = _to_rial(order.amount, order.currency) + order.save(update_fields=['bazaar_price_rial']) + data = { + 'orderId': str(order.id), + 'sku': order.bazaar_product_sku, + 'productType': 'subscription' if order.subscription_id else 'inapp', + 'amount': order.amount, + 'dynamicPriceToken': token, + } + return Response(BazaarStartResponseSerializer(data).data) + + +def _to_rial(amount: int, currency: str) -> int: + if currency == 'IRT': + return amount * 10 + if currency == 'IRR': + return amount + raise ValueError(f'unknown currency {currency!r}') + + class OrderDetailView(APIView): """GET /api/payment/orders/{id} — وضعیت سفارش""" diff --git a/subscription/migrations/0014_bazaar_skus_2026_10.py b/subscription/migrations/0014_bazaar_skus_2026_10.py new file mode 100644 index 0000000..8b66af8 --- /dev/null +++ b/subscription/migrations/0014_bazaar_skus_2026_10.py @@ -0,0 +1,39 @@ +""" +شناسه‌ی کالای بازار برای اشتراک‌های سه‌ماهه و شش‌ماهه (۲۰۲۶-۱۰-۱۲). + +از پیشخانِ بازار (عکسِ سینیور): «اشتراکی ۹۰ روزه» = 10343، «اشتراکی ۱۸۰ روزه» = 10344. بازار هر دو را زیرِ +com.tadrisino.math.app شناخت (پرسشِ API با توکنِ ساختگی: «purchase not found»، نه «product not found»). +10342 هم در بازار هست ولی کسی نگفته چیست؛ عمداً به هیچ پلنی وصل نشده. + +پلن‌ها با مدت پیدا می‌شوند، نه با id (idها بین دیتابیس‌ها فرق دارند). اگر دقیقاً یک پلنِ فروشیِ هم‌مدت +نباشد، migration می‌شکند تا دستی دیده شود؛ SKUی که ادمین از پیش گذاشته دست نمی‌خورد. +""" +from django.db import migrations + +SKUS = {90: '10343', 180: '10344'} + + +def forward(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + for days, sku in SKUS.items(): + if Plan.objects.filter(bazaar_sku=sku).exists(): + continue + plans = list(Plan.objects.filter(kind='subscription', on_sale=True, duration_days=days)) + if len(plans) != 1: + raise RuntimeError( + f'SKUِ بازار {sku}: باید دقیقاً یک اشتراکِ فروشیِ {days} روزه باشد، {len(plans)} تا پیدا شد.') + plan = plans[0] + if plan.bazaar_sku: + continue + plan.bazaar_sku = sku + plan.save(update_fields=['bazaar_sku']) + + +def backward(apps, schema_editor): + Plan = apps.get_model('subscription', 'Plan') + Plan.objects.filter(bazaar_sku__in=SKUS.values()).update(bazaar_sku='') + + +class Migration(migrations.Migration): + dependencies = [('subscription', '0013_monthly_plans_2026_10')] + operations = [migrations.RunPython(forward, backward)] diff --git a/subscription/models.py b/subscription/models.py index ddc2a28..0725b39 100644 --- a/subscription/models.py +++ b/subscription/models.py @@ -43,7 +43,7 @@ class Plan(models.Model): default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.') # پلنی که دیگر فروخته نمی‌شود ولی مشترک‌های فعلی‌اش هنوز به آن وصل‌اند. on_sale = models.BooleanField(default=True, help_text='در صفحه‌ی خرید نشان داده شود.') - # شناسه‌ی همین بسته در پیشخانِ کافه‌بازار (محصولِ درون‌برنامه‌ای، مصرف‌شدنی). خریدِ بازار + # شناسه‌ی همین پلن در پیشخانِ کافه‌بازار: بسته = محصولِ مصرف‌شدنی، اشتراک = محصولِ «اشتراکی». خریدِ بازار # SKU را فقط از اینجا می‌گیرد، هرگز از کلاینت: وگرنه توکنِ ارزان‌ترین محصول بسته‌ی گران را # فعال می‌کرد. خالی یعنی این بسته در بازار فروخته نمی‌شود (درخواستش ۴۰۰ می‌گیرد). bazaar_sku = models.CharField( diff --git a/subscription/serializers.py b/subscription/serializers.py index f174a5d..3c1a88e 100644 --- a/subscription/serializers.py +++ b/subscription/serializers.py @@ -16,7 +16,7 @@ class PlanSerializer(serializers.Serializer): questionCredits = serializers.IntegerField(help_text='بسته: سوال‌هایی که به اعتبار اضافه می‌شود (منقضی نمی‌شود).') heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.') bazaarSku = serializers.CharField( - allow_blank=True, help_text='SKUِ همین بسته در کافه‌بازار؛ خالی یعنی در بازار خریدنی نیست.') + allow_blank=True, help_text='SKUِ همین پلن (بسته یا اشتراک) در کافه‌بازار؛ خالی یعنی در بازار خریدنی نیست.') class PlansListResponseSerializer(serializers.Serializer): @@ -40,6 +40,9 @@ class PurchaseResponseSerializer(serializers.Serializer): bazaarSku = serializers.CharField( required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey می‌خرد؛ ' 'developerPayload همان orderId باشد.') + bazaarProductType = serializers.ChoiceField( + choices=['inapp', 'subscription'], required=False, + help_text='فقط بازار: inapp (بسته؛ purchaseProduct و بعد consume) یا subscription (subscribeProduct، بی‌consume).') class SubscriptionStatusResponseSerializer(serializers.Serializer): diff --git a/subscription/services.py b/subscription/services.py index 2277088..0bd0d17 100644 --- a/subscription/services.py +++ b/subscription/services.py @@ -32,6 +32,19 @@ def has_active_subscription(user_id: int) -> bool: models.Q(expires_at__isnull=True) | models.Q(expires_at__gt=now)).exists() +def has_subscription_access(user_id: int) -> bool: + """ + دیوارِ پرداخت (نقشه و فصل‌ها). مثلِ has_active_subscription، با این فرق که اگر اشتراک تمام شده، اول + تمدیدِ خودکارِ بازار را می‌پرسد (payment.services.sync_bazaar_renewals): بازار خبر نمی‌دهد، و بی‌این + مشترکی که بازار تمدیدش کرده تا باز کردنِ فروشگاه پشتِ دیوار می‌ماند. برای کسی که سفارشِ اشتراکِ بازار + ندارد فقط یک کوئری است؛ فراخوانیِ بازار هر توکن ساعتی یک بار. + """ + 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) + + def hearts_pass_until(user_id: int): """تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده.""" hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first() diff --git a/subscription/views.py b/subscription/views.py index 246a188..3aac2d5 100644 --- a/subscription/views.py +++ b/subscription/views.py @@ -103,20 +103,29 @@ 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': - # فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس - # اشتراک فعلاً فقط از زرین‌پال فروخته می‌شود. SKU از پلن، نه از کلاینت. - if plan.kind != 'pack': - raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'}) if not plan.bazaar_sku: - raise ValidationError({'planId': 'این بسته هنوز در کافه‌بازار تعریف نشده است.'}) + raise ValidationError({'planId': 'این مورد هنوز در کافه‌بازار تعریف نشده است.'}) + provider = 'bazaar' + bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else '' + + 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') + 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=plan.bazaar_sku, + provider='bazaar', bazaar_product_sku=bazaar_sku, ) - return Response(PurchaseResponseSerializer( - {'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data) + return response(order, None) if plan.kind == 'pack': # بسته به Subscription دست نمی‌زند: اعتبار و روزهای قلب بعد از پرداخت از روی @@ -187,6 +196,8 @@ class PurchaseView(APIView): idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title, + provider=provider, + bazaar_product_sku=bazaar_sku, ) except Exception: # Guard by pk+status+plan_id+order_id+version so this cleanup only @@ -212,8 +223,7 @@ class PurchaseView(APIView): subscription.order_id = order.id subscription.save(update_fields=['order_id']) - data = {'orderId': str(order.id), 'paymentUrl': payment_url} - return Response(PurchaseResponseSerializer(data).data) + return response(order, payment_url) class SubscriptionStatusView(APIView):