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, events, zarinpal from .models import Order from .permissions import HasInternalServiceKey from .serializers import ( InvoiceListResponseSerializer, BazaarStartResponseSerializer, BazaarVerifyRequestSerializer, BazaarVerifyResponseSerializer, 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 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 try: result = zarinpal.request_payment(order) except Exception as exc: events.record('gateway_request_failed', order=order, error=type(exc).__name__) raise 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']) events.record('gateway_redirect', order=order, status=order.status, authority_set=bool(order.gateway_authority)) 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 = Order.objects.filter(gateway_authority=authority, provider='gateway').first() if order is None: # کال‌بکِ بی‌سفارش: یا Authorityِ جعلی یا سفارشی که از دیتابیس رفته؛ در دیتابیس ردی ندارد. logger.warning('zarinpal callback: no order for authority=%s status=%s', authority[:64], status_param[:10]) events.record('callback_unknown_authority', provider='gateway', authority=authority[:64], status_param=status_param[:10]) raise Http404 events.record('callback_received', order=order, authority=authority[:64], status_param=status_param[:10]) 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( 'اتصال به بازار' f'

{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) # داره. throttle_scope = 'bazaar_verify' throttle_classes = [ScopedRateThrottle] 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() # فقط اشتراک از بازار فروخته می‌شود (subscription.0017)؛ سفارشِ بی‌اشتراک چیزی برای فعال کردن ندارد. if not order_lookup.subscription_id: 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', ) 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.bazaar_valid_until = result.valid_until order.save(update_fields=['bazaar_purchase_token', 'bazaar_valid_until']) 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 BazaarStartPurchaseView(APIView): """ POST /api/payment/bazaar/orders/{id}/start — آنچه اپِ اندروید برای شروعِ خرید در بازار لازم دارد. پس از ساخت سفارش (`POST /api/subscription/purchase` با `paymentMethod: bazaar`) و کدِ تخفیفِ احتمالی (`POST /api/discount/redeem`) صدا زده می‌شود. اگر کدِ تخفیف مبلغ را کم کرده، `dynamicPriceToken` همان مبلغ را (به ریال) برای بازار امضا می‌کند؛ بی‌تخفیف null است و بازار قیمتِ پیشخان را می‌گیرد (عمداً: توکن روی بازارِ قدیمی‌تر از ۱۳.۳ خطا می‌دهد). بعد از این، کدِ تخفیفِ تازه روی سفارش نمی‌نشیند. """ permission_classes = [IsAuthenticated] throttle_scope = 'bazaar_start' throttle_classes = [ScopedRateThrottle] @extend_schema(request=None, responses=BazaarStartResponseSerializer, tags=['Payment (payment)']) def post(self, request, order_id): with transaction.atomic(): order = get_object_or_404( Order.objects.select_for_update(), pk=order_id, user_id=request.user.id, provider='bazaar') if order.status != 'pending': raise ValidationError({'orderId': 'این سفارش دیگر در انتظارِ پرداخت نیست.'}) if not order.bazaar_product_sku: raise ValidationError({'orderId': 'این سفارش محصولِ بازار ندارد.'}) token = None full_price = order.list_price if order.list_price is not None else order.amount if order.amount < full_price: if not bazaar.dynamic_price_configured(): # redeem بی‌کلید تخفیف را روی سفارشِ بازار نمی‌گذارد؛ رسیدن به این‌جا یعنی کلید بعد از آن # از .env رفته. بی‌توکن بازار قیمتِ کامل را می‌گرفت. logger.error('bazaar start: سفارشِ %s تخفیف دارد ولی BAZAAR_DYNAMIC_PRICE_KEY نیست', order.id) raise bazaar.BazaarAPIError() price_rial = _to_rial(order.amount, order.currency) token = bazaar.dynamic_price_token(sku=order.bazaar_product_sku, price_rial=price_rial) order.bazaar_price_rial = price_rial order.save(update_fields=['bazaar_price_rial']) elif order.bazaar_price_rial is None: # علامتِ «خرید شروع شد» تا کدِ تخفیفِ بعدی نیاید؛ قیمت همان قیمتِ کامل است. order.bazaar_price_rial = _to_rial(order.amount, order.currency) order.save(update_fields=['bazaar_price_rial']) events.record('bazaar_start', order=order, amount=order.amount, discounted=token is not None) data = { 'orderId': str(order.id), 'sku': order.bazaar_product_sku, 'amount': order.amount, 'dynamicPriceToken': token, } return Response(BazaarStartResponseSerializer(data).data) def _to_rial(amount: int, currency: str) -> int: if currency == 'IRT': return amount * 10 if currency == 'IRR': return amount raise ValueError(f'unknown currency {currency!r}') class OrderDetailView(APIView): """GET /api/payment/orders/{id} — وضعیت سفارش""" # سقفِ نرخِ این مسیر تا ۲۰۲۶-۱۰-۰۷ نبود؛ `read` همان نرخِ مسیرهای هم‌خانواده است. throttle_scope = 'read' throttle_classes = [ScopedRateThrottle] 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)