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:
2026-10-12 17:53:11 +03:30
parent 2e0753587c
commit 8077ab4350
20 changed files with 580 additions and 40 deletions

View File

@@ -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=
# ═══════════════════════════════════════════════════════════════════════
# ۶) اختیاری — پیش‌فرض‌ها معمولاً کافی‌اند

View File

@@ -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,

View File

@@ -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'))

View File

@@ -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):

View File

@@ -91,9 +91,11 @@ 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':
from payment import bazaar
if not bazaar.dynamic_price_configured():
raise ValidationError({'orderId': 'کدِ تخفیف روی خرید از کافه‌بازار اعمال نمی‌شود.'})
try:
@@ -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():

View File

@@ -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 از سوی بازار و نه تمدیدِ واقعی.
## ورودِ توسعه‌دهنده‌ها (بدونِ سرویسِ پیامک)

View File

@@ -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)}'

View 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),
),
]

View File

@@ -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). همون چیزیه که زرین‌پال

View File

@@ -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()

View File

@@ -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):

View File

@@ -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)

View 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)

View File

@@ -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/<int:order_id>/start', BazaarStartPurchaseView.as_view(),
name='payment-bazaar-start'),
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>/pay', PaymentRedirectView.as_view(), name='payment-order-pay'),

View File

@@ -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} — وضعیت سفارش"""

View 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)]

View File

@@ -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(

View File

@@ -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):

View File

@@ -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()

View File

@@ -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):