Files
MathApp/payment/views.py
Zareian fedd10ff22 Money review: Bazaar rejection no longer burns the purchase token, read-only order admin, pack grant from order snapshot
- Bazaar verify: a rejected token is not stored on the order or the failed transaction, so the
  same purchase can still pay the right order; a refunded order refuses a new token (409).
- OrderAdmin: every field read-only, no add/delete; Transaction admin read-only.
- grant_pack uses the order's snapshot when the plan was deleted, logs ERROR when nothing is granted.
- Pay link: authority is stored only if the order amount did not change during PaymentRequest.
- PlanAdmin accepts only IRT (the app always renders toman).
- reconcile_pending_order logs unexpected exceptions.

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

486 lines
26 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.
import logging
from urllib.parse import urlsplit
from django.conf import settings
from django.db import transaction
from django.http import Http404, 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 import status
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, pay_link_is_valid, reconcile_pending_order
logger = logging.getLogger(__name__)
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']).isascii() and 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 نمی‌فرستد؛ به‌جایش پارامترِ `t`، امضای
کوتاه‌عمری که فقط در paymentUrlِ صاحبِ سفارش می‌آید (payment/services.py،
pay_link_token)، سنجیده می‌شود. بی‌امضا یا امضای سفارشِ دیگر همان ۴۰۴ِ سفارشِ
ناموجود را می‌گیرد تا وجودِ شناسه هم لو نرود.
"""
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(
parameters=[OpenApiParameter(
't', OpenApiTypes.STR, required=True,
description='امضای همراهِ paymentUrl؛ کلاینت خودش نمی‌سازد، همان آدرسِ کامل را باز می‌کند.',
)],
responses={302: OpenApiTypes.NONE},
description='به درگاهِ زرین‌پال ریدایرکت می‌کند؛ بدنه‌ی JSON ندارد.',
tags=['Payment (payment)'],
)
def get(self, request, order_id):
if not pay_link_is_valid(order_id, request.query_params.get('t', '')):
raise Http404
order = get_object_or_404(Order, pk=order_id, provider='gateway')
# authority فقط وقتی ذخیره می‌شود که هنوز خالی است (درخواستِ هم‌زمانِ دیگری زودتر نساخته باشد)
# و مبلغِ سفارش همانی است که به زرین‌پال گفته شد. کدِ تخفیفی که وسطِ PaymentRequest ثبت شود
# مبلغ را کم می‌کرد و authority با مبلغِ قبلی می‌نشست: کاربر مبلغِ کامل را می‌پرداخت و verify با
# مبلغِ تازه رد می‌شد (بازتولید شد). در آن حالت یک بار دیگر با مبلغِ تازه درخواست می‌شود.
for _attempt in range(2):
if order.status != 'pending' or order.gateway_authority:
break
requested_amount = order.amount
result = zarinpal.request_payment(order)
Order.objects.filter(pk=order.pk, gateway_authority='', amount=requested_amount).update(
gateway_authority=result.authority,
)
order.refresh_from_db(fields=['gateway_authority', 'amount', 'status'])
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:
logger.error('pay link: amount of order %s kept changing during PaymentRequest', order.id)
return Response({'detail': 'مبلغِ سفارش در حینِ رفتن به درگاه عوض شد؛ دوباره «پرداخت» را بزن.'},
status=status.HTTP_409_CONFLICT)
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', '')
# بی‌Authority، جستجو هر سفارشی را می‌گرفت که صفحه‌ی پرداختش هنوز باز نشده (authority خالی):
# یک NOKِ ناشناس آن را «ناموفق» می‌کرد و با دو سفارش ۵۰۰ می‌داد.
if not authority:
raise ValidationError({'Authority': 'لازم است.'})
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': 'این سفارش محصولِ بازار ندارد.'})
if self._second_purchase_on_paid_order(order_lookup, v['purchaseToken']):
return self._second_purchase_response()
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',
)
if self._second_purchase_on_paid_order(order, v['purchaseToken']):
return self._second_purchase_response()
new_status = 'paid' if result.is_valid else 'failed'
# توکن فقط با پاسخِ «معتبر» روی سفارش و تراکنش می‌نشیند. قیدِ یکتای (gateway, ref) همه‌ی
# وضعیت‌ها را می‌گیرد؛ وقتی ردِ بازار (not_found، یا توکن با سفارشِ اشتباه) هم توکن را ثبت
# می‌کرد، همان خرید دیگر روی هیچ سفارشی، حتی همین، «paid» نمی‌شد (بازتولید شد).
if result.is_valid:
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'] if result.is_valid else '',
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)
@staticmethod
def _second_purchase_on_paid_order(order, purchase_token: str) -> bool:
"""
خریدِ دیگری برای سفارشی که با یک خریدِ دیگر پرداخت شده. پیش‌تر پاسخ «paid» بود، چیزی داده
نمی‌شد و توکنِ دوم هم برای هر سفارشِ بعدی سوخته بود؛ کاربر دو بار پول داده بود.
"""
# «refunded» هم: سفارشِ بازپرداخت‌شده با توکنِ تازه دوباره «paid» می‌شد ولی اعتبارش (که یک بار
# برای همین سفارش داده شده بود) دیگر داده نمی‌شد؛ کاربر پولِ خریدِ دوم را بی‌هیچ چیزی می‌داد.
return (order.status in ('paid', 'refunded') and bool(order.bazaar_purchase_token)
and order.bazaar_purchase_token != purchase_token)
@staticmethod
def _second_purchase_response():
return Response({'detail': 'این سفارش با خریدِ دیگری پرداخت شده است؛ برای این خرید سفارشِ تازه بساز.'},
status=status.HTTP_409_CONFLICT)
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)