payment.services.call_payment_gateway was a stub returning a fake
payment.gateway/pay/{id} URL. It's replaced with a real Zarinpal
integration (payment/zarinpal.py, Payment API v4):
- create_order/CreateOrderView still never call Zarinpal directly —
paymentUrl points at our own GET /api/payment/orders/{id}/pay
(PaymentRedirectView), which requests the real Zarinpal authority on
first visit and redirects to StartPay. This keeps order creation from
depending on Zarinpal's uptime/latency, and means none of the existing
purchase/idempotency tests needed to change.
- GET /api/payment/zarinpal/callback (ZarinpalCallbackView) is where
Zarinpal redirects the user back to; it always re-verifies
server-to-server via zarinpal.verify_payment before marking an order
paid, so a forged Status=OK in the query string alone does nothing.
- zarinpal.py fails closed (503 / invalid) when ZARINPAL_MERCHANT_ID
isn't configured, matching payment/bazaar.py's existing convention —
never a fake successful payment.
Adds Order.gateway_authority, PUBLIC_BASE_URL/ZARINPAL_* settings, and
a payment_pay throttle scope.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
358 lines
18 KiB
Python
358 lines
18 KiB
Python
from urllib.parse import urlsplit
|
||
|
||
from django.conf import settings
|
||
from django.db import transaction
|
||
from django.http import HttpResponseRedirect
|
||
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 AllowAny, IsAuthenticated
|
||
from rest_framework.response import Response
|
||
from rest_framework.throttling import ScopedRateThrottle
|
||
from rest_framework.views import APIView
|
||
|
||
from . import bazaar, zarinpal
|
||
from .models import Order
|
||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||
from .serializers import (
|
||
BazaarVerifyRequestSerializer,
|
||
BazaarVerifyResponseSerializer,
|
||
CreateOrderRequestSerializer,
|
||
CreateOrderResponseSerializer,
|
||
OrderDetailResponseSerializer,
|
||
WebhookRequestSerializer,
|
||
WebhookResponseSerializer,
|
||
ZarinpalCallbackResponseSerializer,
|
||
)
|
||
from .services import apply_order_payment_result, create_order
|
||
|
||
|
||
def _redirect_to_configured_url(url: str) -> HttpResponseRedirect:
|
||
"""
|
||
HttpResponseRedirect معمولی فقط اسکیمِ http/https/ftp رو قبول میکنه؛
|
||
ZARINPAL_REDIRECT_URL ممکنه یک deep link با اسکیمِ سفارشیِ اپ باشه
|
||
(مثلاً tadrisino://...). چون این URL از settings میاد (نه از ورودیِ
|
||
کاربر)، اضافهکردنِ اسکیمش به allowed_schemes امن است.
|
||
"""
|
||
scheme = urlsplit(url).scheme
|
||
redirect_cls = HttpResponseRedirect
|
||
if scheme and scheme not in redirect_cls.allowed_schemes:
|
||
redirect_cls = type('ConfiguredSchemeRedirect', (HttpResponseRedirect,), {
|
||
'allowed_schemes': [*HttpResponseRedirect.allowed_schemes, scheme],
|
||
})
|
||
return redirect_cls(url)
|
||
|
||
|
||
class GuestAwareBazaarThrottle(ScopedRateThrottle):
|
||
"""همون الگوی GuestAwareAIThrottle/GuestAwareFamilyThrottle/etc: چرخشِ
|
||
حساب مهمان (هر deviceId یه pk تازه) نباید bucket تازهی bazaar_verify
|
||
بده، چون هر درخواستِ پذیرفتهشده یه فراخوانیِ سرور-به-سرور به بازاره.
|
||
برای مهمانها کلیدِ 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 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'),
|
||
provider=v.get('provider') or 'gateway',
|
||
bazaar_product_sku=v.get('bazaarProductSku') or '',
|
||
)
|
||
data = {
|
||
'orderId': str(order.id), 'status': order.status,
|
||
'paymentUrl': payment_url, 'provider': order.provider,
|
||
}
|
||
return Response(CreateOrderResponseSerializer(data).data)
|
||
|
||
|
||
class PaymentWebhookView(APIView):
|
||
"""
|
||
POST /api/payment/webhook — وبهوکِ عمومیِ shared-secret (نه مخصوصِ زرینپال).
|
||
|
||
جریانِ واقعیِ زرینپال از PaymentRedirectView/ZarinpalCallbackView رد
|
||
میشه (زرینپال خودش webhookِ سرور-به-سرور نمیزنه، فقط مرورگرِ کاربر رو
|
||
ریدایرکت میکنه). این endpoint برای تاییدِ دستی/تستی یا درگاههای دیگهای
|
||
که واقعاً webhook POST میزنن نگه داشته شده؛ محافظتشده با
|
||
HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
|
||
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
|
||
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
|
||
notify تکراری نمیسازه.
|
||
"""
|
||
|
||
permission_classes = [HasWebhookSecret]
|
||
# این endpoint فقط با مقایسهی هدرِ ثابتِ X-Webhook-Secret محافظت میشه،
|
||
# پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون
|
||
# caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور
|
||
# استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است.
|
||
throttle_scope = 'payment_webhook'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
# ⚠️ افزوده شده: مستندسازی هدر 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'])
|
||
# ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی
|
||
# تکراری از سمت درگاه) یک Transaction جدید میساخت. حالا این
|
||
# idempotency + اثرات جانبی (فعالسازی اشتراک/notify/statuslog)
|
||
# داخل apply_order_payment_result مشترکه (هم اینجا هم بازار).
|
||
apply_order_payment_result(
|
||
order=order,
|
||
gateway=v.get('gateway') or 'zarinpal',
|
||
gateway_ref_id=gateway_ref_id,
|
||
new_status=v['status'],
|
||
event='gateway_webhook',
|
||
)
|
||
|
||
return Response(WebhookResponseSerializer({'received': True}).data)
|
||
|
||
|
||
class PaymentRedirectView(APIView):
|
||
"""
|
||
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
|
||
CreateOrderResponseSerializer برای provider='gateway' برمیگرداند؛
|
||
کلاینت (مرورگر/وبویو) این را باز میکند.
|
||
|
||
فراخوانیِ واقعیِ PaymentRequest به زرینپال عمداً همینجاست، نه در
|
||
create_order: create_order از subscription صدا زده میشود و نباید
|
||
موفقیتِ ساختِ سفارش به دسترسبودنِ زرینپال گره بخورد. اولین بار که این
|
||
URL باز شود authority ساخته و روی سفارش ذخیره میشود؛ بازکردنِ دوباره
|
||
(مثلاً دکمهی back مرورگر) همان authority قبلی را دوباره استفاده
|
||
میکند، نه یک PaymentRequest تازه.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
throttle_scope = 'payment_pay'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
def get(self, request, order_id):
|
||
order = get_object_or_404(Order, pk=order_id, provider='gateway')
|
||
|
||
if order.status != 'pending':
|
||
# سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست.
|
||
return HttpResponseRedirect(settings.PUBLIC_BASE_URL or '/')
|
||
|
||
if not order.gateway_authority:
|
||
result = zarinpal.request_payment(order)
|
||
# ⚠️ محافظت در برابرِ race: اگه بینِ خواندنِ بالا و اینجا یک
|
||
# درخواستِ همزمانِ دیگر authority ساخته باشد، همان مقدارِ اول را
|
||
# نگه میداریم، نه این result تازه را (فقط وقتی هنوز خالی است آپدیت میشود).
|
||
Order.objects.filter(pk=order.pk, gateway_authority='').update(
|
||
gateway_authority=result.authority,
|
||
)
|
||
order.refresh_from_db(fields=['gateway_authority'])
|
||
|
||
return HttpResponseRedirect(f'{settings.ZARINPAL_STARTPAY_BASE}/{order.gateway_authority}')
|
||
|
||
|
||
class ZarinpalCallbackView(APIView):
|
||
"""
|
||
GET /api/payment/zarinpal/callback — کالبکی که زرینپال بعدِ پرداخت،
|
||
مرورگرِ کاربر رو بهش ریدایرکت میکنه (Authority, Status در query string).
|
||
|
||
برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)،
|
||
اینجا caller خودِ مرورگرِ کاربره، نه زرینپال مستقیماً — پس هیچ
|
||
هدر/سکرتی برای اعتمادکردن نیست. امنیت از verify_payment میاد (یک
|
||
فراخوانیِ سرور-به-سرور با Authority+amount): حتی اگه کاربر Status=OK
|
||
رو دستی توی URL جعل کنه، بدونِ تاییدِ واقعیِ زرینپال چیزی paid نمیشه.
|
||
"""
|
||
|
||
permission_classes = [AllowAny]
|
||
throttle_scope = 'payment_webhook'
|
||
throttle_classes = [ScopedRateThrottle]
|
||
|
||
@extend_schema(
|
||
parameters=[
|
||
OpenApiParameter('Authority', OpenApiTypes.STR, required=True),
|
||
OpenApiParameter('Status', OpenApiTypes.STR, required=True),
|
||
],
|
||
responses=ZarinpalCallbackResponseSerializer,
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def get(self, request):
|
||
authority = request.query_params.get('Authority', '')
|
||
status_param = request.query_params.get('Status', '')
|
||
order = get_object_or_404(Order, gateway_authority=authority, provider='gateway')
|
||
|
||
if status_param != 'OK':
|
||
new_status, gateway_ref_id = 'failed', ''
|
||
else:
|
||
result = zarinpal.verify_payment(
|
||
authority=authority, amount=order.amount, currency=order.currency,
|
||
)
|
||
new_status = 'paid' if result.is_valid else 'failed'
|
||
gateway_ref_id = result.ref_id
|
||
|
||
with transaction.atomic():
|
||
locked_order = get_object_or_404(Order.objects.select_for_update(), pk=order.pk)
|
||
apply_order_payment_result(
|
||
order=locked_order, gateway='zarinpal', gateway_ref_id=gateway_ref_id,
|
||
new_status=new_status, event='zarinpal_callback',
|
||
)
|
||
order.refresh_from_db(fields=['status'])
|
||
|
||
if settings.ZARINPAL_REDIRECT_URL:
|
||
return _redirect_to_configured_url(
|
||
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
|
||
)
|
||
data = {'orderId': str(order.id), 'status': order.status}
|
||
return Response(ZarinpalCallbackResponseSerializer(data).data)
|
||
|
||
|
||
class BazaarVerifyPurchaseView(APIView):
|
||
"""
|
||
POST /api/payment/bazaar/verify — تایید پرداخت درونبرنامهای بازار
|
||
|
||
برخلاف درگاه معمولی که با webhook خودِ درگاه تایید میشه، در پرداخت
|
||
درونبرنامهای بازار هیچ webhookی نیست: کلاینت اندروید بعد از خرید از
|
||
SDK بازار (IInAppBillingService)، یک purchaseToken میگیره و باید همون
|
||
رو برای این endpoint بفرسته تا بکاند با فراخوانی سرور-به-سرور به API
|
||
رسمی بازار (bazaar.verify_purchase)، صحتش رو تایید کنه — مشابه دقیقاً
|
||
همون چیزی که سند رسمی بازار توصیه میکنه: «هیچوقت فقط به گفتهی کلاینت
|
||
اعتماد نکنید، حتماً سمت سرور verify کنید».
|
||
"""
|
||
|
||
permission_classes = [IsAuthenticated]
|
||
# این endpoint یک فراخوانیِ سرور-به-سرورِ واقعی به API بازار (verify_purchase)
|
||
# تریگر میکنه، برای همین throttle_scope مجزا و نرخِ سختگیرانهتر (۱۰/hour)
|
||
# داره. کاربرهای مهمان با هر deviceId یک pk تازه میگیرن، پس
|
||
# GuestAwareBazaarThrottle برای اونها بر اساسِ IP کلید میسازه تا چرخشِ
|
||
# حساب bucket تازه نگیره؛ برای کاربرهای غیرمهمان رفتارِ per-user حفظ میشه.
|
||
throttle_scope = 'bazaar_verify'
|
||
throttle_classes = [GuestAwareBazaarThrottle]
|
||
|
||
@extend_schema(
|
||
request=BazaarVerifyRequestSerializer,
|
||
responses=BazaarVerifyResponseSerializer,
|
||
tags=['Payment (payment)'],
|
||
)
|
||
def post(self, request):
|
||
serializer = BazaarVerifyRequestSerializer(data=request.data)
|
||
serializer.is_valid(raise_exception=True)
|
||
v = serializer.validated_data
|
||
|
||
# verify_purchase (تا ~۲۰ ثانیه، دو درخواستِ پشتسرهم) عمداً *قبل* از
|
||
# بازشدنِ atomic/lock صدا زده میشه، نه داخلش — وگرنه ردیفِ Order (و هر
|
||
# تراکنشِ دیگهای روی همون Order/Subscription) تا برگشتِ پاسخِ بازار
|
||
# قفل میموند. لاک فقط برای اعمالِ نتیجه (چند میلیثانیه) گرفته میشه.
|
||
order_lookup = get_object_or_404(
|
||
Order, pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||
)
|
||
|
||
result = bazaar.verify_purchase(
|
||
product_sku=order_lookup.bazaar_product_sku or v['productSku'],
|
||
purchase_token=v['purchaseToken'],
|
||
)
|
||
|
||
with transaction.atomic():
|
||
order = get_object_or_404(
|
||
Order.objects.select_for_update(),
|
||
pk=v['orderId'], user_id=request.user.id, provider='bazaar',
|
||
)
|
||
|
||
new_status = 'paid' if result.is_valid else 'failed'
|
||
order.bazaar_purchase_token = v['purchaseToken']
|
||
order.save(update_fields=['bazaar_purchase_token'])
|
||
|
||
apply_order_payment_result(
|
||
order=order,
|
||
gateway='bazaar',
|
||
gateway_ref_id=v['purchaseToken'],
|
||
new_status=new_status,
|
||
event='bazaar_verify',
|
||
)
|
||
|
||
data = {'orderId': str(order.id), 'status': order.status, 'valid': result.is_valid}
|
||
return Response(BazaarVerifyResponseSerializer(data).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)
|