175 lines
8.2 KiB
Python
175 lines
8.2 KiB
Python
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)
|