176 lines
5.9 KiB
Python
176 lines
5.9 KiB
Python
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
|