GoPay
This commit is contained in:
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.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"])
|
||||
|
||||
Reference in New Issue
Block a user