Compare commits
4 Commits
ce99eba922
...
db36551211
| Author | SHA1 | Date | |
|---|---|---|---|
| db36551211 | |||
| a150b18fe7 | |||
| f3c93f500e | |||
| d9767ff0a7 |
@@ -3,6 +3,10 @@
|
||||
## Project Goal
|
||||
Build a reliable, maintainable salon booking platform with Django (backend) and React (frontend), optimized for KSA needs (phone auth, local payments) while keeping a clean path to scale.
|
||||
|
||||
## Coding
|
||||
|
||||
- Comment concisely and often as appropriate
|
||||
|
||||
## Current Plan (Roadmap)
|
||||
### Phase 1: Core MVP Reliability
|
||||
- Phone-first auth with OTP (SMS/WhatsApp), rate limits, and social login.
|
||||
@@ -61,7 +65,8 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
|
||||
- Avoid destructive git commands unless explicitly asked.
|
||||
- Update `docs/risks.md` when adding or closing a significant gap.
|
||||
- Keep README instructions current when tooling changes.
|
||||
- Prefer feature branches for significant work; commit early with clear summary messages.
|
||||
|
||||
# ExecPlans
|
||||
|
||||
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The active ExecPlan is `docs/execplans/booking-integrity.md`.
|
||||
When writing complex features or significant refactors, use an ExecPlan (as described in PLANS.md) from design to implementation. The active ExecPlan is `docs/execplans/payments-moyasar.md`.
|
||||
|
||||
@@ -4,7 +4,7 @@ This document describes the requirements for an execution plan ("ExecPlan"), a d
|
||||
|
||||
## Active ExecPlans
|
||||
|
||||
The current execution plan is `docs/execplans/booking-integrity.md`. It focuses on booking integrity (availability checks, staff schedules, overlap prevention) as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||
The current execution plan is `docs/execplans/payments-moyasar.md`. It focuses on Moyasar payments integration with webhooks and idempotency as the next Phase 1 reliability milestone. Keep it updated in line with the requirements below.
|
||||
|
||||
## How to use ExecPlans and PLANS.md
|
||||
|
||||
|
||||
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
|
||||
MOYASAR_SECRET_KEY=
|
||||
MOYASAR_PUBLISHABLE_KEY=
|
||||
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):
|
||||
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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 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)),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -126,10 +126,10 @@ class Command(BaseCommand):
|
||||
booking=booking,
|
||||
provider=PaymentProvider.MOYASAR,
|
||||
defaults={
|
||||
"status": PaymentStatus.CREATED,
|
||||
"status": PaymentStatus.INITIATED,
|
||||
"amount": booking.price_amount,
|
||||
"currency": booking.currency,
|
||||
"external_id": "",
|
||||
"external_id": None,
|
||||
"metadata": {"note": "Demo payment record"},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -4,3 +4,4 @@ djangorestframework-simplejwt>=5.3
|
||||
django-cors-headers>=4.3
|
||||
psycopg[binary]>=3.1
|
||||
python-dotenv>=1.0
|
||||
requests>=2.31
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
# Payments Integration (Moyasar, Webhooks, Idempotency)
|
||||
|
||||
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||
|
||||
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
||||
|
||||
## Purpose / Big Picture
|
||||
|
||||
After this change, the backend can create Moyasar payments, track their state transitions, and reconcile them via webhooks in an idempotent and auditable way. A user can create a booking payment and see it progress from initiated to paid or failed. You can see it working by creating a payment, receiving a webhook callback that marks it as paid, and observing the payment record transition with a recorded provider reference and idempotency key.
|
||||
|
||||
## Progress
|
||||
|
||||
- [x] (2026-02-28 14:35Z) Created ExecPlan for payments integration (Moyasar + webhooks + idempotency).
|
||||
- [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: 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
|
||||
|
||||
- Decision: Model payment state transitions as explicit status changes with audit-friendly timestamps.
|
||||
Rationale: Payment flows must be auditable and deterministic under retries.
|
||||
Date/Author: 2026-02-28, Codex
|
||||
- Decision: Require idempotency keys on payment creation requests.
|
||||
Rationale: Prevents duplicate charges when clients retry.
|
||||
Date/Author: 2026-02-28, Codex
|
||||
- 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
|
||||
|
||||
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
|
||||
|
||||
Payments live in `backend/apps/payments/` with current models and API endpoints. The system currently stores payment records but does not integrate with Moyasar or reconcile webhooks. Booking flows live in `backend/apps/bookings/` and should link to payments. The project standards require business logic in services and predictable error responses.
|
||||
|
||||
## Plan of Work
|
||||
|
||||
First, review existing payment models and endpoints to avoid breaking field names. Identify whether `Payment` includes a reference to `Booking`, a `provider_reference`, and a status field. If any are missing, add them along with timestamps for `initiated_at`, `paid_at`, and `failed_at`. Create a migration for the new fields. Ensure status choices include at least `initiated`, `pending`, `paid`, `failed`, and `refunded` if refunds are in scope.
|
||||
|
||||
Next, introduce idempotency tracking. Add a `idempotency_key` field to the payment model (unique, indexed) and validate that payment creation requests require it. If a request repeats with the same key, return the existing payment without creating a new provider charge.
|
||||
|
||||
Then, implement the Moyasar payment creation service in `backend/apps/payments/services.py`. The service should build the provider request using amount, currency, description, and return URLs, and persist the `provider_reference` (payment id returned by Moyasar). Store the full provider response in a JSON field for audit if available.
|
||||
|
||||
Add a dedicated API endpoint for payment creation in `backend/apps/payments/views.py` and `backend/apps/payments/urls.py`. It should:
|
||||
|
||||
- Require authentication.
|
||||
- Validate booking ownership and amount.
|
||||
- Require `idempotency_key`.
|
||||
- Call the service to create the provider payment.
|
||||
- Return the payment record plus any provider redirect URL if applicable.
|
||||
|
||||
Then, implement the webhook endpoint (`/api/payments/webhook/`) with signature verification using Moyasar’s secret. It should parse the event, locate the payment by `provider_reference`, apply an idempotent state transition, and record timestamps. Unknown events should be logged but return 200 to avoid retries if possible.
|
||||
|
||||
Finally, add tests in `backend/apps/payments/tests/`:
|
||||
|
||||
- Creating a payment succeeds and stores provider reference.
|
||||
- Creating with the same idempotency key returns the original record.
|
||||
- Webhook for `paid` updates status and timestamp.
|
||||
- Webhook with invalid signature is rejected.
|
||||
- Webhook is idempotent (replay does not change state or duplicate logs).
|
||||
|
||||
Update `docs/risks.md` to mark payment integration gaps as addressed.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
|
||||
|
||||
1. Inspect payments models and endpoints.
|
||||
- Read `backend/apps/payments/models.py`, `backend/apps/payments/views.py`, and `backend/apps/payments/serializers.py`.
|
||||
|
||||
2. Add fields for provider reference, status timestamps, and idempotency.
|
||||
- Update `backend/apps/payments/models.py` and create a migration.
|
||||
- Run:
|
||||
python3 backend/manage.py makemigrations payments
|
||||
|
||||
3. Implement services and endpoints.
|
||||
- Add `backend/apps/payments/services.py`.
|
||||
- Update serializers and views accordingly.
|
||||
|
||||
4. Add webhook endpoint and signature verification.
|
||||
- Update `backend/apps/payments/urls.py` and `backend/apps/payments/views.py`.
|
||||
|
||||
5. Add tests.
|
||||
- Create `backend/apps/payments/tests/test_payments_flow.py`.
|
||||
|
||||
6. Run tests.
|
||||
- Backend:
|
||||
source venv/bin/activate
|
||||
cd backend
|
||||
python3 -m pytest
|
||||
|
||||
## Validation and Acceptance
|
||||
|
||||
- Creating a payment with a new idempotency key returns HTTP 201 and a provider reference.
|
||||
- Creating the same payment with the same idempotency key returns HTTP 200/201 with the original payment (no new provider request).
|
||||
- A valid webhook updates the payment status to `paid` and sets `paid_at`.
|
||||
- An invalid webhook signature returns HTTP 400/401 and does not mutate data.
|
||||
- `python3 -m pytest` passes with the new payments tests.
|
||||
|
||||
## Idempotence and Recovery
|
||||
|
||||
Payment creation is safe to retry with idempotency keys. Webhook processing is idempotent and can be replayed safely. If a payment status change is applied incorrectly, it can be corrected manually via admin and will be documented in audit fields.
|
||||
|
||||
## Artifacts and Notes
|
||||
|
||||
Example idempotency pattern:
|
||||
|
||||
existing = Payment.objects.filter(idempotency_key=key).first()
|
||||
if existing:
|
||||
return existing
|
||||
|
||||
Example overlap-safe webhook logic:
|
||||
|
||||
if payment.status == PaymentStatus.PAID:
|
||||
return
|
||||
payment.mark_paid()
|
||||
|
||||
## Interfaces and Dependencies
|
||||
|
||||
- `backend/apps/payments/models.py` must include fields: `provider_reference`, `idempotency_key`, `status`, `initiated_at`, `paid_at`, `failed_at`, and (optionally) `provider_payload` (JSON).
|
||||
- `backend/apps/payments/services.py` must define `create_payment_for_booking(booking, idempotency_key, request_data)` and `verify_webhook_signature(request)`.
|
||||
- `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
@@ -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.
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Payments Sanity Check (Moyasar Mock + Demo Data)
|
||||
|
||||
This runbook documents the end-to-end sanity check for the Moyasar payments flow using demo data and a local mock provider. It is intended for developers and agents validating payment creation + webhook reconciliation before merging to `main`.
|
||||
|
||||
## Purpose
|
||||
|
||||
Verify that the payment creation endpoint and webhook processing work end-to-end in a local environment without hitting Moyasar.
|
||||
|
||||
## Preconditions
|
||||
|
||||
- Backend dependencies installed in the Python venv.
|
||||
- Frontend is not required for this check.
|
||||
- `backend/` database is migrated and uses SQLite for local dev.
|
||||
|
||||
## High-level Flow
|
||||
|
||||
1. Start a local mock Moyasar server (HTTP) that emulates `/v1/payments` responses.
|
||||
2. Run migrations and seed demo data.
|
||||
3. Start Django with a local payment configuration pointing to the mock server.
|
||||
4. Obtain a JWT access token for the demo customer.
|
||||
5. Create a payment for an existing booking.
|
||||
6. Send a webhook payload to mark it as paid.
|
||||
7. Verify the payment status updates.
|
||||
|
||||
## Steps
|
||||
|
||||
### 1) Start the mock Moyasar server
|
||||
|
||||
The mock server responds to `POST /v1/payments` with a static `id` and `transaction_url`.
|
||||
|
||||
Create the mock server at `/tmp/moyasar_mock.py` and run it:
|
||||
|
||||
python3 /tmp/moyasar_mock.py
|
||||
|
||||
Expected: the process stays running, listening on `http://127.0.0.1:8001`.
|
||||
|
||||
### 2) Run migrations and seed demo data
|
||||
|
||||
source venv/bin/activate
|
||||
cd backend
|
||||
python3 manage.py migrate
|
||||
python3 manage.py seed_demo
|
||||
|
||||
Expected: `Demo data seeded.`
|
||||
|
||||
### 3) Start Django with the mock provider
|
||||
|
||||
Run the backend with environment variables pointing to the mock server:
|
||||
|
||||
DJANGO_DEBUG=1 \
|
||||
MOYASAR_SECRET_KEY=sk_test \
|
||||
MOYASAR_PUBLISHABLE_KEY=pk_test \
|
||||
MOYASAR_BASE_URL=http://127.0.0.1:8001 \
|
||||
MOYASAR_WEBHOOK_SECRET=whsec \
|
||||
python3 manage.py runserver 8000
|
||||
|
||||
Expected: server starts at `http://127.0.0.1:8000/`.
|
||||
|
||||
### 4) Obtain a JWT access token
|
||||
|
||||
The demo customer is:
|
||||
|
||||
- `customer@example.com`
|
||||
- `Customer123!`
|
||||
|
||||
Fetch the access token:
|
||||
|
||||
curl -s -X POST http://127.0.0.1:8000/api/auth/token/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"customer@example.com","password":"Customer123!"}'
|
||||
|
||||
Expected: JSON containing `access` and `refresh` tokens.
|
||||
|
||||
### 5) Create a payment
|
||||
|
||||
Pick a booking (demo data creates bookings; you can list them):
|
||||
|
||||
curl -s -H "Authorization: Bearer <ACCESS>" http://127.0.0.1:8000/api/bookings/
|
||||
|
||||
Then create a payment (example uses booking id `3`):
|
||||
|
||||
curl -s -X POST http://127.0.0.1:8000/api/payments/ \
|
||||
-H "Authorization: Bearer <ACCESS>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"booking_id": 3,
|
||||
"provider": "moyasar",
|
||||
"idempotency_key": "<UUID>",
|
||||
"source": {"type": "stcpay", "mobile": "0500000000"}
|
||||
}'
|
||||
|
||||
Expected: response includes:
|
||||
|
||||
- `status: initiated`
|
||||
- `external_id: pay_mock_123`
|
||||
- `redirect_url: https://moyasar.example/tx/mock`
|
||||
|
||||
### 6) Send webhook for paid state
|
||||
|
||||
curl -s -X POST http://127.0.0.1:8000/api/payments/webhook/ \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"type":"payment_paid","secret_token":"whsec","data":{"id":"pay_mock_123"}}'
|
||||
|
||||
Expected: `{ "detail": "Webhook processed" }`
|
||||
|
||||
### 7) Verify payment state
|
||||
|
||||
curl -s -H "Authorization: Bearer <ACCESS>" http://127.0.0.1:8000/api/payments/
|
||||
|
||||
Expected: payment record shows:
|
||||
|
||||
- `status: paid`
|
||||
- `paid_at` set
|
||||
- `metadata.last_webhook` populated
|
||||
|
||||
## Considerations and Edge Cases
|
||||
|
||||
- **Webhook secret**: `MOYASAR_WEBHOOK_SECRET` must be set. Requests missing or mismatching `secret_token` return `401`.
|
||||
- **Idempotency**: reuse the same `idempotency_key` to verify the API returns the existing payment without creating another provider charge.
|
||||
- **Unsupported sources**: `creditcard` is rejected by the backend. Use `stcpay`, `token`, or `applepay`.
|
||||
- **Callback URL**: required for `token` payments; otherwise validation fails.
|
||||
- **Demo data**: `seed_demo` creates a payment with `external_id=None` (not empty string) to avoid violating unique constraints.
|
||||
- **Debug mode**: `DJANGO_DEBUG=1` is required for local `runserver` if `ALLOWED_HOSTS` is not set.
|
||||
- **JWT warnings**: short JWT secret keys can trigger warnings in logs; this is acceptable for local sanity checks but should be hardened in production.
|
||||
|
||||
## What to Look For
|
||||
|
||||
- Payment creation returns `external_id` from the mock server.
|
||||
- Webhook transitions the payment to `paid` and populates `paid_at`.
|
||||
- `metadata.last_webhook` persists the payload for audit.
|
||||
|
||||
## Cleanup
|
||||
|
||||
- Stop the Django server (`Ctrl+C`).
|
||||
- Stop the mock server (`Ctrl+C`).
|
||||
- Optionally delete `/tmp/moyasar_mock.py`.
|
||||
Generated
+4487
File diff suppressed because it is too large
Load Diff
+152
-2
@@ -1,14 +1,33 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { apiGet } from "./api/client";
|
||||
import { apiGet, apiPost } from "./api/client";
|
||||
import { setLocale } from "./i18n";
|
||||
|
||||
export default function App() {
|
||||
const [salons, setSalons] = useState([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [status, setStatus] = useState("idle");
|
||||
const [paymentBookingId, setPaymentBookingId] = useState("");
|
||||
const [paymentToken, setPaymentToken] = useState(() => localStorage.getItem("auth_token") || "");
|
||||
const [paymentSourceType, setPaymentSourceType] = useState("stcpay");
|
||||
const [paymentSourceValue, setPaymentSourceValue] = useState("");
|
||||
const [paymentCallbackUrl, setPaymentCallbackUrl] = useState("");
|
||||
const [paymentStatus, setPaymentStatus] = useState("idle");
|
||||
const [paymentResult, setPaymentResult] = useState(null);
|
||||
const [paymentError, setPaymentError] = useState("");
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const idempotencyKey = useMemo(() => {
|
||||
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("auth_token", paymentToken);
|
||||
}, [paymentToken]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
|
||||
@@ -33,6 +52,60 @@ export default function App() {
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
async function handlePaymentSubmit(event) {
|
||||
event.preventDefault();
|
||||
setPaymentStatus("loading");
|
||||
setPaymentError("");
|
||||
setPaymentResult(null);
|
||||
|
||||
if (!paymentBookingId) {
|
||||
setPaymentStatus("error");
|
||||
setPaymentError(t("payment.errors.bookingRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
const source = { type: paymentSourceType };
|
||||
if (paymentSourceType === "stcpay") {
|
||||
if (!paymentSourceValue) {
|
||||
setPaymentStatus("error");
|
||||
setPaymentError(t("payment.errors.mobileRequired"));
|
||||
return;
|
||||
}
|
||||
source.mobile = paymentSourceValue;
|
||||
}
|
||||
if (paymentSourceType === "token") {
|
||||
if (!paymentSourceValue) {
|
||||
setPaymentStatus("error");
|
||||
setPaymentError(t("payment.errors.tokenRequired"));
|
||||
return;
|
||||
}
|
||||
source.token = paymentSourceValue;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
booking_id: Number(paymentBookingId),
|
||||
provider: "moyasar",
|
||||
idempotency_key: idempotencyKey,
|
||||
source,
|
||||
};
|
||||
|
||||
if (paymentCallbackUrl) {
|
||||
payload.callback_url = paymentCallbackUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await apiPost("/payments/", payload, paymentToken);
|
||||
setPaymentResult(data);
|
||||
setPaymentStatus("ready");
|
||||
if (data?.redirect_url) {
|
||||
window.location.assign(data.redirect_url);
|
||||
}
|
||||
} catch (error) {
|
||||
setPaymentStatus("error");
|
||||
setPaymentError(error.message || t("payment.errors.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header className="hero">
|
||||
@@ -90,6 +163,83 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="payments">
|
||||
<div className="payments-header">
|
||||
<div>
|
||||
<h2>{t("payment.title")}</h2>
|
||||
<p className="payments-subtitle">{t("payment.subtitle")}</p>
|
||||
</div>
|
||||
<span className="payments-badge">{t("payment.badge")}</span>
|
||||
</div>
|
||||
|
||||
<form className="payments-form" onSubmit={handlePaymentSubmit}>
|
||||
<label className="field">
|
||||
<span>{t("payment.bookingId")}</span>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={paymentBookingId}
|
||||
onChange={(event) => setPaymentBookingId(event.target.value)}
|
||||
placeholder="123"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("payment.accessToken")}</span>
|
||||
<input
|
||||
type="password"
|
||||
value={paymentToken}
|
||||
onChange={(event) => setPaymentToken(event.target.value)}
|
||||
placeholder={t("payment.accessTokenPlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("payment.sourceType")}</span>
|
||||
<select
|
||||
value={paymentSourceType}
|
||||
onChange={(event) => setPaymentSourceType(event.target.value)}
|
||||
>
|
||||
<option value="stcpay">{t("payment.sources.stcpay")}</option>
|
||||
<option value="token">{t("payment.sources.token")}</option>
|
||||
<option value="applepay">{t("payment.sources.applepay")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("payment.sourceValue")}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={paymentSourceValue}
|
||||
onChange={(event) => setPaymentSourceValue(event.target.value)}
|
||||
placeholder={t("payment.sourceValuePlaceholder")}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t("payment.callbackUrl")}</span>
|
||||
<input
|
||||
type="url"
|
||||
value={paymentCallbackUrl}
|
||||
onChange={(event) => setPaymentCallbackUrl(event.target.value)}
|
||||
placeholder="https://example.com/payments/return"
|
||||
/>
|
||||
</label>
|
||||
<div className="payments-actions">
|
||||
<button type="submit" disabled={paymentStatus === "loading"}>
|
||||
{paymentStatus === "loading" ? t("payment.processing") : t("payment.payNow")}
|
||||
</button>
|
||||
<p className="helper">
|
||||
{t("payment.idempotency")}: {idempotencyKey}
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{paymentStatus === "error" && paymentError && (
|
||||
<p className="error">{paymentError}</p>
|
||||
)}
|
||||
{paymentStatus === "ready" && paymentResult && (
|
||||
<pre className="payment-result">{JSON.stringify(paymentResult, null, 2)}</pre>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,30 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { vi } from "vitest";
|
||||
import App from "./App.jsx";
|
||||
import i18n from "./i18n";
|
||||
|
||||
vi.mock("./api/client", () => ({
|
||||
apiGet: vi.fn().mockResolvedValue([]),
|
||||
apiPost: vi.fn()
|
||||
}));
|
||||
|
||||
describe("App", () => {
|
||||
it("renders the hero copy", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
render(<App />);
|
||||
expect(
|
||||
screen.getByText("Find, compare, and book top salons near you.")
|
||||
await screen.findByText("Find, compare, and book top salons near you.")
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches to Arabic and sets RTL direction", async () => {
|
||||
await i18n.changeLanguage("en");
|
||||
render(<App />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
|
||||
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
||||
fireEvent.click(arabicButton);
|
||||
await waitFor(() => {
|
||||
expect(document.documentElement.dir).toBe("rtl");
|
||||
});
|
||||
expect(screen.getByText("الصالونات")).toBeInTheDocument();
|
||||
expect(arabicButton).toHaveClass("active");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,3 +18,20 @@ export async function apiGet(path) {
|
||||
});
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
export async function apiPost(path, body, token) {
|
||||
const headers = {
|
||||
"Accept-Language": getActiveLocale(),
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return handleResponse(response);
|
||||
}
|
||||
|
||||
@@ -19,5 +19,31 @@
|
||||
"label": "اللغة",
|
||||
"arabic": "العربية",
|
||||
"english": "الإنجليزية"
|
||||
},
|
||||
"payment": {
|
||||
"title": "المدفوعات (تجريبي)",
|
||||
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
|
||||
"badge": "المدفوعات",
|
||||
"bookingId": "رقم الحجز",
|
||||
"accessToken": "رمز الوصول",
|
||||
"accessTokenPlaceholder": "الصقي رمز JWT",
|
||||
"sourceType": "نوع المصدر",
|
||||
"sourceValue": "قيمة المصدر",
|
||||
"sourceValuePlaceholder": "رقم الجوال أو الرمز",
|
||||
"callbackUrl": "رابط العودة",
|
||||
"payNow": "ادفع الآن",
|
||||
"processing": "جارٍ المعالجة...",
|
||||
"idempotency": "مفتاح التكرار",
|
||||
"sources": {
|
||||
"stcpay": "stc pay (جوال)",
|
||||
"token": "دفع عبر رمز",
|
||||
"applepay": "Apple Pay"
|
||||
},
|
||||
"errors": {
|
||||
"bookingRequired": "رقم الحجز مطلوب.",
|
||||
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
|
||||
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
|
||||
"generic": "فشل طلب الدفع."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,31 @@
|
||||
"label": "Language",
|
||||
"arabic": "العربية",
|
||||
"english": "English"
|
||||
},
|
||||
"payment": {
|
||||
"title": "Payment (Beta)",
|
||||
"subtitle": "Send a Moyasar payment for an existing booking.",
|
||||
"badge": "Payments",
|
||||
"bookingId": "Booking ID",
|
||||
"accessToken": "Access token",
|
||||
"accessTokenPlaceholder": "Paste JWT access token",
|
||||
"sourceType": "Source type",
|
||||
"sourceValue": "Source value",
|
||||
"sourceValuePlaceholder": "Mobile number or token",
|
||||
"callbackUrl": "Callback URL",
|
||||
"payNow": "Pay now",
|
||||
"processing": "Processing...",
|
||||
"idempotency": "Idempotency key",
|
||||
"sources": {
|
||||
"stcpay": "stc pay (mobile)",
|
||||
"token": "tokenized payment",
|
||||
"applepay": "Apple Pay"
|
||||
},
|
||||
"errors": {
|
||||
"bookingRequired": "Booking ID is required.",
|
||||
"mobileRequired": "Mobile number is required for stc pay.",
|
||||
"tokenRequired": "Token is required for token payments.",
|
||||
"generic": "Payment request failed."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,98 @@ h1 {
|
||||
margin-top: 48px;
|
||||
}
|
||||
|
||||
.payments {
|
||||
margin-top: 48px;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
border-radius: 24px;
|
||||
padding: 28px;
|
||||
box-shadow: 0 18px 32px rgba(23, 23, 23, 0.08);
|
||||
}
|
||||
|
||||
.payments-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.payments-subtitle {
|
||||
margin: 8px 0 0;
|
||||
color: #5c5a5f;
|
||||
}
|
||||
|
||||
.payments-badge {
|
||||
background: #1c1b1f;
|
||||
color: #fff;
|
||||
padding: 6px 12px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.payments-form {
|
||||
margin-top: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 14px;
|
||||
color: #3c3a3f;
|
||||
}
|
||||
|
||||
.field input,
|
||||
.field select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #dad3ca;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.payments-actions {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.payments-actions button {
|
||||
padding: 10px 18px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: #1c1b1f;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.payments-actions button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.helper {
|
||||
font-size: 13px;
|
||||
color: #5c5a5f;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.payment-result {
|
||||
margin-top: 16px;
|
||||
background: #f5f5f5;
|
||||
border-radius: 12px;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: "jsdom",
|
||||
setupFiles: "./src/test/setupTests.js"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user