from django.db import models 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)} 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= - 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 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= "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"])