Implement Moyasar payments flow with webhooks
This commit is contained in:
@@ -2,15 +2,35 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class PaymentGatewayError(RuntimeError):
|
||||
def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[dict] = None) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.payload = payload or {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaymentInitResult:
|
||||
external_id: str
|
||||
status: Optional[str]
|
||||
redirect_url: Optional[str]
|
||||
payload: dict
|
||||
|
||||
|
||||
class BasePaymentGateway:
|
||||
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult:
|
||||
def create_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
description: str,
|
||||
source: dict,
|
||||
callback_url: Optional[str],
|
||||
given_id: str,
|
||||
metadata: dict,
|
||||
) -> PaymentInitResult:
|
||||
raise NotImplementedError
|
||||
|
||||
def capture_payment(self, external_id: str) -> None:
|
||||
@@ -30,9 +50,56 @@ class MoyasarGateway(BasePaymentGateway):
|
||||
if not self.secret_key or not self.publishable_key:
|
||||
raise ValueError("Moyasar credentials are not configured")
|
||||
|
||||
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult:
|
||||
def create_payment(
|
||||
self,
|
||||
amount: int,
|
||||
currency: str,
|
||||
description: str,
|
||||
source: dict,
|
||||
callback_url: Optional[str],
|
||||
given_id: str,
|
||||
metadata: dict,
|
||||
) -> PaymentInitResult:
|
||||
self._assert_config()
|
||||
raise NotImplementedError("Moyasar gateway integration not implemented yet")
|
||||
url = f"{self.base_url}/v1/payments"
|
||||
payload = {
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"description": description,
|
||||
"source": source,
|
||||
"given_id": given_id,
|
||||
"metadata": metadata,
|
||||
}
|
||||
if callback_url:
|
||||
payload["callback_url"] = callback_url
|
||||
|
||||
try:
|
||||
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||
except requests.RequestException as exc:
|
||||
raise PaymentGatewayError("Failed to reach Moyasar") from exc
|
||||
|
||||
try:
|
||||
data = response.json() if response.content else {}
|
||||
except ValueError as exc:
|
||||
raise PaymentGatewayError("Invalid response from Moyasar") from exc
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise PaymentGatewayError(
|
||||
"Moyasar returned an error",
|
||||
status_code=response.status_code,
|
||||
payload=data,
|
||||
)
|
||||
redirect_url = None
|
||||
source_payload = data.get("source") or {}
|
||||
if isinstance(source_payload, dict):
|
||||
redirect_url = source_payload.get("transaction_url")
|
||||
|
||||
return PaymentInitResult(
|
||||
external_id=data.get("id"),
|
||||
status=data.get("status"),
|
||||
redirect_url=redirect_url,
|
||||
payload=data,
|
||||
)
|
||||
|
||||
def capture_payment(self, external_id: str) -> None:
|
||||
self._assert_config()
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.bookings.models import Booking
|
||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
|
||||
|
||||
CURRENCY_DECIMALS = {
|
||||
"SAR": 2,
|
||||
"USD": 2,
|
||||
"EUR": 2,
|
||||
"GBP": 2,
|
||||
"KWD": 3,
|
||||
"BHD": 3,
|
||||
"JOD": 3,
|
||||
}
|
||||
|
||||
MoyasarAllowedSourceTypes = {"token", "stcpay", "applepay", "samsungpay"}
|
||||
|
||||
|
||||
def _to_minor_units(amount: Decimal, currency: str) -> int:
|
||||
decimals = CURRENCY_DECIMALS.get(currency.upper(), 2)
|
||||
factor = Decimal("1") if decimals == 0 else Decimal(10) ** decimals
|
||||
minor = (amount * factor).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||
return int(minor)
|
||||
|
||||
|
||||
def _map_provider_status(status: Optional[str]) -> Optional[str]:
|
||||
if not status:
|
||||
return None
|
||||
status = status.lower()
|
||||
mapping = {
|
||||
"initiated": PaymentStatus.INITIATED,
|
||||
"authorized": PaymentStatus.AUTHORIZED,
|
||||
"captured": PaymentStatus.CAPTURED,
|
||||
"paid": PaymentStatus.PAID,
|
||||
"failed": PaymentStatus.FAILED,
|
||||
"refunded": PaymentStatus.REFUNDED,
|
||||
"voided": PaymentStatus.VOIDED,
|
||||
"verified": PaymentStatus.VERIFIED,
|
||||
}
|
||||
return mapping.get(status)
|
||||
|
||||
|
||||
def _apply_status(payment: Payment, status: str) -> None:
|
||||
now = timezone.now()
|
||||
payment.status = status
|
||||
payment.status_updated_at = now
|
||||
if status == PaymentStatus.AUTHORIZED:
|
||||
payment.authorized_at = now
|
||||
elif status == PaymentStatus.CAPTURED:
|
||||
payment.captured_at = now
|
||||
elif status == PaymentStatus.PAID:
|
||||
payment.paid_at = now
|
||||
elif status == PaymentStatus.FAILED:
|
||||
payment.failed_at = now
|
||||
elif status == PaymentStatus.REFUNDED:
|
||||
payment.refunded_at = now
|
||||
elif status == PaymentStatus.VOIDED:
|
||||
payment.voided_at = now
|
||||
elif status == PaymentStatus.VERIFIED:
|
||||
payment.verified_at = now
|
||||
|
||||
|
||||
def create_payment_for_booking(
|
||||
booking: Booking,
|
||||
provider: str,
|
||||
idempotency_key,
|
||||
source: dict,
|
||||
callback_url: Optional[str] = None,
|
||||
) -> Tuple[Payment, bool, Optional[str]]:
|
||||
if provider != PaymentProvider.MOYASAR:
|
||||
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||
|
||||
existing = Payment.objects.filter(idempotency_key=idempotency_key).first()
|
||||
if existing:
|
||||
if existing.booking_id != booking.id or existing.provider != provider:
|
||||
raise serializers.ValidationError({"idempotency_key": _("Idempotency key already used")})
|
||||
return existing, False, existing.metadata.get("redirect_url")
|
||||
|
||||
source_type = (source or {}).get("type")
|
||||
if source_type not in MoyasarAllowedSourceTypes:
|
||||
raise serializers.ValidationError({"source": _("Unsupported payment source type")})
|
||||
|
||||
payment = Payment.objects.create(
|
||||
booking=booking,
|
||||
provider=provider,
|
||||
status=PaymentStatus.INITIATED,
|
||||
amount=booking.price_amount,
|
||||
currency=booking.currency,
|
||||
idempotency_key=idempotency_key,
|
||||
metadata={"booking_id": booking.id},
|
||||
)
|
||||
_apply_status(payment, PaymentStatus.INITIATED)
|
||||
payment.save(update_fields=["status", "status_updated_at"])
|
||||
|
||||
amount_minor = _to_minor_units(booking.price_amount, booking.currency)
|
||||
description = f"Booking {booking.id}"
|
||||
gateway = MoyasarGateway()
|
||||
|
||||
try:
|
||||
result = gateway.create_payment(
|
||||
amount=amount_minor,
|
||||
currency=booking.currency,
|
||||
description=description,
|
||||
source=source,
|
||||
callback_url=callback_url,
|
||||
given_id=str(idempotency_key),
|
||||
metadata={"booking_id": booking.id},
|
||||
)
|
||||
except PaymentGatewayError as exc:
|
||||
_apply_status(payment, PaymentStatus.FAILED)
|
||||
payment.metadata["gateway_error"] = {
|
||||
"message": str(exc),
|
||||
"status_code": exc.status_code,
|
||||
"payload": exc.payload,
|
||||
}
|
||||
payment.save(update_fields=[
|
||||
"status",
|
||||
"status_updated_at",
|
||||
"failed_at",
|
||||
"metadata",
|
||||
])
|
||||
raise serializers.ValidationError({"detail": _("Payment provider error")}) from exc
|
||||
|
||||
if not result.external_id:
|
||||
_apply_status(payment, PaymentStatus.FAILED)
|
||||
payment.metadata["gateway_error"] = {"message": "Missing payment reference from provider"}
|
||||
payment.save(update_fields=[
|
||||
"status",
|
||||
"status_updated_at",
|
||||
"failed_at",
|
||||
"metadata",
|
||||
])
|
||||
raise serializers.ValidationError({"detail": _("Payment provider error")})
|
||||
|
||||
payment.external_id = result.external_id
|
||||
payment.provider_payload = result.payload
|
||||
payment.metadata["redirect_url"] = result.redirect_url
|
||||
|
||||
mapped_status = _map_provider_status(result.status)
|
||||
if mapped_status:
|
||||
_apply_status(payment, mapped_status)
|
||||
|
||||
payment.save()
|
||||
return payment, True, result.redirect_url
|
||||
|
||||
|
||||
def apply_webhook_event(payment: Payment, event_type: str, payload: dict) -> bool:
|
||||
mapping = {
|
||||
"payment_authorized": PaymentStatus.AUTHORIZED,
|
||||
"payment_captured": PaymentStatus.CAPTURED,
|
||||
"payment_paid": PaymentStatus.PAID,
|
||||
"payment_failed": PaymentStatus.FAILED,
|
||||
"payment_faild": PaymentStatus.FAILED,
|
||||
"payment_abandoned": PaymentStatus.FAILED,
|
||||
"payment_refunded": PaymentStatus.REFUNDED,
|
||||
"payment_voided": PaymentStatus.VOIDED,
|
||||
"payment_verified": PaymentStatus.VERIFIED,
|
||||
}
|
||||
target_status = mapping.get(event_type)
|
||||
if not target_status:
|
||||
return False
|
||||
if payment.status == target_status:
|
||||
return False
|
||||
_apply_status(payment, target_status)
|
||||
payment.metadata["last_webhook"] = payload
|
||||
payment.save()
|
||||
return True
|
||||
Reference in New Issue
Block a user