Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db36551211 | |||
| a150b18fe7 | |||
| f3c93f500e | |||
| d9767ff0a7 |
@@ -3,6 +3,10 @@
|
|||||||
## Project Goal
|
## 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.
|
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)
|
## Current Plan (Roadmap)
|
||||||
### Phase 1: Core MVP Reliability
|
### Phase 1: Core MVP Reliability
|
||||||
- Phone-first auth with OTP (SMS/WhatsApp), rate limits, and social login.
|
- 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.
|
- Avoid destructive git commands unless explicitly asked.
|
||||||
- Update `docs/risks.md` when adding or closing a significant gap.
|
- Update `docs/risks.md` when adding or closing a significant gap.
|
||||||
- Keep README instructions current when tooling changes.
|
- Keep README instructions current when tooling changes.
|
||||||
|
- Prefer feature branches for significant work; commit early with clear summary messages.
|
||||||
|
|
||||||
# ExecPlans
|
# 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
|
## 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
|
## How to use ExecPlans and PLANS.md
|
||||||
|
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
|
|||||||
MOYASAR_SECRET_KEY=
|
MOYASAR_SECRET_KEY=
|
||||||
MOYASAR_PUBLISHABLE_KEY=
|
MOYASAR_PUBLISHABLE_KEY=
|
||||||
MOYASAR_BASE_URL=
|
MOYASAR_BASE_URL=
|
||||||
|
MOYASAR_WEBHOOK_SECRET=
|
||||||
|
|||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-28 09:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='authorized_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='captured_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='failed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='idempotency_key',
|
||||||
|
field=models.UUIDField(blank=True, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='paid_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='provider_payload',
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='refunded_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status_updated_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='verified_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='voided_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='external_id',
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('created', 'Created'), ('authorized', 'Authorized'), ('captured', 'Captured'), ('paid', 'Paid'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('voided', 'Voided'), ('verified', 'Verified')], default='initiated', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class PaymentStatus(models.TextChoices):
|
class PaymentStatus(models.TextChoices):
|
||||||
|
INITIATED = "initiated", "Initiated"
|
||||||
CREATED = "created", "Created"
|
CREATED = "created", "Created"
|
||||||
AUTHORIZED = "authorized", "Authorized"
|
AUTHORIZED = "authorized", "Authorized"
|
||||||
CAPTURED = "captured", "Captured"
|
CAPTURED = "captured", "Captured"
|
||||||
|
PAID = "paid", "Paid"
|
||||||
FAILED = "failed", "Failed"
|
FAILED = "failed", "Failed"
|
||||||
REFUNDED = "refunded", "Refunded"
|
REFUNDED = "refunded", "Refunded"
|
||||||
|
VOIDED = "voided", "Voided"
|
||||||
|
VERIFIED = "verified", "Verified"
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
||||||
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
||||||
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED)
|
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.INITIATED)
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
external_id = models.CharField(max_length=200, blank=True)
|
external_id = models.CharField(max_length=200, null=True, blank=True, unique=True)
|
||||||
|
idempotency_key = models.UUIDField(null=True, blank=True, unique=True)
|
||||||
|
provider_payload = models.JSONField(null=True, blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
authorized_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
captured_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
failed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
refunded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
voided_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status_updated_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from rest_framework import serializers
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
class PaymentSerializer(serializers.ModelSerializer):
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
@@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
"amount",
|
"amount",
|
||||||
"currency",
|
"currency",
|
||||||
"external_id",
|
"external_id",
|
||||||
|
"idempotency_key",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"authorized_at",
|
||||||
|
"captured_at",
|
||||||
|
"paid_at",
|
||||||
|
"failed_at",
|
||||||
|
"refunded_at",
|
||||||
|
"voided_at",
|
||||||
|
"verified_at",
|
||||||
|
"status_updated_at",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
@@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
class PaymentCreateSerializer(serializers.ModelSerializer):
|
class PaymentCreateSerializer(serializers.ModelSerializer):
|
||||||
booking_id = serializers.IntegerField(write_only=True)
|
booking_id = serializers.IntegerField(write_only=True)
|
||||||
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
||||||
|
idempotency_key = serializers.UUIDField(write_only=True)
|
||||||
|
source = serializers.JSONField(write_only=True, required=False)
|
||||||
|
callback_url = serializers.URLField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Payment
|
model = Payment
|
||||||
fields = ["booking_id", "provider"]
|
fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"]
|
||||||
|
|
||||||
def validate_booking_id(self, value):
|
def validate_booking_id(self, value):
|
||||||
if not Booking.objects.filter(id=value).exists():
|
if not Booking.objects.filter(id=value).exists():
|
||||||
raise serializers.ValidationError(_("Booking not found"))
|
raise serializers.ValidationError(_("Booking not found"))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def create(self, validated_data):
|
def validate(self, attrs):
|
||||||
booking = Booking.objects.get(id=validated_data["booking_id"])
|
provider = attrs.get("provider")
|
||||||
return Payment.objects.create(
|
source = attrs.get("source")
|
||||||
booking=booking,
|
if provider != PaymentProvider.MOYASAR:
|
||||||
provider=validated_data["provider"],
|
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||||
status=PaymentStatus.CREATED,
|
if source is None:
|
||||||
amount=booking.price_amount,
|
raise serializers.ValidationError({"source": _("Payment source is required")})
|
||||||
currency=booking.currency,
|
source_type = source.get("type")
|
||||||
metadata={},
|
if not source_type:
|
||||||
|
raise serializers.ValidationError({"source": _("Payment source type is required")})
|
||||||
|
if source_type == "creditcard":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"source": _("Card data must not be sent to the backend; use frontend tokenization")}
|
||||||
)
|
)
|
||||||
|
if source_type == "token" and not attrs.get("callback_url"):
|
||||||
|
raise serializers.ValidationError({"callback_url": _("Callback URL is required for token payments")})
|
||||||
|
return attrs
|
||||||
|
|||||||
@@ -2,15 +2,35 @@ import os
|
|||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentGatewayError(RuntimeError):
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[dict] = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.payload = payload or {}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class PaymentInitResult:
|
class PaymentInitResult:
|
||||||
external_id: str
|
external_id: str
|
||||||
|
status: Optional[str]
|
||||||
redirect_url: Optional[str]
|
redirect_url: Optional[str]
|
||||||
|
payload: dict
|
||||||
|
|
||||||
|
|
||||||
class BasePaymentGateway:
|
class BasePaymentGateway:
|
||||||
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult:
|
def create_payment(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
currency: str,
|
||||||
|
description: str,
|
||||||
|
source: dict,
|
||||||
|
callback_url: Optional[str],
|
||||||
|
given_id: str,
|
||||||
|
metadata: dict,
|
||||||
|
) -> PaymentInitResult:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
def capture_payment(self, external_id: str) -> None:
|
||||||
@@ -30,9 +50,56 @@ class MoyasarGateway(BasePaymentGateway):
|
|||||||
if not self.secret_key or not self.publishable_key:
|
if not self.secret_key or not self.publishable_key:
|
||||||
raise ValueError("Moyasar credentials are not configured")
|
raise ValueError("Moyasar credentials are not configured")
|
||||||
|
|
||||||
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult:
|
def create_payment(
|
||||||
|
self,
|
||||||
|
amount: int,
|
||||||
|
currency: str,
|
||||||
|
description: str,
|
||||||
|
source: dict,
|
||||||
|
callback_url: Optional[str],
|
||||||
|
given_id: str,
|
||||||
|
metadata: dict,
|
||||||
|
) -> PaymentInitResult:
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
raise NotImplementedError("Moyasar gateway integration not implemented yet")
|
url = f"{self.base_url}/v1/payments"
|
||||||
|
payload = {
|
||||||
|
"amount": amount,
|
||||||
|
"currency": currency,
|
||||||
|
"description": description,
|
||||||
|
"source": source,
|
||||||
|
"given_id": given_id,
|
||||||
|
"metadata": metadata,
|
||||||
|
}
|
||||||
|
if callback_url:
|
||||||
|
payload["callback_url"] = callback_url
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
raise PaymentGatewayError("Failed to reach Moyasar") from exc
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = response.json() if response.content else {}
|
||||||
|
except ValueError as exc:
|
||||||
|
raise PaymentGatewayError("Invalid response from Moyasar") from exc
|
||||||
|
|
||||||
|
if response.status_code not in (200, 201):
|
||||||
|
raise PaymentGatewayError(
|
||||||
|
"Moyasar returned an error",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
redirect_url = None
|
||||||
|
source_payload = data.get("source") or {}
|
||||||
|
if isinstance(source_payload, dict):
|
||||||
|
redirect_url = source_payload.get("transaction_url")
|
||||||
|
|
||||||
|
return PaymentInitResult(
|
||||||
|
external_id=data.get("id"),
|
||||||
|
status=data.get("status"),
|
||||||
|
redirect_url=redirect_url,
|
||||||
|
payload=data,
|
||||||
|
)
|
||||||
|
|
||||||
def capture_payment(self, external_id: str) -> None:
|
def capture_payment(self, external_id: str) -> None:
|
||||||
self._assert_config()
|
self._assert_config()
|
||||||
|
|||||||
@@ -0,0 +1,175 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal, ROUND_HALF_UP
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.bookings.models import Booking
|
||||||
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
|
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
|
||||||
|
|
||||||
|
CURRENCY_DECIMALS = {
|
||||||
|
"SAR": 2,
|
||||||
|
"USD": 2,
|
||||||
|
"EUR": 2,
|
||||||
|
"GBP": 2,
|
||||||
|
"KWD": 3,
|
||||||
|
"BHD": 3,
|
||||||
|
"JOD": 3,
|
||||||
|
}
|
||||||
|
|
||||||
|
MoyasarAllowedSourceTypes = {"token", "stcpay", "applepay", "samsungpay"}
|
||||||
|
|
||||||
|
|
||||||
|
def _to_minor_units(amount: Decimal, currency: str) -> int:
|
||||||
|
decimals = CURRENCY_DECIMALS.get(currency.upper(), 2)
|
||||||
|
factor = Decimal("1") if decimals == 0 else Decimal(10) ** decimals
|
||||||
|
minor = (amount * factor).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
|
||||||
|
return int(minor)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_provider_status(status: Optional[str]) -> Optional[str]:
|
||||||
|
if not status:
|
||||||
|
return None
|
||||||
|
status = status.lower()
|
||||||
|
mapping = {
|
||||||
|
"initiated": PaymentStatus.INITIATED,
|
||||||
|
"authorized": PaymentStatus.AUTHORIZED,
|
||||||
|
"captured": PaymentStatus.CAPTURED,
|
||||||
|
"paid": PaymentStatus.PAID,
|
||||||
|
"failed": PaymentStatus.FAILED,
|
||||||
|
"refunded": PaymentStatus.REFUNDED,
|
||||||
|
"voided": PaymentStatus.VOIDED,
|
||||||
|
"verified": PaymentStatus.VERIFIED,
|
||||||
|
}
|
||||||
|
return mapping.get(status)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status(payment: Payment, status: str) -> None:
|
||||||
|
now = timezone.now()
|
||||||
|
payment.status = status
|
||||||
|
payment.status_updated_at = now
|
||||||
|
if status == PaymentStatus.AUTHORIZED:
|
||||||
|
payment.authorized_at = now
|
||||||
|
elif status == PaymentStatus.CAPTURED:
|
||||||
|
payment.captured_at = now
|
||||||
|
elif status == PaymentStatus.PAID:
|
||||||
|
payment.paid_at = now
|
||||||
|
elif status == PaymentStatus.FAILED:
|
||||||
|
payment.failed_at = now
|
||||||
|
elif status == PaymentStatus.REFUNDED:
|
||||||
|
payment.refunded_at = now
|
||||||
|
elif status == PaymentStatus.VOIDED:
|
||||||
|
payment.voided_at = now
|
||||||
|
elif status == PaymentStatus.VERIFIED:
|
||||||
|
payment.verified_at = now
|
||||||
|
|
||||||
|
|
||||||
|
def create_payment_for_booking(
|
||||||
|
booking: Booking,
|
||||||
|
provider: str,
|
||||||
|
idempotency_key,
|
||||||
|
source: dict,
|
||||||
|
callback_url: Optional[str] = None,
|
||||||
|
) -> Tuple[Payment, bool, Optional[str]]:
|
||||||
|
if provider != PaymentProvider.MOYASAR:
|
||||||
|
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||||
|
|
||||||
|
existing = Payment.objects.filter(idempotency_key=idempotency_key).first()
|
||||||
|
if existing:
|
||||||
|
if existing.booking_id != booking.id or existing.provider != provider:
|
||||||
|
raise serializers.ValidationError({"idempotency_key": _("Idempotency key already used")})
|
||||||
|
return existing, False, existing.metadata.get("redirect_url")
|
||||||
|
|
||||||
|
source_type = (source or {}).get("type")
|
||||||
|
if source_type not in MoyasarAllowedSourceTypes:
|
||||||
|
raise serializers.ValidationError({"source": _("Unsupported payment source type")})
|
||||||
|
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=provider,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
idempotency_key=idempotency_key,
|
||||||
|
metadata={"booking_id": booking.id},
|
||||||
|
)
|
||||||
|
_apply_status(payment, PaymentStatus.INITIATED)
|
||||||
|
payment.save(update_fields=["status", "status_updated_at"])
|
||||||
|
|
||||||
|
amount_minor = _to_minor_units(booking.price_amount, booking.currency)
|
||||||
|
description = f"Booking {booking.id}"
|
||||||
|
gateway = MoyasarGateway()
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = gateway.create_payment(
|
||||||
|
amount=amount_minor,
|
||||||
|
currency=booking.currency,
|
||||||
|
description=description,
|
||||||
|
source=source,
|
||||||
|
callback_url=callback_url,
|
||||||
|
given_id=str(idempotency_key),
|
||||||
|
metadata={"booking_id": booking.id},
|
||||||
|
)
|
||||||
|
except PaymentGatewayError as exc:
|
||||||
|
_apply_status(payment, PaymentStatus.FAILED)
|
||||||
|
payment.metadata["gateway_error"] = {
|
||||||
|
"message": str(exc),
|
||||||
|
"status_code": exc.status_code,
|
||||||
|
"payload": exc.payload,
|
||||||
|
}
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"status",
|
||||||
|
"status_updated_at",
|
||||||
|
"failed_at",
|
||||||
|
"metadata",
|
||||||
|
])
|
||||||
|
raise serializers.ValidationError({"detail": _("Payment provider error")}) from exc
|
||||||
|
|
||||||
|
if not result.external_id:
|
||||||
|
_apply_status(payment, PaymentStatus.FAILED)
|
||||||
|
payment.metadata["gateway_error"] = {"message": "Missing payment reference from provider"}
|
||||||
|
payment.save(update_fields=[
|
||||||
|
"status",
|
||||||
|
"status_updated_at",
|
||||||
|
"failed_at",
|
||||||
|
"metadata",
|
||||||
|
])
|
||||||
|
raise serializers.ValidationError({"detail": _("Payment provider error")})
|
||||||
|
|
||||||
|
payment.external_id = result.external_id
|
||||||
|
payment.provider_payload = result.payload
|
||||||
|
payment.metadata["redirect_url"] = result.redirect_url
|
||||||
|
|
||||||
|
mapped_status = _map_provider_status(result.status)
|
||||||
|
if mapped_status:
|
||||||
|
_apply_status(payment, mapped_status)
|
||||||
|
|
||||||
|
payment.save()
|
||||||
|
return payment, True, result.redirect_url
|
||||||
|
|
||||||
|
|
||||||
|
def apply_webhook_event(payment: Payment, event_type: str, payload: dict) -> bool:
|
||||||
|
mapping = {
|
||||||
|
"payment_authorized": PaymentStatus.AUTHORIZED,
|
||||||
|
"payment_captured": PaymentStatus.CAPTURED,
|
||||||
|
"payment_paid": PaymentStatus.PAID,
|
||||||
|
"payment_failed": PaymentStatus.FAILED,
|
||||||
|
"payment_faild": PaymentStatus.FAILED,
|
||||||
|
"payment_abandoned": PaymentStatus.FAILED,
|
||||||
|
"payment_refunded": PaymentStatus.REFUNDED,
|
||||||
|
"payment_voided": PaymentStatus.VOIDED,
|
||||||
|
"payment_verified": PaymentStatus.VERIFIED,
|
||||||
|
}
|
||||||
|
target_status = mapping.get(event_type)
|
||||||
|
if not target_status:
|
||||||
|
return False
|
||||||
|
if payment.status == target_status:
|
||||||
|
return False
|
||||||
|
_apply_status(payment, target_status)
|
||||||
|
payment.metadata["last_webhook"] = payload
|
||||||
|
payment.save()
|
||||||
|
return True
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import uuid
|
||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.accounts.models import User, UserRole
|
||||||
|
from apps.bookings.models import Booking, BookingStatus
|
||||||
|
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
||||||
|
from apps.salons.models import Salon, Service, StaffProfile
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def booking_entities():
|
||||||
|
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
|
||||||
|
customer = User.objects.create_user(email="customer@example.com", password="pass")
|
||||||
|
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
|
||||||
|
|
||||||
|
salon = Salon.objects.create(
|
||||||
|
owner=owner,
|
||||||
|
name="Main Salon",
|
||||||
|
description="",
|
||||||
|
address="123 King Rd",
|
||||||
|
city="Riyadh",
|
||||||
|
phone_number="0512345678",
|
||||||
|
)
|
||||||
|
service = Service.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
name="Haircut",
|
||||||
|
description="",
|
||||||
|
duration_minutes=60,
|
||||||
|
price_amount=120,
|
||||||
|
currency="SAR",
|
||||||
|
)
|
||||||
|
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
|
||||||
|
|
||||||
|
start_time = timezone.now() + timedelta(days=1)
|
||||||
|
end_time = start_time + timedelta(minutes=60)
|
||||||
|
booking = Booking.objects.create(
|
||||||
|
salon=salon,
|
||||||
|
customer=customer,
|
||||||
|
service=service,
|
||||||
|
staff=staff,
|
||||||
|
start_time=start_time,
|
||||||
|
end_time=end_time,
|
||||||
|
status=BookingStatus.PENDING,
|
||||||
|
price_amount=service.price_amount,
|
||||||
|
currency=service.currency,
|
||||||
|
notes="",
|
||||||
|
)
|
||||||
|
return customer, booking
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_gateway_response(payment_id="pay_test", status="initiated"):
|
||||||
|
response = Mock()
|
||||||
|
response.status_code = 201
|
||||||
|
response.json.return_value = {
|
||||||
|
"id": payment_id,
|
||||||
|
"status": status,
|
||||||
|
"source": {"transaction_url": "https://moyasar.example/tx"},
|
||||||
|
}
|
||||||
|
response.content = b"{}"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
@patch("apps.payments.services.gateway.requests.post")
|
||||||
|
def test_create_payment_idempotency_returns_existing(mock_post, booking_entities, monkeypatch):
|
||||||
|
customer, booking = booking_entities
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=customer)
|
||||||
|
|
||||||
|
monkeypatch.setenv("MOYASAR_SECRET_KEY", "sk_test")
|
||||||
|
monkeypatch.setenv("MOYASAR_PUBLISHABLE_KEY", "pk_test")
|
||||||
|
|
||||||
|
mock_post.return_value = _mock_gateway_response()
|
||||||
|
request_id = str(uuid.uuid4())
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_id": booking.id,
|
||||||
|
"provider": PaymentProvider.MOYASAR,
|
||||||
|
"idempotency_key": request_id,
|
||||||
|
"source": {"type": "stcpay", "mobile": "0500000000"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
response_repeat = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response_repeat.status_code == 200
|
||||||
|
assert Payment.objects.count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_rejects_creditcard_source(booking_entities):
|
||||||
|
customer, booking = booking_entities
|
||||||
|
client = APIClient()
|
||||||
|
client.force_authenticate(user=customer)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"booking_id": booking.id,
|
||||||
|
"provider": PaymentProvider.MOYASAR,
|
||||||
|
"idempotency_key": str(uuid.uuid4()),
|
||||||
|
"source": {"type": "creditcard", "number": "4111111111111111"},
|
||||||
|
}
|
||||||
|
|
||||||
|
response = client.post(reverse("payment-list"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert "source" in response.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_paid_updates_status(booking_entities, monkeypatch):
|
||||||
|
_, booking = booking_entities
|
||||||
|
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||||
|
|
||||||
|
payment = Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=PaymentProvider.MOYASAR,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
external_id="pay_webhook",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "payment_paid",
|
||||||
|
"secret_token": "secret",
|
||||||
|
"data": {"id": "pay_webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
payment.refresh_from_db()
|
||||||
|
assert payment.status == PaymentStatus.PAID
|
||||||
|
assert payment.paid_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
def test_webhook_invalid_secret_is_rejected(booking_entities, monkeypatch):
|
||||||
|
_, booking = booking_entities
|
||||||
|
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
|
||||||
|
|
||||||
|
Payment.objects.create(
|
||||||
|
booking=booking,
|
||||||
|
provider=PaymentProvider.MOYASAR,
|
||||||
|
status=PaymentStatus.INITIATED,
|
||||||
|
amount=booking.price_amount,
|
||||||
|
currency=booking.currency,
|
||||||
|
external_id="pay_webhook",
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"type": "payment_paid",
|
||||||
|
"secret_token": "wrong",
|
||||||
|
"data": {"id": "pay_webhook"},
|
||||||
|
}
|
||||||
|
|
||||||
|
client = APIClient()
|
||||||
|
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
|
||||||
|
assert response.status_code == 401
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from apps.payments.views import PaymentViewSet
|
from apps.payments.views import PaymentViewSet, payment_webhook
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"", PaymentViewSet, basename="payment")
|
router.register(r"", PaymentViewSet, basename="payment")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("webhook/", payment_webhook, name="payment-webhook"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
import logging
|
||||||
from rest_framework.response import Response
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
||||||
|
from apps.payments.services.payments import apply_webhook_event, create_payment_for_booking
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def user_can_access_booking(user, booking: Booking) -> bool:
|
def user_can_access_booking(user, booking: Booking) -> bool:
|
||||||
@@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
|||||||
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
||||||
if not user_can_access_booking(request.user, booking):
|
if not user_can_access_booking(request.user, booking):
|
||||||
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
||||||
payment = serializer.save()
|
payment, created, redirect_url = create_payment_for_booking(
|
||||||
return Response(
|
booking=booking,
|
||||||
{
|
provider=serializer.validated_data["provider"],
|
||||||
"detail": _("Payment record created. Provider integration pending."),
|
idempotency_key=serializer.validated_data["idempotency_key"],
|
||||||
"payment_id": payment.id,
|
source=serializer.validated_data["source"],
|
||||||
"amount": str(payment.amount),
|
callback_url=serializer.validated_data.get("callback_url"),
|
||||||
"currency": payment.currency,
|
|
||||||
"status": payment.status,
|
|
||||||
},
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
)
|
||||||
|
response_data = PaymentSerializer(payment).data
|
||||||
|
response_data["redirect_url"] = redirect_url
|
||||||
|
response_data["created"] = created
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([permissions.AllowAny])
|
||||||
|
def payment_webhook(request):
|
||||||
|
secret = os.getenv("MOYASAR_WEBHOOK_SECRET")
|
||||||
|
payload = request.data or {}
|
||||||
|
if not secret:
|
||||||
|
return Response({"detail": _("Webhook secret not configured")}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
if payload.get("secret_token") != secret:
|
||||||
|
return Response({"detail": _("Invalid webhook signature")}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
event_type = payload.get("type")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
external_id = data.get("id")
|
||||||
|
if not external_id:
|
||||||
|
return Response({"detail": _("Missing payment reference")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
payment = Payment.objects.filter(external_id=external_id, provider=PaymentProvider.MOYASAR).first()
|
||||||
|
if not payment:
|
||||||
|
logger.warning("Moyasar webhook for unknown payment %s", external_id)
|
||||||
|
return Response({"detail": _("Payment not found")}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
applied = apply_webhook_event(payment, event_type, payload)
|
||||||
|
if not applied:
|
||||||
|
return Response({"detail": _("Event ignored")}, status=status.HTTP_200_OK)
|
||||||
|
return Response({"detail": _("Webhook processed")}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ class Command(BaseCommand):
|
|||||||
booking=booking,
|
booking=booking,
|
||||||
provider=PaymentProvider.MOYASAR,
|
provider=PaymentProvider.MOYASAR,
|
||||||
defaults={
|
defaults={
|
||||||
"status": PaymentStatus.CREATED,
|
"status": PaymentStatus.INITIATED,
|
||||||
"amount": booking.price_amount,
|
"amount": booking.price_amount,
|
||||||
"currency": booking.currency,
|
"currency": booking.currency,
|
||||||
"external_id": "",
|
"external_id": None,
|
||||||
"metadata": {"note": "Demo payment record"},
|
"metadata": {"note": "Demo payment record"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
||||||
|
|||||||
@@ -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 { useTranslation } from "react-i18next";
|
||||||
import { apiGet } from "./api/client";
|
import { apiGet, apiPost } from "./api/client";
|
||||||
import { setLocale } from "./i18n";
|
import { setLocale } from "./i18n";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [salons, setSalons] = useState([]);
|
const [salons, setSalons] = useState([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState("idle");
|
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 { 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(() => {
|
useEffect(() => {
|
||||||
let ignore = false;
|
let ignore = false;
|
||||||
|
|
||||||
@@ -33,6 +52,60 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [query]);
|
}, [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 (
|
return (
|
||||||
<div className="page">
|
<div className="page">
|
||||||
<header className="hero">
|
<header className="hero">
|
||||||
@@ -90,6 +163,83 @@ export default function App() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { vi } from "vitest";
|
||||||
import App from "./App.jsx";
|
import App from "./App.jsx";
|
||||||
import i18n from "./i18n";
|
import i18n from "./i18n";
|
||||||
|
|
||||||
|
vi.mock("./api/client", () => ({
|
||||||
|
apiGet: vi.fn().mockResolvedValue([]),
|
||||||
|
apiPost: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
describe("App", () => {
|
describe("App", () => {
|
||||||
it("renders the hero copy", async () => {
|
it("renders the hero copy", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />);
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("Find, compare, and book top salons near you.")
|
await screen.findByText("Find, compare, and book top salons near you.")
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches to Arabic and sets RTL direction", async () => {
|
it("switches to Arabic and sets RTL direction", async () => {
|
||||||
await i18n.changeLanguage("en");
|
await i18n.changeLanguage("en");
|
||||||
render(<App />);
|
render(<App />);
|
||||||
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
|
const arabicButton = screen.getByRole("button", { name: "العربية" });
|
||||||
|
fireEvent.click(arabicButton);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(document.documentElement.dir).toBe("rtl");
|
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);
|
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": "اللغة",
|
"label": "اللغة",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "الإنجليزية"
|
"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",
|
"label": "Language",
|
||||||
"arabic": "العربية",
|
"arabic": "العربية",
|
||||||
"english": "English"
|
"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;
|
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 {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: "./src/test/setupTests.js"
|
setupFiles: "./src/test/setupTests.js"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user