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, HasWebhookSecret from .serializers import ( BazaarVerifyRequestSerializer, BazaarVerifyResponseSerializer, CreateOrderRequestSerializer, CreateOrderResponseSerializer, OrderDetailResponseSerializer, WebhookRequestSerializer, WebhookResponseSerializer, ZarinpalCallbackResponseSerializer, ) from .services import apply_order_payment_result, create_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 PaymentWebhookView(APIView): """ POST /api/payment/webhook — وب‌هوکِ عمومیِ shared-secret (نه مخصوصِ زرین‌پال). جریانِ واقعیِ زرین‌پال از PaymentRedirectView/ZarinpalCallbackView رد می‌شه (زرین‌پال خودش webhookِ سرور-به-سرور نمی‌زنه، فقط مرورگرِ کاربر رو ریدایرکت می‌کنه). این endpoint برای تاییدِ دستی/تستی یا درگاه‌های دیگه‌ای که واقعاً webhook POST می‌زنن نگه داشته شده؛ محافظت‌شده با HasWebhookSecret (هدر X-Webhook-Secret) تا هر کلاینتی نتونه با یک orderId معتبر و status=paid، بدون پرداخت واقعی، اشتراک فعال کنه. همچنین idempotent شده: اگه سفارش از قبل paid باشه، دوباره Transaction/ notify تکراری نمی‌سازه. """ permission_classes = [HasWebhookSecret] # این endpoint فقط با مقایسه‌ی هدرِ ثابتِ X-Webhook-Secret محافظت می‌شه، # پس throttle لازمه تا حدسِ سکرت (یا fuzzِ orderId) محدود بمونه. چون # caller خودِ درگاهه نه یک کاربرِ اپ، از ScopedRateThrottleِ IP-محور # استفاده شده، نه الگویِ GuestAware*Throttle که نیازمندِ request.user است. throttle_scope = 'payment_webhook' throttle_classes = [ScopedRateThrottle] # ⚠️ افزوده شده: مستندسازی هدر X-Webhook-Secret که قبلاً در Swagger UI # قابل تنظیم/تست نبود. @extend_schema( request=WebhookRequestSerializer, responses=WebhookResponseSerializer, parameters=[ OpenApiParameter( 'X-Webhook-Secret', OpenApiTypes.STR, OpenApiParameter.HEADER, required=True, description='کلید مخفیِ وب‌هوک درگاه پرداخت (settings.PAYMENT_WEBHOOK_SECRET).', ), ], tags=['Payment (payment)'], ) def post(self, request): serializer = WebhookRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) v = serializer.validated_data gateway_ref_id = v.get('gatewayRefId', '') with transaction.atomic(): order = get_object_or_404(Order.objects.select_for_update(), pk=v['orderId']) # ⚠️ اصلاح امنیتی/صحت داده (قبلی): هر بار فراخوانی webhook (حتی # تکراری از سمت درگاه) یک Transaction جدید می‌ساخت. حالا این # idempotency + اثرات جانبی (فعال‌سازی اشتراک/notify/statuslog) # داخل apply_order_payment_result مشترکه (هم اینجا هم بازار). apply_order_payment_result( order=order, gateway=v.get('gateway') or 'zarinpal', gateway_ref_id=gateway_ref_id, new_status=v['status'], event='gateway_webhook', ) return Response(WebhookResponseSerializer({'received': True}).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 تازه. """ 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)؛ چیزی برای پرداختِ تازه نیست. 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). برخلافِ PaymentWebhookView (یک POSTِ سرور-به-سرور با سکرتِ مشترک)، اینجا 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) 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)