Files
MathApp/payment/views.py
Zareian d5dbd1b23d keep the Referer on the redirect to Zarinpal so StartPay accepts our domain
A test payment on 2026-10-01 stopped at StartPay with "access from this
domain is not allowed". The app opens /api/payment/orders/{id}/pay, which
302s to Zarinpal, and that response carried Django's default
Referrer-Policy: same-origin, so the browser dropped the Referer on the
cross-site hop and Zarinpal could not see tadrisino2.ir. Only this view now
sends strict-origin-when-cross-origin (origin only, no path or query);
every other response keeps same-origin.

Not yet confirmed by a second real attempt in a browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 08:56:13 +03:30

368 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from urllib.parse import urlsplit
from django.conf import settings
from django.db import transaction
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.html import escape
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
from .serializers import (
BazaarVerifyRequestSerializer,
BazaarVerifyResponseSerializer,
CreateOrderRequestSerializer,
CreateOrderResponseSerializer,
OrderDetailResponseSerializer,
ZarinpalCallbackResponseSerializer,
)
from .services import apply_order_payment_result, create_order, reconcile_pending_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 PaymentRedirectView(APIView):
"""
GET /api/payment/orders/{id}/pay — همان paymentUrlی که
CreateOrderResponseSerializer برای provider='gateway' برمی‌گرداند؛
کلاینت (مرورگر/وب‌ویو) این را باز می‌کند.
فراخوانیِ واقعیِ PaymentRequest به زرین‌پال عمداً همین‌جاست، نه در
create_order: create_order از subscription صدا زده می‌شود و نباید
موفقیتِ ساختِ سفارش به دسترس‌بودنِ زرین‌پال گره بخورد. اولین بار که این
URL باز شود authority ساخته و روی سفارش ذخیره می‌شود؛ بازکردنِ دوباره
(مثلاً دکمه‌ی back مرورگر) همان authority قبلی را دوباره استفاده
می‌کند، نه یک PaymentRequest تازه.
⚠️ AllowAny است و مالکیتِ سفارش را نمی‌سنجد (مرورگر هدرِ Bearer نمی‌فرستد)؛
شناسه‌ی سفارش ترتیبی است. هر کسی با باز کردنِ `/orders/<n>/pay` برای سفارشِ
دیگران authority می‌سازد و از آن به بعد صاحبِ سفارش دیگر نمی‌تواند کدِ
تخفیف بزند (بازبینیِ ۲۰۲۶-۰۹-۲۵، بازتولید شد).
"""
permission_classes = [AllowAny]
throttle_scope = 'payment_pay'
throttle_classes = [ScopedRateThrottle]
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
# زرین‌پال در StartPay از روی Referer می‌سنجد کاربر از دامنه‌ی ثبت‌شده
# آمده یا نه. پیش‌فرضِ جنگو `same-origin` است و روی همین ریدایرکتِ
# بین‌دامنه‌ای Referer را حذف می‌کرد؛ زرین‌پال می‌گفت «دسترسی از این
# دامنه مجاز نمی‌باشد» (۲۰۲۶-۱۰-۰۱، پرداختِ آزمایشیِ صاحبِ پروژه).
# با این مقدار فقط origin صفحه‌ی فرانت (math.tadrisino2.ir) می‌رود، نه
# مسیر و کوئری. SecurityMiddleware با setdefault این را دست نمی‌زند.
response['Referrer-Policy'] = 'strict-origin-when-cross-origin'
return response
@extend_schema(
responses={302: OpenApiTypes.NONE},
description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.',
tags=['Payment (payment)'],
)
def get(self, request, order_id):
order = get_object_or_404(Order, pk=order_id, provider='gateway')
if order.status != 'pending':
# سفارش از قبل نتیجه گرفته (paid/failed)؛ چیزی برای پرداختِ تازه نیست.
# کاربر به اپ برمی‌گردد با همان نتیجه، نه به ریشه‌ی دامنه‌ی API.
if settings.ZARINPAL_REDIRECT_URL:
return _redirect_to_configured_url(
f'{settings.ZARINPAL_REDIRECT_URL}?orderId={order.id}&status={order.status}'
)
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).
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)
def _bazaar_oauth_page(message: str, *, status: int) -> HttpResponse:
# این را صاحبِ حسابِ بازار در مرورگر می‌بیند، نه اپ؛ پس HTML ساده.
return HttpResponse(
'<!doctype html><meta charset="utf-8"><title>اتصال به بازار</title>'
f'<p dir="rtl" style="font:16px sans-serif;margin:2em">{escape(message)}</p>',
status=status,
)
class BazaarVerifyPurchaseView(APIView):
"""
POST /api/payment/bazaar/verify — تایید پرداخت درون‌برنامه‌ای بازار
GET /api/payment/bazaar/verify?code=… — Redirect URIِ OAuthِ بازار
همین آدرس در پنلِ بازار Redirect URI ثبت شده (خواستِ مدیرِ حساب،
۲۰۲۶-۰۹-۲۶)، پس دو کار دارد که با متد از هم جدا می‌شوند: GET را مرورگرِ
صاحبِ حساب بعد از «اجازه» می‌زند و بی‌احراز است (فقط داخلِ پنجره‌ای که
start_authorization باز کرده اثر دارد)؛ POST را اپِ اندروید بعد از خرید.
برخلاف درگاه معمولی که با 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]
def get_permissions(self):
if self.request.method == 'GET':
return [AllowAny()]
return super().get_permissions()
def get_throttles(self):
if self.request.method == 'GET':
self.throttle_scope = 'bazaar_oauth'
return super().get_throttles()
@extend_schema(exclude=True)
def get(self, request):
if request.query_params.get('error'):
return _bazaar_oauth_page(
f'بازار اجازه نداد: {request.query_params.get("error")}', status=400,
)
code = request.query_params.get('code', '')
if not code:
return _bazaar_oauth_page(
'این آدرس برای برگشت از بازار است و بدون code کاری نمی‌کند.', status=400,
)
try:
bazaar.complete_authorization(code=code, state=request.query_params.get('state', ''))
except bazaar.BazaarAuthorizationError as exc:
return _bazaar_oauth_page(str(exc), status=400)
return _bazaar_oauth_page('اتصال به بازار انجام شد. می‌توانید این صفحه را ببندید.', status=200)
@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)
if reconcile_pending_order(order):
order.refresh_from_db()
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)