- 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>
259 lines
13 KiB
Python
259 lines
13 KiB
Python
from django.conf import settings
|
||
from django.core.cache import cache
|
||
from django.db import transaction
|
||
from django.db.models import F
|
||
from django.shortcuts import get_object_or_404
|
||
from drf_spectacular.types import OpenApiTypes
|
||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||
from rest_framework.exceptions import ValidationError
|
||
from rest_framework.permissions import IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from payment.models import Order
|
||
from payment.services import create_order, reconcile_user_orders
|
||
|
||
from .models import Plan, Subscription
|
||
from .serializers import (
|
||
PlansListResponseSerializer,
|
||
PurchaseRequestSerializer,
|
||
PurchaseResponseSerializer,
|
||
SubscriptionStatusResponseSerializer,
|
||
)
|
||
from .services import ai_daily_quota, credit_balance, has_active_subscription, hearts_pass_until
|
||
|
||
|
||
# پلنها تقریباً هیچوقت عوض نمیشوند و روی صفحهی اشتراک هر بار خوانده میشوند.
|
||
# با هر save/delete روی Plan کش باطل میشود (subscription/receivers.py).
|
||
PLANS_CACHE_KEY = 'subscription:plans:v3' # v3: بستههای سوال
|
||
PLANS_CACHE_TTL = 3600
|
||
|
||
|
||
class PlansListView(APIView):
|
||
"""GET /api/subscription/plans — list available plans."""
|
||
|
||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||
throttle_scope = 'read'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
|
||
def get(self, request):
|
||
def build():
|
||
return {
|
||
'plans': [
|
||
{
|
||
'id': str(p.id),
|
||
'title': p.title,
|
||
'price': p.price,
|
||
'currency': p.currency,
|
||
'popular': p.popular,
|
||
'discountPercent': p.discount_percent,
|
||
'durationDays': p.duration_days,
|
||
'features': p.features or [],
|
||
'badge': p.badge,
|
||
'aiDailyQuota': p.ai_daily_quota,
|
||
'kind': p.kind,
|
||
'questionCredits': p.question_credits,
|
||
'heartsDays': p.hearts_days,
|
||
'bazaarSku': p.bazaar_sku,
|
||
}
|
||
for p in Plan.objects.filter(on_sale=True).order_by('kind', 'price')
|
||
],
|
||
'freeAiDailyQuota': settings.AI_DAILY_QUOTA_FREE,
|
||
}
|
||
|
||
data = cache.get_or_set(PLANS_CACHE_KEY, build, PLANS_CACHE_TTL)
|
||
return Response(PlansListResponseSerializer(data).data)
|
||
|
||
|
||
class PurchaseView(APIView):
|
||
"""POST /api/subscription/purchase — starts a purchase (Idempotency-Key
|
||
header supported). Creates/updates a pending Subscription, then calls
|
||
the payment app's service directly (in-process, not a separate HTTP call)
|
||
to create an Order."""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# این throttle مستقلِ Idempotency-Key است چون اون فقط جلوی تکرارِ *همون*
|
||
# کلید رو میگیره، نه حجمِ درخواستهای متفاوت.
|
||
throttle_scope = 'purchase'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
request=PurchaseRequestSerializer,
|
||
responses=PurchaseResponseSerializer,
|
||
parameters=[
|
||
OpenApiParameter(
|
||
'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False,
|
||
description=(
|
||
'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. '
|
||
'در صورت عدم ارسال، یک کلید تصادفی ساخته میشود.'
|
||
),
|
||
),
|
||
],
|
||
tags=['Subscription (subscription)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = PurchaseRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
user = request.user
|
||
|
||
# پلنی که دیگر فروخته نمیشود (اشتراکهای ماهانه/سالانه پس از آمدنِ بستهها) خریدنی نیست.
|
||
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
|
||
idempotency_key = request.headers.get('Idempotency-Key')
|
||
|
||
# بازار: SKU از پلن، نه از کلاینت. بسته محصولِ مصرفشدنیِ بازار است و اشتراک محصولِ «اشتراکی»
|
||
# (از ۲۰۲۶-۱۰-۱۲؛ تمدیدِ خودکارش در payment.services.sync_bazaar_renewals). خریدِ واقعی هنوز آزموده نشده.
|
||
provider = 'gateway'
|
||
if v['paymentMethod'] == 'bazaar':
|
||
if not plan.bazaar_sku:
|
||
raise ValidationError({'planId': 'این مورد هنوز در کافهبازار تعریف نشده است.'})
|
||
provider = 'bazaar'
|
||
bazaar_sku = plan.bazaar_sku if provider == 'bazaar' else ''
|
||
|
||
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=bazaar_sku,
|
||
)
|
||
return response(order, None)
|
||
|
||
if plan.kind == 'pack':
|
||
# بسته به Subscription دست نمیزند: اعتبار و روزهای قلب بعد از پرداخت از روی
|
||
# خودِ سفارش داده میشوند (payment.services → subscription.services.grant_pack).
|
||
order, payment_url = create_order(
|
||
user_id=user.id, amount=plan.price, currency=plan.currency,
|
||
idempotency_key=idempotency_key, plan_id=plan.id, plan_title=plan.title,
|
||
)
|
||
return Response(PurchaseResponseSerializer({'orderId': str(order.id), 'paymentUrl': payment_url}).data)
|
||
|
||
# Three-phase flow so create_order never runs inside a DB transaction/lock.
|
||
# (create_order itself no longer calls Zarinpal — PaymentRedirectView
|
||
# does, on first visit — so this split is now defensive.) Phase 1 creates/updates
|
||
# the pending Subscription, phase 2 calls create_order and restores the
|
||
# prior state on failure, phase 3 attaches order_id atomically.
|
||
existing_order = None
|
||
if idempotency_key:
|
||
existing_order = Order.objects.filter(idempotency_key=idempotency_key).first()
|
||
|
||
prior_subscription = Subscription.objects.filter(user_id=user.id).first()
|
||
prior_snapshot = None
|
||
if prior_subscription is not None:
|
||
prior_snapshot = {
|
||
'status': prior_subscription.status,
|
||
'plan_id': prior_subscription.plan_id,
|
||
'order_id': prior_subscription.order_id,
|
||
}
|
||
|
||
# اشتراکِ فعال با باز کردنِ صفحهی خرید دست نمیخورد. پیشتر وضعیتش
|
||
# همینجا «pending» میشد و کاربرِ پولی اگر خرید را تمام نمیکرد
|
||
# مزیتش را از دست میداد. پلن و تمدید از خودِ سفارش میآیند
|
||
# (activate_subscription)، پس لازم نیست Subscription الان عوض شود.
|
||
currently_active = prior_subscription is not None and has_active_subscription(user.id)
|
||
|
||
if currently_active:
|
||
subscription = prior_subscription
|
||
subscription_is_new = False
|
||
prior_snapshot = None
|
||
elif existing_order is not None:
|
||
# Retry path: leave an existing subscription untouched, only
|
||
# create one if it's genuinely missing.
|
||
if prior_subscription is None:
|
||
# Fresh row: version starts at 1 to give it a CAS token.
|
||
subscription = Subscription.objects.create(
|
||
user_id=user.id, plan=plan, status='pending', version=1,
|
||
)
|
||
subscription_is_new = True
|
||
else:
|
||
subscription = prior_subscription
|
||
subscription_is_new = False
|
||
else:
|
||
subscription, subscription_is_new = Subscription.objects.update_or_create(
|
||
user_id=user.id,
|
||
defaults={'plan': plan, 'status': 'pending'},
|
||
)
|
||
# Two concurrent requests can reach an identical intermediate state,
|
||
# so version is bumped atomically here to give this write a unique
|
||
# CAS token the cleanup below can match against.
|
||
Subscription.objects.filter(pk=subscription.pk).update(version=F('version') + 1)
|
||
subscription.refresh_from_db(fields=['version'])
|
||
|
||
try:
|
||
order, payment_url = create_order(
|
||
user_id=user.id,
|
||
amount=plan.price,
|
||
currency=plan.currency,
|
||
subscription_id=subscription.id,
|
||
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
|
||
# matches the exact row state phase 1 wrote; any concurrent writer
|
||
# (another request, a payment webhook, etc.) changes the match and
|
||
# the delete/update below becomes a no-op instead of clobbering it.
|
||
guard = {
|
||
'pk': subscription.pk,
|
||
'status': subscription.status,
|
||
'plan_id': subscription.plan_id,
|
||
'order_id': subscription.order_id,
|
||
'version': subscription.version,
|
||
}
|
||
with transaction.atomic():
|
||
if subscription_is_new:
|
||
Subscription.objects.filter(**guard).delete()
|
||
elif prior_snapshot is not None:
|
||
Subscription.objects.filter(**guard).update(**prior_snapshot)
|
||
raise
|
||
|
||
if subscription.order_id != order.id and not currently_active:
|
||
with transaction.atomic():
|
||
subscription.order_id = order.id
|
||
subscription.save(update_fields=['order_id'])
|
||
|
||
return response(order, payment_url)
|
||
|
||
|
||
class SubscriptionStatusView(APIView):
|
||
"""GET /api/subscription/status — current subscription status."""
|
||
|
||
# سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای همخانواده است.
|
||
throttle_scope = 'read'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
|
||
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
|
||
def get(self, request):
|
||
# پرداختی که تأییدش در کالبک گم شده، اینجا دوباره سنجیده میشود.
|
||
reconcile_user_orders(request.user.id)
|
||
subscription = Subscription.objects.select_related('plan').filter(user_id=request.user.id).first()
|
||
active = has_active_subscription(request.user.id)
|
||
data = {
|
||
# وضعیتِ «active» با گذشتِ تاریخ خودبهخود عوض نمیشود؛ همان شرطی که
|
||
# قلبِ نامحدود را میدهد ملاک است، وگرنه اشتراکِ منقضی «فعال» دیده میشد.
|
||
'active': active,
|
||
'planTitle': subscription.plan.title if active and subscription.plan else None,
|
||
# طولِ دورهی پلن، برای نوارِ «چقدر مانده» در پروفایل. تمدیدِ زودتر از موعد روزها را روی هم
|
||
# میگذارد، پس روزِ مانده میتواند از این بیشتر باشد؛ فرانت نوار را در ۱۰۰٪ میبندد.
|
||
'planDurationDays': subscription.plan.duration_days if active and subscription.plan else None,
|
||
'aiDailyQuota': ai_daily_quota(request.user.id),
|
||
'planId': str(subscription.plan_id) if subscription and subscription.plan_id else None,
|
||
'expiresAt': subscription.expires_at if subscription else None,
|
||
'questionCredits': credit_balance(request.user.id),
|
||
'unlimitedHeartsUntil': hearts_pass_until(request.user.id),
|
||
}
|
||
return Response(SubscriptionStatusResponseSerializer(data).data)
|