gopay
This commit is contained in:
328
backend/thirdparty/gopay/views.py
vendored
328
backend/thirdparty/gopay/views.py
vendored
@@ -1,187 +1,189 @@
|
||||
from typing import Optional
|
||||
from __future__ import annotations
|
||||
# Tato aplikace poskytuje samostatné HTTP API pro GoPay, připravené k připojení do e‑shopu.
|
||||
# Pohledy volají GoPay SDK a ukládají minimální data o životním cyklu (platby/refundy).
|
||||
# Koncové body jsou dokumentovány pro Swagger/Redoc pomocí drf-spectacular.
|
||||
|
||||
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
|
||||
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework import permissions, status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status, permissions, serializers
|
||||
from rest_framework.views import APIView
|
||||
from drf_spectacular.utils import (
|
||||
extend_schema,
|
||||
OpenApiParameter,
|
||||
OpenApiResponse,
|
||||
OpenApiExample,
|
||||
OpenApiTypes,
|
||||
)
|
||||
|
||||
import gopay
|
||||
|
||||
from .models import GoPayPayment
|
||||
from . import client as goclient
|
||||
from .models import GoPayPayment, GoPayRefund
|
||||
from .serializers import (
|
||||
PaymentCreateSerializer,
|
||||
RefundSerializer,
|
||||
)
|
||||
|
||||
|
||||
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 CreatePaymentView(APIView):
|
||||
"""Vytvoří novou platbu v GoPay a uloží odpověď lokálně.
|
||||
|
||||
Typický e‑shop flow:
|
||||
- Frontend zavolá tento endpoint s GoPay payloadem.
|
||||
- Backend předá payload GoPay SDK a vrátí upstream odpověď.
|
||||
- Vznikne lokální záznam GoPayPayment pro pozdější sledování stavu.
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
@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.",
|
||||
request=PaymentCreateSerializer,
|
||||
responses={
|
||||
201: OpenApiResponse(
|
||||
response=OpenApiTypes.OBJECT,
|
||||
description="Platba vytvořena (odpověď z GoPay)."
|
||||
),
|
||||
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
||||
},
|
||||
examples=[
|
||||
OpenApiExample(
|
||||
"Základní platba kartou",
|
||||
value={
|
||||
"payment": {
|
||||
"amount": 19900,
|
||||
"currency": "CZK",
|
||||
"order_number": "ORDER-1001",
|
||||
"items": [{"name": "T-Shirt", "amount": 19900, "count": 1}],
|
||||
"callback": {"return_url": "https://shop.example.com/return"},
|
||||
}
|
||||
},
|
||||
)
|
||||
],
|
||||
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)
|
||||
ser.is_valid(raise_exception=True)
|
||||
payload = ser.validated_data["payment"]
|
||||
|
||||
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)}
|
||||
res = goclient.create_payment(payload) # Expecting dict-like 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", "")
|
||||
|
||||
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
|
||||
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,
|
||||
status=status_text,
|
||||
preauthorized=bool(payload.get("preauthorize", False)),
|
||||
request_payload=payload,
|
||||
response_payload=res if isinstance(res, dict) else {"raw": str(res)},
|
||||
)
|
||||
|
||||
|
||||
# --- 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 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()
|
||||
return Response({"payment": payment.response_payload}, status=status.HTTP_201_CREATED)
|
||||
|
||||
|
||||
class PaymentStatusView(APIView):
|
||||
"""
|
||||
GET /api/payments/payment/{id}
|
||||
- Refresh status from GoPay (if provider_payment_id present).
|
||||
- Return current local payment record (read-only).
|
||||
"""
|
||||
"""Načte aktuální stav platby GoPay a případně synchronizuje lokální záznam."""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
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 PaymentRefundListView(APIView):
|
||||
"""
|
||||
GET /api/payments/payment/{id}/refunds
|
||||
- List local refund records for a payment (read-only).
|
||||
"""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@method_decorator(csrf_exempt, name='dispatch')
|
||||
class GoPayWebhookView(APIView):
|
||||
"""
|
||||
GET /api/payments/gopay/webhook?id=<provider_payment_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]
|
||||
|
||||
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)
|
||||
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
operation_id="gopay_get_status",
|
||||
summary="Získat stav platby",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="payment_id",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
description="ID platby GoPay",
|
||||
)
|
||||
],
|
||||
responses={
|
||||
200: OpenApiResponse(OpenApiTypes.OBJECT, description="Aktuální stav platby"),
|
||||
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
||||
},
|
||||
)
|
||||
def get(self, request, payment_id: str | int):
|
||||
try:
|
||||
provider_id_int = int(provider_id)
|
||||
except Exception:
|
||||
provider_id_int = provider_id # fallback
|
||||
res = goclient.get_status(payment_id)
|
||||
except Exception as e:
|
||||
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||
|
||||
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
|
||||
# Update local status if we have it
|
||||
try:
|
||||
payment = GoPayPayment.objects.get(provider_payment_id=provider_id_int)
|
||||
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)}
|
||||
local.save(update_fields=["status", "response_payload", "updated_at"])
|
||||
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
|
||||
pass
|
||||
|
||||
if not payment:
|
||||
return Response({"detail": "Payment not found locally", "provider_id": provider_id_int}, status=status.HTTP_202_ACCEPTED)
|
||||
return Response(res, status=status.HTTP_200_OK)
|
||||
|
||||
payment.status = _map_status(state)
|
||||
payment.raw_last_status = data
|
||||
payment.save(update_fields=["status", "raw_last_status", "updated_at"])
|
||||
|
||||
return Response({"ok": True})
|
||||
class RefundPaymentView(APIView):
|
||||
"""Provede refundaci platby v GoPay a uloží záznam refundace."""
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
# 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.
|
||||
@extend_schema(
|
||||
tags=["GoPay"],
|
||||
operation_id="gopay_refund_payment",
|
||||
summary="Refundovat platbu",
|
||||
parameters=[
|
||||
OpenApiParameter(
|
||||
name="payment_id",
|
||||
type=OpenApiTypes.STR,
|
||||
location=OpenApiParameter.PATH,
|
||||
description="ID platby GoPay k refundaci",
|
||||
)
|
||||
],
|
||||
request=RefundSerializer,
|
||||
responses={
|
||||
200: OpenApiResponse(OpenApiTypes.OBJECT, description="Výsledek refundace"),
|
||||
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
||||
},
|
||||
)
|
||||
def post(self, request, payment_id: str | int):
|
||||
ser = RefundSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
amount = ser.validated_data["amount"]
|
||||
|
||||
try:
|
||||
res = goclient.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 ""),
|
||||
amount=amount,
|
||||
status=(res.get("state") if isinstance(res, dict) else getattr(res, "state", "")) or "",
|
||||
payload=res if isinstance(res, dict) else {"raw": str(res)},
|
||||
)
|
||||
|
||||
return Response({"refund": ref.payload}, status=status.HTTP_200_OK)
|
||||
|
||||
Reference in New Issue
Block a user