from django.db import transaction from django.shortcuts import get_object_or_404 from django.utils import timezone 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.views import APIView from .models import Order, Transaction from .permissions import HasInternalServiceKey, HasWebhookSecret from .serializers import ( CreateOrderRequestSerializer, CreateOrderResponseSerializer, OrderDetailResponseSerializer, WebhookRequestSerializer, WebhookResponseSerializer, ) from .services import create_order class CreateOrderView(APIView): """ POST /api/payment/orders — ساخت سفارش (فراخوانی‌شده از اپ Subscription) طبق سند API، این یک فراخوانی سرور-به-سرور از اپ Subscription‌ه، نه از کلاینت. در عمل subscription/views.py مستقیماً تابع create_order رو import و صدا می‌زنه، پس این endpoint برای جریان عادی از بیرون صدا زده نمی‌شه؛ برای همین به‌جای AllowAny یک shared-secret داخلی (HasInternalServiceKey، هدر X-Internal-Key) لازمه، وگرنه هر کلاینتی می‌تونست با یک userId دلخواه سفارش بسازه. """ permission_classes = [HasInternalServiceKey] # ⚠️ افزوده شده: هدرهای موردنیاز (X-Internal-Key, Idempotency-Key) قبلاً # مستند نشده بودن و از Swagger UI اصلاً قابل ست‌کردن/تست‌کردن نبودن. @extend_schema( request=CreateOrderRequestSerializer, responses=CreateOrderResponseSerializer, parameters=[ OpenApiParameter( 'X-Internal-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, description='کلید مخفیِ فراخوانی داخلی/سرور-به-سرور (settings.INTERNAL_SERVICE_KEY).', ), OpenApiParameter( 'Idempotency-Key', OpenApiTypes.STR, OpenApiParameter.HEADER, required=False, description=( 'کلید یکتای idempotency برای جلوگیری از ساخت سفارش تکراری هنگام retry. ' 'در صورت عدم ارسال، یک کلید تصادفی ساخته می‌شود.' ), ), ], tags=['Payment (payment)'], ) def post(self, request): serializer = CreateOrderRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data order, payment_url = create_order( user_id=int(v['userId']), amount=v['amount'], currency=v.get('currency') or 'IRT', subscription_id=int(v['subscriptionId']) if v.get('subscriptionId') else None, idempotency_key=request.headers.get('Idempotency-Key'), ) data = {'orderId': str(order.id), 'status': order.status, 'paymentUrl': payment_url} return Response(CreateOrderResponseSerializer(data).data) class PaymentWebhookView(APIView): """ POST /api/payment/webhook — کال‌بک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت) محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/notify تکراری نمی‌سازه (مثلاً وقتی درگاه callback رو دوباره می‌فرسته). """ permission_classes = [HasWebhookSecret] # ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI # قابل تنظیم/تست نبود. @extend_schema( request=WebhookRequestSerializer, responses=WebhookResponseSerializer, parameters=[ OpenApiParameter( 'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).', ), ], tags=['Payment (payment)'], ) def post(self, request): serializer = WebhookRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data gateway_ref_id = v.get('gatewayRefId', '') with transaction.atomic(): order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId']) already_paid = order.status == 'paid' # ⚠️ اصلاح امنیتی/صحت داده: قبلاً هر بار فراخوانی webhook (حتی # اگه درگاه به‌خاطر عدم دریافت ۲۰۰ یا هر دلیل دیگه‌ای همون # کال‌بک رو دوباره می‌فرستاد) یک رکورد Transaction جدید می‌ساخت. # حالا اگه رکوردی با همین order/status/gatewayRefId از قبل # موجود باشه، تکراری در نظر گرفته می‌شه و نه Transaction جدید # ساخته می‌شه و نه اثرات جانبی (فعال‌سازی اشتراک/notify) دوباره # اجرا می‌شن. is_duplicate = Transaction.objects.filter( order=order, status=v['status'], gateway_ref_id=gateway_ref_id, ).exists() if not is_duplicate: order.status = v['status'] order.save(update_fields=['status']) Transaction.objects.create( order=order, gateway=v.get('gateway') or 'zarinpal', gateway_ref_id=gateway_ref_id, status=v['status'], paid_at=timezone.now() if v['status'] == 'paid' else None, ) if v['status'] == 'paid' and not already_paid and not is_duplicate: if order.subscription_id: # فعال‌سازی اشتراک بعد از پرداخت موفق. ارتباط با اپ # subscription هم مثل create_order یک import مستقیم به # تابع سرویسه (رفرنس سبک با id، نه FK واقعی و نه HTTP # call به خودمون). from subscription.services import activate_subscription activate_subscription(order.subscription_id) # رویداد order_paid طبق سند notifications باید ساطع بشه؛ # قبلاً این فراخوانی جا افتاده بود (فقط اشتراک فعال می‌شد). from notifications.services import notify notify( user_id=order.user_id, type='order_paid', title='پرداخت با موفقیت انجام شد', related_id=order.id, ) return Response(WebhookResponseSerializer({'received': True}).data) class OrderDetailView(APIView): """GET /api/payment/orders/{id} — وضعیت سفارش""" permission_classes = [IsAuthenticated] @extend_schema(responses=OrderDetailResponseSerializer, tags=['Payment (payment)']) def get(self, request, order_id): order = get_object_or_404(Order, pk=order_id, user_id=request.user.id) data = { 'orderId': str(order.id), 'status': order.status, 'amount': order.amount, 'transactions': [ { 'id': str(t.id), 'gateway': t.gateway, 'status': t.status, 'paidAt': t.paid_at, } for t in order.transactions.all() ], } return Response(OrderDetailResponseSerializer(data).data)