Files
MathApp/subscription/views.py
Zareian 947f946909 Question packs removed at the root; all subscriptions 4 assistant questions a day; Bazaar is subscription-only
- subscription.0017/0018, payment.0013: pack plans deleted, QuestionCredit/HeartsPass models and Plan.kind /
  question_credits / hearts_days / Order.question_credits / hearts_days dropped. The only paid pack order was the
  company owner's test account (user 26).
- Assistant: no credit spending; quota is the daily cap only (free 2, every plan 4, gift subscription 4).
- Bazaar: verify_purchase is subscription-only (no inapp guess), start/purchase responses lose productType,
  verify refuses an order without a subscription. A paid order with no subscription now logs ERROR.
- Removed POST /api/payment/orders (internal; no caller). Its permission tests stay.
- Pack-based tests rewritten on subscriptions (refund, security, Bazaar token); pack-only test files deleted.
- docs: profit table for 4/day on both payment routes plus free users; handbook, operations, CLAUDE.md current.
949 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 20:33:38 +03:30

230 lines
11 KiB
Python
Raw Permalink 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, has_active_subscription
# پلن‌ها تقریباً هیچ‌وقت عوض نمی‌شوند و روی صفحه‌ی اشتراک هر بار خوانده می‌شوند.
# با هر save/delete روی Plan کش باطل می‌شود (subscription/receivers.py).
PLANS_CACHE_KEY = 'subscription:plans:v4' # v4: بی بسته‌ی سوال (subscription.0017)
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,
'bazaarSku': p.bazaar_sku,
}
for p in Plan.objects.filter(on_sale=True).order_by('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
# پلنی که دیگر فروخته نمی‌شود (on_sale=False، مثلِ اشتراک‌های قدیمیِ ماهانه/سالانه) خریدنی نیست.
plan = get_object_or_404(Plan, pk=v['planId'], on_sale=True)
idempotency_key = request.headers.get('Idempotency-Key')
# روشِ پرداخت (زرین‌پال/بازار) و تفاوت‌هایش از payment/providers.py؛ SKUِ بازار از پلن، نه از کلاینت.
from payment.providers import provider_for_method
provider = provider_for_method(v['paymentMethod'])
provider.check_plan(plan)
def response(order, payment_url):
data = {'orderId': str(order.id), 'paymentUrl': payment_url, **provider.purchase_extras(order, plan)}
return Response(PurchaseResponseSerializer(data).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.key,
bazaar_product_sku=provider.sku_for(plan),
)
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,
}
return Response(SubscriptionStatusResponseSerializer(data).data)