Files
MathApp/subscription/views.py
Zareian 64c4c09326 Paywall: first unit of each grade free; 1/3/6-month subscriptions; AI sold separately
- content: unit_is_free/has_unit_access; questions, node quiz, answer and lesson/node
  complete return 402 subscription_required outside the first unit without an active
  subscription. /api/home units carry requiresSubscription.
- subscription.0013: monthly 498k, quarterly 798k, six-month 1,298k toman, unlimited
  hearts, 3 AI questions/day; packs stay on sale for more questions.
- AI_DAILY_QUOTA_FREE default 3 -> 2.
- /api/subscription/status adds planTitle, planDurationDays, aiDailyQuota.
- tests: content/tests_paywall.py; existing unlock-order tests get a subscription.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 01:19:02 +03:30

249 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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')
if v['paymentMethod'] == 'bazaar':
# فقط بسته: مسیرِ اشتراکِ بازار (تمدیدِ خودکار) هرگز با خریدِ واقعی آزموده نشده، پس
# اشتراک فعلاً فقط از زرین‌پال فروخته می‌شود. SKU از پلن، نه از کلاینت.
if plan.kind != 'pack':
raise ValidationError({'paymentMethod': 'خرید از بازار فقط برای بسته‌های سوال است.'})
if not plan.bazaar_sku:
raise ValidationError({'planId': 'این بسته هنوز در کافه‌بازار تعریف نشده است.'})
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,
)
return Response(PurchaseResponseSerializer(
{'orderId': str(order.id), 'paymentUrl': None, 'bazaarSku': order.bazaar_product_sku}).data)
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,
)
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'])
data = {'orderId': str(order.id), 'paymentUrl': payment_url}
return Response(PurchaseResponseSerializer(data).data)
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)