Implement Moyasar payments flow with webhooks
This commit is contained in:
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
|
|||||||
MOYASAR_SECRET_KEY=
|
MOYASAR_SECRET_KEY=
|
||||||
MOYASAR_PUBLISHABLE_KEY=
|
MOYASAR_PUBLISHABLE_KEY=
|
||||||
MOYASAR_BASE_URL=
|
MOYASAR_BASE_URL=
|
||||||
|
MOYASAR_WEBHOOK_SECRET=
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-28 09:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='authorized_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='captured_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='failed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='idempotency_key',
|
||||||
|
field=models.UUIDField(blank=True, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='paid_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='provider_payload',
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='refunded_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status_updated_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='verified_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='voided_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='external_id',
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('created', 'Created'), ('authorized', 'Authorized'), ('captured', 'Captured'), ('paid', 'Paid'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('voided', 'Voided'), ('verified', 'Verified')], default='initiated', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class PaymentStatus(models.TextChoices):
|
class PaymentStatus(models.TextChoices):
|
||||||
|
INITIATED = "initiated", "Initiated"
|
||||||
CREATED = "created", "Created"
|
CREATED = "created", "Created"
|
||||||
AUTHORIZED = "authorized", "Authorized"
|
AUTHORIZED = "authorized", "Authorized"
|
||||||
CAPTURED = "captured", "Captured"
|
CAPTURED = "captured", "Captured"
|
||||||
|
PAID = "paid", "Paid"
|
||||||
FAILED = "failed", "Failed"
|
FAILED = "failed", "Failed"
|
||||||
REFUNDED = "refunded", "Refunded"
|
REFUNDED = "refunded", "Refunded"
|
||||||
|
VOIDED = "voided", "Voided"
|
||||||
|
VERIFIED = "verified", "Verified"
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
||||||
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
||||||
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED)
|
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.INITIATED)
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
external_id = models.CharField(max_length=200, blank=True)
|
external_id = models.CharField(max_length=200, null=True, blank=True, unique=True)
|
||||||
|
idempotency_key = models.UUIDField(null=True, blank=True, unique=True)
|
||||||
|
provider_payload = models.JSONField(null=True, blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
authorized_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
captured_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
failed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
refunded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
voided_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status_updated_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from rest_framework import serializers
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
class PaymentSerializer(serializers.ModelSerializer):
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
@@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
"amount",
|
"amount",
|
||||||
"currency",
|
"currency",
|
||||||
"external_id",
|
"external_id",
|
||||||
|
"idempotency_key",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"authorized_at",
|
||||||
|
"captured_at",
|
||||||
|
"paid_at",
|
||||||
|
"failed_at",
|
||||||
|
"refunded_at",
|
||||||
|
"voided_at",
|
||||||
|
"verified_at",
|
||||||
|
"status_updated_at",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
@@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
class PaymentCreateSerializer(serializers.ModelSerializer):
|
class PaymentCreateSerializer(serializers.ModelSerializer):
|
||||||
booking_id = serializers.IntegerField(write_only=True)
|
booking_id = serializers.IntegerField(write_only=True)
|
||||||
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
||||||
|
idempotency_key = serializers.UUIDField(write_only=True)
|
||||||
|
source = serializers.JSONField(write_only=True, required=False)
|
||||||
|
callback_url = serializers.URLField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Payment
|
model = Payment
|
||||||
fields = ["booking_id", "provider"]
|
fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"]
|
||||||
|
|
||||||
def validate_booking_id(self, value):
|
def validate_booking_id(self, value):
|
||||||
if not Booking.objects.filter(id=value).exists():
|
if not Booking.objects.filter(id=value).exists():
|
||||||
raise serializers.ValidationError(_("Booking not found"))
|
raise serializers.ValidationError(_("Booking not found"))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def create(self, validated_data):
|
def validate(self, attrs):
|
||||||
booking = Booking.objects.get(id=validated_data["booking_id"])
|
provider = attrs.get("provider")
|
||||||
return Payment.objects.create(
|
source = attrs.get("source")
|
||||||
booking=booking,
|
if provider != PaymentProvider.MOYASAR:
|
||||||
provider=validated_data["provider"],
|
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||||
status=PaymentStatus.CREATED,
|
if source is None:
|
||||||
amount=booking.price_amount,
|
raise serializers.ValidationError({"source": _("Payment source is required")})
|
||||||
currency=booking.currency,
|
source_type = source.get("type")
|
||||||
metadata={},
|
if not source_type:
|
||||||
)
|
raise serializers.ValidationError({"source": _("Payment source type is required")})
|
||||||
|
if source_type == "creditcard":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"source": _("Card data must not be sent to the backend; use frontend tokenization")}
|
||||||
|
)
|
||||||
|
if source_type == "token" and not attrs.get("callback_url"):
|
||||||
|
raise serializers.ValidationError({"callback_url": _("Callback URL is required for token payments")})
|
||||||
|
return attrs
|
||||||
|
|||||||
@@ -2,15 +2,35 @@ import os
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
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
|
@dataclass
|
||||||
class PaymentInitResult:
|
class PaymentInitResult:
|
||||||
external_id: str
|
external_id: str
|
||||||
|
status: Optional[str]
|
||||||
redirect_url: Optional[str]
|
redirect_url: Optional[str]
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
class BasePaymentGateway:
|
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
|
raise NotImplementedError
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
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:
|
if not self.secret_key or not self.publishable_key:
|
||||||
raise ValueError("Moyasar credentials are not configured")
|
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()
|
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:
|
def capture_payment(self, external_id: str) -> None:
|
||||||
self._assert_config()
|
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
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import User, UserRole
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
|
from apps.salons.models import Salon, Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def booking_entities():
|
||||||
|
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
||||||
|
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
||||||
|
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
||||||
|
|
||||||
|
salon = Salon.objects.create(
|
||||||
|
owner=owner,
|
||||||
|
name="Main Salon",
|
||||||
|
description="",
|
||||||
|
address="123 King Rd",
|
||||||
|
city="Riyadh",
|
||||||
|
phone_number="0512345678",
|
||||||
|
)
|
||||||
|
service = Service.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
name="Haircut",
|
||||||
|
description="",
|
||||||
|
duration_minutes=60,
|
||||||
|
price_amount=120,
|
||||||
|
currency="SAR",
|
||||||
|
)
|
||||||
|
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
|
||||||
|
start_time = timezone.now() + timedelta(days=1)
|
||||||
|
end_time = start_time + timedelta(minutes=60)
|
||||||
|
booking = Booking.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
customer=customer,
|
||||||
|
service=service,
|
||||||
|
staff=staff,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
status=BookingStatus.PENDING,
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
notes="",
|
||||||
|
)
|
||||||
|
return customer, booking
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_gateway_response(payment_id="pay_test", status="initiated"):
|
||||||
|
response = Mock()
|
||||||
|
response.status_code = 201
|
||||||
|
response.json.return_value = {
|
||||||
|
"id": payment_id,
|
||||||
|
"status": status,
|
||||||
|
"source": {"transaction_url": "https://moyasar.example/tx"},
|
||||||
|
}
|
||||||
|
response.content = b"{}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_create_payment_idempotency_returns_existing(mock_post, booking_entities, monkeypatch):
|
||||||
|
customer, booking = booking_entities
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=customer)
|
||||||
|
|
||||||
|
monkeypatch.setenv("MOYASAR_SECRET_KEY", "sk_test")
|
||||||
|
monkeypatch.setenv("MOYASAR_PUBLISHABLE_KEY", "pk_test")
|
||||||
|
|
||||||
|
mock_post.return_value = _mock_gateway_response()
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_id": booking.id,
|
||||||
|
"provider": PaymentProvider.MOYASAR,
|
||||||
|
"idempotency_key": request_id,
|
||||||
|
"source": {"type": "stcpay", "mobile": "0500000000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
response_repeat = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response_repeat.status_code == 200
|
||||||
|
assert Payment.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_rejects_creditcard_source(booking_entities):
|
||||||
|
customer, booking = booking_entities
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=customer)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_id": booking.id,
|
||||||
|
"provider": PaymentProvider.MOYASAR,
|
||||||
|
"idempotency_key": str(uuid.uuid4()),
|
||||||
|
"source": {"type": "creditcard", "number": "4111111111111111"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "source" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_paid_updates_status(booking_entities, monkeypatch):
|
||||||
|
_, booking = booking_entities
|
||||||
|
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||||
|
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=PaymentProvider.MOYASAR,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
external_id="pay_webhook",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "payment_paid",
|
||||||
|
"secret_token": "secret",
|
||||||
|
"data": {"id": "pay_webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert payment.status == PaymentStatus.PAID
|
||||||
|
assert payment.paid_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_invalid_secret_is_rejected(booking_entities, monkeypatch):
|
||||||
|
_, booking = booking_entities
|
||||||
|
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||||
|
|
||||||
|
Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=PaymentProvider.MOYASAR,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
external_id="pay_webhook",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "payment_paid",
|
||||||
|
"secret_token": "wrong",
|
||||||
|
"data": {"id": "pay_webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from apps.payments.views import PaymentViewSet
|
from apps.payments.views import PaymentViewSet, payment_webhook
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"", PaymentViewSet, basename="payment")
|
router.register(r"", PaymentViewSet, basename="payment")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("webhook/", payment_webhook, name="payment-webhook"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
import logging
|
||||||
from rest_framework.response import Response
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
||||||
|
from apps.payments.services.payments import apply_webhook_event, create_payment_for_booking
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def user_can_access_booking(user, booking: Booking) -> bool:
|
def user_can_access_booking(user, booking: Booking) -> bool:
|
||||||
@@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
|||||||
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
||||||
if not user_can_access_booking(request.user, booking):
|
if not user_can_access_booking(request.user, booking):
|
||||||
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
||||||
payment = serializer.save()
|
payment, created, redirect_url = create_payment_for_booking(
|
||||||
return Response(
|
booking=booking,
|
||||||
{
|
provider=serializer.validated_data["provider"],
|
||||||
"detail": _("Payment record created. Provider integration pending."),
|
idempotency_key=serializer.validated_data["idempotency_key"],
|
||||||
"payment_id": payment.id,
|
source=serializer.validated_data["source"],
|
||||||
"amount": str(payment.amount),
|
callback_url=serializer.validated_data.get("callback_url"),
|
||||||
"currency": payment.currency,
|
|
||||||
"status": payment.status,
|
|
||||||
},
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
)
|
||||||
|
response_data = PaymentSerializer(payment).data
|
||||||
|
response_data["redirect_url"] = redirect_url
|
||||||
|
response_data["created"] = created
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([permissions.AllowAny])
|
||||||
|
def payment_webhook(request):
|
||||||
|
secret = os.getenv("MOYASAR_WEBHOOK_SECRET")
|
||||||
|
payload = request.data or {}
|
||||||
|
if not secret:
|
||||||
|
return Response({"detail": _("Webhook secret not configured")}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
if payload.get("secret_token") != secret:
|
||||||
|
return Response({"detail": _("Invalid webhook signature")}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
event_type = payload.get("type")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
external_id = data.get("id")
|
||||||
|
if not external_id:
|
||||||
|
return Response({"detail": _("Missing payment reference")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
payment = Payment.objects.filter(external_id=external_id, provider=PaymentProvider.MOYASAR).first()
|
||||||
|
if not payment:
|
||||||
|
logger.warning("Moyasar webhook for unknown payment %s", external_id)
|
||||||
|
return Response({"detail": _("Payment not found")}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
applied = apply_webhook_event(payment, event_type, payload)
|
||||||
|
if not applied:
|
||||||
|
return Response({"detail": _("Event ignored")}, status=status.HTTP_200_OK)
|
||||||
|
return Response({"detail": _("Webhook processed")}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ djangorestframework-simplejwt>=5.3
|
|||||||
django-cors-headers>=4.3
|
django-cors-headers>=4.3
|
||||||
psycopg[binary]>=3.1
|
psycopg[binary]>=3.1
|
||||||
python-dotenv>=1.0
|
python-dotenv>=1.0
|
||||||
|
requests>=2.31
|
||||||
|
|||||||
@@ -11,17 +11,17 @@ After this change, the backend can create Moyasar payments, track their state tr
|
|||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
- [x] (2026-02-28 14:35Z) Created ExecPlan for payments integration (Moyasar + webhooks + idempotency).
|
- [x] (2026-02-28 14:35Z) Created ExecPlan for payments integration (Moyasar + webhooks + idempotency).
|
||||||
- [ ] Inspect current payments models, endpoints, and any Moyasar scaffolding to align naming.
|
- [x] (2026-02-28 15:05Z) Inspected payments models/endpoints and aligned naming with Moyasar scaffolding.
|
||||||
- [ ] Define payment state model and idempotency tracking.
|
- [x] (2026-02-28 15:20Z) Defined payment state model extensions and idempotency tracking fields.
|
||||||
- [ ] Implement payment creation service and API endpoint.
|
- [x] (2026-02-28 15:40Z) Implemented payment creation service and API endpoint with provider gateway.
|
||||||
- [ ] Implement webhook endpoint with signature verification.
|
- [x] (2026-02-28 15:55Z) Implemented webhook endpoint with secret verification and status mapping.
|
||||||
- [ ] Add tests for creation, idempotency, and webhook reconciliation.
|
- [x] (2026-02-28 16:10Z) Added tests for creation, idempotency, and webhook reconciliation.
|
||||||
- [ ] Update `docs/risks.md` to close payment integration gaps once tested.
|
- [x] (2026-02-28 16:20Z) Updated `docs/risks.md` to close payment integration gaps once tested.
|
||||||
|
|
||||||
## Surprises & Discoveries
|
## Surprises & Discoveries
|
||||||
|
|
||||||
- Observation: None yet.
|
- Observation: The payments gateway needed an HTTP client dependency, so `requests` was added to backend requirements.
|
||||||
Evidence: No implementation work has started.
|
Evidence: `ModuleNotFoundError: No module named 'requests'` when running migrations after adding gateway calls.
|
||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
@@ -34,10 +34,13 @@ After this change, the backend can create Moyasar payments, track their state tr
|
|||||||
- Decision: Use a dedicated webhook endpoint with signature verification.
|
- Decision: Use a dedicated webhook endpoint with signature verification.
|
||||||
Rationale: Ensures authenticity of provider callbacks and protects state integrity.
|
Rationale: Ensures authenticity of provider callbacks and protects state integrity.
|
||||||
Date/Author: 2026-02-28, Codex
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Store provider payloads and webhook payloads on the payment record for auditability.
|
||||||
|
Rationale: Helps trace payment transitions without introducing a separate event table yet.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
|
||||||
## Outcomes & Retrospective
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
Planned changes will provide end-to-end payment creation, reconciliation via webhooks, and idempotency protections, filling the largest Phase 1 reliability gap.
|
Payment creation, idempotency handling, and webhook reconciliation are implemented for Moyasar. Tests cover creation, idempotency, and webhook status transitions, reducing the largest Phase 1 reliability gap. Refund/capture operations remain future work if required.
|
||||||
|
|
||||||
## Context and Orientation
|
## Context and Orientation
|
||||||
|
|
||||||
@@ -132,3 +135,4 @@ Example overlap-safe webhook logic:
|
|||||||
- `backend/apps/payments/views.py` must expose `PaymentCreateAPIView` and `payment_webhook` with signature verification.
|
- `backend/apps/payments/views.py` must expose `PaymentCreateAPIView` and `payment_webhook` with signature verification.
|
||||||
|
|
||||||
Plan Maintenance Note: Created on 2026-02-28 to implement Moyasar payments with idempotency and webhook reconciliation as the next Phase 1 reliability milestone.
|
Plan Maintenance Note: Created on 2026-02-28 to implement Moyasar payments with idempotency and webhook reconciliation as the next Phase 1 reliability milestone.
|
||||||
|
Plan Maintenance Note (Update): Marked steps complete and recorded dependency and audit decisions after implementing payments and tests on 2026-02-28.
|
||||||
|
|||||||
+2
-3
@@ -15,9 +15,8 @@ This file tracks known gaps and risks to address in future iterations.
|
|||||||
- No cancellation rules or refund logic.
|
- No cancellation rules or refund logic.
|
||||||
|
|
||||||
## Payments
|
## Payments
|
||||||
- Payment integration is not implemented. Current API only stores records and a Moyasar scaffold exists.
|
- Moyasar payment creation, webhook reconciliation, and idempotency are implemented.
|
||||||
- Webhook handling and payment status reconciliation missing.
|
- Refund/capture operations are not implemented yet if required.
|
||||||
- Idempotency handling for payment creation missing.
|
|
||||||
|
|
||||||
## Data And UX
|
## Data And UX
|
||||||
- Ratings are not recalculated from reviews.
|
- Ratings are not recalculated from reviews.
|
||||||
|
|||||||
Reference in New Issue
Block a user