diff --git a/backend/env b/backend/env deleted file mode 100644 index 28950cb..0000000 --- a/backend/env +++ /dev/null @@ -1,59 +0,0 @@ -# ------------------ NGINX ------------------ -FRONTEND_URL=http://192.168.67.98 -#FRONTEND_URL=http://localhost:5173 - - -# ------------------ CORE ------------------ -DEBUG=True -SSL=False -DJANGO_SECRET_KEY=CHANGE_ME_SECURE_RANDOM_KEY - - -# ------------------ DATABASE (Postgres in Docker) ------------------ -USE_DOCKER_DB=True -DATABASE_ENGINE=django.db.backends.postgresql -DATABASE_HOST=db -DATABASE_PORT=5432 - -POSTGRES_DB=djangoDB -POSTGRES_USER=dockerDBuser -POSTGRES_PASSWORD=AWSJeMocDrahaZalezitost - -# Legacy/unused (was: USE_PRODUCTION_DB) removed - -# ------------------ MEDIA / STATIC ------------------ -#MEDIA_URL=http://192.168.67.98/media/ - -# ------------------ REDIS / CACHING / CHANNELS ------------------ -# Was REDIS=... (not used). Docker expects REDIS_PASSWORD. -REDIS_PASSWORD=passwd - -# ------------------ CELERY ------------------ -CELERY_BROKER_URL=redis://redis:6379/0 -CELERY_RESULT_BACKEND=redis://redis:6379/0 -CELERY_ACCEPT_CONTENT=json -CELERY_TASK_SERIALIZER=json -CELERY_TIMEZONE=Europe/Prague -CELERY_BEAT_SCHEDULER=django_celery_beat.schedulers:DatabaseScheduler - -# ------------------ EMAIL (dev/prod logic in settings) ------------------ -EMAIL_HOST_DEV=kerio4.vitkovice.cz -EMAIL_PORT_DEV=465 -EMAIL_USER_DEV=Test.django@vitkovice.cz -EMAIL_USER_PASSWORD_DEV=PRneAP0819b -# DEFAULT_FROM_EMAIL_DEV unused in settings; kept for reference -DEFAULT_FROM_EMAIL_DEV=Test.django@vitkovice.cz - -# ------------------ AWS (disabled unless USE_AWS=True) ------------------ -USE_AWS=False -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_STORAGE_BUCKET_NAME= -AWS_S3_REGION_NAME=eu-central-1 - -# ------------------ JWT / TOKENS (lifetimes defined in code) ------------------ -# (No env vars needed; kept placeholder section) - -# ------------------ MISC ------------------ -# FRONTEND_URL_DEV not used; rely on FRONTEND_URL -# Add any extra custom vars below diff --git a/backend/thirdparty/gopay/admin.py b/backend/thirdparty/gopay/admin.py index 8c38f3f..d30964c 100644 --- a/backend/thirdparty/gopay/admin.py +++ b/backend/thirdparty/gopay/admin.py @@ -1,3 +1,20 @@ from django.contrib import admin +from .models import GoPayPayment, GoPayRefund, GoPaySubscription -# Register your models here. +@admin.register(GoPayPayment) +class GoPayPaymentAdmin(admin.ModelAdmin): + list_display = ("gopay_id", "status", "amount", "currency", "user", "created_at") + search_fields = ("gopay_id", "order_number", "status") + list_filter = ("status", "currency", "preauthorized") + +@admin.register(GoPayRefund) +class GoPayRefundAdmin(admin.ModelAdmin): + list_display = ("payment", "gopay_refund_id", "amount", "status", "created_at") + search_fields = ("gopay_refund_id", "payment__gopay_id") + list_filter = ("status",) + +@admin.register(GoPaySubscription) +class GoPaySubscriptionAdmin(admin.ModelAdmin): + list_display = ("parent_payment", "recurrence_id", "status", "canceled", "created_at") + search_fields = ("recurrence_id", "parent_payment__gopay_id") + list_filter = ("status", "canceled") diff --git a/backend/thirdparty/gopay/client.py b/backend/thirdparty/gopay/client.py new file mode 100644 index 0000000..ffba8b9 --- /dev/null +++ b/backend/thirdparty/gopay/client.py @@ -0,0 +1,74 @@ +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/models.py b/backend/thirdparty/gopay/models.py index bbdecb9..1382d1f 100644 --- a/backend/thirdparty/gopay/models.py +++ b/backend/thirdparty/gopay/models.py @@ -1,206 +1,60 @@ -from django.db import models +from __future__ import annotations + from django.conf import settings -from django.db.models.signals import post_save -from django.dispatch import receiver - -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)} +from django.db import models +from django.utils import timezone 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', + # Optional user association + user = models.ForeignKey( + settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="gopay_payments" ) + # External identifiers and core attributes + gopay_id = models.CharField(max_length=64, unique=True, db_index=True) + order_number = models.CharField(max_length=128, blank=True, default="") + amount = models.BigIntegerField(help_text="Amount in minor units (e.g., CZK in haléř).") + currency = models.CharField(max_length=10) + status = models.CharField(max_length=64, db_index=True, default="") + preauthorized = models.BooleanField(default=False) + captured_amount = models.BigIntegerField(default=0) - # Basic money + currency - amount_cents = models.PositiveBigIntegerField() - currency = models.CharField(max_length=8, default='CZK') + # Raw payloads for traceability + request_payload = models.JSONField(default=dict, blank=True) + response_payload = models.JSONField(default=dict, blank=True) - # 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) + created_at = models.DateTimeField(default=timezone.now, db_index=True) updated_at = models.DateTimeField(auto_now=True) def __str__(self) -> str: - return f'GoPayPayment#{self.pk} {self.amount_cents} {self.currency} [{self.status}]' + return f"GoPayPayment(id={self.gopay_id}, status={self.status}, amount={self.amount} {self.currency})" 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) + payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, null=True, blank=True, related_name="refunds") + gopay_refund_id = models.CharField(max_length=64, blank=True, default="") + amount = models.BigIntegerField(help_text="Amount in minor units.") + status = models.CharField(max_length=64, blank=True, default="") + payload = models.JSONField(default=dict, 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'] + created_at = models.DateTimeField(default=timezone.now, db_index=True) 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}' + return f"GoPayRefund(payment={self.payment_id}, amount={self.amount}, status={self.status})" -@receiver(post_save, sender=GoPayPayment) -def create_gopay_payment(sender, instance: GoPayPayment, created: bool, **kwargs): - """ - On first save (creation), create the payment at GoPay. +class GoPaySubscription(models.Model): + parent_payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, related_name="subscriptions") + recurrence_id = models.CharField(max_length=64, blank=True, default="") + status = models.CharField(max_length=64, blank=True, default="") + interval = models.CharField(max_length=64, blank=True, default="") + next_payment_on = models.DateTimeField(null=True, blank=True) + payload = models.JSONField(default=dict, blank=True) - 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 + canceled = models.BooleanField(default=False) + canceled_at = models.DateTimeField(null=True, blank=True) - api = _gopay_api() + created_at = models.DateTimeField(default=timezone.now, db_index=True) - 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"]) + def __str__(self) -> str: + return f"GoPaySubscription(parent={self.parent_payment_id}, status={self.status})" diff --git a/backend/thirdparty/gopay/serializers.py b/backend/thirdparty/gopay/serializers.py index 42314d4..caff414 100644 --- a/backend/thirdparty/gopay/serializers.py +++ b/backend/thirdparty/gopay/serializers.py @@ -1,21 +1,15 @@ +from __future__ import annotations + from rest_framework import serializers -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 PaymentCreateSerializer(serializers.Serializer): + # Entire GoPay payment payload is passed through + payment = serializers.DictField() + + # Optional: help store local metadata + user_id = serializers.IntegerField(required=False) -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 +class RefundSerializer(serializers.Serializer): + amount = serializers.IntegerField(min_value=1, help_text="Minor units") diff --git a/backend/thirdparty/gopay/urls.py b/backend/thirdparty/gopay/urls.py index 91bcac5..71faca8 100644 --- a/backend/thirdparty/gopay/urls.py +++ b/backend/thirdparty/gopay/urls.py @@ -1,11 +1,12 @@ from django.urls import path -from .views import PaymentStatusView, PaymentRefundListView, GoPayWebhookView + +from . import views + +app_name = "gopay" urlpatterns = [ - # 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 + # Payments + path("create/", views.CreatePaymentView.as_view(), name="create"), + path("/status/", views.PaymentStatusView.as_view(), name="status"), + path("/refund/", views.RefundPaymentView.as_view(), name="refund"), +] diff --git a/backend/thirdparty/gopay/views.py b/backend/thirdparty/gopay/views.py index 8d533fb..b731cc8 100644 --- a/backend/thirdparty/gopay/views.py +++ b/backend/thirdparty/gopay/views.py @@ -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= - - 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) diff --git a/backend/vontor_cz/urls.py b/backend/vontor_cz/urls.py index 04bbb31..96da0a4 100644 --- a/backend/vontor_cz/urls.py +++ b/backend/vontor_cz/urls.py @@ -38,5 +38,5 @@ urlpatterns = [ path('api/stripe/', include('thirdparty.stripe.urls')), path('api/trading212/', include('thirdparty.trading212.urls')), path('api/downloader/', include('thirdparty.downloader.urls')), - + path("api/payments/gopay/", include("thirdparty.gopay.urls", namespace="gopay")), ]