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
+1
View File
@@ -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=
@@ -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),
),
]
+16 -2
View File
@@ -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):
+31 -12
View File
@@ -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
+70 -3
View File
@@ -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()
+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
@@ -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
+2 -1
View File
@@ -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)),
] ]
+48 -13
View File
@@ -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)
+1
View File
@@ -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
+13 -9
View File
@@ -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
View File
@@ -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.