- 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>
230 lines
11 KiB
Python
230 lines
11 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, 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)
|