Files
MathApp/payment/views.py
Zareian 0580aec7b0 harden OTP for real SMS and close the second audit round
Kavenegar is being switched on, so every OTP request now costs credit:
- phone numbers are normalised to 09XXXXXXXXX everywhere (login, OTP,
  family invite, TEST_LOGIN_PHONES keys) and existing rows are migrated;
  non-Iranian and landline numbers are rejected before any SMS
- cooldown and a daily per-phone cap are counted in the database, so the
  three gunicorn workers no longer triple them
- a global daily OTP SMS cap (default 300) stops SMS pumping
- per-IP rates widened for CGNAT / school Wi-Fi (env-overridable)
- provider message id is logged; SMS text warns not to share the code

Second audit round: an offer no longer shortens a longer paid plan,
Zarinpal error bodies no longer 500, island XP reaches the parent report,
a non-numeric questionId no longer 500s after the answer is generated.

596 tests green.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:28:06 +03:30

314 lines
15 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 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
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]
@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)
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)
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)