193 lines
8.4 KiB
Python
193 lines
8.4 KiB
Python
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.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
|
|
|
|
from .models import Plan, Subscription
|
|
from .serializers import (
|
|
PlansListResponseSerializer,
|
|
PurchaseRequestSerializer,
|
|
PurchaseResponseSerializer,
|
|
SubscriptionStatusResponseSerializer,
|
|
)
|
|
|
|
|
|
class GuestAwarePurchaseThrottle(ScopedRateThrottle):
|
|
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
|
حساب مهمان نباید bucket تازهی purchase بده — این endpoint حساسِ مالیه
|
|
(Subscription میسازه/آپدیت میکنه، create_order رو صدا میزنه). برای
|
|
مهمانها کلیدِ cache بر اساسِ IP هست، نه pk."""
|
|
|
|
def get_cache_key(self, request, view):
|
|
user = request.user
|
|
if user and user.is_authenticated and getattr(user, 'is_guest', False):
|
|
ident = f'guest-ip:{self.get_ident(request)}'
|
|
elif user and user.is_authenticated:
|
|
ident = user.pk
|
|
else:
|
|
ident = self.get_ident(request)
|
|
|
|
return self.cache_format % {'scope': self.scope, 'ident': ident}
|
|
|
|
|
|
class PlansListView(APIView):
|
|
"""GET /api/subscription/plans — list available plans."""
|
|
|
|
@extend_schema(responses=PlansListResponseSerializer, tags=['Subscription (subscription)'])
|
|
def get(self, request):
|
|
plans = Plan.objects.all()
|
|
data = {
|
|
'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,
|
|
}
|
|
for p in plans
|
|
]
|
|
}
|
|
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 است چون اون فقط جلوی تکرارِ *همون*
|
|
# کلید رو میگیره، نه حجمِ درخواستهای متفاوت. کاربرهای مهمان با هر
|
|
# deviceId یک pk تازه میگیرن، پس کلیدِ per-user ساده قابلدورزدنه؛
|
|
# GuestAwarePurchaseThrottle برای مهمانها بر اساسِ IP کلید میسازه
|
|
# (مثلِ GuestAwareAIThrottle/FamilyThrottle/DiscountThrottle) و برای
|
|
# کاربرهای غیرمهمان رفتارِ per-user را حفظ میکنه.
|
|
throttle_scope = 'purchase'
|
|
throttle_classes = [GuestAwarePurchaseThrottle]
|
|
|
|
@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'])
|
|
idempotency_key = request.headers.get('Idempotency-Key')
|
|
|
|
# Three-phase flow so create_order (which may call an external payment
|
|
# gateway) never runs inside a DB transaction/lock: 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,
|
|
}
|
|
|
|
if 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,
|
|
)
|
|
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:
|
|
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."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
@extend_schema(responses=SubscriptionStatusResponseSerializer, tags=['Subscription (subscription)'])
|
|
def get(self, request):
|
|
subscription = Subscription.objects.filter(user_id=request.user.id).first()
|
|
data = {
|
|
'active': bool(subscription and subscription.status == 'active'),
|
|
'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)
|