Implement Moyasar payments flow with webhooks

This commit is contained in:
2026-02-28 13:01:12 +03:00
parent d9767ff0a7
commit f3c93f500e
12 changed files with 600 additions and 43 deletions
+70 -3
View File
@@ -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()
+175
View File
@@ -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