Compare commits
2 Commits
4791bbc92c
...
a324a9cf49
| Author | SHA1 | Date | |
|---|---|---|---|
| a324a9cf49 | |||
| 47b9770a70 |
205
backend/thirdparty/gopay/models.py
vendored
205
backend/thirdparty/gopay/models.py
vendored
@@ -1,3 +1,206 @@
|
|||||||
from django.db import models
|
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=<payment_id>
|
||||||
|
- notification_url: GoPay sends HTTP GET with ?id=<payment_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=<payment_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"])
|
||||||
|
|||||||
50
backend/thirdparty/gopay/serializers.py
vendored
50
backend/thirdparty/gopay/serializers.py
vendored
@@ -1,37 +1,21 @@
|
|||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
class GoPayRefundROSerializer(serializers.Serializer):
|
||||||
class GoPayCreatePaymentRequestSerializer(serializers.Serializer):
|
id = serializers.IntegerField()
|
||||||
amount = serializers.DecimalField(max_digits=12, decimal_places=2, min_value=0.01)
|
amount_cents = serializers.IntegerField(allow_null=True)
|
||||||
currency = serializers.CharField(required=False, default="CZK")
|
reason = serializers.CharField(allow_null=True, allow_blank=True)
|
||||||
order_number = serializers.CharField(required=False, allow_blank=True, default="order-001")
|
provider_refund_id = serializers.CharField(allow_null=True)
|
||||||
order_description = serializers.CharField(required=False, allow_blank=True, default="Example GoPay payment")
|
created_at = serializers.DateTimeField()
|
||||||
return_url = serializers.URLField(required=False)
|
|
||||||
notify_url = serializers.URLField(required=False)
|
|
||||||
preauthorize = serializers.BooleanField(required=False, default=False)
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayPaymentCreatedResponseSerializer(serializers.Serializer):
|
class GoPayPaymentROSerializer(serializers.Serializer):
|
||||||
id = serializers.IntegerField()
|
id = serializers.IntegerField()
|
||||||
state = serializers.CharField()
|
order = serializers.IntegerField(allow_null=True)
|
||||||
gw_url = serializers.URLField(required=False, allow_null=True)
|
amount_cents = serializers.IntegerField()
|
||||||
|
currency = serializers.CharField()
|
||||||
|
status = serializers.CharField()
|
||||||
class GoPayStatusResponseSerializer(serializers.Serializer):
|
refunded_amount_cents = serializers.IntegerField()
|
||||||
id = serializers.IntegerField()
|
gw_url = serializers.URLField(allow_null=True)
|
||||||
state = serializers.CharField()
|
provider_payment_id = serializers.IntegerField(allow_null=True)
|
||||||
|
created_at = serializers.DateTimeField()
|
||||||
|
updated_at = serializers.DateTimeField()
|
||||||
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")
|
|
||||||
25
backend/thirdparty/gopay/urls.py
vendored
25
backend/thirdparty/gopay/urls.py
vendored
@@ -1,20 +1,11 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import (
|
from .views import PaymentStatusView, PaymentRefundListView, GoPayWebhookView
|
||||||
GoPayPaymentView,
|
|
||||||
GoPayPaymentStatusView,
|
|
||||||
GoPayRefundPaymentView,
|
|
||||||
GoPayCaptureAuthorizationView,
|
|
||||||
GoPayVoidAuthorizationView,
|
|
||||||
GoPayCreateRecurrenceView,
|
|
||||||
GoPayPaymentInstrumentsView,
|
|
||||||
)
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('payment/', GoPayPaymentView.as_view(), name='gopay-payment'),
|
# Dotaz na stav platby (GET)
|
||||||
path('payment/<int:payment_id>/status/', GoPayPaymentStatusView.as_view(), name='gopay-payment-status'),
|
path('api/payments/payment/<int:pk>', PaymentStatusView.as_view(), name='gopay-payment-status'),
|
||||||
path('payment/<int:payment_id>/refund/', GoPayRefundPaymentView.as_view(), name='gopay-refund-payment'),
|
# Historie refundací (GET)
|
||||||
path('payment/<int:payment_id>/capture/', GoPayCaptureAuthorizationView.as_view(), name='gopay-capture-authorization'),
|
path('api/payments/payment/<int:pk>/refunds', PaymentRefundListView.as_view(), name='gopay-payment-refunds'),
|
||||||
path('payment/<int:payment_id>/void/', GoPayVoidAuthorizationView.as_view(), name='gopay-void-authorization'),
|
# Webhook od GoPay (HTTP GET s ?id=...)
|
||||||
path('payment/<int:payment_id>/recurrence/', GoPayCreateRecurrenceView.as_view(), name='gopay-create-recurrence'),
|
path('api/payments/gopay/webhook', GoPayWebhookView.as_view(), name='gopay-webhook'),
|
||||||
path('payment-instruments/', GoPayPaymentInstrumentsView.as_view(), name='gopay-payment-instruments'),
|
]
|
||||||
]
|
|
||||||
374
backend/thirdparty/gopay/views.py
vendored
374
backend/thirdparty/gopay/views.py
vendored
@@ -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.views import APIView
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.permissions import IsAuthenticated
|
from rest_framework import status, permissions, serializers
|
||||||
|
|
||||||
import gopay
|
import gopay
|
||||||
from gopay.enums import TokenScope, Language
|
|
||||||
import os
|
from .models import GoPayPayment
|
||||||
from drf_spectacular.utils import extend_schema, OpenApiResponse, OpenApiExample, OpenApiParameter
|
|
||||||
from .serializers import (
|
|
||||||
GoPayCreatePaymentRequestSerializer,
|
|
||||||
GoPayPaymentCreatedResponseSerializer,
|
|
||||||
GoPayStatusResponseSerializer,
|
|
||||||
GoPayRefundRequestSerializer,
|
|
||||||
GoPayCaptureRequestSerializer,
|
|
||||||
GoPayCreateRecurrenceRequestSerializer,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayClientMixin:
|
def _gopay_api():
|
||||||
"""Shared helpers for configuring GoPay client and formatting responses."""
|
# SDK handles token internally; credentials from settings/env
|
||||||
def get_gopay_client(self):
|
return gopay.payments({
|
||||||
gateway_url = os.getenv("GOPAY_GATEWAY_URL", "https://gw.sandbox.gopay.com/api")
|
"goid": settings.GOPAY_GOID,
|
||||||
return gopay.payments({
|
"client_id": settings.GOPAY_CLIENT_ID,
|
||||||
"goid": os.getenv("GOPAY_GOID"),
|
"client_secret": settings.GOPAY_CLIENT_SECRET,
|
||||||
"client_id": os.getenv("GOPAY_CLIENT_ID"),
|
"gateway_url": getattr(settings, 'GOPAY_GATEWAY_URL', 'https://gw.sandbox.gopay.com/api'),
|
||||||
"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)
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayPaymentView(GoPayClientMixin, APIView):
|
def _as_dict(resp):
|
||||||
permission_classes = [IsAuthenticated]
|
if resp is None:
|
||||||
|
return None
|
||||||
@extend_schema(
|
if hasattr(resp, "json") and not callable(getattr(resp, "json")):
|
||||||
tags=["GoPay"],
|
return resp.json
|
||||||
summary="Create GoPay payment",
|
if hasattr(resp, "json") and callable(getattr(resp, "json")):
|
||||||
description="Creates a GoPay payment and returns gateway URL and payment info.",
|
try:
|
||||||
request=GoPayCreatePaymentRequestSerializer,
|
return resp.json()
|
||||||
responses={
|
except Exception:
|
||||||
200: OpenApiResponse(response=GoPayPaymentCreatedResponseSerializer, description="Payment created"),
|
pass
|
||||||
400: OpenApiResponse(description="Validation error or SDK error"),
|
if isinstance(resp, dict):
|
||||||
},
|
return resp
|
||||||
examples=[
|
try:
|
||||||
OpenApiExample(
|
return dict(resp)
|
||||||
"Create payment",
|
except Exception:
|
||||||
value={
|
return {"raw": str(resp)}
|
||||||
"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)
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayPaymentStatusView(GoPayClientMixin, APIView):
|
def _map_status(provider_state: Optional[str]) -> str:
|
||||||
permission_classes = [IsAuthenticated]
|
if not provider_state:
|
||||||
|
return 'UNKNOWN'
|
||||||
@extend_schema(
|
state = provider_state.upper()
|
||||||
tags=["GoPay"],
|
if state == 'PAID':
|
||||||
summary="Get GoPay payment status",
|
return 'PAID'
|
||||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
if state in ('AUTHORIZED',):
|
||||||
responses={200: OpenApiResponse(response=GoPayStatusResponseSerializer, description="Payment status")},
|
return 'AUTHORIZED'
|
||||||
)
|
if state in ('PAYMENT_METHOD_CHOSEN',):
|
||||||
def get(self, request, payment_id: int):
|
return 'PAYMENT_METHOD_CHOSEN'
|
||||||
payments = self.get_gopay_client()
|
if state in ('CREATED', 'CREATED_WITH_PAYMENT', 'PENDING'):
|
||||||
resp = payments.get_status(payment_id)
|
return 'CREATED'
|
||||||
return self._to_response(resp)
|
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):
|
# --- Serializers kept here (small and read-only) ---
|
||||||
permission_classes = [IsAuthenticated]
|
class GoPayRefundROSerializer(serializers.Serializer):
|
||||||
|
id = serializers.IntegerField()
|
||||||
@extend_schema(
|
amount_cents = serializers.IntegerField(allow_null=True)
|
||||||
tags=["GoPay"],
|
reason = serializers.CharField(allow_null=True, allow_blank=True)
|
||||||
summary="Refund GoPay payment",
|
provider_refund_id = serializers.CharField(allow_null=True)
|
||||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
created_at = serializers.DateTimeField()
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayCaptureAuthorizationView(GoPayClientMixin, APIView):
|
class GoPayPaymentROSerializer(serializers.Serializer):
|
||||||
permission_classes = [IsAuthenticated]
|
id = serializers.IntegerField()
|
||||||
|
order = serializers.IntegerField(source='order_id', allow_null=True)
|
||||||
@extend_schema(
|
amount_cents = serializers.IntegerField()
|
||||||
tags=["GoPay"],
|
currency = serializers.CharField()
|
||||||
summary="Capture GoPay authorization",
|
status = serializers.CharField()
|
||||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
refunded_amount_cents = serializers.IntegerField()
|
||||||
request=GoPayCaptureRequestSerializer,
|
gw_url = serializers.URLField(allow_null=True)
|
||||||
responses={200: OpenApiResponse(description="Capture processed")},
|
provider_payment_id = serializers.IntegerField(allow_null=True)
|
||||||
)
|
created_at = serializers.DateTimeField()
|
||||||
def post(self, request, payment_id: int):
|
updated_at = serializers.DateTimeField()
|
||||||
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 GoPayVoidAuthorizationView(GoPayClientMixin, APIView):
|
class PaymentStatusView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
"""
|
||||||
|
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(
|
def get(self, request, pk: int):
|
||||||
tags=["GoPay"],
|
payment = get_object_or_404(GoPayPayment, pk=pk)
|
||||||
summary="Void GoPay authorization",
|
|
||||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
if payment.provider_payment_id:
|
||||||
responses={200: OpenApiResponse(description="Authorization voided")},
|
api = _gopay_api()
|
||||||
)
|
resp = api.get_status(payment.provider_payment_id)
|
||||||
def post(self, request, payment_id: int):
|
if getattr(resp, "success", False):
|
||||||
payments = self.get_gopay_client()
|
data = getattr(resp, "json", None)
|
||||||
resp = payments.void_authorization(payment_id)
|
payment.status = _map_status(data.get('state'))
|
||||||
return self._to_response(resp)
|
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):
|
class PaymentRefundListView(APIView):
|
||||||
permission_classes = [IsAuthenticated]
|
"""
|
||||||
|
GET /api/payments/payment/{id}/refunds
|
||||||
|
- List local refund records for a payment (read-only).
|
||||||
|
"""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
@extend_schema(
|
def get(self, request, pk: int):
|
||||||
tags=["GoPay"],
|
payment = get_object_or_404(GoPayPayment, pk=pk)
|
||||||
summary="Create GoPay recurrence",
|
refunds = payment.refunds.all().values(
|
||||||
parameters=[OpenApiParameter(name="payment_id", required=True, type=int, location=OpenApiParameter.PATH)],
|
'id', 'amount_cents', 'reason', 'provider_refund_id', 'created_at'
|
||||||
request=GoPayCreateRecurrenceRequestSerializer,
|
)
|
||||||
responses={200: OpenApiResponse(description="Recurrence created")},
|
ser = GoPayRefundROSerializer(refunds, many=True)
|
||||||
)
|
return Response(ser.data)
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
class GoPayPaymentInstrumentsView(GoPayClientMixin, APIView):
|
@method_decorator(csrf_exempt, name='dispatch')
|
||||||
permission_classes = [IsAuthenticated]
|
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]
|
||||||
|
|
||||||
@extend_schema(
|
def get(self, request):
|
||||||
tags=["GoPay"],
|
provider_id = request.GET.get("id") or request.GET.get("payment_id")
|
||||||
summary="Get GoPay payment instruments",
|
if not provider_id:
|
||||||
parameters=[OpenApiParameter(name="currency", required=False, type=str, location=OpenApiParameter.QUERY)],
|
return Response({"detail": "Missing payment id"}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
responses={200: OpenApiResponse(description="Available payment instruments returned")},
|
|
||||||
)
|
try:
|
||||||
def get(self, request):
|
provider_id_int = int(provider_id)
|
||||||
currency = request.query_params.get("currency", "CZK")
|
except Exception:
|
||||||
goid = os.getenv("GOPAY_GOID")
|
provider_id_int = provider_id # fallback
|
||||||
if not goid:
|
|
||||||
return Response({"error": "GOPAY_GOID is not configured"}, status=500)
|
api = _gopay_api()
|
||||||
payments = self.get_gopay_client()
|
resp = api.get_status(provider_id_int)
|
||||||
resp = payments.get_payment_instruments(goid, currency)
|
if not getattr(resp, "success", False):
|
||||||
return self._to_response(resp)
|
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.
|
||||||
|
|||||||
@@ -332,6 +332,7 @@ MY_CREATED_APPS = [
|
|||||||
'thirdparty.downloader',
|
'thirdparty.downloader',
|
||||||
'thirdparty.stripe', # register Stripe app so its models are recognized
|
'thirdparty.stripe', # register Stripe app so its models are recognized
|
||||||
'thirdparty.trading212',
|
'thirdparty.trading212',
|
||||||
|
'thirdparty.gopay', # add GoPay app
|
||||||
]
|
]
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
@@ -917,6 +918,14 @@ SPECTACULAR_DEFAULTS: Dict[str, Any] = {
|
|||||||
'OAUTH2_SCOPES': None,
|
'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 LIMITS------------------------------------
|
||||||
DOWNLOADER_MAX_SIZE_MB = int(os.getenv("DOWNLOADER_MAX_SIZE_MB", "200")) # Raspberry Pi safe cap
|
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
|
DOWNLOADER_MAX_SIZE_BYTES = DOWNLOADER_MAX_SIZE_MB * 1024 * 1024
|
||||||
|
|||||||
Reference in New Issue
Block a user