gopay
This commit is contained in:
59
backend/env
59
backend/env
@@ -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
|
|
||||||
19
backend/thirdparty/gopay/admin.py
vendored
19
backend/thirdparty/gopay/admin.py
vendored
@@ -1,3 +1,20 @@
|
|||||||
from django.contrib import admin
|
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")
|
||||||
|
|||||||
74
backend/thirdparty/gopay/client.py
vendored
Normal file
74
backend/thirdparty/gopay/client.py
vendored
Normal file
@@ -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)
|
||||||
224
backend/thirdparty/gopay/models.py
vendored
224
backend/thirdparty/gopay/models.py
vendored
@@ -1,206 +1,60 @@
|
|||||||
from django.db import models
|
from __future__ import annotations
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db.models.signals import post_save
|
from django.db import models
|
||||||
from django.dispatch import receiver
|
from django.utils import timezone
|
||||||
|
|
||||||
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):
|
class GoPayPayment(models.Model):
|
||||||
"""
|
# Optional user association
|
||||||
Local representation of a GoPay payment. Creating this model will create a real payment
|
user = models.ForeignKey(
|
||||||
at GoPay (via post_save hook) and persist provider identifiers and gateway URL.
|
settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, blank=True, related_name="gopay_payments"
|
||||||
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',
|
|
||||||
)
|
)
|
||||||
|
# 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
|
# Raw payloads for traceability
|
||||||
amount_cents = models.PositiveBigIntegerField()
|
request_payload = models.JSONField(default=dict, blank=True)
|
||||||
currency = models.CharField(max_length=8, default='CZK')
|
response_payload = models.JSONField(default=dict, blank=True)
|
||||||
|
|
||||||
# Provider identifiers + redirect URL
|
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
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)
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
def __str__(self) -> str:
|
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):
|
class GoPayRefund(models.Model):
|
||||||
"""
|
payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, null=True, blank=True, related_name="refunds")
|
||||||
Local representation of a GoPay refund. Creating this model will trigger
|
gopay_refund_id = models.CharField(max_length=64, blank=True, default="")
|
||||||
a refund call at GoPay (via post_save hook).
|
amount = models.BigIntegerField(help_text="Amount in minor units.")
|
||||||
If amount_cents is null, a full refund is attempted by provider.
|
status = models.CharField(max_length=64, blank=True, default="")
|
||||||
"""
|
payload = models.JSONField(default=dict, blank=True)
|
||||||
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)
|
created_at = models.DateTimeField(default=timezone.now, db_index=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:
|
def __str__(self) -> str:
|
||||||
amount = self.amount_cents if self.amount_cents is not None else 'FULL'
|
return f"GoPayRefund(payment={self.payment_id}, amount={self.amount}, status={self.status})"
|
||||||
return f'GoPayRefund#{self.pk} payment={self.payment_id} amount={amount}'
|
|
||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=GoPayPayment)
|
class GoPaySubscription(models.Model):
|
||||||
def create_gopay_payment(sender, instance: GoPayPayment, created: bool, **kwargs):
|
parent_payment = models.ForeignKey(GoPayPayment, on_delete=models.CASCADE, related_name="subscriptions")
|
||||||
"""
|
recurrence_id = models.CharField(max_length=64, blank=True, default="")
|
||||||
On first save (creation), create the payment at GoPay.
|
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:
|
canceled = models.BooleanField(default=False)
|
||||||
- return_url: user is redirected back with ?id=<payment_id>
|
canceled_at = models.DateTimeField(null=True, blank=True)
|
||||||
- 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()
|
created_at = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
|
||||||
payload = {
|
def __str__(self) -> str:
|
||||||
"amount": int(instance.amount_cents), # GoPay expects minor units
|
return f"GoPaySubscription(parent={self.parent_payment_id}, status={self.status})"
|
||||||
"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"])
|
|
||||||
|
|||||||
28
backend/thirdparty/gopay/serializers.py
vendored
28
backend/thirdparty/gopay/serializers.py
vendored
@@ -1,21 +1,15 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
class GoPayRefundROSerializer(serializers.Serializer):
|
|
||||||
id = serializers.IntegerField()
|
class PaymentCreateSerializer(serializers.Serializer):
|
||||||
amount_cents = serializers.IntegerField(allow_null=True)
|
# Entire GoPay payment payload is passed through
|
||||||
reason = serializers.CharField(allow_null=True, allow_blank=True)
|
payment = serializers.DictField()
|
||||||
provider_refund_id = serializers.CharField(allow_null=True)
|
|
||||||
created_at = serializers.DateTimeField()
|
# Optional: help store local metadata
|
||||||
|
user_id = serializers.IntegerField(required=False)
|
||||||
|
|
||||||
|
|
||||||
class GoPayPaymentROSerializer(serializers.Serializer):
|
class RefundSerializer(serializers.Serializer):
|
||||||
id = serializers.IntegerField()
|
amount = serializers.IntegerField(min_value=1, help_text="Minor units")
|
||||||
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()
|
|
||||||
|
|||||||
15
backend/thirdparty/gopay/urls.py
vendored
15
backend/thirdparty/gopay/urls.py
vendored
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import path
|
from django.urls import path
|
||||||
from .views import PaymentStatusView, PaymentRefundListView, GoPayWebhookView
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "gopay"
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# Dotaz na stav platby (GET)
|
# Payments
|
||||||
path('api/payments/payment/<int:pk>', PaymentStatusView.as_view(), name='gopay-payment-status'),
|
path("create/", views.CreatePaymentView.as_view(), name="create"),
|
||||||
# Historie refundací (GET)
|
path("<str:payment_id>/status/", views.PaymentStatusView.as_view(), name="status"),
|
||||||
path('api/payments/payment/<int:pk>/refunds', PaymentRefundListView.as_view(), name='gopay-payment-refunds'),
|
path("<str:payment_id>/refund/", views.RefundPaymentView.as_view(), name="refund"),
|
||||||
# Webhook od GoPay (HTTP GET s ?id=...)
|
|
||||||
path('api/payments/gopay/webhook', GoPayWebhookView.as_view(), name='gopay-webhook'),
|
|
||||||
]
|
]
|
||||||
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 rest_framework import permissions, status
|
||||||
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.response import Response
|
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 . import client as goclient
|
||||||
|
from .models import GoPayPayment, GoPayRefund
|
||||||
from .models import GoPayPayment
|
from .serializers import (
|
||||||
|
PaymentCreateSerializer,
|
||||||
|
RefundSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _gopay_api():
|
class CreatePaymentView(APIView):
|
||||||
# SDK handles token internally; credentials from settings/env
|
"""Vytvoří novou platbu v GoPay a uloží odpověď lokálně.
|
||||||
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'),
|
|
||||||
})
|
|
||||||
|
|
||||||
|
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:
|
try:
|
||||||
return resp.json()
|
res = goclient.create_payment(payload) # Expecting dict-like response
|
||||||
except Exception:
|
except Exception as e:
|
||||||
pass
|
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||||
if isinstance(resp, dict):
|
|
||||||
return resp
|
|
||||||
try:
|
|
||||||
return dict(resp)
|
|
||||||
except Exception:
|
|
||||||
return {"raw": str(resp)}
|
|
||||||
|
|
||||||
|
# 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:
|
payment = GoPayPayment.objects.create(
|
||||||
if not provider_state:
|
user=request.user if request.user and request.user.is_authenticated else None,
|
||||||
return 'UNKNOWN'
|
gopay_id=gopay_id or payload.get("id", ""),
|
||||||
state = provider_state.upper()
|
order_number=str(payload.get("order_number", "")),
|
||||||
if state == 'PAID':
|
amount=amount,
|
||||||
return 'PAID'
|
currency=currency,
|
||||||
if state in ('AUTHORIZED',):
|
status=status_text,
|
||||||
return 'AUTHORIZED'
|
preauthorized=bool(payload.get("preauthorize", False)),
|
||||||
if state in ('PAYMENT_METHOD_CHOSEN',):
|
request_payload=payload,
|
||||||
return 'PAYMENT_METHOD_CHOSEN'
|
response_payload=res if isinstance(res, dict) else {"raw": str(res)},
|
||||||
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
|
|
||||||
|
|
||||||
|
return Response({"payment": payment.response_payload}, status=status.HTTP_201_CREATED)
|
||||||
# --- 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()
|
|
||||||
|
|
||||||
|
|
||||||
class PaymentStatusView(APIView):
|
class PaymentStatusView(APIView):
|
||||||
"""
|
"""Načte aktuální stav platby GoPay a případně synchronizuje lokální záznam."""
|
||||||
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]
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
def get(self, request, pk: int):
|
@extend_schema(
|
||||||
payment = get_object_or_404(GoPayPayment, pk=pk)
|
tags=["GoPay"],
|
||||||
|
operation_id="gopay_get_status",
|
||||||
if payment.provider_payment_id:
|
summary="Získat stav platby",
|
||||||
api = _gopay_api()
|
parameters=[
|
||||||
resp = api.get_status(payment.provider_payment_id)
|
OpenApiParameter(
|
||||||
if getattr(resp, "success", False):
|
name="payment_id",
|
||||||
data = getattr(resp, "json", None)
|
type=OpenApiTypes.STR,
|
||||||
payment.status = _map_status(data.get('state'))
|
location=OpenApiParameter.PATH,
|
||||||
payment.raw_last_status = data
|
description="ID platby GoPay",
|
||||||
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)
|
responses={
|
||||||
|
200: OpenApiResponse(OpenApiTypes.OBJECT, description="Aktuální stav platby"),
|
||||||
|
502: OpenApiResponse(description="Chyba upstream GoPay"),
|
||||||
@method_decorator(csrf_exempt, name='dispatch')
|
},
|
||||||
class GoPayWebhookView(APIView):
|
)
|
||||||
"""
|
def get(self, request, payment_id: str | int):
|
||||||
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)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
provider_id_int = int(provider_id)
|
res = goclient.get_status(payment_id)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
provider_id_int = provider_id # fallback
|
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
api = _gopay_api()
|
# Update local status if we have it
|
||||||
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
|
|
||||||
try:
|
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:
|
except GoPayPayment.DoesNotExist:
|
||||||
order_number = data.get("order_number")
|
pass
|
||||||
if order_number:
|
|
||||||
|
return Response(res, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
class RefundPaymentView(APIView):
|
||||||
|
"""Provede refundaci platby v GoPay a uloží záznam refundace."""
|
||||||
|
permission_classes = [permissions.IsAuthenticated]
|
||||||
|
|
||||||
|
@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:
|
try:
|
||||||
payment = GoPayPayment.objects.get(pk=int(order_number))
|
res = goclient.refund_payment(payment_id, amount)
|
||||||
except Exception:
|
except Exception as e:
|
||||||
payment = None
|
return Response({"detail": str(e)}, status=status.HTTP_502_BAD_GATEWAY)
|
||||||
|
|
||||||
if not payment:
|
payment = GoPayPayment.objects.filter(gopay_id=str(payment_id)).first()
|
||||||
return Response({"detail": "Payment not found locally", "provider_id": provider_id_int}, status=status.HTTP_202_ACCEPTED)
|
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)},
|
||||||
|
)
|
||||||
|
|
||||||
payment.status = _map_status(state)
|
return Response({"refund": ref.payload}, status=status.HTTP_200_OK)
|
||||||
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.
|
|
||||||
|
|||||||
@@ -38,5 +38,5 @@ urlpatterns = [
|
|||||||
path('api/stripe/', include('thirdparty.stripe.urls')),
|
path('api/stripe/', include('thirdparty.stripe.urls')),
|
||||||
path('api/trading212/', include('thirdparty.trading212.urls')),
|
path('api/trading212/', include('thirdparty.trading212.urls')),
|
||||||
path('api/downloader/', include('thirdparty.downloader.urls')),
|
path('api/downloader/', include('thirdparty.downloader.urls')),
|
||||||
|
path("api/payments/gopay/", include("thirdparty.gopay.urls", namespace="gopay")),
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user