This commit is contained in:
2025-11-06 01:40:02 +01:00
3 changed files with 169 additions and 117 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -13,8 +13,40 @@ 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,
@@ -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 ""),