Bazaar: subscriptions (10343/10344), dynamic price (discount codes on Bazaar), auto-renewal sync
- Purchase with paymentMethod=bazaar now works for subscription plans with a bazaar_sku (not only packs);
response carries bazaarProductType. Migration subscription.0014 puts 10343/10344 on the 90/180-day plans.
- Verify asks Bazaar for the right product type (from the order) and stores validUntil (payment.0012).
- POST /api/payment/bazaar/orders/{id}/start: SKU, type, and an HS256 dynamic-price JWT when a discount
code lowered the price (BAZAAR_DYNAMIC_PRICE_KEY); discount on Bazaar orders allowed only with that key
and only before start.
- Auto-renewal: sync_bazaar_renewals re-asks Bazaar when the paid period is near its end (status page and
the paywall via has_subscription_access); each new period becomes a paid order.
- Default BAZAAR_PACKAGE_NAME = com.tadrisino.math.app; health shows bazaarDynamicPrice.
979 tests green. No real Bazaar purchase or renewal tested.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -111,7 +111,9 @@ BAZAAR_PISHKHAN_API_SECRET=
|
|||||||
BAZAAR_CLIENT_ID=
|
BAZAAR_CLIENT_ID=
|
||||||
BAZAAR_CLIENT_SECRET=
|
BAZAAR_CLIENT_SECRET=
|
||||||
BAZAAR_REFRESH_TOKEN=
|
BAZAAR_REFRESH_TOKEN=
|
||||||
# BAZAAR_PACKAGE_NAME=ir.tadrisino.app
|
# BAZAAR_PACKAGE_NAME=com.tadrisino.math.app
|
||||||
|
# کلیدِ «تخفیف پویا» از پیشخان (سمتِ سرور؛ قیمتِ ادمین و کدِ تخفیف را به بازار میگوید).
|
||||||
|
BAZAAR_DYNAMIC_PRICE_KEY=
|
||||||
|
|
||||||
# ═══════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════
|
||||||
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
|
# ۶) اختیاری — پیشفرضها معمولاً کافیاند
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ def _check_integrations() -> dict:
|
|||||||
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
|
'openai': bool(getattr(settings, 'OPENAI_API_KEY', '')),
|
||||||
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
|
'sms': bool(getattr(settings, 'KAVENEGAR_API_KEY', '')),
|
||||||
'bazaar': _bazaar_configured(),
|
'bazaar': _bazaar_configured(),
|
||||||
|
'bazaarDynamicPrice': bool(getattr(settings, 'BAZAAR_DYNAMIC_PRICE_KEY', '')),
|
||||||
'aiProvider': _ai_provider_state(),
|
'aiProvider': _ai_provider_state(),
|
||||||
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
|
'chatModel': getattr(settings, 'OPENAI_CHAT_MODEL', ''),
|
||||||
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
|
'chatModelUpper': getattr(settings, 'OPENAI_CHAT_MODEL_UPPER', '') or None,
|
||||||
|
|||||||
@@ -559,6 +559,8 @@ REST_FRAMEWORK = {
|
|||||||
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره میبندد، نه این.
|
# را سقفِ هر کد (OTP_MAX_FAILED_ATTEMPTS=۵، در دیتابیس) و هر شماره میبندد، نه این.
|
||||||
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
|
'otp_login': os.environ.get('OTP_LOGIN_IP_RATE', '300/hour'),
|
||||||
'bazaar_verify': '10/hour',
|
'bazaar_verify': '10/hour',
|
||||||
|
# شروعِ خریدِ بازار (امضای قیمت)؛ بیفراخوانیِ بیرونی، ولی هر بار یک توکنِ تازه.
|
||||||
|
'bazaar_start': '30/hour',
|
||||||
'bazaar_oauth': '20/hour',
|
'bazaar_oauth': '20/hour',
|
||||||
'ai_ask': '20/hour',
|
'ai_ask': '20/hour',
|
||||||
'rag_ingest': '30/hour',
|
'rag_ingest': '30/hour',
|
||||||
@@ -697,6 +699,7 @@ SPECTACULAR_SETTINGS = {
|
|||||||
'ENUM_NAME_OVERRIDES': {
|
'ENUM_NAME_OVERRIDES': {
|
||||||
'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')],
|
'LessonKindEnum': [('quiz', 'quiz'), ('video', 'video')],
|
||||||
'PlanKindEnum': [('pack', 'pack'), ('subscription', 'subscription')],
|
'PlanKindEnum': [('pack', 'pack'), ('subscription', 'subscription')],
|
||||||
|
'BazaarProductTypeEnum': [('inapp', 'inapp'), ('subscription', 'subscription')],
|
||||||
},
|
},
|
||||||
'SWAGGER_UI_SETTINGS': {
|
'SWAGGER_UI_SETTINGS': {
|
||||||
'deepLinking': True,
|
'deepLinking': True,
|
||||||
@@ -884,7 +887,9 @@ INTERNAL_SERVICE_KEY = os.environ.get('INTERNAL_SERVICE_KEY', '')
|
|||||||
# گرفته میشه، بعد بکاند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه میگیره.
|
# گرفته میشه، بعد بکاند با همون REFRESH_TOKEN، ACCESS_TOKEN تازه میگیره.
|
||||||
# قبل از اتصال واقعی، مسیرِ endpointها رو از پنل devapi تایید کنید — بازار
|
# قبل از اتصال واقعی، مسیرِ 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_ID = os.environ.get('BAZAAR_CLIENT_ID', '')
|
||||||
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
BAZAAR_CLIENT_SECRET = os.environ.get('BAZAAR_CLIENT_SECRET', '')
|
||||||
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
BAZAAR_REFRESH_TOKEN = os.environ.get('BAZAAR_REFRESH_TOKEN', '')
|
||||||
@@ -900,6 +905,11 @@ BAZAAR_REDIRECT_URI = (
|
|||||||
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
|
os.environ.get('BAZAAR_REDIRECT_URI', '').strip()
|
||||||
or f'{PUBLIC_BASE_URL}/api/payment/bazaar/verify'
|
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'))
|
BAZAAR_AUTHORIZE_WINDOW_HOURS = int(os.environ.get('BAZAAR_AUTHORIZE_WINDOW_HOURS', '72'))
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
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):
|
if unit_is_free(unit_id, grade_id):
|
||||||
return True
|
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:
|
def subscription_required_response() -> Response:
|
||||||
@@ -468,8 +468,8 @@ class HomeView(APIView):
|
|||||||
).values_list('node_id', flat=True)
|
).values_list('node_id', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
from subscription.services import has_active_subscription
|
from subscription.services import has_subscription_access
|
||||||
subscribed = user_id is not None and has_active_subscription(user_id)
|
subscribed = user_id is not None and has_subscription_access(user_id)
|
||||||
unlock_all = user_has_all_nodes_unlocked(user_id)
|
unlock_all = user_has_all_nodes_unlocked(user_id)
|
||||||
units_payload = []
|
units_payload = []
|
||||||
for index, unit in enumerate(tree):
|
for index, unit in enumerate(tree):
|
||||||
|
|||||||
@@ -91,10 +91,12 @@ 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)
|
||||||
# بازار قیمتِ ثابتِ SKU را میگیرد، هرچه مبلغِ سفارش باشد؛ تخفیف روی سفارشِ بازار فقط
|
# بازار بی«تخفیف پویا» قیمتِ ثابتِ پیشخان را میگیرد؛ تخفیف آنوقت فقط کد را میسوزاند. با کلید،
|
||||||
# کد را میسوزاند و کاربر همان قیمتِ کامل را میپرداخت.
|
# مبلغِ کمشده در JWT به بازار میرود (payment.views.BazaarStartPurchaseView).
|
||||||
if order.provider == 'bazaar':
|
if order.provider == 'bazaar':
|
||||||
raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافهبازار اعمال نمیشود.'})
|
from payment import bazaar
|
||||||
|
if not bazaar.dynamic_price_configured():
|
||||||
|
raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافهبازار اعمال نمیشود.'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -121,6 +123,9 @@ class RedeemDiscountView(APIView):
|
|||||||
# اشتراکی فعال نمیشود.
|
# اشتراکی فعال نمیشود.
|
||||||
if order_locked.gateway_authority:
|
if order_locked.gateway_authority:
|
||||||
raise ValidationError({'code': 'کدِ تخفیف را پیش از رفتن به درگاهِ پرداخت وارد کنید.'})
|
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():
|
if Redemption.objects.filter(order_id=order_locked.id).exists():
|
||||||
|
|||||||
@@ -278,22 +278,38 @@ callbackِ زرینپال، برگشت از پرداخت، `BAZAAR_REDIRECT_U
|
|||||||
«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
|
«پرداختها و فاکتورها» میبیند. کد در `payment/services.py` (`refund_order`، `cancel_pending_order`)
|
||||||
است و تستهایش در `payment/tests_refund.py`.
|
است و تستهایش در `payment/tests_refund.py`.
|
||||||
|
|
||||||
## فروشِ بسته در کافهبازار
|
## فروش در کافهبازار (اشتراک و بسته)
|
||||||
|
|
||||||
از ۲۰۲۶-۱۰-۰۳. اپِ اندروید (پوستهی WebViewِ همین سایت، `android/` در ریپوی فرانت) بستهها را با
|
اپِ اندروید (پوستهی WebViewِ همین سایت، `android/` در ریپوی فرانت) با پرداختِ درونبرنامهایِ بازار میفروشد. در
|
||||||
پرداختِ درونبرنامهایِ بازار میفروشد؛ در مرورگرِ معمولی همهچیز مثلِ قبل از زرینپال است.
|
برگهی پرداخت دو کاشی همیشه کنارِ هماند: «کارتِ بانکی» (زرینپال) و «کافهبازار». در مرورگر، یا وقتی پلن SKU ندارد،
|
||||||
|
کاشیِ بازار غیرفعال است و دلیلش را میگوید.
|
||||||
|
|
||||||
1. در پیشخانِ بازار برای هر بسته یک **محصولِ مصرفشدنی** با همان قیمت بسازید.
|
| چه | کجا | الان |
|
||||||
2. شناسهی آن (SKU) را در `/admin/subscription/plan/` در فیلدِ «Bazaar sku» همان بسته بنویسید. سرور SKU
|
|---|---|---|
|
||||||
را فقط از همینجا میگیرد، نه از اپ؛ بستهی بیSKU داخلِ اپ «بهزودی در بازار» نشان داده میشود و
|
| محصول در پیشخانِ بازار | اشتراک = محصولِ «اشتراکی» (بازار خودکار تمدید میکند)، بسته = «مصرفشدنی» | سهماهه `10343`، ششماهه `10344`؛ `10342` هم هست و معلوم نیست چیست |
|
||||||
درخواستش ۴۰۰ است. یک SKU روی دو بسته نمینشیند.
|
| SKU روی پلن | `/admin/subscription/plan/` ← «Bazaar sku» (سرور SKU را فقط از اینجا میگیرد) | دو اشتراکِ بالا (migrationِ `subscription.0014`) |
|
||||||
3. کلیدِ RSAِ عمومیِ اپ (از پیشخان) در `android/app/build.gradle.kts` میرود، نه در `.env`.
|
| قیمت | قیمتِ پیشخان باید با قیمتِ ادمین یکی باشد | **سنجیده نشده** |
|
||||||
|
| کلیدِ «تخفیف پویا» | `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`
|
|
||||||
توکن را از خودِ بازار میسنجد و اعتبار میدهد. کدِ تخفیف روی سفارشِ بازار رد میشود (بازار قیمتِ
|
1. `POST /api/subscription/purchase` با `paymentMethod: "bazaar"` → سفارش، `bazaarSku`، `bazaarProductType`.
|
||||||
ثابتِ محصول را میگیرد). تستها: `payment/tests_bazaar_pack.py`. **هنوز هیچ خریدِ واقعیای از بازار
|
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 از سوی بازار و نه تمدیدِ واقعی.
|
||||||
|
|
||||||
## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
|
## ورودِ توسعهدهندهها (بدونِ سرویسِ پیامک)
|
||||||
|
|
||||||
|
|||||||
@@ -22,10 +22,15 @@ developers.cafebazaar.ir/fa/guidelines/in-app-billing/api/postman-and-bazaar-api
|
|||||||
fail-closed عمل میکنه و invalid برمیگردونه؛ هیچوقت به purchaseToken
|
fail-closed عمل میکنه و invalid برمیگردونه؛ هیچوقت به purchaseToken
|
||||||
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
ارسالیِ کلاینت، بدون صحتسنجیِ واقعی با سرور بازار، اعتماد نمیشه.
|
||||||
"""
|
"""
|
||||||
|
import base64
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import timedelta
|
from datetime import datetime, timedelta, timezone as dt_timezone
|
||||||
from urllib.parse import quote, urlencode
|
from urllib.parse import quote, urlencode
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
@@ -56,6 +61,9 @@ class BazaarPurchaseResult:
|
|||||||
consumption_state: int | None = None
|
consumption_state: int | None = None
|
||||||
developer_payload: str = ''
|
developer_payload: str = ''
|
||||||
raw: dict | None = None
|
raw: dict | None = None
|
||||||
|
# فقط اشتراک: پایانِ دورهی پرداختشده (با هر تمدیدِ خودکار جلو میرود) و اینکه تمدید روشن است.
|
||||||
|
valid_until: 'datetime | None' = None
|
||||||
|
auto_renewing: bool | None = None
|
||||||
|
|
||||||
|
|
||||||
def _authorize_window() -> timedelta:
|
def _authorize_window() -> timedelta:
|
||||||
@@ -290,7 +298,7 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
|
|||||||
|
|
||||||
def fetch(subscription: bool):
|
def fetch(subscription: bool):
|
||||||
if subscription:
|
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:
|
else:
|
||||||
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
|
url = f'{settings.BAZAAR_API_BASE}/api/validate/{pkg}/inapp/{sku}/purchases/{token}/'
|
||||||
try:
|
try:
|
||||||
@@ -327,7 +335,13 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
|
|||||||
# سند میگوید validUntil را با اکنون مقایسه کنید.
|
# سند میگوید validUntil را با اکنون مقایسه کنید.
|
||||||
valid_until = data.get('validUntilTimestampMsec') or 0
|
valid_until = data.get('validUntilTimestampMsec') or 0
|
||||||
is_valid = valid_until > timezone.now().timestamp() * 1000
|
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:
|
else:
|
||||||
purchase_state = data.get('purchaseState')
|
purchase_state = data.get('purchaseState')
|
||||||
is_valid = purchase_state == 0 # ۰ یعنی خریداریشده؛ ۱ یعنی refund
|
is_valid = purchase_state == 0 # ۰ یعنی خریداریشده؛ ۱ یعنی refund
|
||||||
@@ -338,3 +352,36 @@ def verify_purchase(*, product_sku: str, purchase_token: str,
|
|||||||
developer_payload=data.get('developerPayload', ''),
|
developer_payload=data.get('developerPayload', ''),
|
||||||
raw=data,
|
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)}'
|
||||||
|
|||||||
23
payment/migrations/0012_bazaar_subscription.py
Normal file
23
payment/migrations/0012_bazaar_subscription.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -44,6 +44,12 @@ class Order(models.Model):
|
|||||||
# بازار میگیره و برای verify به بکاند میفرسته.
|
# بازار میگیره و برای verify به بکاند میفرسته.
|
||||||
bazaar_product_sku = models.CharField(max_length=100, blank=True)
|
bazaar_product_sku = models.CharField(max_length=100, blank=True)
|
||||||
bazaar_purchase_token = models.CharField(max_length=300, 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ِ برگشتی از
|
# فقط وقتی provider == 'gateway' (زرینپال) پر میشه: Authorityِ برگشتی از
|
||||||
# PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ
|
# PaymentRequest، وقتی کاربر واقعاً لینکِ پرداخت رو باز کنه (نه موقعِ
|
||||||
# ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرینپال
|
# ساختِ سفارش؛ نگاه کنید به PaymentRedirectView). همون چیزیه که زرینپال
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ class BazaarVerifyRequestSerializer(serializers.Serializer):
|
|||||||
purchaseToken = serializers.CharField()
|
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):
|
class BazaarVerifyResponseSerializer(serializers.Serializer):
|
||||||
orderId = serializers.CharField()
|
orderId = serializers.CharField()
|
||||||
status = serializers.CharField()
|
status = serializers.CharField()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
جنگوییه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
|
جنگوییه (نه میکروسرویس واقعی)، «فراخوانی سرور-به-سرور» با یک import مستقیم
|
||||||
پیادهسازی شده، نه یک HTTP call به خودمون.
|
پیادهسازی شده، نه یک HTTP call به خودمون.
|
||||||
"""
|
"""
|
||||||
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
@@ -281,12 +282,97 @@ def reconcile_pending_order(order: Order) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def reconcile_user_orders(user_id: int) -> None:
|
def reconcile_user_orders(user_id: int) -> None:
|
||||||
"""سفارشهای pendingِ اخیرِ یک کاربر؛ از صفحهی وضعیتِ اشتراک صدا زده میشود."""
|
"""سفارشهای pendingِ اخیرِ یک کاربر و تمدیدِ اشتراکِ بازار؛ از صفحهی وضعیتِ اشتراک صدا زده میشود."""
|
||||||
since = timezone.now() - timezone.timedelta(days=2)
|
since = timezone.now() - timezone.timedelta(days=2)
|
||||||
for order in Order.objects.filter(
|
for order in Order.objects.filter(
|
||||||
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
|
user_id=user_id, status='pending', provider='gateway', created_at__gte=since,
|
||||||
).exclude(gateway_authority='')[:3]:
|
).exclude(gateway_authority='')[:3]:
|
||||||
reconcile_pending_order(order)
|
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):
|
class OrderStateError(Exception):
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class BazaarValidateAgainstDocsTests(TestCase):
|
|||||||
self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
|
self.assertTrue(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
mock_get.call_args.args[0],
|
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._resp(mock_get, 200, {'validUntilTimestampMsec': now_ms - 1000})
|
||||||
self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
|
self.assertFalse(bazaar.verify_purchase(product_sku='sub', purchase_token='t', is_subscription=True).is_valid)
|
||||||
|
|||||||
203
payment/tests_bazaar_subscription.py
Normal file
203
payment/tests_bazaar_subscription.py
Normal file
@@ -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)
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
|
|
||||||
from .views import (
|
from .views import (
|
||||||
|
BazaarStartPurchaseView,
|
||||||
BazaarVerifyPurchaseView,
|
BazaarVerifyPurchaseView,
|
||||||
CreateOrderView,
|
CreateOrderView,
|
||||||
InvoiceListView,
|
InvoiceListView,
|
||||||
@@ -13,6 +14,8 @@ urlpatterns = [
|
|||||||
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
path('payment/orders', CreateOrderView.as_view(), name='payment-create-order'),
|
||||||
path('payment/zarinpal/callback', ZarinpalCallbackView.as_view(), name='payment-zarinpal-callback'),
|
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/verify', BazaarVerifyPurchaseView.as_view(), name='payment-bazaar-verify'),
|
||||||
|
path('payment/bazaar/orders/<int:order_id>/start', BazaarStartPurchaseView.as_view(),
|
||||||
|
name='payment-bazaar-start'),
|
||||||
path('payment/invoices', InvoiceListView.as_view(), name='payment-invoices'),
|
path('payment/invoices', InvoiceListView.as_view(), name='payment-invoices'),
|
||||||
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
path('payment/orders/<int:order_id>', OrderDetailView.as_view(), name='payment-order-detail'),
|
||||||
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),
|
path('payment/orders/<int:order_id>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .models import Order
|
|||||||
from .permissions import HasInternalServiceKey
|
from .permissions import HasInternalServiceKey
|
||||||
from .serializers import (
|
from .serializers import (
|
||||||
InvoiceListResponseSerializer,
|
InvoiceListResponseSerializer,
|
||||||
|
BazaarStartResponseSerializer,
|
||||||
BazaarVerifyRequestSerializer,
|
BazaarVerifyRequestSerializer,
|
||||||
BazaarVerifyResponseSerializer,
|
BazaarVerifyResponseSerializer,
|
||||||
CreateOrderRequestSerializer,
|
CreateOrderRequestSerializer,
|
||||||
@@ -339,9 +340,12 @@ class BazaarVerifyPurchaseView(APIView):
|
|||||||
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
|
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
|
||||||
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
|
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
|
||||||
return self._second_purchase_response()
|
return self._second_purchase_response()
|
||||||
|
# نوعِ محصول از سفارش: سفارشِ اشتراک subscription_id دارد (PurchaseView)، بسته ندارد. پیشتر نوع
|
||||||
|
# حدس زده میشد (اول تکخرید، بعد اشتراک).
|
||||||
result = bazaar.verify_purchase(
|
result = bazaar.verify_purchase(
|
||||||
product_sku=order_lookup.bazaar_product_sku,
|
product_sku=order_lookup.bazaar_product_sku,
|
||||||
purchase_token=v['purchaseToken'],
|
purchase_token=v['purchaseToken'],
|
||||||
|
is_subscription=bool(order_lookup.subscription_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
with transaction.atomic():
|
with transaction.atomic():
|
||||||
@@ -358,7 +362,8 @@ class BazaarVerifyPurchaseView(APIView):
|
|||||||
# میکرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمیشد (بازتولید شد).
|
# میکرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمیشد (بازتولید شد).
|
||||||
if result.is_valid:
|
if result.is_valid:
|
||||||
order.bazaar_purchase_token = v['purchaseToken']
|
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(
|
apply_order_payment_result(
|
||||||
order=order,
|
order=order,
|
||||||
@@ -391,6 +396,63 @@ class BazaarVerifyPurchaseView(APIView):
|
|||||||
status=status.HTTP_409_CONFLICT)
|
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):
|
class OrderDetailView(APIView):
|
||||||
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
"""GET /api/payment/orders/{id} — وضعیت سفارش"""
|
||||||
|
|
||||||
|
|||||||
39
subscription/migrations/0014_bazaar_skus_2026_10.py
Normal file
39
subscription/migrations/0014_bazaar_skus_2026_10.py
Normal file
@@ -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)]
|
||||||
@@ -43,7 +43,7 @@ class Plan(models.Model):
|
|||||||
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
|
default=0, help_text='فقط بسته: چند روز قلبِ نامحدود هدیه.')
|
||||||
# پلنی که دیگر فروخته نمیشود ولی مشترکهای فعلیاش هنوز به آن وصلاند.
|
# پلنی که دیگر فروخته نمیشود ولی مشترکهای فعلیاش هنوز به آن وصلاند.
|
||||||
on_sale = models.BooleanField(default=True, help_text='در صفحهی خرید نشان داده شود.')
|
on_sale = models.BooleanField(default=True, help_text='در صفحهی خرید نشان داده شود.')
|
||||||
# شناسهی همین بسته در پیشخانِ کافهبازار (محصولِ درونبرنامهای، مصرفشدنی). خریدِ بازار
|
# شناسهی همین پلن در پیشخانِ کافهبازار: بسته = محصولِ مصرفشدنی، اشتراک = محصولِ «اشتراکی». خریدِ بازار
|
||||||
# SKU را فقط از اینجا میگیرد، هرگز از کلاینت: وگرنه توکنِ ارزانترین محصول بستهی گران را
|
# SKU را فقط از اینجا میگیرد، هرگز از کلاینت: وگرنه توکنِ ارزانترین محصول بستهی گران را
|
||||||
# فعال میکرد. خالی یعنی این بسته در بازار فروخته نمیشود (درخواستش ۴۰۰ میگیرد).
|
# فعال میکرد. خالی یعنی این بسته در بازار فروخته نمیشود (درخواستش ۴۰۰ میگیرد).
|
||||||
bazaar_sku = models.CharField(
|
bazaar_sku = models.CharField(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class PlanSerializer(serializers.Serializer):
|
|||||||
questionCredits = serializers.IntegerField(help_text='بسته: سوالهایی که به اعتبار اضافه میشود (منقضی نمیشود).')
|
questionCredits = serializers.IntegerField(help_text='بسته: سوالهایی که به اعتبار اضافه میشود (منقضی نمیشود).')
|
||||||
heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.')
|
heartsDays = serializers.IntegerField(help_text='بسته: روزهای قلبِ نامحدودِ هدیه.')
|
||||||
bazaarSku = serializers.CharField(
|
bazaarSku = serializers.CharField(
|
||||||
allow_blank=True, help_text='SKUِ همین بسته در کافهبازار؛ خالی یعنی در بازار خریدنی نیست.')
|
allow_blank=True, help_text='SKUِ همین پلن (بسته یا اشتراک) در کافهبازار؛ خالی یعنی در بازار خریدنی نیست.')
|
||||||
|
|
||||||
|
|
||||||
class PlansListResponseSerializer(serializers.Serializer):
|
class PlansListResponseSerializer(serializers.Serializer):
|
||||||
@@ -40,6 +40,9 @@ class PurchaseResponseSerializer(serializers.Serializer):
|
|||||||
bazaarSku = serializers.CharField(
|
bazaarSku = serializers.CharField(
|
||||||
required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey میخرد؛ '
|
required=False, help_text='فقط بازار: SKUی که اپِ اندروید با Poolakey میخرد؛ '
|
||||||
'developerPayload همان orderId باشد.')
|
'developerPayload همان orderId باشد.')
|
||||||
|
bazaarProductType = serializers.ChoiceField(
|
||||||
|
choices=['inapp', 'subscription'], required=False,
|
||||||
|
help_text='فقط بازار: inapp (بسته؛ purchaseProduct و بعد consume) یا subscription (subscribeProduct، بیconsume).')
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
class SubscriptionStatusResponseSerializer(serializers.Serializer):
|
||||||
|
|||||||
@@ -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()
|
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):
|
def hearts_pass_until(user_id: int):
|
||||||
"""تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده."""
|
"""تا کِی قلبِ نامحدودِ بسته برقرار است؛ None اگر ندارد یا تمام شده."""
|
||||||
hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first()
|
hp = HeartsPass.objects.filter(user_id=user_id, expires_at__gt=timezone.now()).first()
|
||||||
|
|||||||
@@ -103,20 +103,29 @@ 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.services.sync_bazaar_renewals). خریدِ واقعی هنوز آزموده نشده.
|
||||||
|
provider = 'gateway'
|
||||||
if v['paymentMethod'] == 'bazaar':
|
if v['paymentMethod'] == 'bazaar':
|
||||||
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
|
|
||||||
# اشتراک فعلاً فقط از زرینپال فروخته میشود. SKU از پلن، نه از کلاینت.
|
|
||||||
if plan.kind != 'pack':
|
|
||||||
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بستههای سوال است.'})
|
|
||||||
if not plan.bazaar_sku:
|
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(
|
order, _ = 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='bazaar', bazaar_product_sku=plan.bazaar_sku,
|
provider='bazaar', bazaar_product_sku=bazaar_sku,
|
||||||
)
|
)
|
||||||
return Response(PurchaseResponseSerializer(
|
return response(order, None)
|
||||||
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
|
|
||||||
|
|
||||||
if plan.kind == 'pack':
|
if plan.kind == 'pack':
|
||||||
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
||||||
@@ -187,6 +196,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,
|
||||||
|
bazaar_product_sku=bazaar_sku,
|
||||||
)
|
)
|
||||||
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
|
||||||
@@ -212,8 +223,7 @@ class PurchaseView(APIView):
|
|||||||
subscription.order_id = order.id
|
subscription.order_id = order.id
|
||||||
subscription.save(update_fields=['order_id'])
|
subscription.save(update_fields=['order_id'])
|
||||||
|
|
||||||
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
|
return response(order, payment_url)
|
||||||
return Response(PurchaseResponseSerializer(data).data)
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionStatusView(APIView):
|
class SubscriptionStatusView(APIView):
|
||||||
|
|||||||
Reference in New Issue
Block a user