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( '
{escape(message)}
', 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)