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_PUBLISHABLE_KEY=
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):
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):
+30 -11
View File
@@ -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
+70 -3
View File
@@ -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()
+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 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)),
]
+48 -13
View File
@@ -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)
+1
View File
@@ -4,3 +4,4 @@ djangorestframework-simplejwt>=5.3
django-cors-headers>=4.3
psycopg[binary]>=3.1
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
- [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.
+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.
## 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.