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:
2026-09-06 13:27:13 +03:30
parent a275eac7fd
commit b5476e1c5d
11 changed files with 630 additions and 14 deletions

View File

@@ -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 — تایید پرداخت درون‌برنامه‌ای بازار