Files
MathApp/payment/views.py
Zareian ab5e53730a Audit fixes 2026-10-03: seed keeps admin edits, plan cache, refresh limit, payment and tutor guards
- seed_content no longer undoes admin work: a SeedRecord ledger (content/seed_sync.py)
  stops deleted/renamed/re-worded content from being recreated and keeps admin-edited
  lesson text; new rows in the seed files still arrive (migration content.0010)
- plan list cache is invalidated on Plan save (receiver used a stale 'v1' key)
- token refresh per-IP cap 30/hour -> 600/hour (TOKEN_REFRESH_IP_RATE)
- a failed gateway result only changes a pending order (forged NOK no longer
  overwrites refunded/canceled)
- tutor gets the correct answer only after the student has answered
- support reply notification moved to a TicketMessage post_save signal
- global daily cap on paid AI questions (AI_GLOBAL_DAILY_MAX, default 3000)
- Bazaar verify uses only the order's SKU; bazaar orders require a SKU
- /api/health warns on the committed signing key; deploy.sh saves local commits;
  400 for bad ?unit= / ?period=; account delete keeps the referrer's Conversion
- earlier today: no stub answers outside DEBUG, ?grade= removed, logged fallbacks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 16:05:27 +03:30

440 lines
22 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.exceptions import ValidationError
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 (
InvoiceListResponseSerializer,
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/GuestAwareDiscountThrottle/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
if v.get('provider') == 'bazaar' and not v.get('bazaarProductSku'):
raise ValidationError({'bazaarProductSku': 'سفارشِ بازار بدونِ SKU ساخته نمی‌شود.'})
plan = None
if v.get('planId'):
from subscription.models import Plan
plan = Plan.objects.filter(pk=v['planId']).first() if str(v['planId']).isdigit() else None
if plan is None:
raise ValidationError({'planId': 'پلنی با این شناسه وجود ندارد.'})
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 '',
plan_id=plan.id if plan else None,
plan_title=plan.title if plan else '',
)
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',
)
# SKU فقط از خودِ سفارش. تا ۲۰۲۶-۱۰-۰۳ اگر سفارش SKU نداشت، SKUِ فرستاده‌ی کلاینت سنجیده
# می‌شد؛ یعنی توکنِ خریدِ ارزان‌ترین محصول سفارشِ گران‌ترین پلن را «paid» می‌کرد.
if not order_lookup.bazaar_product_sku:
raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'})
result = bazaar.verify_purchase(
product_sku=order_lookup.bazaar_product_sku,
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',
)
# apply_order_payment_result سفارش را از نو می‌خواند و همان را عوض می‌کند؛ این نمونه هنوز
# وضعیتِ پیش از پرداخت را دارد. بدونِ این، پاسخ «pending» بود درحالی‌که سفارش «paid» شده بود.
order.refresh_from_db(fields=['status'])
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)
INVOICE_LIMIT = 50
def invoice_payload(order) -> dict:
"""یک سفارش به شکلِ فاکتور. کدِ تخفیف و کدِ پیگیری از جدول‌های خودشان خوانده می‌شوند."""
from discount.models import Redemption
redemption = Redemption.objects.filter(order_id=order.id).select_related('discount_code').first()
paid = next((t for t in order.transactions.all() if t.status == 'paid'), None)
# کدِ تخفیفِ ۱۰۰٪ هم یک Transactionِ «paid» می‌سازد، با refی ساختگی (discount-…)؛
# آن کدِ پیگیریِ بانکی نیست و به کاربر نشان داده نمی‌شود.
ref = paid.gateway_ref_id if paid and paid.gateway != 'discount' else ''
return {
'orderId': str(order.id),
'invoiceNumber': f'TD-{order.id:06d}',
'status': order.status,
'provider': order.provider,
'planTitle': order.plan_title,
'listPrice': order.list_price,
'discountAmount': (order.list_price - order.amount) if order.list_price is not None else None,
'discountCode': redemption.discount_code.code if redemption else '',
'amount': order.amount,
'currency': order.currency,
'createdAt': order.created_at,
'paidAt': paid.paid_at if paid else None,
'refId': ref,
}
class InvoiceListView(APIView):
"""
GET /api/payment/invoices — پرداخت‌ها و فاکتورهای خودِ کاربر، تازه‌ترین اول.
همه‌ی سفارش‌ها برمی‌گردند، نه فقط پرداخت‌شده‌ها، تا کسی که پولش کم شده ولی
اشتراکش فعال نشده سفارشِ «در انتظار» یا «ناموفق» خودش را ببیند و کدش را به
پشتیبانی بدهد. این‌جا از زرین‌پال بازسنجی نمی‌شود (برای ۵۰ سفارش ۵۰ فراخوانی
می‌شد)؛ GET /api/payment/orders/{id} و وضعیتِ اشتراک این کار را می‌کنند.
"""
permission_classes = [IsAuthenticated]
throttle_scope = 'read'
throttle_classes = [ScopedRateThrottle]
@extend_schema(responses=InvoiceListResponseSerializer, tags=['Payment (payment)'])
def get(self, request):
orders = (Order.objects.filter(user_id=request.user.id)
.prefetch_related('transactions').order_by('-created_at', '-id')[:INVOICE_LIMIT])
return Response(InvoiceListResponseSerializer({'invoices': [invoice_payload(o) for o in orders]}).data)