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