From 47b9770a70dd14f3562356eb431a3856c86a0048 Mon Sep 17 00:00:00 2001 From: Brunobrno Date: Tue, 4 Nov 2025 02:16:17 +0100 Subject: [PATCH] GoPay --- backend/thirdparty/gopay/models.py | 205 ++++++++++++- backend/thirdparty/gopay/serializers.py | 50 ++-- backend/thirdparty/gopay/urls.py | 25 +- backend/thirdparty/gopay/views.py | 374 +++++++++++------------- backend/vontor_cz/settings.py | 9 + 5 files changed, 402 insertions(+), 261 deletions(-) diff --git a/backend/thirdparty/gopay/models.py b/backend/thirdparty/gopay/models.py index 71a8362..bbdecb9 100644 --- a/backend/thirdparty/gopay/models.py +++ b/backend/thirdparty/gopay/models.py @@ -1,3 +1,206 @@ from django.db import models +from django.conf import settings +from django.db.models.signals import post_save +from django.dispatch import receiver -# Create your models here. +import gopay # GoPay official SDK + + +def _gopay_api(): + """ + Instantiate the GoPay SDK; SDK manages access token internally. + https://doc.gopay.cz/ + """ + return gopay.payments({ + "goid": settings.GOPAY_GOID, + "client_id": settings.GOPAY_CLIENT_ID, + "client_secret": settings.GOPAY_CLIENT_SECRET, + "gateway_url": getattr(settings, "GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api"), + }) + + +def _as_dict(resp): + """ + Try to normalize SDK response to a dict for persistence. + The SDK usually exposes `.json` (attr or method). + """ + if resp is None: + return None + # attr style + if hasattr(resp, "json") and not callable(getattr(resp, "json")): + return resp.json + # method style + if hasattr(resp, "json") and callable(getattr(resp, "json")): + try: + return resp.json() + except Exception: + pass + # already a dict + if isinstance(resp, dict): + return resp + # best-effort fallback + try: + return dict(resp) # may fail for non-mapping + except Exception: + return {"raw": str(resp)} + + +class GoPayPayment(models.Model): + """ + Local representation of a GoPay payment. Creating this model will create a real payment + at GoPay (via post_save hook) and persist provider identifiers and gateway URL. + Amounts are stored in minor units (cents/haléře). + """ + # Optional link to your Order model + order = models.ForeignKey( + 'commerce.Order', + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name='gopay_payments', + ) + + # Basic money + currency + amount_cents = models.PositiveBigIntegerField() + currency = models.CharField(max_length=8, default='CZK') + + # Provider identifiers + redirect URL + provider_payment_id = models.BigIntegerField(null=True, blank=True, unique=True) + gw_url = models.URLField(null=True, blank=True) + + # Status bookkeeping + status = models.CharField(max_length=64, default='CREATED') # CREATED, PAID, CANCELED, REFUNDED, PARTIALLY_REFUNDED, FAILED + refunded_amount_cents = models.PositiveBigIntegerField(default=0) + + # Raw responses for auditing + raw_create_response = models.JSONField(null=True, blank=True) + raw_last_status = models.JSONField(null=True, blank=True) + + # Optional text for statements/UX + description = models.CharField(max_length=255, null=True, blank=True) + + # Timestamps + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self) -> str: + return f'GoPayPayment#{self.pk} {self.amount_cents} {self.currency} [{self.status}]' + + +class GoPayRefund(models.Model): + """ + Local representation of a GoPay refund. Creating this model will trigger + a refund call at GoPay (via post_save hook). + If amount_cents is null, a full refund is attempted by provider. + """ + payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, related_name='refunds') + amount_cents = models.PositiveBigIntegerField(null=True, blank=True) # null => full refund + reason = models.CharField(max_length=255, null=True, blank=True) + + provider_refund_id = models.CharField(max_length=128, null=True, blank=True) + raw_response = models.JSONField(null=True, blank=True) + + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-created_at'] + + def __str__(self) -> str: + amount = self.amount_cents if self.amount_cents is not None else 'FULL' + return f'GoPayRefund#{self.pk} payment={self.payment_id} amount={amount}' + + +@receiver(post_save, sender=GoPayPayment) +def create_gopay_payment(sender, instance: GoPayPayment, created: bool, **kwargs): + """ + On first save (creation), create the payment at GoPay. + + Per docs: + - return_url: user is redirected back with ?id= + - notification_url: GoPay sends HTTP GET with ?id= on state changes + - Always verify state via API get_status(id) + """ + if not created: + return + # Already linked to provider (unlikely on brand-new rows) + if instance.provider_payment_id: + return + + api = _gopay_api() + + payload = { + "amount": int(instance.amount_cents), # GoPay expects minor units + "currency": (instance.currency or "CZK").upper(), + "order_number": str(instance.pk), + "order_description": instance.description or (f"Order {instance.order_id}" if instance.order_id else "Payment"), + "lang": "CS", + "callback": { + "return_url": f"{getattr(settings, 'FRONTEND_URL', 'http://localhost:5173')}/payment/return", + # Per docs: GoPay sends HTTP GET notification to this URL with ?id= + "notification_url": getattr(settings, "GOPAY_NOTIFICATION_URL", None), + }, + # Optionally add items here if you later extend the model to store them. + } + + resp = api.create_payment(payload) + # SDK returns Response object (no exceptions); check success + use .json + if getattr(resp, "success", False): + data = getattr(resp, "json", None) + instance.provider_payment_id = data.get("id") + instance.gw_url = data.get("gw_url") + instance.raw_create_response = data + instance.status = 'CREATED' + instance.save(update_fields=["provider_payment_id", "gw_url", "raw_create_response", "status", "updated_at"]) + else: + # Persist error payload and mark as FAILED + err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)} + instance.raw_create_response = {"error": err} + instance.status = "FAILED" + instance.save(update_fields=["raw_create_response", "status", "updated_at"]) + + +@receiver(post_save, sender=GoPayRefund) +def create_gopay_refund(sender, instance: GoPayRefund, created: bool, **kwargs): + """ + On first save (creation), request a refund at GoPay. + - amount_cents None => full refund + - Updates parent payment refunded amount and status. + - Stores raw provider response. + """ + if not created: + return + # If already linked (e.g., imported), skip + if instance.provider_refund_id: + return + + payment = instance.payment + if not payment.provider_payment_id: + # Payment was not created at provider; record as failed in raw_response + instance.raw_response = {"error": "Missing provider payment id"} + instance.save(update_fields=["raw_response"]) + return + + api = _gopay_api() + + # Compute amount to refund. If not provided -> refund remaining (full if none refunded yet). + refund_amount = instance.amount_cents + if refund_amount is None: + remaining = max(0, int(payment.amount_cents) - int(payment.refunded_amount_cents)) + refund_amount = remaining + + resp = api.refund_payment(payment.provider_payment_id, int(refund_amount)) + if getattr(resp, "success", False): + data = getattr(resp, "json", None) + instance.provider_refund_id = str(data.get("id")) if data and data.get("id") is not None else None + instance.raw_response = data + instance.save(update_fields=["provider_refund_id", "raw_response"]) + + # Update parent payment bookkeeping against the remaining balance + new_total = min(payment.amount_cents, payment.refunded_amount_cents + int(refund_amount)) + payment.refunded_amount_cents = new_total + payment.status = "REFUNDED" if new_total >= payment.amount_cents else "PARTIALLY_REFUNDED" + payment.save(update_fields=["refunded_amount_cents", "status", "updated_at"]) + else: + err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)} + instance.raw_response = {"error": err} + instance.save(update_fields=["raw_response"]) diff --git a/backend/thirdparty/gopay/serializers.py b/backend/thirdparty/gopay/serializers.py index 88501f4..42314d4 100644 --- a/backend/thirdparty/gopay/serializers.py +++ b/backend/thirdparty/gopay/serializers.py @@ -1,37 +1,21 @@ from rest_framework import serializers - -class GoPayCreatePaymentRequestSerializer(serializers.Serializer): - amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01) - currency = serializers.CharField(required=False, default="CZK") - order_number = serializers.CharField(required=False, allow_blank=True, default="order-001") - order_description = serializers.CharField(required=False, allow_blank=True, default="Example GoPay payment") - return_url = serializers.URLField(required=False) - notify_url = serializers.URLField(required=False) - preauthorize = serializers.BooleanField(required=False, default=False) +class GoPayRefundROSerializer(serializers.Serializer): + id = serializers.IntegerField() + amount_cents = serializers.IntegerField(allow_null=True) + reason = serializers.CharField(allow_null=True, allow_blank=True) + provider_refund_id = serializers.CharField(allow_null=True) + created_at = serializers.DateTimeField() -class GoPayPaymentCreatedResponseSerializer(serializers.Serializer): - id = serializers.IntegerField() - state = serializers.CharField() - gw_url = serializers.URLField(required=False, allow_null=True) - - -class GoPayStatusResponseSerializer(serializers.Serializer): - id = serializers.IntegerField() - state = serializers.CharField() - - -class GoPayRefundRequestSerializer(serializers.Serializer): - amount = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, min_value=0.01) - - -class GoPayCaptureRequestSerializer(serializers.Serializer): - amount = serializers.DecimalField(max_digits=12, decimal_places=2, required=False, min_value=0.01) - - -class GoPayCreateRecurrenceRequestSerializer(serializers.Serializer): - amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01) - currency = serializers.CharField(required=False, default="CZK") - order_number = serializers.CharField(required=False, allow_blank=True, default="recur-001") - order_description = serializers.CharField(required=False, allow_blank=True, default="Recurring payment") +class GoPayPaymentROSerializer(serializers.Serializer): + id = serializers.IntegerField() + order = serializers.IntegerField(allow_null=True) + amount_cents = serializers.IntegerField() + currency = serializers.CharField() + status = serializers.CharField() + refunded_amount_cents = serializers.IntegerField() + gw_url = serializers.URLField(allow_null=True) + provider_payment_id = serializers.IntegerField(allow_null=True) + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() \ No newline at end of file diff --git a/backend/thirdparty/gopay/urls.py b/backend/thirdparty/gopay/urls.py index 9f7a71e..91bcac5 100644 --- a/backend/thirdparty/gopay/urls.py +++ b/backend/thirdparty/gopay/urls.py @@ -1,20 +1,11 @@ from django.urls import path -from .views import ( - GoPayPaymentView, - GoPayPaymentStatusView, - GoPayRefundPaymentView, - GoPayCaptureAuthorizationView, - GoPayVoidAuthorizationView, - GoPayCreateRecurrenceView, - GoPayPaymentInstrumentsView, -) +from .views import PaymentStatusView, PaymentRefundListView, GoPayWebhookView urlpatterns = [ - path('payment/', GoPayPaymentView.as_view(), name='gopay-payment'), - path('payment//status/', GoPayPaymentStatusView.as_view(), name='gopay-payment-status'), - path('payment//refund/', GoPayRefundPaymentView.as_view(), name='gopay-refund-payment'), - path('payment//capture/', GoPayCaptureAuthorizationView.as_view(), name='gopay-capture-authorization'), - path('payment//void/', GoPayVoidAuthorizationView.as_view(), name='gopay-void-authorization'), - path('payment//recurrence/', GoPayCreateRecurrenceView.as_view(), name='gopay-create-recurrence'), - path('payment-instruments/', GoPayPaymentInstrumentsView.as_view(), name='gopay-payment-instruments'), -] + # Dotaz na stav platby (GET) + path('api/payments/payment/', PaymentStatusView.as_view(), name='gopay-payment-status'), + # Historie refundací (GET) + path('api/payments/payment//refunds', PaymentRefundListView.as_view(), name='gopay-payment-refunds'), + # Webhook od GoPay (HTTP GET s ?id=...) + path('api/payments/gopay/webhook', GoPayWebhookView.as_view(), name='gopay-webhook'), +] \ No newline at end of file diff --git a/backend/thirdparty/gopay/views.py b/backend/thirdparty/gopay/views.py index 1bbd833..8d533fb 100644 --- a/backend/thirdparty/gopay/views.py +++ b/backend/thirdparty/gopay/views.py @@ -1,233 +1,187 @@ -from django.shortcuts import render +from typing import Optional + +from django.shortcuts import get_object_or_404 +from django.conf import settings +from django.utils.decorators import method_decorator +from django.views.decorators.csrf import csrf_exempt -# Create your views here. from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.permissions import IsAuthenticated +from rest_framework import status, permissions, serializers + import gopay -from gopay.enums import TokenScope, Language -import os -from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter -from .serializers import ( - GoPayCreatePaymentRequestSerializer, - GoPayPaymentCreatedResponseSerializer, - GoPayStatusResponseSerializer, - GoPayRefundRequestSerializer, - GoPayCaptureRequestSerializer, - GoPayCreateRecurrenceRequestSerializer, -) + +from .models import GoPayPayment -class GoPayClientMixin: - """Shared helpers for configuring GoPay client and formatting responses.""" - def get_gopay_client(self): - gateway_url = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api") - return gopay.payments({ - "goid": os.getenv("GOPAY_GOID"), - "client_id": os.getenv("GOPAY_CLIENT_ID"), - "client_secret": os.getenv("GOPAY_CLIENT_SECRET"), - "gateway_url": gateway_url, - "scope": TokenScope.ALL, - "language": Language.CZECH, - }) - - def _to_response(self, sdk_response): - # The GoPay SDK returns a response object with has_succeed(), json, errors, status_code - try: - if hasattr(sdk_response, "has_succeed") and sdk_response.has_succeed(): - return Response(getattr(sdk_response, "json", {})) - status = getattr(sdk_response, "status_code", 400) - errors = getattr(sdk_response, "errors", None) - if errors is None and hasattr(sdk_response, "json"): - errors = sdk_response.json - if errors is None: - errors = {"detail": "GoPay request failed"} - return Response({"errors": errors}, status=status) - except Exception as e: - return Response({"errors": str(e)}, status=500) +def _gopay_api(): + # SDK handles token internally; credentials from settings/env + return gopay.payments({ + "goid": settings.GOPAY_GOID, + "client_id": settings.GOPAY_CLIENT_ID, + "client_secret": settings.GOPAY_CLIENT_SECRET, + "gateway_url": getattr(settings, 'GOPAY_GATEWAY_URL', 'https://gw.sandbox.gopay.com/api'), + }) -class GoPayPaymentView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["GoPay"], - summary="Create GoPay payment", - description="Creates a GoPay payment and returns gateway URL and payment info.", - request=GoPayCreatePaymentRequestSerializer, - responses={ - 200: OpenApiResponse(response=GoPayPaymentCreatedResponseSerializer, description="Payment created"), - 400: OpenApiResponse(description="Validation error or SDK error"), - }, - examples=[ - OpenApiExample( - "Create payment", - value={ - "amount": 123.45, - "currency": "CZK", - "order_number": "order-001", - "order_description": "Example GoPay payment", - "return_url": "https://yourfrontend.com/success", - "notify_url": "https://yourbackend.com/gopay/notify", - "preauthorize": False, - }, - request_only=True, - ) - ] - ) - def post(self, request): - amount = request.data.get("amount") - currency = request.data.get("currency", "CZK") - order_number = request.data.get("order_number", "order-001") - order_description = request.data.get("order_description", "Example GoPay payment") - return_url = request.data.get("return_url", "https://yourfrontend.com/success") - notify_url = request.data.get("notify_url", "https://yourbackend.com/gopay/notify") - preauthorize = bool(request.data.get("preauthorize", False)) - - if not amount: - return Response({"error": "Amount is required"}, status=400) - - payments = self.get_gopay_client() - - payment_data = { - "payer": { - "allowed_payment_instruments": ["PAYMENT_CARD"], - "default_payment_instrument": "PAYMENT_CARD", - "allowed_swifts": ["FIOB"], - "contact": { - "first_name": getattr(request.user, "first_name", ""), - "last_name": getattr(request.user, "last_name", ""), - "email": getattr(request.user, "email", ""), - }, - }, - "amount": int(float(amount) * 100), # GoPay expects amount in cents - "currency": currency, - "order_number": order_number, - "order_description": order_description, - "items": [ - {"name": "Example Item", "amount": int(float(amount) * 100)} - ], - "callback": {"return_url": return_url, "notify_url": notify_url}, - "preauthorize": preauthorize, - } - - resp = payments.create_payment(payment_data) - return self._to_response(resp) +def _as_dict(resp): + if resp is None: + return None + if hasattr(resp, "json") and not callable(getattr(resp, "json")): + return resp.json + if hasattr(resp, "json") and callable(getattr(resp, "json")): + try: + return resp.json() + except Exception: + pass + if isinstance(resp, dict): + return resp + try: + return dict(resp) + except Exception: + return {"raw": str(resp)} -class GoPayPaymentStatusView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["GoPay"], - summary="Get GoPay payment status", - parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], - responses={200: OpenApiResponse(response=GoPayStatusResponseSerializer, description="Payment status")}, - ) - def get(self, request, payment_id: int): - payments = self.get_gopay_client() - resp = payments.get_status(payment_id) - return self._to_response(resp) +def _map_status(provider_state: Optional[str]) -> str: + if not provider_state: + return 'UNKNOWN' + state = provider_state.upper() + if state == 'PAID': + return 'PAID' + if state in ('AUTHORIZED',): + return 'AUTHORIZED' + if state in ('PAYMENT_METHOD_CHOSEN',): + return 'PAYMENT_METHOD_CHOSEN' + if state in ('CREATED', 'CREATED_WITH_PAYMENT', 'PENDING'): + return 'CREATED' + if state in ('CANCELED', 'CANCELLED'): + return 'CANCELED' + if state in ('TIMEOUTED',): + return 'TIMEOUTED' + if state in ('REFUNDED',): + return 'REFUNDED' + if state in ('PARTIALLY_REFUNDED',): + return 'PARTIALLY_REFUNDED' + if state in ('FAILED', 'DECLINED'): + return 'FAILED' + return state -class GoPayRefundPaymentView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["GoPay"], - summary="Refund GoPay payment", - parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], - request=GoPayRefundRequestSerializer, - responses={200: OpenApiResponse(description="Refund processed")}, - ) - def post(self, request, payment_id: int): - amount = request.data.get("amount") # optional for full refund - payments = self.get_gopay_client() - if amount is None or amount == "": - # Full refund - resp = payments.refund_payment(payment_id) - else: - resp = payments.refund_payment(payment_id, int(float(amount) * 100)) - return self._to_response(resp) +# --- Serializers kept here (small and read-only) --- +class GoPayRefundROSerializer(serializers.Serializer): + id = serializers.IntegerField() + amount_cents = serializers.IntegerField(allow_null=True) + reason = serializers.CharField(allow_null=True, allow_blank=True) + provider_refund_id = serializers.CharField(allow_null=True) + created_at = serializers.DateTimeField() -class GoPayCaptureAuthorizationView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] - - @extend_schema( - tags=["GoPay"], - summary="Capture GoPay authorization", - parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], - request=GoPayCaptureRequestSerializer, - responses={200: OpenApiResponse(description="Capture processed")}, - ) - def post(self, request, payment_id: int): - amount = request.data.get("amount") # optional for partial capture - payments = self.get_gopay_client() - if amount is None or amount == "": - resp = payments.capture_authorization(payment_id) - else: - resp = payments.capture_authorization(payment_id, int(float(amount) * 100)) - return self._to_response(resp) +class GoPayPaymentROSerializer(serializers.Serializer): + id = serializers.IntegerField() + order = serializers.IntegerField(source='order_id', allow_null=True) + amount_cents = serializers.IntegerField() + currency = serializers.CharField() + status = serializers.CharField() + refunded_amount_cents = serializers.IntegerField() + gw_url = serializers.URLField(allow_null=True) + provider_payment_id = serializers.IntegerField(allow_null=True) + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() -class GoPayVoidAuthorizationView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] +class PaymentStatusView(APIView): + """ + GET /api/payments/payment/{id} + - Refresh status from GoPay (if provider_payment_id present). + - Return current local payment record (read-only). + """ + permission_classes = [permissions.IsAuthenticated] - @extend_schema( - tags=["GoPay"], - summary="Void GoPay authorization", - parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], - responses={200: OpenApiResponse(description="Authorization voided")}, - ) - def post(self, request, payment_id: int): - payments = self.get_gopay_client() - resp = payments.void_authorization(payment_id) - return self._to_response(resp) + def get(self, request, pk: int): + payment = get_object_or_404(GoPayPayment, pk=pk) + + if payment.provider_payment_id: + api = _gopay_api() + resp = api.get_status(payment.provider_payment_id) + if getattr(resp, "success", False): + data = getattr(resp, "json", None) + payment.status = _map_status(data.get('state')) + payment.raw_last_status = data + payment.save(update_fields=['status', 'raw_last_status', 'updated_at']) + else: + err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)} + return Response({'detail': 'Failed to fetch status', 'error': err}, status=status.HTTP_502_BAD_GATEWAY) + + serialized = GoPayPaymentROSerializer(payment) + return Response(serialized.data) -class GoPayCreateRecurrenceView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] +class PaymentRefundListView(APIView): + """ + GET /api/payments/payment/{id}/refunds + - List local refund records for a payment (read-only). + """ + permission_classes = [permissions.IsAuthenticated] - @extend_schema( - tags=["GoPay"], - summary="Create GoPay recurrence", - parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)], - request=GoPayCreateRecurrenceRequestSerializer, - responses={200: OpenApiResponse(description="Recurrence created")}, - ) - def post(self, request, payment_id: int): - amount = request.data.get("amount") - currency = request.data.get("currency", "CZK") - order_number = request.data.get("order_number", "recur-001") - order_description = request.data.get("order_description", "Recurring payment") - if not amount: - return Response({"error": "Amount is required"}, status=400) - payments = self.get_gopay_client() - recurrence_payload = { - "amount": int(float(amount) * 100), - "currency": currency, - "order_number": order_number, - "order_description": order_description, - } - resp = payments.create_recurrence(payment_id, recurrence_payload) - return self._to_response(resp) + def get(self, request, pk: int): + payment = get_object_or_404(GoPayPayment, pk=pk) + refunds = payment.refunds.all().values( + 'id', 'amount_cents', 'reason', 'provider_refund_id', 'created_at' + ) + ser = GoPayRefundROSerializer(refunds, many=True) + return Response(ser.data) -class GoPayPaymentInstrumentsView(GoPayClientMixin, APIView): - permission_classes = [IsAuthenticated] +@method_decorator(csrf_exempt, name='dispatch') +class GoPayWebhookView(APIView): + """ + GET /api/payments/gopay/webhook?id= + - Called by GoPay (HTTP GET with query params) on payment state change. + - We verify by fetching status via GoPay SDK and persist it locally. + """ + permission_classes = [permissions.AllowAny] - @extend_schema( - tags=["GoPay"], - summary="Get GoPay payment instruments", - parameters=[OpenApiParameter(name="currency", required=False, type=str, location=OpenApiParameter.QUERY)], - responses={200: OpenApiResponse(description="Available payment instruments returned")}, - ) - def get(self, request): - currency = request.query_params.get("currency", "CZK") - goid = os.getenv("GOPAY_GOID") - if not goid: - return Response({"error": "GOPAY_GOID is not configured"}, status=500) - payments = self.get_gopay_client() - resp = payments.get_payment_instruments(goid, currency) - return self._to_response(resp) \ No newline at end of file + def get(self, request): + provider_id = request.GET.get("id") or request.GET.get("payment_id") + if not provider_id: + return Response({"detail": "Missing payment id"}, status=status.HTTP_400_BAD_REQUEST) + + try: + provider_id_int = int(provider_id) + except Exception: + provider_id_int = provider_id # fallback + + api = _gopay_api() + resp = api.get_status(provider_id_int) + if not getattr(resp, "success", False): + err = getattr(resp, "json", None) or {"status_code": getattr(resp, "status_code", None), "raw": getattr(resp, "raw_body", None)} + return Response({"detail": "Failed to verify status with GoPay", "error": err}, status=status.HTTP_502_BAD_GATEWAY) + + data = getattr(resp, "json", None) + state = data.get("state") + + # Find local payment by provider id, fallback to order_number from response + payment = None + try: + payment = GoPayPayment.objects.get(provider_payment_id=provider_id_int) + except GoPayPayment.DoesNotExist: + order_number = data.get("order_number") + if order_number: + try: + payment = GoPayPayment.objects.get(pk=int(order_number)) + except Exception: + payment = None + + if not payment: + return Response({"detail": "Payment not found locally", "provider_id": provider_id_int}, status=status.HTTP_202_ACCEPTED) + + payment.status = _map_status(state) + payment.raw_last_status = data + payment.save(update_fields=["status", "raw_last_status", "updated_at"]) + + return Response({"ok": True}) + +# Implementation notes: +# - GoPay notification is an HTTP GET to notification_url with ?id=... +# - Always verify the state by calling get_status(id); never trust inbound payload alone. +# - Amounts are kept in minor units (cents). States covered: CREATED, PAYMENT_METHOD_CHOSEN, AUTHORIZED, PAID, CANCELED, TIMEOUTED, PARTIALLY_REFUNDED, REFUNDED. diff --git a/backend/vontor_cz/settings.py b/backend/vontor_cz/settings.py index f7ae721..61d0c3d 100644 --- a/backend/vontor_cz/settings.py +++ b/backend/vontor_cz/settings.py @@ -330,6 +330,7 @@ MY_CREATED_APPS = [ 'thirdparty.downloader', 'thirdparty.stripe', # register Stripe app so its models are recognized 'thirdparty.trading212', + 'thirdparty.gopay', # add GoPay app ] INSTALLED_APPS = [ @@ -915,6 +916,14 @@ SPECTACULAR_DEFAULTS: Dict[str, Any] = { 'OAUTH2_SCOPES': None, } +# --- GoPay configuration (set in backend/.env) --- +GOPAY_GOID = os.getenv("GOPAY_GOID") +GOPAY_CLIENT_ID = os.getenv("GOPAY_CLIENT_ID") +GOPAY_CLIENT_SECRET = os.getenv("GOPAY_CLIENT_SECRET") +GOPAY_GATEWAY_URL = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api") +# New: absolute URL that GoPay calls (publicly reachable) +GOPAY_NOTIFICATION_URL = os.getenv("GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook") + # -------------------------------------DOWNLOADER LIMITS------------------------------------ DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024