connect Zarinpal as the real payment gateway
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>
This commit is contained in:
132
payment/views.py
132
payment/views.py
@@ -1,13 +1,17 @@
|
||||
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 IsAuthenticated
|
||||
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
|
||||
from . import bazaar, zarinpal
|
||||
from .models import Order
|
||||
from .permissions import HasInternalServiceKey, HasWebhookSecret
|
||||
from .serializers import (
|
||||
@@ -18,10 +22,27 @@ from .serializers import (
|
||||
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
|
||||
@@ -96,13 +117,16 @@ class CreateOrderView(APIView):
|
||||
|
||||
class PaymentWebhookView(APIView):
|
||||
"""
|
||||
POST /api/payment/webhook — کالبک درگاه پرداخت (از سمت درگاه میاد، نه کلاینت)
|
||||
POST /api/payment/webhook — وبهوکِ عمومیِ shared-secret (نه مخصوصِ زرینپال).
|
||||
|
||||
محافظتشده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی
|
||||
نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک
|
||||
فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره
|
||||
Transaction/notify تکراری نمیسازه (مثلاً وقتی درگاه callback رو دوباره
|
||||
میفرسته).
|
||||
جریانِ واقعیِ زرینپال از PaymentRedirectView/ZarinpalCallbackView رد
|
||||
میشه (زرینپال خودش webhookِ سرور-به-سرور نمیزنه، فقط مرورگرِ کاربر رو
|
||||
ریدایرکت میکنه). این endpoint برای تاییدِ دستی/تستی یا درگاههای دیگهای
|
||||
که واقعاً webhook POST میزنن نگه داشته شده؛ محافظتشده با
|
||||
HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک
|
||||
orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه.
|
||||
همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/
|
||||
notify تکراری نمیسازه.
|
||||
"""
|
||||
|
||||
permission_classes = [HasWebhookSecret]
|
||||
@@ -149,6 +173,98 @@ class PaymentWebhookView(APIView):
|
||||
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 — تایید پرداخت درونبرنامهای بازار
|
||||
|
||||
Reference in New Issue
Block a user