From f3c93f500e69a148ae5cb41319c5b2840ac22d19 Mon Sep 17 00:00:00 2001 From: mohammad Date: Sat, 28 Feb 2026 13:01:12 +0300 Subject: [PATCH] Implement Moyasar payments flow with webhooks --- backend/.env.example | 1 + ...horized_at_payment_captured_at_and_more.py | 73 ++++++++ backend/apps/payments/models.py | 18 +- backend/apps/payments/serializers.py | 43 +++-- backend/apps/payments/services/gateway.py | 73 +++++++- backend/apps/payments/services/payments.py | 175 ++++++++++++++++++ .../apps/payments/tests/test_payments_flow.py | 168 +++++++++++++++++ backend/apps/payments/urls.py | 3 +- backend/apps/payments/views.py | 61 ++++-- backend/requirements.txt | 1 + docs/execplans/payments-moyasar.md | 22 ++- docs/risks.md | 5 +- 12 files changed, 600 insertions(+), 43 deletions(-) create mode 100644 backend/apps/payments/migrations/0002_payment_authorized_at_payment_captured_at_and_more.py create mode 100644 backend/apps/payments/services/payments.py create mode 100644 backend/apps/payments/tests/test_payments_flow.py diff --git a/backend/.env.example b/backend/.env.example index 74448a9..0d2310f 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER= MOYASAR_SECRET_KEY= MOYASAR_PUBLISHABLE_KEY= MOYASAR_BASE_URL= +MOYASAR_WEBHOOK_SECRET= diff --git a/backend/apps/payments/migrations/0002_payment_authorized_at_payment_captured_at_and_more.py b/backend/apps/payments/migrations/0002_payment_authorized_at_payment_captured_at_and_more.py new file mode 100644 index 0000000..608cca7 --- /dev/null +++ b/backend/apps/payments/migrations/0002_payment_authorized_at_payment_captured_at_and_more.py @@ -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), + ), + ] diff --git a/backend/apps/payments/models.py b/backend/apps/payments/models.py index a56ab35..b5d122e 100644 --- a/backend/apps/payments/models.py +++ b/backend/apps/payments/models.py @@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices): class PaymentStatus(models.TextChoices): + INITIATED = "initiated", "Initiated" CREATED = "created", "Created" AUTHORIZED = "authorized", "Authorized" CAPTURED = "captured", "Captured" + PAID = "paid", "Paid" FAILED = "failed", "Failed" REFUNDED = "refunded", "Refunded" + VOIDED = "voided", "Voided" + VERIFIED = "verified", "Verified" class Payment(models.Model): booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments") 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) 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) + 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) def __str__(self): diff --git a/backend/apps/payments/serializers.py b/backend/apps/payments/serializers.py index 2f0fac4..c977a33 100644 --- a/backend/apps/payments/serializers.py +++ b/backend/apps/payments/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers from django.utils.translation import gettext_lazy as _ 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): @@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer): "amount", "currency", "external_id", + "idempotency_key", "metadata", + "authorized_at", + "captured_at", + "paid_at", + "failed_at", + "refunded_at", + "voided_at", + "verified_at", + "status_updated_at", "created_at", ] read_only_fields = fields @@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer): class PaymentCreateSerializer(serializers.ModelSerializer): booking_id = serializers.IntegerField(write_only=True) 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: model = Payment - fields = ["booking_id", "provider"] + fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"] def validate_booking_id(self, value): if not Booking.objects.filter(id=value).exists(): raise serializers.ValidationError(_("Booking not found")) return value - def create(self, validated_data): - booking = Booking.objects.get(id=validated_data["booking_id"]) - return Payment.objects.create( - booking=booking, - provider=validated_data["provider"], - status=PaymentStatus.CREATED, - amount=booking.price_amount, - currency=booking.currency, - metadata={}, - ) + def validate(self, attrs): + provider = attrs.get("provider") + source = attrs.get("source") + if provider != PaymentProvider.MOYASAR: + raise serializers.ValidationError({"provider": _("Provider integration not implemented")}) + if source is None: + raise serializers.ValidationError({"source": _("Payment source is required")}) + source_type = source.get("type") + 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 diff --git a/backend/apps/payments/services/gateway.py b/backend/apps/payments/services/gateway.py index 9683a68..afed0df 100644 --- a/backend/apps/payments/services/gateway.py +++ b/backend/apps/payments/services/gateway.py @@ -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() diff --git a/backend/apps/payments/services/payments.py b/backend/apps/payments/services/payments.py new file mode 100644 index 0000000..221871c --- /dev/null +++ b/backend/apps/payments/services/payments.py @@ -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 diff --git a/backend/apps/payments/tests/test_payments_flow.py b/backend/apps/payments/tests/test_payments_flow.py new file mode 100644 index 0000000..bdbdeda --- /dev/null +++ b/backend/apps/payments/tests/test_payments_flow.py @@ -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 diff --git a/backend/apps/payments/urls.py b/backend/apps/payments/urls.py index 082f141..e14b7e2 100644 --- a/backend/apps/payments/urls.py +++ b/backend/apps/payments/urls.py @@ -1,11 +1,12 @@ from django.urls import include, path from rest_framework.routers import DefaultRouter -from apps.payments.views import PaymentViewSet +from apps.payments.views import PaymentViewSet, payment_webhook router = DefaultRouter() router.register(r"", PaymentViewSet, basename="payment") urlpatterns = [ + path("webhook/", payment_webhook, name="payment-webhook"), path("", include(router.urls)), ] diff --git a/backend/apps/payments/views.py b/backend/apps/payments/views.py index 6081c77..646169e 100644 --- a/backend/apps/payments/views.py +++ b/backend/apps/payments/views.py @@ -1,10 +1,17 @@ -from rest_framework import permissions, status, viewsets -from rest_framework.response import Response +import logging +import os + 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.payments.models import Payment +from apps.payments.models import Payment, PaymentProvider 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: @@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet): booking = Booking.objects.get(id=serializer.validated_data["booking_id"]) if not user_can_access_booking(request.user, booking): return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN) - payment = serializer.save() - return Response( - { - "detail": _("Payment record created. Provider integration pending."), - "payment_id": payment.id, - "amount": str(payment.amount), - "currency": payment.currency, - "status": payment.status, - }, - status=status.HTTP_201_CREATED, + payment, created, redirect_url = create_payment_for_booking( + booking=booking, + provider=serializer.validated_data["provider"], + idempotency_key=serializer.validated_data["idempotency_key"], + source=serializer.validated_data["source"], + callback_url=serializer.validated_data.get("callback_url"), ) + 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) diff --git a/backend/requirements.txt b/backend/requirements.txt index cdf6add..dc6668c 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -4,3 +4,4 @@ djangorestframework-simplejwt>=5.3 django-cors-headers>=4.3 psycopg[binary]>=3.1 python-dotenv>=1.0 +requests>=2.31 diff --git a/docs/execplans/payments-moyasar.md b/docs/execplans/payments-moyasar.md index 9891388..2d3ab01 100644 --- a/docs/execplans/payments-moyasar.md +++ b/docs/execplans/payments-moyasar.md @@ -11,17 +11,17 @@ After this change, the backend can create Moyasar payments, track their state tr ## Progress - [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. -- [ ] Define payment state model and idempotency tracking. -- [ ] Implement payment creation service and API endpoint. -- [ ] Implement webhook endpoint with signature verification. -- [ ] Add tests for creation, idempotency, and webhook reconciliation. -- [ ] Update `docs/risks.md` to close payment integration gaps once tested. +- [x] (2026-02-28 15:05Z) Inspected payments models/endpoints and aligned naming with Moyasar scaffolding. +- [x] (2026-02-28 15:20Z) Defined payment state model extensions and idempotency tracking fields. +- [x] (2026-02-28 15:40Z) Implemented payment creation service and API endpoint with provider gateway. +- [x] (2026-02-28 15:55Z) Implemented webhook endpoint with secret verification and status mapping. +- [x] (2026-02-28 16:10Z) Added tests for creation, idempotency, and webhook reconciliation. +- [x] (2026-02-28 16:20Z) Updated `docs/risks.md` to close payment integration gaps once tested. ## Surprises & Discoveries -- Observation: None yet. - Evidence: No implementation work has started. +- Observation: The payments gateway needed an HTTP client dependency, so `requests` was added to backend requirements. + Evidence: `ModuleNotFoundError: No module named 'requests'` when running migrations after adding gateway calls. ## 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. Rationale: Ensures authenticity of provider callbacks and protects state integrity. 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 -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 @@ -132,3 +135,4 @@ Example overlap-safe webhook logic: - `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 (Update): Marked steps complete and recorded dependency and audit decisions after implementing payments and tests on 2026-02-28. diff --git a/docs/risks.md b/docs/risks.md index 4fce8ac..c31ea36 100644 --- a/docs/risks.md +++ b/docs/risks.md @@ -15,9 +15,8 @@ This file tracks known gaps and risks to address in future iterations. - No cancellation rules or refund logic. ## Payments -- Payment integration is not implemented. Current API only stores records and a Moyasar scaffold exists. -- Webhook handling and payment status reconciliation missing. -- Idempotency handling for payment creation missing. +- Moyasar payment creation, webhook reconciliation, and idempotency are implemented. +- Refund/capture operations are not implemented yet if required. ## Data And UX - Ratings are not recalculated from reviews.