diff --git a/backend/thirdparty/gopay/client.py b/backend/thirdparty/gopay/client.py deleted file mode 100644 index ffba8b9..0000000 --- a/backend/thirdparty/gopay/client.py +++ /dev/null @@ -1,74 +0,0 @@ -from __future__ import annotations - -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured - -try: - # Expecting official SDK providing `payments` with required methods - from gopay import payments as _payments # type: ignore -except Exception: # pragma: no cover - _payments = None - - -def _get_client(): - if _payments is None: - raise ImproperlyConfigured( - "GoPay SDK not installed or not importable. Install and configure the GoPay SDK." - ) - # If your SDK requires explicit initialization with credentials, do it here. - # Example (pseudo): - # return PaymentsClient( - # goid=settings.GOPAY_GOID, - # client_id=settings.GOPAY_CLIENT_ID, - # client_secret=settings.GOPAY_CLIENT_SECRET, - # gateway_url=settings.GOPAY_GATEWAY_URL, - # ) - return _payments - - -def create_payment(payload: dict): - return _get_client().create_payment(payload) - - -def get_status(payment_id: str | int): - return _get_client().get_status(payment_id) - - -def refund_payment(payment_id: str | int, amount: int): - return _get_client().refund_payment(payment_id, amount) - - -def create_recurrence(payment_id: str | int, payload: dict): - return _get_client().create_recurrence(payment_id, payload) - - -def void_recurrence(payment_id: str | int): - return _get_client().void_recurrence(payment_id) - - -def capture_authorization(payment_id: str | int): - return _get_client().capture_authorization(payment_id) - - -def capture_authorization_partial(payment_id: str | int, payload: dict): - return _get_client().capture_authorization_partial(payment_id, payload) - - -def get_card_details(card_id: str | int): - return _get_client().get_card_details(card_id) - - -def delete_card(card_id: str | int): - return _get_client().delete_card(card_id) - - -def get_payment_instruments(goid: str | int, currency: str): - return _get_client().get_payment_instruments(goid, currency) - - -def get_payment_instruments_all(goid: str | int): - return _get_client().get_payment_instruments_all(goid) - - -def get_account_statement(statement_request: dict): - return _get_client().get_account_statement(statement_request) diff --git a/backend/thirdparty/gopay/serializers.py b/backend/thirdparty/gopay/serializers.py index caff414..bef0a77 100644 --- a/backend/thirdparty/gopay/serializers.py +++ b/backend/thirdparty/gopay/serializers.py @@ -1,13 +1,86 @@ from __future__ import annotations +from django.conf import settings from rest_framework import serializers -class PaymentCreateSerializer(serializers.Serializer): - # Entire GoPay payment payload is passed through - payment = serializers.DictField() +class ContactSerializer(serializers.Serializer): + email = serializers.EmailField() + first_name = serializers.CharField(required=False, allow_blank=True) + last_name = serializers.CharField(required=False, allow_blank=True) + phone_number = serializers.CharField(required=False, allow_blank=True) + city = serializers.CharField(required=False, allow_blank=True) + street = serializers.CharField(required=False, allow_blank=True) + postal_code = serializers.CharField(required=False, allow_blank=True) + country_code = serializers.CharField(required=False, allow_blank=True) - # Optional: help store local metadata + +class PayerSerializer(serializers.Serializer): + contact = ContactSerializer() + + # Optional controls – keep everything optional, no card numbers allowed (SDK handles UI) + allowed_payment_instruments = serializers.ListField( + child=serializers.CharField(), required=False + ) + default_payment_instrument = serializers.CharField(required=False) + allowed_swifts = serializers.ListField(child=serializers.CharField(), required=False) + default_swift = serializers.CharField(required=False) + + +class CallbackSerializer(serializers.Serializer): + return_url = serializers.URLField() + notification_url = serializers.URLField(required=False) + + def validate(self, attrs): + # Default notification_url from settings if not provided + if not attrs.get("notification_url"): + attrs["notification_url"] = getattr( + settings, "GOPAY_NOTIFICATION_URL", "http://localhost:8000/api/payments/gopay/webhook" + ) + return attrs + + +class ItemSerializer(serializers.Serializer): + name = serializers.CharField() + amount = serializers.IntegerField(min_value=1, help_text="Minor units") + type = serializers.CharField(required=False, default="ITEM") + count = serializers.IntegerField(required=False, min_value=1, default=1) + + +class AdditionalParamSerializer(serializers.Serializer): + name = serializers.CharField() + value = serializers.CharField() + + +class PaymentBodySerializer(serializers.Serializer): + # Minimal required + amount = serializers.IntegerField(min_value=1, help_text="Minor units (e.g. 100 CZK = 10000)") + currency = serializers.CharField() + order_number = serializers.CharField() + + # Optional + order_description = serializers.CharField(required=False, allow_blank=True) + payer = PayerSerializer() + callback = CallbackSerializer() + items = ItemSerializer(many=True, required=False) + additional_params = AdditionalParamSerializer(many=True, required=False) + lang = serializers.CharField(required=False) + preauthorize = serializers.BooleanField(required=False) + + def validate(self, attrs): + # Explicitly reject any card details if someone tries to sneak them in + forbidden_keys = {"card", "card_data", "pan", "cvv", "expiry", "card_number"} + for key in list(attrs.keys()): + if key in forbidden_keys: + raise serializers.ValidationError({key: "Card details must not be sent to the server."}) + return attrs + + +class PaymentCreateSerializer(serializers.Serializer): + # Frontend posts { payment: { ... } } + payment = PaymentBodySerializer() + + # Optional: local metadata only (not sent to GoPay) user_id = serializers.IntegerField(required=False) diff --git a/backend/thirdparty/gopay/views.py b/backend/thirdparty/gopay/views.py index b731cc8..948255a 100644 --- a/backend/thirdparty/gopay/views.py +++ b/backend/thirdparty/gopay/views.py @@ -13,12 +13,44 @@ from drf_spectacular.utils import ( OpenApiExample, OpenApiTypes, ) +from django.conf import settings +from django.core.exceptions import ImproperlyConfigured + +import gopay +from gopay.enums import TokenScope, Language + + +def payments_client(): + """Create a fresh GoPay payments client from settings. + + Keeps it simple and explicit; no shared global state. + """ + required = [ + ("GOPAY_GOID", settings.GOPAY_GOID), + ("GOPAY_CLIENT_ID", settings.GOPAY_CLIENT_ID), + ("GOPAY_CLIENT_SECRET", settings.GOPAY_CLIENT_SECRET), + ("GOPAY_GATEWAY_URL", settings.GOPAY_GATEWAY_URL), + ] + missing = [name for name, val in required if not val] + if missing: + raise ImproperlyConfigured(f"Missing GoPay settings: {', '.join(missing)}") + + cfg = { + "goid": settings.GOPAY_GOID, + "client_id": settings.GOPAY_CLIENT_ID, + "client_secret": settings.GOPAY_CLIENT_SECRET, + "gateway_url": settings.GOPAY_GATEWAY_URL, + # reasonable defaults; can be changed later via settings if desired + "scope": TokenScope.ALL, + "language": Language.CZECH, + } + return gopay.payments(cfg) + -from . import client as goclient from .models import GoPayPayment, GoPayRefund from .serializers import ( - PaymentCreateSerializer, - RefundSerializer, + PaymentCreateSerializer, + RefundSerializer, ) @@ -35,43 +67,38 @@ class CreatePaymentView(APIView): @extend_schema( tags=["GoPay"], operation_id="gopay_create_payment", - summary="Vytvořit platbu", - description="Vytvoří platbu v GoPay. Objekt 'payment' je předán SDK beze změn.", + summary="Vytvořit platbu (minimální vstup)", + description=( + "Vytvoří platbu v GoPay s minimálními povinnými poli. Citlivé údaje o kartě se neposílají," + " platbu obslouží GoPay stránka (gw_url). Pokud není zadán notification_url, použije se" + " hodnota ze settings.GOPAY_NOTIFICATION_URL." + ), request=PaymentCreateSerializer, responses={ 201: OpenApiResponse( response=OpenApiTypes.OBJECT, - description="Platba vytvořena (odpověď z GoPay)." + description="Platba vytvořena. Vrací gw_url pro přesměrování na platební bránu." ), 502: OpenApiResponse(description="Chyba upstream GoPay"), }, examples=[ OpenApiExample( - "Základní platba kartou", + "Minimální platba", value={ "payment": { - "amount": 19900, + "amount": 10000, "currency": "CZK", - "order_number": "ORDER-1001", - "items": [{"name": "T-Shirt", "amount": 19900, "count": 1}], - "callback": {"return_url": "https://shop.example.com/return"}, + "order_number": "123456", + "payer": {"contact": {"email": "john.doe@example.com"}}, + "callback": { + "return_url": "https://example.com/your-return-url", + "notification_url": "https://example.com/your-notify-url" + } } }, ) ], - extensions={ - "x-codeSamples": [ - { - "lang": "typescript", - "label": "Client.auth (frontend)", - "source": 'await Client.auth.post("/api/payments/gopay/create/", { payment });', - }, - { - "lang": "curl", - "source": 'curl -X POST http://localhost:8000/api/payments/gopay/create/ -H "Content-Type: application/json" -d \'{"payment": {"amount":19900,"currency":"CZK"}}\'', - }, - ] - }, + ) def post(self, request): ser = PaymentCreateSerializer(data=request.data) @@ -79,29 +106,39 @@ class CreatePaymentView(APIView): payload = ser.validated_data["payment"] try: - res = goclient.create_payment(payload) # Expecting dict-like response + res = payments_client().create_payment(payload) # Expecting dict-like/dict response except Exception as e: return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY) # Map fields defensively - gopay_id = str((res.get("id") if isinstance(res, dict) else getattr(res, "id", "")) or "") - status_text = (res.get("state") if isinstance(res, dict) else getattr(res, "state", "")) or "" - amount = int((res.get("amount") if isinstance(res, dict) else getattr(res, "amount", 0)) or payload.get("amount") or 0) - currency = (res.get("currency") if isinstance(res, dict) else getattr(res, "currency", "")) or payload.get("currency", "") + as_dict = res if isinstance(res, dict) else getattr(res, "__dict__", {}) or {} + gopay_id = str(as_dict.get("id", "")) + status_text = str(as_dict.get("state", "")) + amount = int(as_dict.get("amount", payload.get("amount", 0)) or 0) + currency = as_dict.get("currency", payload.get("currency", "")) + gw_url = as_dict.get("gw_url") or as_dict.get("gwUrl") or as_dict.get("gw-url") payment = GoPayPayment.objects.create( user=request.user if request.user and request.user.is_authenticated else None, gopay_id=gopay_id or payload.get("id", ""), order_number=str(payload.get("order_number", "")), amount=amount, - currency=currency, + currency=currency or "", status=status_text, preauthorized=bool(payload.get("preauthorize", False)), request_payload=payload, - response_payload=res if isinstance(res, dict) else {"raw": str(res)}, + response_payload=as_dict or {"raw": str(res)}, ) - return Response({"payment": payment.response_payload}, status=status.HTTP_201_CREATED) + return Response( + { + "payment_id": payment.gopay_id, + "state": payment.status, + "gw_url": gw_url, + "raw": payment.response_payload, + }, + status=status.HTTP_201_CREATED, + ) class PaymentStatusView(APIView): @@ -127,22 +164,37 @@ class PaymentStatusView(APIView): ) def get(self, request, payment_id: str | int): try: - res = goclient.get_status(payment_id) + res = payments_client().get_status(payment_id) except Exception as e: return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY) + # Normalize GoPay SDK response into a dict body + body = None + if hasattr(res, "success") and hasattr(res, "json"): + # Official SDK-style: check success and extract JSON body + ok = bool(getattr(res, "success", False)) + if not ok: + return Response({"detail": "GoPay upstream error", "success": False}, status=status.HTTP_502_BAD_GATEWAY) + json_attr = getattr(res, "json") + body = json_attr() if callable(json_attr) else json_attr + elif isinstance(res, dict): + body = res + else: + body = getattr(res, "__dict__", {}) or {"raw": str(res)} + + state_val = body.get("state") if isinstance(body, dict) else None + # Update local status if we have it try: local = GoPayPayment.objects.get(gopay_id=str(payment_id)) - status_text = (res.get("state") if isinstance(res, dict) else getattr(res, "state", "")) or "" - if status_text: - local.status = status_text - local.response_payload = res if isinstance(res, dict) else {"raw": str(res)} + if state_val: + local.status = state_val + local.response_payload = body if isinstance(body, dict) else {"raw": str(body)} local.save(update_fields=["status", "response_payload", "updated_at"]) except GoPayPayment.DoesNotExist: pass - return Response(res, status=status.HTTP_200_OK) + return Response(body, status=status.HTTP_200_OK) class RefundPaymentView(APIView): @@ -173,11 +225,12 @@ class RefundPaymentView(APIView): amount = ser.validated_data["amount"] try: - res = goclient.refund_payment(payment_id, amount) + res = payments_client().refund_payment(payment_id, amount) except Exception as e: return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY) payment = GoPayPayment.objects.filter(gopay_id=str(payment_id)).first() + ref = GoPayRefund.objects.create( payment=payment, gopay_refund_id=str((res.get("id") if isinstance(res, dict) else getattr(res, "id", "")) or ""),