16 Commits

Author SHA1 Message Date
mohd de8cbfec23 Add Gitea Actions CI baseline
CI / Backend tests (push) Has been cancelled
CI / Frontend tests (push) Has been cancelled
2026-03-02 21:10:22 +03:00
mohd b8218669c2 added claude.md 2026-03-02 00:58:00 +03:00
mohd 2305c3dc9d feat: add Arabic translations and fix frontend i18n gaps
- Add backend/locale/ar_SA/LC_MESSAGES/django.po with Arabic (ar-sa) translations
  for all 62 user-facing error/validation strings across accounts, bookings,
  payments, and notifications apps; compile to django.mo
- Add common.loading and salon.unknownStaff keys to both ar-sa.json and en.json
- ProtectedRoute: replace hardcoded "Loading..." with t("common.loading")
- BookPage, SalonDetailPage: replace `Staff ${s.id}` fallback with
  t("salon.unknownStaff", { id: s.id })
- BookingsPage: pass getActiveLocale() to toLocaleString so date/time
  format matches the active app language

All 35 backend tests and 7 frontend tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 00:53:24 +03:00
mohd ef60218c4c fix: make booking overlap check atomic with select_for_update
Wrap the overlap query and Booking.objects.create() in a single
transaction.atomic() block inside BookingCreateSerializer.create().
Lock the StaffProfile row with select_for_update() so concurrent
requests for the same staff slot are serialized at the DB level;
only one writer can hold the lock at a time, eliminating the race
window between validate() and save().

The early check in validate() is kept for fast user feedback in
the common non-concurrent case. The locked re-check in create()
is the correctness guarantee.

On SQLite (dev/tests) FOR UPDATE is silently ignored but writes
are still serialized. PostgreSQL (production) gets row-level locking.

Update docs/risks.md to mark the race condition as fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-02 00:27:04 +03:00
mohd 8018710d31 fix: use phone_number as USERNAME_FIELD on User model
- USERNAME_FIELD = "phone_number" (was "email") — email is optional on
  this platform; most customers will be phone-only
- Add REQUIRED_FIELDS = [] to make the intent explicit
- Update create_superuser to accept phone_number as the identifier and
  pass it through to create_user as a keyword argument
- All 35 backend tests pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:58:02 +03:00
mohd 229975c612 docs: revise ADR 0001, risks, and architecture for accuracy
- ADR 0001: distinguish payment/OTP (sync by design) from notifications
  (fire-and-forget); correct misleading claim that notification failures
  surface to clients — they are silently absorbed as FAILED status
- risks.md: upgrade USERNAME_FIELD entry with concrete breakage (admin,
  create_superuser, JWT lookup); add booking overlap race condition with
  root cause and fix (select_for_update)
- architecture.md: document notification/OTP provider coupling as an MVP
  shortcut and note the Phase 2 fix (dedicated NotificationProvider)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:30:04 +03:00
mohd aa607b9b6e Fleshed out documentation 2026-02-28 17:41:00 +03:00
mohd 828cbcc822 Authentica OTP tests 2026-02-28 17:31:03 +03:00
mohd 4253f6f650 Added Authentica OTP 2026-02-28 16:58:50 +03:00
mohd a1da918f95 Enhance documentation, implement Twilio OTP delivery, and update payment gateway methods. Updated AGENTS.md and README.md for clarity on ExecPlans and architecture. Added Twilio as a dependency and implemented capture/refund methods in MoyasarGateway. Improved frontend routing with react-router-dom and added authentication context. Updated styles and localization files for better user experience. 2026-02-28 15:33:50 +03:00
mohd 86fd07c778 miscellaneous documentation 2026-02-28 15:33:18 +03:00
mohd ca2a6b58b6 Booking lifecycle notifications and status updates 2026-02-28 15:06:35 +03:00
mohd db36551211 Document payments sanity check and fix demo seed 2026-02-28 13:28:58 +03:00
mohd a150b18fe7 Wire payments UI and fix frontend tests 2026-02-28 13:15:41 +03:00
mohd f3c93f500e Implement Moyasar payments flow with webhooks 2026-02-28 13:01:12 +03:00
mohd d9767ff0a7 Add payments ExecPlan and set as active 2026-02-28 12:47:59 +03:00
89 changed files with 9385 additions and 223 deletions
+58
View File
@@ -0,0 +1,58 @@
name: CI
on:
push:
pull_request:
jobs:
backend-tests:
name: Backend tests
runs-on: ubuntu-latest
env:
DJANGO_SECRET_KEY: ci-test-key
DJANGO_DEBUG: "0"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: "pip"
cache-dependency-path: |
backend/requirements.txt
backend/requirements-dev.txt
- name: Install backend dependencies
run: |
python -m pip install --upgrade pip
python -m pip install -r backend/requirements.txt -r backend/requirements-dev.txt
- name: Run backend tests
working-directory: backend
run: python -m pytest
frontend-tests:
name: Frontend tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run frontend tests
working-directory: frontend
env:
CI: "true"
run: npm run test -- --run
+1
View File
@@ -17,3 +17,4 @@ dist/
# OS # OS
.DS_Store .DS_Store
backend/tmp_authentica_request_id.txt
+7 -1
View File
@@ -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, especially where intent, edge cases, or business rules are not obvious.
## 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.
@@ -46,6 +50,7 @@ Build a reliable, maintainable salon booking platform with Django (backend) and
- Use explicit, readable model fields and serializers. - Use explicit, readable model fields and serializers.
- Small, wellnamed functions > monolithic handlers. - Small, wellnamed functions > monolithic handlers.
- Prefer predictable error responses (HTTP status + `detail`). - Prefer predictable error responses (HTTP status + `detail`).
- Prefer short, intent-focused comments over silent complexity.
## Known Gaps (Tracked) ## Known Gaps (Tracked)
- See `docs/risks.md` for current gaps/risks to address. - See `docs/risks.md` for current gaps/risks to address.
@@ -61,7 +66,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 current active ExecPlan is defined in PLANS.md. Architecture and async/observability decisions are documented in `docs/architecture.md`.
+127
View File
@@ -0,0 +1,127 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Goal
A salon booking platform for KSA (Saudi Arabia) with Django REST API backend and React/Vite frontend. Optimized for phone-first auth (OTP via SMS/WhatsApp), Moyasar payments, Arabic locale (ar-sa), and Riyadh timezone.
## Commands
### Backend
```bash
# Setup (from repo root)
python3 -m venv venv && source venv/bin/activate
pip install -r backend/requirements.txt -r backend/requirements-dev.txt
cp backend/.env.example backend/.env
# Migrations and dev server (from backend/)
cd backend
python3 manage.py migrate
python3 manage.py runserver
# Seed demo data
python3 manage.py seed_demo
# Run all tests (from backend/ with venv active)
cd backend
python3 -m pytest
# Run a single test file
python3 -m pytest apps/accounts/tests/test_otp_limits.py
# Run a single test
python3 -m pytest apps/accounts/tests/test_otp_limits.py::TestClassName::test_method_name
# Run external/integration tests (hits real third-party services)
PYTEST_ADDOPTS='' python3 -m pytest -m external
```
### Authentica E2E Testing
Set these env vars before running external tests:
- `AUTHENTICA_E2E=1`
- `AUTHENTICA_API_KEY=...`
- `AUTHENTICA_E2E_PHONE=...` (phone that will receive OTP)
- `AUTHENTICA_E2E_CODE=...` (OTP code received)
### Frontend
```bash
# From frontend/
cd frontend
npm install
npm run dev # dev server at localhost:5173, proxies /api to localhost:8000
npm run test # vitest
npm run build
```
## Architecture
### Backend (`backend/`)
Django project lives in `backend/salon_api/` (settings, root urls, wsgi/asgi). All domain apps are under `backend/apps/`:
| App | Responsibility |
|-----|----------------|
| `accounts` | Custom User model, phone/OTP auth, JWT tokens, locale preferences |
| `salons` | Salon catalog, services, staff profiles, availability windows, reviews |
| `bookings` | Booking lifecycle, availability/overlap validation, status transitions |
| `payments` | Moyasar integration (create, capture, refund), webhook reconciliation, idempotency |
| `notifications` | Booking lifecycle SMS/WhatsApp messages, stored for auditability |
**Service layer pattern:** Business logic lives in `apps/<app>/services/` (not in views). Views are thin — they validate input, call services, return responses. Keep it this way.
**OTP providers** (`apps/accounts/services/otp.py`): pluggable via `OTP_PROVIDER` env var. Active providers: `console` (dev), `twilio`, `authentica`. `unifonic` is a scaffold. Authentica is the recommended production provider and uses a server-side OTP flow (`uses_provider_otp = True`) — it generates and verifies the code itself, so the DB stores a placeholder hash.
**Payment gateway** (`apps/payments/services/gateway.py`): `MoyasarGateway` implements `BasePaymentGateway`. Amounts are always in minor units (halalas). `MOYASAR_SECRET_KEY` and `MOYASAR_PUBLISHABLE_KEY` are required.
**Sync-only (MVP):** All external calls (OTP sends, notifications, payment gateway) run synchronously in the request path. No task queue. See `docs/adr/0001-synchronous-external-calls-mvp.md`.
**Database:** SQLite for local dev (default), PostgreSQL via `DATABASE_URL` env var for production. Tests use `TEST_DATABASE_URL` if set.
**Localization:** Default language `ar-sa`, timezone `Asia/Riyadh`. `UserLocaleMiddleware` applies per-user locale preference.
### Frontend (`frontend/`)
React 18 + Vite app. Entry: `src/main.jsx``AuthProvider` wraps `App`.
- **Routing:** `react-router-dom` v7 with pages in `src/pages/`
- **Auth:** JWT tokens managed via `src/contexts/AuthContext.jsx`; `src/components/ProtectedRoute.jsx` guards private pages
- **API:** `src/api/client.js` is the axios/fetch wrapper
- **Hooks:** Domain logic extracted into `src/hooks/` (e.g., `useSalonSearch`, `usePaymentForm`)
- **i18n:** `react-i18next` configured in `src/i18n/index.js`; supports `ar-sa` and `en`
Tests use Vitest + Testing Library. Setup in `src/test/setupTests.js`.
## Key Env Vars
Backend (`backend/.env`):
- `DJANGO_SECRET_KEY`, `DJANGO_DEBUG`, `DATABASE_URL`
- `OTP_PROVIDER``console` | `twilio` | `authentica` | `unifonic`
- `AUTHENTICA_API_KEY`, `AUTHENTICA_BASE_URL`, `AUTHENTICA_SENDER_NAME`
- `TWILIO_ACCOUNT_SID`, `TWILIO_AUTH_TOKEN`, `TWILIO_FROM_NUMBER`, `TWILIO_WHATSAPP_FROM`
- `MOYASAR_SECRET_KEY`, `MOYASAR_PUBLISHABLE_KEY`
- `NOTIFICATION_PROVIDER` — defaults to `OTP_PROVIDER`
- `OTP_EXPIRY_MINUTES`, `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, `OTP_RESEND_COOLDOWN_SECONDS`
- `CORS_ALLOWED_ORIGINS`
## Testing Conventions
- Backend tests live beside their apps: `apps/<app>/tests/test_*.py`
- `pytest.ini` marks `external` tests as opt-in; default runs skip them
- Frontend tests: Vitest + Testing Library; test files colocated with source (`*.test.jsx`)
- Minimum coverage: auth flows, booking validation, payment state transitions
## ExecPlans
For complex features, use an ExecPlan (see `PLANS.md` for the full spec and active plan pointer). ExecPlans are living documents in `docs/execplans/`. The active plan is listed in `PLANS.md`. Update `docs/risks.md` when opening or closing a significant gap.
## Coding Conventions
- Business logic in service layers; keep views thin
- Predictable error responses: HTTP status code + `detail` field
- Comment intent, edge cases, and non-obvious business rules; skip obvious comments
- Payment and booking flows must be idempotent and auditable
- Phone auth must be rate-limited (enforced in `otp.py` via `OtpRateLimitError` / `OtpCooldownError`)
+1 -1
View File
@@ -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/booking-notifications.md`. It focuses on booking lifecycle notifications (confirmation/cancellation) 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
+6 -1
View File
@@ -9,6 +9,7 @@ Location: `backend/`
### Setup ### Setup
1. Create a virtualenv and install dependencies. 1. Create a virtualenv and install dependencies.
- `python3 -m venv venv && source venv/bin/activate` (or `venv\Scripts\activate` on Windows)
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt` - `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
2. Copy `backend/.env.example` to `backend/.env` and adjust values. 2. Copy `backend/.env.example` to `backend/.env` and adjust values.
3. Run migrations and start the server. 3. Run migrations and start the server.
@@ -21,7 +22,8 @@ After migrations, you can seed demo data:
### Tests ### Tests
- `pytest` - From project root with venv active: `venv/bin/python3 -m pytest` (run from `backend/` so `pytest.ini` is picked up)
- External provider tests are skipped by default; run explicitly when needed: `PYTEST_ADDOPTS='' venv/bin/python3 -m pytest -m external`
### Core API endpoints (current scaffold) ### Core API endpoints (current scaffold)
@@ -61,3 +63,6 @@ The dev server proxies `/api` to `http://localhost:8000`.
## Project Notes ## Project Notes
- Known gaps and risks: `docs/risks.md` - Known gaps and risks: `docs/risks.md`
- Architecture and async/observability decisions: `docs/architecture.md`
- Documentation index and standards: `docs/README.md` and `docs/documentation.md`
- CI: Gitea Actions workflow in `.gitea/workflows/ci.yml`
+1
View File
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
MOYASAR_SECRET_KEY= MOYASAR_SECRET_KEY=
MOYASAR_PUBLISHABLE_KEY= MOYASAR_PUBLISHABLE_KEY=
MOYASAR_BASE_URL= MOYASAR_BASE_URL=
MOYASAR_WEBHOOK_SECRET=
+31
View File
@@ -0,0 +1,31 @@
# Backend Notes (MVP Readiness)
## High-Level Takeaways
- Authentica OTP integration is implemented; Moyasar capture/refund are TODOs.
- External calls (OTP, notifications, payment gateway) run synchronously in request/response paths, increasing latency risk.
- Cross-app coupling (bookings ↔ notifications ↔ accounts/payments) will get harder to evolve without clearer service boundaries.
- Phone-first auth works, but `USERNAME_FIELD` is email; align identifier strategy to avoid future auth confusion.
## Near-Term Focus
- Hardening Authentica integration (timeouts, retries, async delivery) and aligning notification provider choices.
**Authentica E2E**
Run the real Authentica OTP flow only when explicitly enabled.
Env vars (in `backend/.env` or shell):
- `AUTHENTICA_E2E=1`
- `AUTHENTICA_API_KEY=...`
- `AUTHENTICA_E2E_PHONE=...` (must receive OTP)
- `AUTHENTICA_E2E_CODE=...` (required; no interactive prompt)
Command:
```bash
cd backend
PYTEST_ADDOPTS='' python3 -m pytest apps/accounts/tests -m external
```
Suggested flow:
1. Trigger the E2E test to send the OTP, then set `AUTHENTICA_E2E_CODE` and re-run if needed.
- Decide and document payment lifecycle scope (capture/refund supported vs explicitly out of scope).
- Add timeouts/logging for external calls or introduce minimal async jobs for OTP/notifications.
- Keep booking, payment, and notification orchestration in service layers, not views.
+4 -3
View File
@@ -29,7 +29,7 @@ class UserManager(BaseUserManager):
user.save(using=self._db) user.save(using=self._db)
return user return user
def create_superuser(self, email, password=None, **extra_fields): def create_superuser(self, phone_number, password=None, **extra_fields):
extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_staff", True)
extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_superuser", True)
extra_fields.setdefault("role", UserRole.ADMIN) extra_fields.setdefault("role", UserRole.ADMIN)
@@ -37,7 +37,7 @@ class UserManager(BaseUserManager):
raise ValueError("Superuser must have is_staff=True") raise ValueError("Superuser must have is_staff=True")
if extra_fields.get("is_superuser") is not True: if extra_fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True") raise ValueError("Superuser must have is_superuser=True")
return self.create_user(email, password, **extra_fields) return self.create_user(phone_number=phone_number, password=password, **extra_fields)
class User(AbstractBaseUser, PermissionsMixin): class User(AbstractBaseUser, PermissionsMixin):
@@ -59,7 +59,8 @@ class User(AbstractBaseUser, PermissionsMixin):
objects = UserManager() objects = UserManager()
USERNAME_FIELD = "email" USERNAME_FIELD = "phone_number"
REQUIRED_FIELDS = [] # email is optional; phone_number is the identifier
def __str__(self): def __str__(self):
return self.email or self.phone_number or str(self.id) return self.email or self.phone_number or str(self.id)
+147 -19
View File
@@ -1,8 +1,8 @@
import logging import logging
import os import os
import secrets import secrets
from datetime import timedelta
from dataclasses import dataclass from dataclasses import dataclass
from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.hashers import check_password, make_password from django.contrib.auth.hashers import check_password, make_password
@@ -34,12 +34,20 @@ class OtpCooldownError(RuntimeError):
class BaseOtpProvider: class BaseOtpProvider:
uses_provider_otp = False
def send_sms(self, to_number: str, message: str) -> None: def send_sms(self, to_number: str, message: str) -> None:
raise NotImplementedError raise NotImplementedError
def send_whatsapp(self, to_number: str, message: str) -> None: def send_whatsapp(self, to_number: str, message: str) -> None:
raise NotImplementedError raise NotImplementedError
def send_otp(self, to_number: str, channel: str) -> None:
raise NotImplementedError
def verify_otp(self, to_number: str, code: str) -> bool:
raise NotImplementedError
class ConsoleOtpProvider(BaseOtpProvider): class ConsoleOtpProvider(BaseOtpProvider):
def send_sms(self, to_number: str, message: str) -> None: def send_sms(self, to_number: str, message: str) -> None:
@@ -50,6 +58,8 @@ class ConsoleOtpProvider(BaseOtpProvider):
class TwilioOtpProvider(BaseOtpProvider): class TwilioOtpProvider(BaseOtpProvider):
"""Twilio provider for SMS and WhatsApp OTP delivery. Requires TWILIO_* env vars."""
def __init__(self) -> None: def __init__(self) -> None:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID") self.account_sid = os.getenv("TWILIO_ACCOUNT_SID")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN") self.auth_token = os.getenv("TWILIO_AUTH_TOKEN")
@@ -60,15 +70,23 @@ class TwilioOtpProvider(BaseOtpProvider):
if not self.account_sid or not self.auth_token or not self.from_number: if not self.account_sid or not self.auth_token or not self.from_number:
raise ValueError(_("Twilio credentials are not configured")) raise ValueError(_("Twilio credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None: def _get_client(self):
from twilio.rest import Client
self._assert_config() self._assert_config()
raise NotImplementedError(_("Twilio SMS adapter not implemented yet")) return Client(self.account_sid, self.auth_token)
def send_sms(self, to_number: str, message: str) -> None:
client = self._get_client()
client.messages.create(body=message, from_=self.from_number, to=to_number)
def send_whatsapp(self, to_number: str, message: str) -> None: def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config() self._assert_config()
if not self.whatsapp_from: if not self.whatsapp_from:
raise ValueError(_("Twilio WhatsApp sender is not configured")) raise ValueError(_("Twilio WhatsApp sender is not configured"))
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet")) client = self._get_client()
from_ = f"whatsapp:{self.whatsapp_from}"
to = f"whatsapp:{to_number}"
client.messages.create(body=message, from_=from_, to=to)
class UnifonicOtpProvider(BaseOtpProvider): class UnifonicOtpProvider(BaseOtpProvider):
@@ -92,21 +110,110 @@ class UnifonicOtpProvider(BaseOtpProvider):
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet")) raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
class AuthenticaOtpProvider(BaseOtpProvider):
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
uses_provider_otp = True
def __init__(self) -> None:
self.api_key = os.getenv("AUTHENTICA_API_KEY")
self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
def _assert_config(self) -> None:
if not self.api_key:
raise ValueError(_("Authentica API key is not configured"))
def _headers(self) -> dict:
return {
"Accept": "application/json",
"Content-Type": "application/json",
"X-Authorization": self.api_key,
}
def _post(self, path: str, payload: dict) -> dict:
import requests
self._assert_config()
base_url = self.base_url.rstrip("/")
url = f"{base_url}{path}"
try:
response = requests.post(
url,
headers=self._headers(),
json=payload,
timeout=self.timeout_seconds,
)
except requests.RequestException as exc:
raise RuntimeError(_("Authentica request failed")) from exc
try:
data = response.json()
except ValueError:
data = {"detail": response.text}
if not response.ok:
if os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1":
raise RuntimeError(
_("Authentica request failed: %(status)s %(body)s")
% {"status": response.status_code, "body": response.text}
)
raise RuntimeError(_("Authentica request failed"))
return data
def send_otp(self, to_number: str, channel: str) -> None:
if channel not in (OtpChannel.SMS, OtpChannel.WHATSAPP):
raise ValueError(_("Unsupported OTP channel"))
method = "sms" if channel == OtpChannel.SMS else "whatsapp"
self._post("/api/v2/send-otp", {"method": method, "phone": to_number})
def verify_otp(self, to_number: str, code: str) -> bool:
data = self._post("/api/v2/verify-otp", {"phone": to_number, "otp": code})
if "verified" in data:
verified = bool(data.get("verified"))
else:
verified = bool(data.get("status")) or data.get("message") == "OTP verified successfully"
if not verified and (os.getenv("AUTHENTICA_DEBUG") == "1" or os.getenv("AUTHENTICA_E2E") == "1"):
raise RuntimeError(_("Authentica verify failed: %(response)s") % {"response": data})
return verified
def send_sms(self, to_number: str, message: str) -> None:
if not self.sender_name:
raise ValueError(_("Authentica sender name is not configured"))
self._post(
"/api/v2/send-sms",
{
"phone": to_number,
"message": message,
"sender_name": self.sender_name,
},
)
def send_whatsapp(self, to_number: str, message: str) -> None:
raise ValueError(_("Authentica WhatsApp messaging is not supported"))
PROVIDERS = { PROVIDERS = {
"console": ConsoleOtpProvider, "console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider, "twilio": TwilioOtpProvider,
"unifonic": UnifonicOtpProvider, "unifonic": UnifonicOtpProvider,
"authentica": AuthenticaOtpProvider,
} }
def get_provider() -> BaseOtpProvider: def _get_provider_for_key(provider_key: str) -> BaseOtpProvider:
provider_key = settings.OTP_PROVIDER
provider_cls = PROVIDERS.get(provider_key) provider_cls = PROVIDERS.get(provider_key)
if not provider_cls: if not provider_cls:
raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key}) raise ValueError(_("Unknown OTP provider: %(provider)s") % {"provider": provider_key})
return provider_cls() return provider_cls()
def get_provider() -> BaseOtpProvider:
return _get_provider_for_key(settings.OTP_PROVIDER)
def generate_code(length: int = 6) -> str: def generate_code(length: int = 6) -> str:
digits = "0123456789" digits = "0123456789"
return "".join(secrets.choice(digits) for _ in range(length)) return "".join(secrets.choice(digits) for _ in range(length))
@@ -139,25 +246,34 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
if elapsed < cooldown_seconds: if elapsed < cooldown_seconds:
raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed)) raise OtpCooldownError(retry_after_seconds=int(cooldown_seconds - elapsed))
code = generate_code() if provider.uses_provider_otp:
code_hash = make_password(secrets.token_urlsafe(16))
message = None
else:
code = generate_code()
code_hash = make_password(code)
message = _(
"Your verification code is %(code)s. It expires in %(minutes)s minutes."
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
otp = PhoneOTP.objects.create( otp = PhoneOTP.objects.create(
phone_number=phone_number, phone_number=phone_number,
channel=channel, channel=channel,
purpose=purpose, purpose=purpose,
provider=settings.OTP_PROVIDER, provider=settings.OTP_PROVIDER,
code_hash=make_password(code), code_hash=code_hash,
expires_at=PhoneOTP.expiry_at(), expires_at=PhoneOTP.expiry_at(),
) )
message = _( if provider.uses_provider_otp:
"Your verification code is %(code)s. It expires in %(minutes)s minutes." provider.send_otp(phone_number, channel)
) % {"code": code, "minutes": settings.OTP_EXPIRY_MINUTES}
if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else: else:
raise ValueError(_("Unsupported OTP channel")) if channel == OtpChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == OtpChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else:
raise ValueError(_("Unsupported OTP channel"))
return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat()) return OtpSendResult(request_id=str(otp.id), expires_at=otp.expires_at.isoformat())
@@ -169,9 +285,21 @@ def verify_otp(otp: PhoneOTP, code: str) -> bool:
if otp.attempt_count > otp.max_attempts: if otp.attempt_count > otp.max_attempts:
otp.save(update_fields=["attempt_count"]) otp.save(update_fields=["attempt_count"])
return False return False
if not check_password(code, otp.code_hash): provider_cls = PROVIDERS.get(otp.provider)
otp.save(update_fields=["attempt_count"]) if provider_cls and getattr(provider_cls, "uses_provider_otp", False):
return False provider = provider_cls()
try:
verified = provider.verify_otp(otp.phone_number, code)
except Exception:
otp.save(update_fields=["attempt_count"])
raise
if not verified:
otp.save(update_fields=["attempt_count"])
return False
else:
if not check_password(code, otp.code_hash):
otp.save(update_fields=["attempt_count"])
return False
otp.verified_at = timezone.now() otp.verified_at = timezone.now()
otp.save(update_fields=["verified_at", "attempt_count"]) otp.save(update_fields=["verified_at", "attempt_count"])
return True return True
@@ -0,0 +1,72 @@
"""Mocked end-to-end phone auth flow using Authentica OTP provider."""
import os
from unittest.mock import MagicMock, patch
import pytest
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="authentica")
@patch("requests.post")
def test_phone_auth_flow_with_authentica_mock(mock_post, client):
def make_response(payload, ok=True):
response = MagicMock()
response.ok = ok
response.json.return_value = payload
response.text = ""
return response
def side_effect(url, headers=None, json=None, timeout=None):
assert headers and headers.get("X-Authorization") == "api-key"
assert timeout == 7.0
if url.endswith("/api/v2/send-otp"):
assert json == {"method": "sms", "phone": "+966512345678"}
return make_response({"success": True})
if url.endswith("/api/v2/verify-otp"):
if json == {"phone": "+966512345678", "otp": "123456"}:
return make_response({"verified": True})
return make_response({"verified": False})
raise AssertionError(f"Unexpected URL {url}")
with patch.dict(
os.environ,
{
"AUTHENTICA_API_KEY": "api-key",
"AUTHENTICA_TIMEOUT_SECONDS": "7",
},
):
mock_post.side_effect = side_effect
request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify")
response = client.post(
request_url,
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
content_type="application/json",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
bad = client.post(
verify_url,
{"request_id": request_id, "code": "000000"},
content_type="application/json",
)
assert bad.status_code == 400
good = client.post(
verify_url,
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert good.status_code == 200
user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None
assert user.is_phone_verified is True
@@ -0,0 +1,72 @@
"""Real Authentica E2E OTP flow. Requires live credentials and a phone receiving OTPs."""
import os
from datetime import timedelta
import pytest
from django.test import override_settings
from django.urls import reverse
from django.utils import timezone
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP, User
from apps.accounts.services.phone import normalize_phone_number
@pytest.mark.django_db
@pytest.mark.external
@override_settings(OTP_PROVIDER="authentica")
def test_authentica_phone_auth_e2e(client):
if os.getenv("AUTHENTICA_E2E") != "1":
pytest.skip("AUTHENTICA_E2E=1 not set")
api_key = os.getenv("AUTHENTICA_API_KEY")
phone_number = os.getenv("AUTHENTICA_E2E_PHONE")
if not api_key or not phone_number:
pytest.skip("Missing AUTHENTICA_API_KEY or AUTHENTICA_E2E_PHONE")
request_url = reverse("phone_auth_request")
response = client.post(
request_url,
{"phone_number": phone_number, "channel": "sms", "first_name": "E2E"},
content_type="application/json",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
assert request_id
code = os.getenv("AUTHENTICA_E2E_CODE")
if not code:
pytest.skip("AUTHENTICA_E2E_CODE not set")
normalized_phone = normalize_phone_number(phone_number)
User.objects.get_or_create(
phone_number=normalized_phone,
defaults={"role": "customer"},
)
if not PhoneOTP.objects.filter(id=request_id).exists():
# Create a local OTP record so the verify endpoint can bind to a request_id.
PhoneOTP.objects.create(
id=request_id,
phone_number=normalized_phone,
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="authentica",
code_hash="placeholder",
expires_at=timezone.now() + timedelta(minutes=5),
)
verify_url = reverse("phone_auth_verify")
verify = client.post(
verify_url,
{"request_id": request_id, "code": code},
content_type="application/json",
)
assert verify.status_code == 200
data = verify.json()
assert "access" in data
assert "refresh" in data
user = User.objects.filter(phone_number=normalized_phone).first()
assert user is not None
assert user.is_phone_verified is True
@@ -0,0 +1,103 @@
"""Tests for Authentica OTP provider implementation."""
import os
from unittest.mock import MagicMock, patch
import pytest
from django.contrib.auth.hashers import make_password
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
from apps.accounts.services.otp import AuthenticaOtpProvider, verify_otp
@patch("requests.post")
def test_authentica_send_otp_sms_calls_api(mock_post):
mock_response = MagicMock(ok=True)
mock_response.json.return_value = {"success": True}
mock_post.return_value = mock_response
with patch.dict(
os.environ,
{
"AUTHENTICA_API_KEY": "api-key",
"AUTHENTICA_BASE_URL": "https://api.authentica.sa",
"AUTHENTICA_TIMEOUT_SECONDS": "7",
},
):
provider = AuthenticaOtpProvider()
provider.send_otp("+966512345678", OtpChannel.SMS)
mock_post.assert_called_once()
url = mock_post.call_args[0][0]
kwargs = mock_post.call_args[1]
assert url == "https://api.authentica.sa/api/v2/send-otp"
assert kwargs["json"] == {"method": "sms", "phone": "+966512345678"}
assert kwargs["headers"]["X-Authorization"] == "api-key"
assert kwargs["timeout"] == 7.0
@patch("requests.post")
def test_authentica_send_sms_calls_api(mock_post):
mock_response = MagicMock(ok=True)
mock_response.json.return_value = {"success": True}
mock_post.return_value = mock_response
with patch.dict(
os.environ,
{
"AUTHENTICA_API_KEY": "api-key",
"AUTHENTICA_SENDER_NAME": "Salon",
},
):
provider = AuthenticaOtpProvider()
provider.send_sms("+966512345678", "Hello")
mock_post.assert_called_once()
url = mock_post.call_args[0][0]
kwargs = mock_post.call_args[1]
assert url == "https://api.authentica.sa/api/v2/send-sms"
assert kwargs["json"] == {
"phone": "+966512345678",
"message": "Hello",
"sender_name": "Salon",
}
@patch("requests.post")
def test_authentica_verify_otp_calls_api(mock_post):
mock_response = MagicMock(ok=True)
mock_response.json.return_value = {"verified": True}
mock_post.return_value = mock_response
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
assert provider.verify_otp("+966512345678", "123456") is True
mock_post.assert_called_once()
url = mock_post.call_args[0][0]
kwargs = mock_post.call_args[1]
assert url == "https://api.authentica.sa/api/v2/verify-otp"
assert kwargs["json"] == {"phone": "+966512345678", "otp": "123456"}
@pytest.mark.django_db
def test_verify_otp_uses_provider_for_authentica():
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="authentica",
code_hash=make_password("unused"),
expires_at=PhoneOTP.expiry_at(),
)
with patch("apps.accounts.services.otp.AuthenticaOtpProvider.verify_otp", return_value=True) as mock_verify:
assert verify_otp(otp, "123456") is True
mock_verify.assert_called_once_with("+966512345678", "123456")
otp.refresh_from_db()
assert otp.verified_at is not None
assert otp.attempt_count == 1
+39 -3
View File
@@ -1,13 +1,49 @@
import pytest import pytest
from django.contrib.auth.hashers import make_password
from django.test import override_settings from django.test import override_settings
from apps.accounts.models import OtpChannel from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp from apps.accounts.services.otp import OtpCooldownError, OtpRateLimitError, create_and_send_otp, verify_otp
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0) @override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
def test_otp_rate_limit(): def test_otp_rate_limit():
create_and_send_otp("+966512345678", OtpChannel.SMS) create_and_send_otp("+966512345678", OtpChannel.SMS)
with pytest.raises(OtpRateLimitError): with pytest.raises(OtpRateLimitError):
create_and_send_otp("+966512345678", OtpChannel.SMS) create_and_send_otp("+966512345678", OtpChannel.SMS)
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=5,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=60,
)
def test_otp_cooldown_enforced():
create_and_send_otp("+966512345678", OtpChannel.SMS)
with pytest.raises(OtpCooldownError):
create_and_send_otp("+966512345678", OtpChannel.SMS)
@pytest.mark.django_db
def test_otp_max_attempts_blocks_verification():
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="console",
code_hash=make_password("123456"),
expires_at=PhoneOTP.expiry_at(),
)
# Burn attempts with wrong code until the limit is exceeded.
for _ in range(otp.max_attempts):
assert verify_otp(otp, "000000") is False
otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
assert otp.attempt_count == otp.max_attempts + 1
assert otp.verified_at is None
+37 -19
View File
@@ -1,31 +1,49 @@
from unittest.mock import patch
import pytest import pytest
from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from apps.accounts.models import PhoneOTP, User from apps.accounts.models import PhoneOTP, User
@pytest.mark.django_db @pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_creates_user_and_issues_tokens(client): def test_phone_auth_creates_user_and_issues_tokens(client):
request_url = reverse("phone_auth_request") # Deterministic OTP so we can verify the flow without external providers.
verify_url = reverse("phone_auth_verify") with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
request_url = reverse("phone_auth_request")
verify_url = reverse("phone_auth_verify")
response = client.post( response = client.post(
request_url, request_url,
{"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"}, {"phone_number": "0512345678", "channel": "sms", "first_name": "Sara"},
content_type="application/json", content_type="application/json",
) )
assert response.status_code == 201 assert response.status_code == 201
request_id = response.json()["request_id"] request_id = response.json()["request_id"]
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first() otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None assert otp is not None
assert str(otp.id) == request_id assert str(otp.id) == request_id
bad = client.post( bad = client.post(
verify_url, verify_url,
{"request_id": request_id, "code": "000000"}, {"request_id": request_id, "code": "000000"},
content_type="application/json", content_type="application/json",
) )
assert bad.status_code == 400 assert bad.status_code == 400
assert User.objects.filter(phone_number="+966512345678").exists() good = client.post(
verify_url,
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert good.status_code == 200
data = good.json()
assert "access" in data
assert "refresh" in data
user = User.objects.filter(phone_number="+966512345678").first()
assert user is not None
assert user.is_phone_verified is True
@@ -0,0 +1,51 @@
"""Tests for Twilio OTP provider implementation."""
import pytest
from unittest.mock import MagicMock, patch
@pytest.mark.django_db
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
def test_twilio_send_sms_calls_client(mock_get_client):
from apps.accounts.services.otp import TwilioOtpProvider
mock_client = MagicMock()
mock_get_client.return_value = mock_client
with patch.dict("os.environ", {
"TWILIO_ACCOUNT_SID": "AC123",
"TWILIO_AUTH_TOKEN": "token",
"TWILIO_FROM_NUMBER": "+966500000000",
}):
provider = TwilioOtpProvider()
provider.send_sms("+966512345678", "Your code is 123456")
mock_client.messages.create.assert_called_once_with(
body="Your code is 123456",
from_="+966500000000",
to="+966512345678",
)
@pytest.mark.django_db
@patch("apps.accounts.services.otp.TwilioOtpProvider._get_client")
def test_twilio_send_whatsapp_calls_client(mock_get_client):
from apps.accounts.services.otp import TwilioOtpProvider
mock_client = MagicMock()
mock_get_client.return_value = mock_client
with patch.dict("os.environ", {
"TWILIO_ACCOUNT_SID": "AC123",
"TWILIO_AUTH_TOKEN": "token",
"TWILIO_FROM_NUMBER": "+966500000000",
"TWILIO_WHATSAPP_FROM": "14155238886",
}):
provider = TwilioOtpProvider()
provider.send_whatsapp("+966512345678", "Your code is 123456")
mock_client.messages.create.assert_called_once_with(
body="Your code is 123456",
from_="whatsapp:14155238886",
to="whatsapp:+966512345678",
)
+62 -13
View File
@@ -1,5 +1,7 @@
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from apps.bookings.models import Booking from apps.bookings.models import Booking, BookingStatus
from apps.bookings.services import validate_booking_request from apps.bookings.services import validate_booking_request
from apps.salons.models import Service, StaffProfile from apps.salons.models import Service, StaffProfile
@@ -27,7 +29,7 @@ class BookingSerializer(serializers.ModelSerializer):
"notes", "notes",
"created_at", "created_at",
] ]
read_only_fields = ["id", "salon", "status", "price_amount", "currency", "created_at"] read_only_fields = ["id", "salon", "price_amount", "currency", "created_at"]
def get_staff_name(self, obj): def get_staff_name(self, obj):
if not obj.staff: if not obj.staff:
@@ -36,6 +38,27 @@ class BookingSerializer(serializers.ModelSerializer):
last = obj.staff.user.last_name or "" last = obj.staff.user.last_name or ""
return (first + " " + last).strip() or obj.staff.user.email return (first + " " + last).strip() or obj.staff.user.email
def validate(self, attrs):
if not self.instance or "status" not in attrs:
return attrs
new_status = attrs["status"]
old_status = self.instance.status
if new_status == old_status:
return attrs
user = self.context["request"].user
role = getattr(user, "role", None)
if new_status == BookingStatus.CONFIRMED and role not in {"manager", "staff", "admin"}:
raise serializers.ValidationError({"status": _("Only staff or managers can confirm bookings.")})
if new_status == BookingStatus.COMPLETED and role not in {"manager", "staff", "admin"}:
raise serializers.ValidationError({"status": _("Only staff or managers can complete bookings.")})
if new_status == BookingStatus.CANCELLED and role not in {"manager", "staff", "admin", "customer"}:
raise serializers.ValidationError({"status": _("You are not allowed to cancel this booking.")})
return attrs
class BookingCreateSerializer(serializers.ModelSerializer): class BookingCreateSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@@ -52,14 +75,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
request = self.context["request"] request = self.context["request"]
service = validated_data["service"] service = validated_data["service"]
return Booking.objects.create( staff = validated_data.get("staff")
salon=service.salon, start_time = validated_data["start_time"]
customer=request.user, end_time = validated_data["end_time"]
service=service,
staff=validated_data.get("staff"), with transaction.atomic():
start_time=validated_data["start_time"], # Lock the staff row so concurrent booking requests for the same staff
end_time=validated_data["end_time"], # member are serialized. Without this, two requests that both pass the
notes=validated_data.get("notes", ""), # overlap check in validate() can race and both commit overlapping
price_amount=service.price_amount, # bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
currency=service.currency, # ignored but the transaction still serializes writes; PostgreSQL
) # (production) gets true row-level locking.
StaffProfile.objects.select_for_update().get(pk=staff.pk)
# Re-run the overlap check inside the lock so the check and the insert
# are atomic with respect to other writers.
overlap = Booking.objects.filter(
staff=staff,
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
start_time__lt=end_time,
end_time__gt=start_time,
).exists()
if overlap:
raise serializers.ValidationError(
{"start_time": _("Booking overlaps an existing appointment")}
)
return Booking.objects.create(
salon=service.salon,
customer=request.user,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
notes=validated_data.get("notes", ""),
price_amount=service.price_amount,
currency=service.currency,
)
+12 -1
View File
@@ -1,7 +1,9 @@
from rest_framework import permissions, viewsets from rest_framework import permissions, viewsets
from apps.bookings.models import Booking from apps.bookings.models import Booking, BookingStatus
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
from apps.notifications.models import NotificationEvent
from apps.notifications.services import notify_booking_lifecycle, notify_on_status_change
class BookingViewSet(viewsets.ModelViewSet): class BookingViewSet(viewsets.ModelViewSet):
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
if self.action == "create": if self.action == "create":
return BookingCreateSerializer return BookingCreateSerializer
return BookingSerializer return BookingSerializer
def perform_create(self, serializer):
booking = serializer.save()
notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CREATED)
def perform_update(self, serializer):
previous_status = self.get_object().status
booking = serializer.save()
notify_on_status_change(booking, previous_status)
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from apps.notifications.models import Notification
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = (
"id",
"event",
"channel",
"status",
"booking",
"recipient",
"phone_number",
"provider",
"sent_at",
"created_at",
)
list_filter = ("event", "channel", "status", "provider")
search_fields = ("phone_number", "message")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.notifications"
@@ -0,0 +1,85 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("bookings", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("phone_number", models.CharField(blank=True, max_length=20)),
(
"channel",
models.CharField(
choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")],
max_length=20,
),
),
(
"event",
models.CharField(
choices=[
("booking_created", "Booking Created"),
("booking_confirmed", "Booking Confirmed"),
("booking_cancelled", "Booking Cancelled"),
],
max_length=50,
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("skipped", "Skipped"),
],
default="pending",
max_length=20,
),
),
("provider", models.CharField(blank=True, max_length=50)),
("message", models.TextField(blank=True)),
("provider_payload", models.JSONField(blank=True, default=dict)),
("error_message", models.TextField(blank=True)),
("sent_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"booking",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.CASCADE,
related_name="notifications",
to="bookings.booking",
),
),
(
"recipient",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
fields=("booking", "recipient", "event", "channel"),
name="uniq_notification_booking_event",
),
),
]
+64
View File
@@ -0,0 +1,64 @@
from django.conf import settings
from django.db import models
from apps.bookings.models import Booking
class NotificationChannel(models.TextChoices):
SMS = "sms", "SMS"
WHATSAPP = "whatsapp", "WhatsApp"
class NotificationEvent(models.TextChoices):
BOOKING_CREATED = "booking_created", "Booking Created"
BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed"
BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled"
class NotificationStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
class Notification(models.Model):
booking = models.ForeignKey(
Booking,
on_delete=models.CASCADE,
related_name="notifications",
null=True,
blank=True,
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="notifications",
null=True,
blank=True,
)
phone_number = models.CharField(max_length=20, blank=True)
channel = models.CharField(max_length=20, choices=NotificationChannel.choices)
event = models.CharField(max_length=50, choices=NotificationEvent.choices)
status = models.CharField(
max_length=20,
choices=NotificationStatus.choices,
default=NotificationStatus.PENDING,
)
provider = models.CharField(max_length=50, blank=True)
message = models.TextField(blank=True)
provider_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["booking", "recipient", "event", "channel"],
name="uniq_notification_booking_event",
)
]
def __str__(self) -> str:
return f"{self.event} to {self.phone_number or self.recipient_id}"
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
from django.db import transaction
from django.utils import timezone, translation
from django.utils.translation import gettext_lazy as _
from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS
from apps.bookings.models import Booking, BookingStatus
from apps.notifications.models import (
Notification,
NotificationChannel,
NotificationEvent,
NotificationStatus,
)
@dataclass
class NotificationSendResult:
status: str
payload: dict
error_message: str = ""
def _get_provider():
provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
provider_cls = OTP_PROVIDERS.get(provider_key)
if not provider_cls:
raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key})
return provider_cls(), provider_key
def _format_start_time(booking: Booking) -> str:
start_local = timezone.localtime(booking.start_time)
return start_local.strftime("%Y-%m-%d %H:%M")
def _build_message(booking: Booking, event: str) -> str:
start_text = _format_start_time(booking)
service_name = booking.service.name
salon_name = booking.salon.name
if event == NotificationEvent.BOOKING_CREATED:
return _(
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
) % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
if event == NotificationEvent.BOOKING_CONFIRMED:
return _(
"Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
) % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
if event == NotificationEvent.BOOKING_CANCELLED:
return _(
"Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
) % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
return _("Booking update for %(service)s at %(salon)s on %(start)s.") % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult:
provider, _ = _get_provider()
try:
if channel == NotificationChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == NotificationChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else:
raise ValueError(_("Unsupported notification channel"))
except Exception as exc: # pragma: no cover - provider failures are environment specific
return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc))
return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="")
def _notification_channel() -> str:
return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS)
def send_booking_notification(booking: Booking, recipient, event: str) -> Notification:
channel = _notification_channel()
phone_number = getattr(recipient, "phone_number", None) or ""
# Render the message in the recipient's preferred language.
with translation.override(getattr(recipient, "preferred_language", None)):
message = _build_message(booking, event)
with transaction.atomic():
notification, created = Notification.objects.get_or_create(
booking=booking,
recipient=recipient,
event=event,
channel=channel,
defaults={
"phone_number": phone_number,
"message": message,
},
)
if not created and notification.status == NotificationStatus.SENT:
return notification
if not phone_number:
# Record the skip for auditability when we cannot deliver.
notification.status = NotificationStatus.SKIPPED
notification.error_message = "Recipient has no phone number"
notification.save(update_fields=["status", "error_message"])
return notification
notification.phone_number = phone_number
notification.message = message
send_result = _send_message(phone_number, channel, message)
notification.status = send_result.status
notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
notification.provider_payload = send_result.payload
notification.error_message = send_result.error_message
notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None
notification.save(
update_fields=[
"phone_number",
"message",
"status",
"provider",
"provider_payload",
"error_message",
"sent_at",
]
)
return notification
def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]:
recipients = [booking.customer]
if booking.staff and booking.staff.user:
recipients.append(booking.staff.user)
notifications = []
for recipient in recipients:
notifications.append(send_booking_notification(booking, recipient, event))
return notifications
def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]:
if booking.status == previous_status:
return []
# Only notify for lifecycle transitions we explicitly support today.
if booking.status == BookingStatus.CONFIRMED:
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED)
if booking.status == BookingStatus.CANCELLED:
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED)
return []
@@ -0,0 +1,121 @@
from datetime import timedelta
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.notifications.models import Notification, NotificationEvent, NotificationStatus
from apps.salons.models import Salon, Service, StaffProfile
@pytest.fixture
def booking_payload():
owner = User.objects.create_user(
email="owner@example.com",
password="pass",
role=UserRole.MANAGER,
phone_number="0500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="0500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="0500000003",
)
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)
return {
"customer": customer,
"staff_user": staff_user,
"service": service,
"staff": staff,
"payload": {
"service": service.id,
"staff": staff.id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"notes": "",
},
}
@pytest.mark.django_db
def test_booking_create_sends_notifications(booking_payload):
client = APIClient()
client.force_authenticate(user=booking_payload["customer"])
response = client.post(
reverse("booking-list"),
booking_payload["payload"],
content_type="application/json",
)
assert response.status_code == 201
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED)
assert notifications.count() == 2
assert all(notification.status == NotificationStatus.SENT for notification in notifications)
@pytest.mark.django_db
def test_booking_status_change_sends_notifications_once(booking_payload):
client = APIClient()
client.force_authenticate(user=booking_payload["customer"])
response = client.post(
reverse("booking-list"),
booking_payload["payload"],
content_type="application/json",
)
assert response.status_code == 201
booking_id = Booking.objects.get(customer=booking_payload["customer"]).id
update_payload = {"status": BookingStatus.CONFIRMED}
client.force_authenticate(user=booking_payload["staff_user"])
response_update = client.patch(
reverse("booking-detail", args=[booking_id]),
update_payload,
content_type="application/json",
)
assert response_update.status_code == 200
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
assert notifications.count() == 2
response_repeat = client.patch(
reverse("booking-detail", args=[booking_id]),
update_payload,
content_type="application/json",
)
assert response_repeat.status_code == 200
notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
assert notifications_repeat.count() == 2
@@ -0,0 +1,73 @@
# Generated by Django 6.0.2 on 2026-02-28 09:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='payment',
name='authorized_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='captured_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='failed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='idempotency_key',
field=models.UUIDField(blank=True, null=True, unique=True),
),
migrations.AddField(
model_name='payment',
name='paid_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='provider_payload',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='refunded_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='status_updated_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='verified_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='voided_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='payment',
name='external_id',
field=models.CharField(blank=True, max_length=200, null=True, unique=True),
),
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('created', 'Created'), ('authorized', 'Authorized'), ('captured', 'Captured'), ('paid', 'Paid'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('voided', 'Voided'), ('verified', 'Verified')], default='initiated', max_length=20),
),
]
+16 -2
View File
@@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices):
class PaymentStatus(models.TextChoices): class PaymentStatus(models.TextChoices):
INITIATED = "initiated", "Initiated"
CREATED = "created", "Created" CREATED = "created", "Created"
AUTHORIZED = "authorized", "Authorized" AUTHORIZED = "authorized", "Authorized"
CAPTURED = "captured", "Captured" CAPTURED = "captured", "Captured"
PAID = "paid", "Paid"
FAILED = "failed", "Failed" FAILED = "failed", "Failed"
REFUNDED = "refunded", "Refunded" REFUNDED = "refunded", "Refunded"
VOIDED = "voided", "Voided"
VERIFIED = "verified", "Verified"
class Payment(models.Model): class Payment(models.Model):
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments") booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
provider = models.CharField(max_length=50, choices=PaymentProvider.choices) provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED) status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.INITIATED)
amount = models.DecimalField(max_digits=10, decimal_places=2) amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR")) currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
external_id = models.CharField(max_length=200, blank=True) external_id = models.CharField(max_length=200, null=True, blank=True, unique=True)
idempotency_key = models.UUIDField(null=True, blank=True, unique=True)
provider_payload = models.JSONField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True) metadata = models.JSONField(default=dict, blank=True)
authorized_at = models.DateTimeField(null=True, blank=True)
captured_at = models.DateTimeField(null=True, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
failed_at = models.DateTimeField(null=True, blank=True)
refunded_at = models.DateTimeField(null=True, blank=True)
voided_at = models.DateTimeField(null=True, blank=True)
verified_at = models.DateTimeField(null=True, blank=True)
status_updated_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
+31 -12
View File
@@ -2,7 +2,7 @@ from rest_framework import serializers
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.bookings.models import Booking from apps.bookings.models import Booking
from apps.payments.models import Payment, PaymentProvider, PaymentStatus from apps.payments.models import Payment, PaymentProvider
class PaymentSerializer(serializers.ModelSerializer): class PaymentSerializer(serializers.ModelSerializer):
@@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer):
"amount", "amount",
"currency", "currency",
"external_id", "external_id",
"idempotency_key",
"metadata", "metadata",
"authorized_at",
"captured_at",
"paid_at",
"failed_at",
"refunded_at",
"voided_at",
"verified_at",
"status_updated_at",
"created_at", "created_at",
] ]
read_only_fields = fields read_only_fields = fields
@@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer):
class PaymentCreateSerializer(serializers.ModelSerializer): class PaymentCreateSerializer(serializers.ModelSerializer):
booking_id = serializers.IntegerField(write_only=True) booking_id = serializers.IntegerField(write_only=True)
provider = serializers.ChoiceField(choices=PaymentProvider.choices) provider = serializers.ChoiceField(choices=PaymentProvider.choices)
idempotency_key = serializers.UUIDField(write_only=True)
source = serializers.JSONField(write_only=True, required=False)
callback_url = serializers.URLField(write_only=True, required=False)
class Meta: class Meta:
model = Payment model = Payment
fields = ["booking_id", "provider"] fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"]
def validate_booking_id(self, value): def validate_booking_id(self, value):
if not Booking.objects.filter(id=value).exists(): if not Booking.objects.filter(id=value).exists():
raise serializers.ValidationError(_("Booking not found")) raise serializers.ValidationError(_("Booking not found"))
return value return value
def create(self, validated_data): def validate(self, attrs):
booking = Booking.objects.get(id=validated_data["booking_id"]) provider = attrs.get("provider")
return Payment.objects.create( source = attrs.get("source")
booking=booking, if provider != PaymentProvider.MOYASAR:
provider=validated_data["provider"], raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
status=PaymentStatus.CREATED, if source is None:
amount=booking.price_amount, raise serializers.ValidationError({"source": _("Payment source is required")})
currency=booking.currency, source_type = source.get("type")
metadata={}, if not source_type:
) raise serializers.ValidationError({"source": _("Payment source type is required")})
if source_type == "creditcard":
raise serializers.ValidationError(
{"source": _("Card data must not be sent to the backend; use frontend tokenization")}
)
if source_type == "token" and not attrs.get("callback_url"):
raise serializers.ValidationError({"callback_url": _("Callback URL is required for token payments")})
return attrs
+105 -10
View File
@@ -2,21 +2,43 @@ 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, amount: Optional[int] = None) -> None:
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
raise NotImplementedError raise NotImplementedError
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
raise NotImplementedError raise NotImplementedError
@@ -30,14 +52,87 @@ 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
def capture_payment(self, external_id: str) -> None: try:
self._assert_config() response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
raise NotImplementedError("Moyasar capture not implemented yet") except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar") from exc
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: 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, amount: Optional[int] = None) -> None:
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
self._assert_config() self._assert_config()
raise NotImplementedError("Moyasar refund not implemented yet") url = f"{self.base_url}/v1/payments/{external_id}/capture"
payload = {} if amount is None else {"amount": amount}
try:
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
if response.status_code not in (200, 201):
data = response.json() if response.content else {}
raise PaymentGatewayError(
"Moyasar capture failed",
status_code=response.status_code,
payload=data,
)
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
self._assert_config()
url = f"{self.base_url}/v1/payments/{external_id}/refund"
payload = {} if amount is None else {"amount": amount}
try:
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
if response.status_code not in (200, 201):
data = response.json() if response.content else {}
raise PaymentGatewayError(
"Moyasar refund failed",
status_code=response.status_code,
payload=data,
)
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Tuple
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from apps.bookings.models import Booking
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
CURRENCY_DECIMALS = {
"SAR": 2,
"USD": 2,
"EUR": 2,
"GBP": 2,
"KWD": 3,
"BHD": 3,
"JOD": 3,
}
MoyasarAllowedSourceTypes = {"token", "stcpay", "applepay", "samsungpay"}
def _to_minor_units(amount: Decimal, currency: str) -> int:
decimals = CURRENCY_DECIMALS.get(currency.upper(), 2)
factor = Decimal("1") if decimals == 0 else Decimal(10) ** decimals
minor = (amount * factor).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(minor)
def _map_provider_status(status: Optional[str]) -> Optional[str]:
if not status:
return None
status = status.lower()
mapping = {
"initiated": PaymentStatus.INITIATED,
"authorized": PaymentStatus.AUTHORIZED,
"captured": PaymentStatus.CAPTURED,
"paid": PaymentStatus.PAID,
"failed": PaymentStatus.FAILED,
"refunded": PaymentStatus.REFUNDED,
"voided": PaymentStatus.VOIDED,
"verified": PaymentStatus.VERIFIED,
}
return mapping.get(status)
def _apply_status(payment: Payment, status: str) -> None:
now = timezone.now()
payment.status = status
payment.status_updated_at = now
if status == PaymentStatus.AUTHORIZED:
payment.authorized_at = now
elif status == PaymentStatus.CAPTURED:
payment.captured_at = now
elif status == PaymentStatus.PAID:
payment.paid_at = now
elif status == PaymentStatus.FAILED:
payment.failed_at = now
elif status == PaymentStatus.REFUNDED:
payment.refunded_at = now
elif status == PaymentStatus.VOIDED:
payment.voided_at = now
elif status == PaymentStatus.VERIFIED:
payment.verified_at = now
def create_payment_for_booking(
booking: Booking,
provider: str,
idempotency_key,
source: dict,
callback_url: Optional[str] = None,
) -> Tuple[Payment, bool, Optional[str]]:
if provider != PaymentProvider.MOYASAR:
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
existing = Payment.objects.filter(idempotency_key=idempotency_key).first()
if existing:
if existing.booking_id != booking.id or existing.provider != provider:
raise serializers.ValidationError({"idempotency_key": _("Idempotency key already used")})
return existing, False, existing.metadata.get("redirect_url")
source_type = (source or {}).get("type")
if source_type not in MoyasarAllowedSourceTypes:
raise serializers.ValidationError({"source": _("Unsupported payment source type")})
payment = Payment.objects.create(
booking=booking,
provider=provider,
status=PaymentStatus.INITIATED,
amount=booking.price_amount,
currency=booking.currency,
idempotency_key=idempotency_key,
metadata={"booking_id": booking.id},
)
_apply_status(payment, PaymentStatus.INITIATED)
payment.save(update_fields=["status", "status_updated_at"])
amount_minor = _to_minor_units(booking.price_amount, booking.currency)
description = f"Booking {booking.id}"
gateway = MoyasarGateway()
try:
result = gateway.create_payment(
amount=amount_minor,
currency=booking.currency,
description=description,
source=source,
callback_url=callback_url,
given_id=str(idempotency_key),
metadata={"booking_id": booking.id},
)
except PaymentGatewayError as exc:
_apply_status(payment, PaymentStatus.FAILED)
payment.metadata["gateway_error"] = {
"message": str(exc),
"status_code": exc.status_code,
"payload": exc.payload,
}
payment.save(update_fields=[
"status",
"status_updated_at",
"failed_at",
"metadata",
])
raise serializers.ValidationError({"detail": _("Payment provider error")}) from exc
if not result.external_id:
_apply_status(payment, PaymentStatus.FAILED)
payment.metadata["gateway_error"] = {"message": "Missing payment reference from provider"}
payment.save(update_fields=[
"status",
"status_updated_at",
"failed_at",
"metadata",
])
raise serializers.ValidationError({"detail": _("Payment provider error")})
payment.external_id = result.external_id
payment.provider_payload = result.payload
payment.metadata["redirect_url"] = result.redirect_url
mapped_status = _map_provider_status(result.status)
if mapped_status:
_apply_status(payment, mapped_status)
payment.save()
return payment, True, result.redirect_url
def apply_webhook_event(payment: Payment, event_type: str, payload: dict) -> bool:
mapping = {
"payment_authorized": PaymentStatus.AUTHORIZED,
"payment_captured": PaymentStatus.CAPTURED,
"payment_paid": PaymentStatus.PAID,
"payment_failed": PaymentStatus.FAILED,
"payment_faild": PaymentStatus.FAILED,
"payment_abandoned": PaymentStatus.FAILED,
"payment_refunded": PaymentStatus.REFUNDED,
"payment_voided": PaymentStatus.VOIDED,
"payment_verified": PaymentStatus.VERIFIED,
}
target_status = mapping.get(event_type)
if not target_status:
return False
if payment.status == target_status:
return False
_apply_status(payment, target_status)
payment.metadata["last_webhook"] = payload
payment.save()
return True
@@ -0,0 +1,54 @@
"""Tests for Moyasar capture and refund gateway methods."""
from unittest.mock import Mock, patch
import pytest
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_capture_calls_api(mock_post):
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
gateway.capture_payment("pay_1")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert "pay_1/capture" in call_args[0][0]
assert call_args[1]["auth"] == ("sk_test", "")
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_refund_calls_api(mock_post):
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
gateway.refund_payment("pay_1")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert "pay_1/refund" in call_args[0][0]
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_capture_raises_on_error(mock_post):
mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
with pytest.raises(PaymentGatewayError) as exc_info:
gateway.capture_payment("pay_1")
assert exc_info.value.status_code == 400
@@ -0,0 +1,168 @@
import uuid
from datetime import timedelta
from unittest.mock import Mock, patch
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import User, UserRole
from apps.bookings.models import Booking, BookingStatus
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
from apps.salons.models import Salon, Service, StaffProfile
@pytest.fixture
def booking_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER)
customer = User.objects.create_user(email="customer@example.com", password="pass")
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF)
salon = Salon.objects.create(
owner=owner,
name="Main Salon",
description="",
address="123 King Rd",
city="Riyadh",
phone_number="0512345678",
)
service = Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=120,
currency="SAR",
)
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
start_time = timezone.now() + timedelta(days=1)
end_time = start_time + timedelta(minutes=60)
booking = Booking.objects.create(
salon=salon,
customer=customer,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
status=BookingStatus.PENDING,
price_amount=service.price_amount,
currency=service.currency,
notes="",
)
return customer, booking
def _mock_gateway_response(payment_id="pay_test", status="initiated"):
response = Mock()
response.status_code = 201
response.json.return_value = {
"id": payment_id,
"status": status,
"source": {"transaction_url": "https://moyasar.example/tx"},
}
response.content = b"{}"
return response
@pytest.mark.django_db
@patch("apps.payments.services.gateway.requests.post")
def test_create_payment_idempotency_returns_existing(mock_post, booking_entities, monkeypatch):
customer, booking = booking_entities
client = APIClient()
client.force_authenticate(user=customer)
monkeypatch.setenv("MOYASAR_SECRET_KEY", "sk_test")
monkeypatch.setenv("MOYASAR_PUBLISHABLE_KEY", "pk_test")
mock_post.return_value = _mock_gateway_response()
request_id = str(uuid.uuid4())
payload = {
"booking_id": booking.id,
"provider": PaymentProvider.MOYASAR,
"idempotency_key": request_id,
"source": {"type": "stcpay", "mobile": "0500000000"},
}
response = client.post(reverse("payment-list"), payload, content_type="application/json")
assert response.status_code == 201
response_repeat = client.post(reverse("payment-list"), payload, content_type="application/json")
assert response_repeat.status_code == 200
assert Payment.objects.count() == 1
@pytest.mark.django_db
def test_rejects_creditcard_source(booking_entities):
customer, booking = booking_entities
client = APIClient()
client.force_authenticate(user=customer)
payload = {
"booking_id": booking.id,
"provider": PaymentProvider.MOYASAR,
"idempotency_key": str(uuid.uuid4()),
"source": {"type": "creditcard", "number": "4111111111111111"},
}
response = client.post(reverse("payment-list"), payload, content_type="application/json")
assert response.status_code == 400
assert "source" in response.json()
@pytest.mark.django_db
def test_webhook_paid_updates_status(booking_entities, monkeypatch):
_, booking = booking_entities
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
payment = Payment.objects.create(
booking=booking,
provider=PaymentProvider.MOYASAR,
status=PaymentStatus.INITIATED,
amount=booking.price_amount,
currency=booking.currency,
external_id="pay_webhook",
metadata={},
)
payload = {
"type": "payment_paid",
"secret_token": "secret",
"data": {"id": "pay_webhook"},
}
client = APIClient()
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
assert response.status_code == 200
payment.refresh_from_db()
assert payment.status == PaymentStatus.PAID
assert payment.paid_at is not None
@pytest.mark.django_db
def test_webhook_invalid_secret_is_rejected(booking_entities, monkeypatch):
_, booking = booking_entities
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
Payment.objects.create(
booking=booking,
provider=PaymentProvider.MOYASAR,
status=PaymentStatus.INITIATED,
amount=booking.price_amount,
currency=booking.currency,
external_id="pay_webhook",
metadata={},
)
payload = {
"type": "payment_paid",
"secret_token": "wrong",
"data": {"id": "pay_webhook"},
}
client = APIClient()
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
assert response.status_code == 401
+2 -1
View File
@@ -1,11 +1,12 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.payments.views import PaymentViewSet from apps.payments.views import PaymentViewSet, payment_webhook
router = DefaultRouter() router = DefaultRouter()
router.register(r"", PaymentViewSet, basename="payment") router.register(r"", PaymentViewSet, basename="payment")
urlpatterns = [ urlpatterns = [
path("webhook/", payment_webhook, name="payment-webhook"),
path("", include(router.urls)), path("", include(router.urls)),
] ]
+48 -13
View File
@@ -1,10 +1,17 @@
from rest_framework import permissions, status, viewsets import logging
from rest_framework.response import Response import os
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from apps.bookings.models import Booking from apps.bookings.models import Booking
from apps.payments.models import Payment from apps.payments.models import Payment, PaymentProvider
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
from apps.payments.services.payments import apply_webhook_event, create_payment_for_booking
logger = logging.getLogger(__name__)
def user_can_access_booking(user, booking: Booking) -> bool: def user_can_access_booking(user, booking: Booking) -> bool:
@@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet):
booking = Booking.objects.get(id=serializer.validated_data["booking_id"]) booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
if not user_can_access_booking(request.user, booking): if not user_can_access_booking(request.user, booking):
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN) return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
payment = serializer.save() payment, created, redirect_url = create_payment_for_booking(
return Response( booking=booking,
{ provider=serializer.validated_data["provider"],
"detail": _("Payment record created. Provider integration pending."), idempotency_key=serializer.validated_data["idempotency_key"],
"payment_id": payment.id, source=serializer.validated_data["source"],
"amount": str(payment.amount), callback_url=serializer.validated_data.get("callback_url"),
"currency": payment.currency,
"status": payment.status,
},
status=status.HTTP_201_CREATED,
) )
response_data = PaymentSerializer(payment).data
response_data["redirect_url"] = redirect_url
response_data["created"] = created
return Response(response_data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
@api_view(["POST"])
@permission_classes([permissions.AllowAny])
def payment_webhook(request):
secret = os.getenv("MOYASAR_WEBHOOK_SECRET")
payload = request.data or {}
if not secret:
return Response({"detail": _("Webhook secret not configured")}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
if payload.get("secret_token") != secret:
return Response({"detail": _("Invalid webhook signature")}, status=status.HTTP_401_UNAUTHORIZED)
event_type = payload.get("type")
data = payload.get("data") or {}
external_id = data.get("id")
if not external_id:
return Response({"detail": _("Missing payment reference")}, status=status.HTTP_400_BAD_REQUEST)
payment = Payment.objects.filter(external_id=external_id, provider=PaymentProvider.MOYASAR).first()
if not payment:
logger.warning("Moyasar webhook for unknown payment %s", external_id)
return Response({"detail": _("Payment not found")}, status=status.HTTP_200_OK)
applied = apply_webhook_event(payment, event_type, payload)
if not applied:
return Response({"detail": _("Event ignored")}, status=status.HTTP_200_OK)
return Response({"detail": _("Webhook processed")}, status=status.HTTP_200_OK)
@@ -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"},
}, },
) )
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
# Arabic (Saudi Arabia) translations for Salon booking platform.
# Copyright (C) 2026
#
msgid ""
msgstr ""
"Project-Id-Version: 1.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
"Last-Translator: Claude\n"
"Language-Team: Arabic (Saudi Arabia)\n"
"Language: ar_SA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#: apps/accounts/services/otp.py:26
msgid "Too many OTP requests. Try again later."
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
#: apps/accounts/services/otp.py:32
msgid "Please wait before requesting another code."
msgstr "يرجى الانتظار قبل طلب رمز آخر."
#: apps/accounts/services/otp.py:71
msgid "Twilio credentials are not configured"
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
#: apps/accounts/services/otp.py:85
msgid "Twilio WhatsApp sender is not configured"
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
#: apps/accounts/services/otp.py:100
msgid "Unifonic credentials are not configured"
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
#: apps/accounts/services/otp.py:104
msgid "Unifonic SMS adapter not implemented yet"
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
#: apps/accounts/services/otp.py:109
msgid "Unifonic WhatsApp sender is not configured"
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
#: apps/accounts/services/otp.py:110
msgid "Unifonic WhatsApp adapter not implemented yet"
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
#: apps/accounts/services/otp.py:126
msgid "Authentica API key is not configured"
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
msgid "Authentica request failed"
msgstr "فشل طلب Authentica"
#: apps/accounts/services/otp.py:159
#, python-format
msgid "Authentica request failed: %(status)s %(body)s"
msgstr "فشل طلب Authentica: %(status)s %(body)s"
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
msgid "Unsupported OTP channel"
msgstr "قناة رمز التحقق غير مدعومة"
#: apps/accounts/services/otp.py:179
#, python-format
msgid "Authentica verify failed: %(response)s"
msgstr "فشل التحقق بـ Authentica: %(response)s"
#: apps/accounts/services/otp.py:184
msgid "Authentica sender name is not configured"
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
#: apps/accounts/services/otp.py:195
msgid "Authentica WhatsApp messaging is not supported"
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
#: apps/accounts/services/otp.py:209
#, python-format
msgid "Unknown OTP provider: %(provider)s"
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
#: apps/accounts/services/otp.py:256
#, python-format
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
#: apps/accounts/services/phone.py:8
msgid "Phone number is required"
msgstr "رقم الهاتف مطلوب"
#: apps/accounts/services/phone.py:17
msgid "Invalid phone number format"
msgstr "تنسيق رقم الهاتف غير صالح"
#: apps/accounts/services/phone.py:28
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
#: apps/accounts/views.py:75 apps/accounts/views.py:138
msgid "Invalid or expired code"
msgstr "الرمز غير صالح أو منتهي الصلاحية"
#: apps/accounts/views.py:82
msgid "Phone verified"
msgstr "تم التحقق من رقم الهاتف"
#: apps/accounts/views.py:99
msgid "Email already in use."
msgstr "البريد الإلكتروني مستخدم بالفعل."
#: apps/accounts/views.py:142
msgid "User not found"
msgstr "المستخدم غير موجود"
#: apps/accounts/views.py:164
msgid "Social login not configured yet. Add OAuth provider config."
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
#: apps/bookings/serializers.py:54
msgid "Only staff or managers can confirm bookings."
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
#: apps/bookings/serializers.py:56
msgid "Only staff or managers can complete bookings."
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
#: apps/bookings/serializers.py:58
msgid "You are not allowed to cancel this booking."
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
msgid "Booking overlaps an existing appointment"
msgstr "يتداخل الحجز مع موعد قائم"
#: apps/bookings/services.py:13
msgid "Staff is required for booking"
msgstr "يجب تحديد موظف للحجز"
#: apps/bookings/services.py:16
msgid "Selected staff does not belong to this salon"
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
#: apps/bookings/services.py:19
msgid "End time must be after start time"
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
#: apps/bookings/services.py:23
msgid "End time must match service duration"
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
#: apps/bookings/services.py:40
msgid "Booking is outside staff availability"
msgstr "الحجز خارج أوقات توفر الموظف"
#: apps/notifications/services.py:31
#, python-format
msgid "Unknown notification provider: %(provider)s"
msgstr "مزود الإشعارات غير معروف: %(provider)s"
#: apps/notifications/services.py:47
#, python-format
msgid ""
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:55
#, python-format
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:63
#, python-format
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:70
#, python-format
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:85
msgid "Unsupported notification channel"
msgstr "قناة الإشعارات غير مدعومة"
#: apps/payments/serializers.py:49
msgid "Booking not found"
msgstr "الحجز غير موجود"
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
msgid "Provider integration not implemented"
msgstr "تكامل المزود غير مُنفَّذ"
#: apps/payments/serializers.py:58
msgid "Payment source is required"
msgstr "مصدر الدفع مطلوب"
#: apps/payments/serializers.py:61
msgid "Payment source type is required"
msgstr "نوع مصدر الدفع مطلوب"
#: apps/payments/serializers.py:64
msgid "Card data must not be sent to the backend; use frontend tokenization"
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
#: apps/payments/serializers.py:67
msgid "Callback URL is required for token payments"
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
#: apps/payments/services/payments.py:84
msgid "Idempotency key already used"
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
#: apps/payments/services/payments.py:89
msgid "Unsupported payment source type"
msgstr "نوع مصدر الدفع غير مدعوم"
#: apps/payments/services/payments.py:130
#: apps/payments/services/payments.py:141
msgid "Payment provider error"
msgstr "خطأ في مزود الدفع"
#: apps/payments/views.py:50
msgid "Not allowed"
msgstr "غير مسموح"
#: apps/payments/views.py:70
msgid "Webhook secret not configured"
msgstr "لم يتم تكوين رمز الـ webhook"
#: apps/payments/views.py:73
msgid "Invalid webhook signature"
msgstr "توقيع الـ webhook غير صالح"
#: apps/payments/views.py:79
msgid "Missing payment reference"
msgstr "مرجع الدفع مفقود"
#: apps/payments/views.py:84
msgid "Payment not found"
msgstr "لم يتم العثور على الدفعة"
#: apps/payments/views.py:88
msgid "Event ignored"
msgstr "تم تجاهل الحدث"
#: apps/payments/views.py:89
msgid "Webhook processed"
msgstr "تمت معالجة الـ webhook"
+3 -1
View File
@@ -1,4 +1,6 @@
[pytest] [pytest]
DJANGO_SETTINGS_MODULE = salon_api.settings DJANGO_SETTINGS_MODULE = salon_api.settings
python_files = tests.py test_*.py *_tests.py python_files = tests.py test_*.py *_tests.py
addopts = -q addopts = -q -m "not external"
markers =
external: hits real third-party services (requires explicit env to run)
+2
View File
@@ -4,3 +4,5 @@ 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
twilio>=9.0
+13 -4
View File
@@ -1,4 +1,5 @@
import os import os
import sys
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -26,6 +27,7 @@ INSTALLED_APPS = [
"apps.salons", "apps.salons",
"apps.bookings", "apps.bookings",
"apps.payments", "apps.payments",
"apps.notifications",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -77,11 +79,14 @@ def parse_database_url(database_url: str):
} }
DATABASE_URL = os.getenv("DATABASE_URL") running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
if DATABASE_URL: test_database_url = os.getenv("TEST_DATABASE_URL")
parsed_db = parse_database_url(DATABASE_URL) database_url = os.getenv("DATABASE_URL")
if running_tests:
parsed_db = parse_database_url(test_database_url) if test_database_url else None
else: else:
parsed_db = None parsed_db = parse_database_url(database_url) if database_url else None
DATABASES = { DATABASES = {
"default": parsed_db "default": parsed_db
@@ -135,8 +140,12 @@ CORS_ALLOWED_ORIGINS = [
] ]
OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console") OTP_PROVIDER = os.getenv("OTP_PROVIDER", "console")
if running_tests:
OTP_PROVIDER = os.getenv("TEST_OTP_PROVIDER", "console")
OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5")) OTP_EXPIRY_MINUTES = int(os.getenv("OTP_EXPIRY_MINUTES", "5"))
OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5")) OTP_MAX_PER_WINDOW = int(os.getenv("OTP_MAX_PER_WINDOW", "5"))
OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15")) OTP_WINDOW_MINUTES = int(os.getenv("OTP_WINDOW_MINUTES", "15"))
OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60")) OTP_RESEND_COOLDOWN_SECONDS = int(os.getenv("OTP_RESEND_COOLDOWN_SECONDS", "60"))
DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR") DEFAULT_CURRENCY = os.getenv("DEFAULT_CURRENCY", "SAR")
NOTIFICATION_PROVIDER = os.getenv("NOTIFICATION_PROVIDER", OTP_PROVIDER)
NOTIFICATION_DEFAULT_CHANNEL = os.getenv("NOTIFICATION_DEFAULT_CHANNEL", "sms")
+36
View File
@@ -0,0 +1,36 @@
# Documentation Index
This directory is the source of truth for product, engineering, and ops documentation. Keep it current as features change.
## Start Here
- Project overview and setup: `README.md` (repo root)
- Architecture overview: `docs/architecture.md`
- Active ExecPlan: `docs/execplans/booking-notifications.md`
- Known risks and gaps: `docs/risks.md`
## Documentation Standards
See `docs/documentation.md` for documentation goals, update triggers, and templates.
## Docs Map
- `docs/architecture.md`: System architecture, boundaries, and MVP async/observability decision.
- `docs/adr/`: Architecture Decision Records (ADRs). New cross-cutting decisions must land here.
- `docs/execplans/`: Execution plans for significant features or refactors.
- `docs/runbooks/`: Operational runbooks and production checklists.
- `docs/risks.md`: Tracked risks and gaps.
- `docs/templates/`: Reusable templates (ADR, runbook).
## Update Triggers (Quick Reference)
- New external dependency, provider, or major flow: add an ADR in `docs/adr/`.
- Change to booking/payment/auth logic: update `docs/architecture.md` and relevant runbook(s).
- New operational procedure: add a runbook in `docs/runbooks/`.
- Close or add a significant risk: update `docs/risks.md`.
## Ownership And Review
- Authors own freshness: if you touch an area, update the docs in the same PR.
- New production flows require at least one runbook.
- Avoid duplicating instructions; link to the single source of truth.
@@ -0,0 +1,33 @@
# ADR 0001: Synchronous External Calls For MVP
## Status
Accepted
## Context
The MVP relies on OTP delivery, booking notifications, and payment gateway calls. Introducing a task queue (Celery/RQ) would add infrastructure (Redis, workers, retries) and operational complexity that is not required for the early launch.
## Decision
For the MVP, OTP sends, booking notifications, and payment gateway calls run synchronously in the request/response path with strict timeouts. A task queue will be revisited when traffic grows or operational needs change.
## Consequences
- Faster initial delivery with fewer moving parts.
- Increased latency risk on endpoints that call external providers.
- Payment and OTP failures are surfaced to clients immediately (correct behaviour — clients need to know).
- Notification failures are absorbed: `notifications/services.py` catches provider errors, stores them as `FAILED` status, and never surfaces them to the client. A failed booking SMS does not cause the booking request to fail. This means notification failures require active monitoring rather than appearing in client-facing error rates.
## Alternatives Considered
- Celery + Redis for all external calls: rejected for MVP due to infra overhead.
- Hybrid async for notifications only: not wrong in principle, deferred for operational simplicity. The three call types have genuinely different semantics:
- **Payment creation**: synchronous by design — the client needs the Moyasar redirect URL before the response returns.
- **OTP sends**: synchronous by design — users expect immediate confirmation that the code was sent.
- **Booking notifications**: fire-and-forget by nature — the booking is already committed and the client does not wait for delivery confirmation.
When notification latency becomes a problem (e.g. under load or with slow SMS providers), only notifications need to move off the request path. Payments and OTP sends should remain synchronous regardless.
## Related
- `docs/architecture.md`
+30
View File
@@ -0,0 +1,30 @@
# ADR 0002: Moyasar As The Payment Gateway
## Status
Accepted
## Context
The platform needs a payment gateway that supports Saudi Arabia, SAR currency defaults, and local payment methods (e.g. STC Pay, Apple Pay, Samsung Pay). The backend already implements a `MoyasarGateway` integration and models `payments.Payment` with a `moyasar` provider option.
## Decision
Use Moyasar as the payment gateway for the MVP. Payment creation, capture, refund, and webhook reconciliation are implemented through `apps.payments.services.gateway.MoyasarGateway`.
## Consequences
- Supports KSA-focused payment methods and SAR by default.
- Operational dependency on Moyasar uptime and API stability.
- Payment flows and webhooks are tied to the Moyasar API surface until a gateway abstraction is expanded.
## Alternatives Considered
- Other regional gateways: deferred until the MVP is validated.
- Stripe or similar global providers: not selected for MVP due to KSA-specific coverage priorities.
## Related
- `backend/apps/payments/services/gateway.py`
- `docs/runbooks/payments_sanity_check.md`
- `docs/architecture.md`
+30
View File
@@ -0,0 +1,30 @@
# ADR 0003: Authentica As Primary OTP Provider
## Status
Accepted
## Context
The platform requires phone-first authentication with OTP delivery for KSA. The codebase includes multiple provider adapters (`console`, `twilio`, `unifonic`, `authentica`) but only Authentica is implemented for provider-managed OTP delivery (send/verify) and direct SMS messaging. Twilio and Unifonic adapters are partial or unimplemented; a console provider exists for local development.
## Decision
Use Authentica as the primary OTP provider for the MVP, with `OTP_PROVIDER=authentica` in production environments. Keep `console` for local development and tests, and retain Twilio/Unifonic adapters as scaffolds for future expansion.
## Consequences
- OTP verification relies on Authentica APIs and credentials in production.
- Local development remains simple with the console provider.
- Adding a second production provider will require completing adapters and updating operational runbooks.
## Alternatives Considered
- Twilio as primary provider: not selected due to KSA-focused delivery needs and current adapter gaps.
- Unifonic as primary provider: deferred until the adapter is fully implemented and validated.
## Related
- `backend/apps/accounts/services/otp.py`
- `backend/salon_api/settings.py`
- `docs/architecture.md`
+5
View File
@@ -0,0 +1,5 @@
# Architecture Decision Records
ADRs capture cross-cutting or hard-to-reverse decisions. Add a new ADR when changing providers, async strategy, data model boundaries, or other architectural choices.
Use the template in `docs/templates/adr.md` and increment the numeric prefix (`0002`, `0003`, ...).
+60
View File
@@ -0,0 +1,60 @@
# Architecture
## Overview
The Salon platform is a Django REST API backend with a React/Vite frontend, optimized for KSA (phone auth, Riyadh timezone, Arabic locale).
## Backend Apps and Responsibilities
| App | Responsibility |
|-----|----------------|
| **accounts** | User model, phone/OTP auth, JWT tokens, locale preferences. OTP providers (console, Twilio, Unifonic) send SMS/WhatsApp. |
| **salons** | Salon catalog, services, staff, availability windows, reviews. Read-only public APIs. |
| **bookings** | Booking model, validation (availability, overlap prevention), status transitions. Triggers notifications on create and status change. |
| **payments** | Payment model, Moyasar integration (create, capture, refund), webhook reconciliation, idempotency. |
| **notifications** | Booking lifecycle notifications (SMS/WhatsApp). Reuses OTP provider classes as an MVP shortcut; sends on booking created/confirmed/cancelled. See note below. |
## Data Model Overview
The core data model centers on users, salons, and time-bound bookings. A booking ties a customer to a service, a staff member, and a scheduled time. Payments are recorded per booking and reconcile to the external gateway. Notifications are stored for every booking lifecycle message for auditability.
- `accounts.User` owns phone, locale, and auth preferences.
- `salons.Salon`, `salons.Service`, and `salons.Staff` define the catalog and scheduling surface.
- `bookings.Booking` links customer, staff, service, and scheduled time, with status transitions.
- `payments.Payment` tracks gateway state and idempotency per booking.
- `notifications.Notification` records each SMS/WhatsApp send attempt tied to a booking event.
## Data Flow
```
User → React Frontend → Django API
accounts (auth) ──→ OTP providers (Twilio/Unifonic/console)
salons (catalog)
bookings ──→ notifications ──→ OTP providers
payments ──→ Moyasar gateway
```
## Notification / OTP Provider Coupling (MVP Shortcut)
`notifications/services.py` imports `PROVIDERS` from `apps.accounts.services.otp` and uses OTP provider instances (e.g. `AuthenticaOtpProvider`) to send booking SMSes. This works today because Authentica handles both authentication OTPs and general SMS delivery.
Consequences of this coupling:
- Notifications and OTP delivery cannot independently use different providers (e.g. Twilio for OTP, Unifonic for notifications).
- The `notifications` app is conceptually coupled to the `accounts` app's auth infrastructure.
This is an acceptable MVP shortcut. Before Phase 2, introduce a dedicated `NotificationProvider` abstraction in `notifications/` (mirroring `OtpProvider`) so the two systems can be configured and tested independently.
## Async and Observability (MVP Decision)
**Decision (MVP):** All OTP sends, booking notifications, and payment gateway calls run **synchronously** in the request/response path. No Celery, RQ, or other task queue for the initial launch.
This is captured in ADR 0001 (`docs/adr/0001-synchronous-external-calls-mvp.md`).
**Rationale:**
- Reduces deployment complexity (no Redis, no worker processes).
- MVP traffic is expected to be low; synchronous latency is acceptable.
- External calls already use timeouts (e.g. Moyasar: 10s, Twilio: SDK default).
**Future:** When scaling, introduce a task queue (e.g. Celery + Redis) for OTP and notification sends. Payment creation and webhooks should remain synchronous for immediate feedback and idempotency.
**Observability:** Errors are logged via Python `logging` and stored in model metadata (e.g. `Payment.metadata["gateway_error"]`, `Notification.error_message`). Structured logging and metrics are Phase 3 work.
+51
View File
@@ -0,0 +1,51 @@
# Documentation Practices
These standards aim to keep documentation reliable as the codebase grows.
## Principles
- Single source of truth: one canonical doc per topic; link instead of duplicating.
- Proximity: keep docs close to the code they describe when possible.
- Freshness: update docs in the same PR as the code change.
- Observable behavior: describe what someone can see or run to validate the behavior.
## Required Docs By Area
- Architecture and major decisions: `docs/architecture.md` and `docs/adr/`.
- Feature delivery plans: `docs/execplans/` (required by `PLANS.md`).
- Operational procedures: `docs/runbooks/`.
- Risks and gaps: `docs/risks.md`.
## When To Write An ADR
Use an ADR for any decision that is cross-cutting or hard to reverse, including:
- External providers or payment/auth strategy changes.
- Async vs synchronous execution decisions.
- Data model changes that affect multiple apps or services.
ADRs live in `docs/adr/` and use the template in `docs/templates/adr.md`.
## Runbook Expectations
Every production-impacting flow should have a runbook that covers:
- Symptoms and impact.
- Detection and quick checks.
- Safe remediation steps.
- Rollback or escalation path.
Use the template in `docs/templates/runbook.md`.
## Writing Style
- Be explicit: include exact commands, paths, and expected output where useful.
- Keep sections short and focused.
- Avoid unstated assumptions; if a step needs a specific directory, say so.
## Review Checklist
- Docs updated or explicitly confirmed unnecessary.
- New runbook added when operational behavior changes.
- ADR added for new cross-cutting decisions.
- `docs/risks.md` updated for meaningful gaps added or closed.
+104
View File
@@ -0,0 +1,104 @@
# Booking Lifecycle Notifications (SMS/WhatsApp)
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, a booking will automatically notify the customer and the assigned staff member when it is created, confirmed, or cancelled. You can see it working by creating a booking and observing two notification records (customer + staff), then changing the booking status to confirmed or cancelled and seeing two more notification records for that event. In the console provider, the messages are logged, giving an immediate, user-visible trace of the booking lifecycle.
## Progress
- [x] (2026-02-28 17:05Z) Created ExecPlan for booking lifecycle notifications and reviewed bookings + notifications gaps.
- [x] (2026-02-28 17:30Z) Implemented notifications app with audit-friendly model, providers, and booking message templates.
- [x] (2026-02-28 17:40Z) Connected booking create/update flows to notification dispatch with idempotent event handling.
- [x] (2026-02-28 17:55Z) Allowed booking status updates with role checks to enable confirmation/cancellation.
- [x] (2026-02-28 18:05Z) Added tests for booking notifications (create, status change, no duplicate sends).
- [x] (2026-02-28 18:10Z) Updated `docs/risks.md` and validated tests (`python3 -m pytest`).
## Surprises & Discoveries
- Observation: Booking status updates were blocked because `status` was read-only on the default booking serializer.
Evidence: `PATCH /api/bookings/<id>` returned HTTP 400 when attempting to confirm.
## Decision Log
- Decision: Store every booking notification in a dedicated `Notification` model for auditability, even when skipped.
Rationale: Lifecycle messages are user-facing and must be traceable for support and compliance.
Date/Author: 2026-02-28, Codex
- Decision: Reuse existing OTP provider adapters for SMS/WhatsApp delivery, with a new `NOTIFICATION_PROVIDER` setting.
Rationale: Avoid duplicate integration code while still allowing independent provider configuration.
Date/Author: 2026-02-28, Codex
- Decision: Default to SMS for booking notifications and use the recipients preferred language when formatting messages.
Rationale: SMS is the most reliable baseline in KSA, and language preference is already captured on the user.
Date/Author: 2026-02-28, Codex
- Decision: Allow booking status changes via `BookingSerializer` with role-based validation.
Rationale: Confirmation/cancellation must be reachable through the existing API, but should still respect basic role boundaries.
Date/Author: 2026-02-28, Codex
## Outcomes & Retrospective
Booking lifecycle notifications are now implemented with audit-friendly records and idempotent sending. Booking creation and status changes (confirmed/cancelled) trigger SMS/WhatsApp notifications for both customer and staff, and role-based validation now governs status updates. Provider adapters remain scaffolds, so production delivery still requires real SMS/WhatsApp wiring.
## Context and Orientation
Booking creation and updates are handled in `backend/apps/bookings/views.py` via a DRF `ModelViewSet`. The booking model is in `backend/apps/bookings/models.py`, with `status` indicating lifecycle state. There is currently no notification system beyond OTP scaffolding in `backend/apps/accounts/services/otp.py`. This plan adds a new Django app at `backend/apps/notifications/` to store notification records, format booking lifecycle messages, and dispatch them via SMS or WhatsApp providers.
A “notification” in this repository means a user-facing message (SMS or WhatsApp) that is stored for auditability in a `Notification` database row. A “lifecycle event” is a booking change that should inform the customer and staff: booking created, confirmed, or cancelled.
## Plan of Work
First, create a `notifications` Django app with models and admin registration. Define `Notification`, `NotificationEvent`, `NotificationStatus`, and `NotificationChannel` in `backend/apps/notifications/models.py`. The model must capture booking, recipient, phone number, event, channel, status, provider, message, and send timestamps, and it must be idempotent by preventing duplicates for the same booking + recipient + event + channel. Register the model in `backend/apps/notifications/admin.py` and add `apps.notifications` to `INSTALLED_APPS` in `backend/salon_api/settings.py`.
Next, implement notification dispatch in `backend/apps/notifications/services.py`. Reuse OTP provider adapters from `apps.accounts.services.otp` with a new `NOTIFICATION_PROVIDER` setting (default to `OTP_PROVIDER`). Add a `NOTIFICATION_DEFAULT_CHANNEL` setting (default `sms`). Implement `send_booking_notification(booking, recipient, event)` to build localized message text using the recipients preferred language, send via the provider, and update the notification status. Implement `notify_booking_lifecycle(booking, event)` for initial sends and `notify_on_status_change(booking, previous_status)` to trigger only on status transitions. If the recipient lacks a phone number, record the notification as `skipped` with a reason.
Then, wire booking lifecycle events in `backend/apps/bookings/views.py`. On `perform_create`, call `notify_booking_lifecycle(..., booking_created)` so both customer and staff receive a message. On `perform_update`, compare the previous status to the new status and call `notify_on_status_change` for confirmed or cancelled transitions. Avoid sending notifications if the status does not change.
Finally, add tests in `backend/apps/notifications/tests/test_booking_notifications.py`. Cover booking creation (two notifications), status change to confirmed (two notifications), and a repeat status update that should not create duplicates. Ensure tests use phone numbers on users to avoid skipped notifications. Update `docs/risks.md` to mark “No notifications (email/SMS) beyond OTP scaffolding” as addressed once tests pass.
## Concrete Steps
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
1. Add notifications app code and migrations.
- Create `backend/apps/notifications/` with `apps.py`, `models.py`, `services.py`, `admin.py`, and a migration `0001_initial.py`.
- Update `backend/salon_api/settings.py` to include `apps.notifications` and notification settings.
2. Wire booking lifecycle events.
- Update `backend/apps/bookings/views.py` to call notification services on create and status changes.
3. Add tests.
- Create `backend/apps/notifications/tests/test_booking_notifications.py`.
4. Run backend tests.
- From `backend/` with the venv active:
python3 -m pytest
## Validation and Acceptance
- Creating a booking returns HTTP 201 and creates two notification records (customer + staff) with event `booking_created`.
- Updating a bookings status to `confirmed` creates two notification records with event `booking_confirmed`.
- Repeating the same status update does not create duplicate notifications (records remain at two for that event).
- `python3 -m pytest` passes, and the new tests fail before the change and pass after.
## Idempotence and Recovery
Notification creation is idempotent by a uniqueness constraint on booking + recipient + event + channel. Re-running the send logic will update a pending or failed notification rather than creating duplicates. If a migration needs to be reverted, use standard Django migration rollback and re-apply. If a notification provider is misconfigured, notifications will be marked failed and can be retried after fixing settings.
## Artifacts and Notes
Expected console-provider log example when creating a booking:
INFO OTP SMS to 0500000002: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
INFO OTP SMS to 0500000003: Your booking request is received for Haircut at Main Salon on 2026-03-01 10:00.
## Interfaces and Dependencies
- `backend/apps/notifications/models.py` must define `Notification`, `NotificationEvent`, `NotificationStatus`, `NotificationChannel`.
- `backend/apps/notifications/services.py` must expose `send_booking_notification`, `notify_booking_lifecycle`, and `notify_on_status_change`.
- `backend/apps/bookings/views.py` must call notification services in `perform_create` and `perform_update`.
- `backend/salon_api/settings.py` must define `NOTIFICATION_PROVIDER` and `NOTIFICATION_DEFAULT_CHANNEL` settings.
Plan Maintenance Note: Created on 2026-02-28 to implement booking lifecycle notifications as the next Phase 1 reliability milestone.
Plan Maintenance Note (Update): Marked milestones complete, recorded the booking status update discovery, and documented role-based status validation after implementing notifications and tests on 2026-02-28.
+138
View File
@@ -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 Moyasars 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.
+7 -6
View File
@@ -5,27 +5,28 @@ This file tracks known gaps and risks to address in future iterations.
## Security And Auth ## Security And Auth
- Phone normalization is KSA-focused and minimal; broaden for multi-country use. - Phone normalization is KSA-focused and minimal; broaden for multi-country use.
- OTP protections are basic; add device fingerprinting and IP throttling if needed. - OTP protections are basic; add device fingerprinting and IP throttling if needed.
- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet. - Authentica OTP provider is implemented (SMS + WhatsApp via Authentica OTP); Unifonic remains a scaffold.
- Social login is a placeholder. - Social login is a placeholder.
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows. - `USERNAME_FIELD` is now `"phone_number"`; `REQUIRED_FIELDS = []`; `create_superuser` accepts `phone_number`. Admin and `createsuperuser` work correctly for phone-only users.
## Booking Integrity ## Booking Integrity
- Availability checks and overlap prevention are now enforced for staff bookings. - Availability checks and overlap prevention are now enforced for staff bookings.
- **Race condition — fixed:** `BookingCreateSerializer.create()` now locks the staff row with `select_for_update()` inside `transaction.atomic()` and re-runs the overlap check before inserting. Concurrent requests for the same staff slot are serialized at the DB level. Requires PostgreSQL in production (SQLite ignores `FOR UPDATE` but still serializes writes).
- No timezone handling or business hours enforcement. - No timezone handling or business hours enforcement.
- 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. - Moyasar capture and refund are implemented in the gateway; API endpoints for admin-initiated capture/refund can be added when needed.
- Idempotency handling for payment creation missing.
## Data And UX ## Data And UX
- Ratings are not recalculated from reviews. - Ratings are not recalculated from reviews.
- No image upload or storage strategy for photos. - No image upload or storage strategy for photos.
- No notifications (email/SMS) beyond OTP scaffolding. - Booking lifecycle notifications are implemented; Authentica can deliver SMS when NOTIFICATION_PROVIDER=authentica.
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending. - Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance ## Ops And Compliance
- No audit logs for admin actions. - No audit logs for admin actions.
- No multi-tenant isolation or data export tooling. - No multi-tenant isolation or data export tooling.
- No GDPR/PDPL data retention policies defined. - No GDPR/PDPL data retention policies defined.
- CI baseline exists, but needs Gitea runner registration and required-check enforcement.
+9
View File
@@ -0,0 +1,9 @@
# Runbooks
Operational procedures live here. Each new production-impacting workflow should add or update a runbook.
Existing runbooks:
- `docs/runbooks/auth_otp_failures.md`
- `docs/runbooks/booking_failures.md`
- `docs/runbooks/payments_sanity_check.md`
+39
View File
@@ -0,0 +1,39 @@
# Runbook: Auth OTP Failures
## Summary
Guide for diagnosing and mitigating OTP send or verify failures in phone-first authentication.
## Symptoms
- Users report not receiving OTP codes.
- `/api/auth/otp/request/` or `/api/auth/phone/request/` returns HTTP 500 or rate-limit errors.
- `/api/auth/otp/verify/` or `/api/auth/phone/verify/` returns invalid or expired OTP errors unexpectedly.
## Impact
- Users cannot sign in or complete phone verification.
- Booking and payment flows are blocked when auth is required.
## Quick Checks
- Confirm the provider configured in `backend/salon_api/settings.py` via `OTP_PROVIDER`.
- Check recent application logs for OTP send errors.
- Verify provider credentials are present in `backend/.env` for the active provider.
## Mitigation Steps
- If provider credentials are missing or invalid, fix the environment variables and restart the API process.
- If the provider is down, temporarily switch to `OTP_PROVIDER=console` for non-production environments and notify support.
- If rate limits are triggered, validate `OTP_MAX_PER_WINDOW`, `OTP_WINDOW_MINUTES`, and `OTP_RESEND_COOLDOWN_SECONDS` values and confirm client behavior is not retrying aggressively.
- If verification is failing, confirm server time is correct and `OTP_EXPIRY_MINUTES` is appropriate.
## Rollback / Escalation
- Roll back recent auth/OTP changes if the failure coincides with a deployment.
- Escalate to the provider (Authentica) with request IDs and timestamps if external API errors persist.
## Notes
- Authentica is the primary OTP provider for MVP; console provider is for local development.
- OTP send/verify logic lives in `backend/apps/accounts/services/otp.py`.
+40
View File
@@ -0,0 +1,40 @@
# Runbook: Booking Failures
## Summary
Guide for diagnosing booking creation or status update failures (availability, overlap prevention, or validation errors).
## Symptoms
- `POST /api/bookings/` returns HTTP 400 or 500.
- `PATCH /api/bookings/<id>/` fails when confirming or cancelling.
- Users report bookings not appearing or incorrect status.
## Impact
- Customers cannot place bookings.
- Staff schedules become inconsistent.
- Notification and payment flows may not trigger.
## Quick Checks
- Confirm the request payload includes a valid `service`, `staff`, and scheduled time.
- Check server logs for booking validation errors or integrity exceptions.
- Verify that staff availability and overlap prevention rules are behaving as expected.
## Mitigation Steps
- Reproduce with a known test user and staff member to isolate data issues.
- If overlap rules are too strict, review booking validation logic and confirm time zone assumptions.
- If status updates are blocked, verify role checks and serializer permissions in `backend/apps/bookings/`.
- If notifications are expected but missing, confirm `NOTIFICATION_PROVIDER` configuration and notification records.
## Rollback / Escalation
- Roll back recent booking-related changes if failures started after a deployment.
- Escalate to engineering with the booking ID, user ID, and timestamps.
## Notes
- Booking validation and status transitions live in `backend/apps/bookings/`.
- Notifications for booking lifecycle are handled in `backend/apps/notifications/`.
+136
View File
@@ -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`.
+25
View File
@@ -0,0 +1,25 @@
# ADR <NNNN>: <Title>
## Status
Proposed | Accepted | Deprecated | Superseded
## Context
Explain the problem and the forces at play. Include constraints, risks, or user needs.
## Decision
State the decision clearly and explicitly.
## Consequences
List the expected positive and negative outcomes, including operational impact.
## Alternatives Considered
Briefly document viable alternatives and why they were rejected.
## Related
Link to relevant PRs, runbooks, or architecture sections.
+29
View File
@@ -0,0 +1,29 @@
# Runbook: <Short Title>
## Summary
One or two sentences describing the situation this runbook covers.
## Symptoms
Describe what an operator or user will observe.
## Impact
Who or what is affected.
## Quick Checks
Exact commands or checks that confirm the issue.
## Mitigation Steps
Step-by-step actions to resolve or reduce impact.
## Rollback / Escalation
How to revert or who to contact if the issue persists.
## Notes
Any caveats, dependencies, or follow-up actions.
+248
View File
@@ -0,0 +1,248 @@
---
name: salon-mvp-roadmap
overview: High-level roadmap to bring the existing Salon Django/React codebase to a reliable MVP aligned with Phase 1 goals in AGENTS.md, plus a review of current architecture and major risks.
todos:
- id: backend-providers-readiness
content: "Harden backend providers: implement at least one real SMS/WhatsApp provider and clarify Moyasar capture/refund behavior for MVP."
status: pending
- id: async-and-observability
content: Decide on async task infrastructure and observability basics for OTP, notifications, and payments, and document the choice.
status: pending
- id: frontend-structure-and-routing
content: Refactor frontend into routed pages with separated components/hooks for search, auth, booking, and payments.
status: pending
- id: auth-and-booking-flows
content: Implement phone-first auth and end-to-end booking flows on the frontend using existing backend APIs.
status: pending
- id: payments-and-notifications-ux
content: Integrate payment initiation and booking lifecycle notifications into user-facing flows, including success/error handling.
status: pending
- id: tests-for-critical-flows
content: Expand backend and frontend tests to cover auth, booking, payment, and notification critical paths for MVP reliability.
status: pending
isProject: false
---
# Salon MVP Roadmap And Architecture Review
## Purpose / Big Picture
This plan reviews the current Salon codebase (Django backend, React/Vite frontend), highlights architectural and design risks, and lays out a pragmatic roadmap to reach an MVP that aligns with **Phase 1: Core MVP Reliability** in `AGENTS.md`: phone-first auth with OTP, robust booking integrity, Moyasar payments, booking lifecycle notifications, localization foundations, and tests for critical flows.
The roadmap assumes a KSA-focused first launch (phone auth and Riyadh timezone defaults) with a clean path to expand later.
## Current State Summary
### Backend (Django, DRF)
- **Project**: `salon_api` in `[backend/salon_api](backend/salon_api)`
- `settings.py` configures `AUTH_USER_MODEL = "accounts.User"`, DRF + SimpleJWT, KSA locale defaults, and custom settings for OTP, notifications, and payments.
- URLs route to app-level APIs at `/api/auth/`, `/api/salons/`, `/api/bookings/`, `/api/payments/`.
- **Auth & Accounts** (`[backend/apps/accounts](backend/apps/accounts)`)
- Custom `User` model with phone-first capabilities (`phone_number`, `is_phone_verified`, `preferred_language`, `role`).
- Phone normalization services tuned for KSA numbers (`[backend/apps/accounts/services/phone.py](backend/apps/accounts/services/phone.py)`).
- OTP domain + service layer with rate limits, cooldowns, expiry, and hashed codes (`[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`).
- Phone-first auth endpoints that issue JWT tokens on successful OTP verification, plus basic email/password registration.
- Social login endpoint is a placeholder that always returns 501.
- **Salons & Catalog** (`[backend/apps/salons](backend/apps/salons)`)
- Models for `Salon`, `Service`, `StaffProfile`, `StaffAvailability`, `Review`, `SalonPhoto`.
- Read-only APIs for salon search, services, staff, and reviews.
- **Bookings** (`[backend/apps/bookings](backend/apps/bookings)`)
- `Booking` model ties together salon, service, staff, customer, time window, price, and status.
- `validate_booking_request` in `[backend/apps/bookings/services.py](backend/apps/bookings/services.py)` enforces staff membership, duration matching, availability windows, and overlap prevention for pending/confirmed bookings.
- `BookingViewSet` in `[backend/apps/bookings/views.py](backend/apps/bookings/views.py)` applies role-based access and hooks into notifications on create and relevant status changes.
- **Payments (Moyasar)** (`[backend/apps/payments](backend/apps/payments)`)
- `Payment` model tracks provider, status (fine-grained transitions), amount, idempotency key, external ID, payload, and timestamps.
- `MoyasarGateway` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` can create payments via HTTP but has `capture_payment`/`refund_payment` as TODOs.
- `create_payment_for_booking` in `[backend/apps/payments/services/payments.py](backend/apps/payments/services/payments.py)` enforces provider choice (Moyasar only), idempotency, and maps webhook events into internal statuses.
- Webhook view in `[backend/apps/payments/views.py](backend/apps/payments/views.py)` authenticates via shared secret and applies provider events idempotently.
- **Notifications** (`[backend/apps/notifications](backend/apps/notifications)`)
- `Notification` model records booking-related notifications and enforces uniqueness per booking/recipient/event/channel.
- Services in `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)` reuse OTP providers to send SMS/WhatsApp messages for booking created/confirmed/cancelled events, with localization via `preferred_language`.
- Booking views call `notify_booking_lifecycle` / `notify_on_status_change` to trigger notifications for customers and staff.
- **Testing**
- Solid tests around:
- Phone normalization, OTP rate limiting, and phone auth flows (`[backend/apps/accounts/tests](backend/apps/accounts/tests)`).
- Booking integrity and overlap rules (`[backend/apps/bookings/tests](backend/apps/bookings/tests)`).
- Payment idempotency and Moyasar webhook handling (`[backend/apps/payments/tests](backend/apps/payments/tests)`).
- Booking notifications on create/status change (`[backend/apps/notifications/tests](backend/apps/notifications/tests)`).
- `docs/risks.md` explicitly tracks several known gaps around auth, booking, payments, data/UX, and ops.
### Frontend (React, Vite)
- **Structure**
- Vite React app at `[frontend](frontend)` with entry in `[frontend/src/main.jsx](frontend/src/main.jsx)` and single top-level component in `[frontend/src/App.jsx](frontend/src/App.jsx)`.
- No `react-router` or multi-page routing; the entire experience is one composed screen.
- **Current Features**
- **Salon search**
- Text search field calling `/salons/?q=<query>` via a small API client in `[frontend/src/api/client.js](frontend/src/api/client.js)`.
- Renders responsive list of salons with rating, city, and phone.
- **Localization/i18n**
- `react-i18next` setup in `[frontend/src/i18n/index.js](frontend/src/i18n/index.js)` with `en` and `ar-sa` translations.
- Locale preference stored in `localStorage`; applies `lang` and `dir` on the document.
- **Payments beta**
- A form in `App.jsx` that sends payment creation requests to `/api/payments/` using the Moyasar-style payload, with configurable `booking_id`, source type, token, and callback URL.
- Optionally includes a Bearer token from a manually-entered access token field.
- On success, can redirect to `redirect_url` and shows the raw JSON response.
- **State & Tests**
- All state is local to `App.jsx` via `useState`/`useEffect`; there is no centralized state management or domain hooks yet.
- A single test file `[frontend/src/App.test.jsx](frontend/src/App.test.jsx)` covers hero copy and locale/RTL behavior, but not search or payments.
## Glaring Design And Architectural Issues
### Backend Risks
- **Incomplete provider implementations for production-critical flows**
- Twilio/Unifonic providers in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)` are stubs with `NotImplementedError` for send methods, yet they are the backbone for both OTP and booking notifications.
- `MoyasarGateway` lacks `capture_payment` and `refund_payment` implementations, limiting payment lifecycle coverage.
- **Risk**: Code reads “production ready” at the API level, but the underlying integrations are not, which could cause outages if deployed naively.
- **Tight coupling between OTP and notifications**
- Notification services import the OTP provider mapping and default `NOTIFICATION_PROVIDER` to `OTP_PROVIDER`, binding booking notifications to auth configuration.
- **Risk**: Changing OTP providers or adding a second channel for marketing/ops notifications will be harder and could have unintended side effects.
- **Synchronous IO-heavy work in request/response path**
- OTP sends, booking notifications, and payment gateway calls all occur synchronously inside view methods (`perform_create`, `create`, etc.).
- **Risk**: Slow or flaky providers will degrade API latency and user experience; retries and backoff are hard to implement without background jobs.
- **Cross-app domain coupling without a clear orchestration layer**
- `apps.bookings` depends on salons and notifications; notifications depend on accounts (OTP providers) and bookings; payments depend on bookings.
- **Risk**: As you add more lifecycle rules (e.g., auto-confirm booking on payment, send reminders, handle refunds), the spaghetti of cross-imports will grow unless you introduce clearer service boundaries.
- **Auth model vs login patterns**
- `User.USERNAME_FIELD` is email, while phone-based JWT issuance happens via custom endpoints.
- **Risk**: This split can confuse clients and admin tooling and may complicate future flows like social login or SSO unless you standardize on an identifier strategy.
- **Docs drift around ExecPlans**
- `AGENTS.md` references `docs/execplans/payments-moyasar.md` as the active plan, while `PLANS.md` names `docs/execplans/booking-notifications.md`.
- **Risk**: Contributors may follow different “active” plans, causing architectural inconsistency.
### Frontend Risks
- **Monolithic `App` component with no routing**
- `App.jsx` mixes hero/search, salon listing, payments, and locale controls.
- There is no `react-router` or notion of separate flows (auth, booking, profile, payments).
- **Risk**: Extending to full MVP flows (auth, booking, history, management) will quickly become unmanageable without a routing/page system and domain separation.
- **Domain logic embedded in UI components**
- API payload construction, validation rules (e.g. for source types), and error handling are implemented directly in `App.jsx` rather than reusable hooks or service modules.
- **Risk**: Code reuse, testing, and evolution (e.g., adding booking pages or admin consoles) will be painful.
- **Minimal test coverage for critical flows**
- Only i18n and hero copy are tested; search behavior, API integration, and payments are untested.
- **Risk**: Regressions in search, booking, and payments UX will slip through as MVP grows.
- **Styling & layout fragility**
- `frontend/src/styles.css` uses `::root` instead of `:root`, which likely breaks intended global CSS variables or base styles.
- Global CSS is tightly bound to the monolithic `App` layout.
- **Risk**: Visual regressions and layout churn when introducing additional pages or components.
- **Ad hoc auth token handling**
- The “access token” is a free-form text field that gets persisted as `auth_token` in `localStorage` and injected into payment requests.
- **Risk**: This is a placeholder pattern that does not scale to full auth (refresh tokens, logout, token rotation) and will need to be replaced.
### Cross-Cutting Risks
- **Lack of async/background processing**
- No Celery/RQ or similar job queue; all side effects are synchronous.
- **Risk**: Scaling SMS/WhatsApp notifications, email, and payment webhook fan-out will be difficult.
- **Observability and admin tooling gaps**
- Errors for payments and notifications are recorded in model metadata but not clearly surfaced in logs, dashboards, or admin views.
- **Risk**: Operational debugging during MVP rollout will be slower and more error-prone.
- **Internationalization strategy vs future markets**
- Phone normalization and defaults are tailored to KSA, which is correct for MVP, but `docs/risks.md` already notes the need to broaden later.
- **Risk**: Without clear boundaries between KSA-specific logic and generic logic, future expansion may require invasive changes.
## MVP Roadmap (Aligned To Phase 1)
This roadmap assumes “MVP” is equivalent to **Phase 1: Core MVP Reliability** in `AGENTS.md`, with a thin but robust frontend on top.
### Phase 0 Architecture & Production Readiness Hardening
- **Finalize critical provider implementations**
- Implement at least one real SMS/WhatsApp provider (Twilio or Unifonic) end-to-end, behind the existing provider abstraction in `[backend/apps/accounts/services/otp.py](backend/apps/accounts/services/otp.py)`, and wire it into `[backend/apps/notifications/services.py](backend/apps/notifications/services.py)`.
- Implement or deliberately fence off `capture_payment` and `refund_payment` in `[backend/apps/payments/services/gateway.py](backend/apps/payments/services/gateway.py)` so that the MVP either fully supports or explicitly does not support partial captures/refunds.
- **Clarify and document boundaries**
- Add a short architecture section in `README`/docs describing how `accounts`, `salons`, `bookings`, `payments`, and `notifications` interact, and what each service is responsible for.
- Resolve the ExecPlan drift by making `AGENTS.md` and `PLANS.md` agree on the current active plan.
- **Introduce minimal async infrastructure (optional but recommended)**
- Decide whether MVP will ship with a task queue (e.g., Celery with Redis) or keep everything synchronous for the initial launch.
- If yes, introduce a thin task layer for OTP sends and booking notifications while preserving current APIs; if not, at least add clear timeouts/logging to external calls.
- **Frontend scaffolding for growth**
- Introduce `react-router` and refactor `App.jsx` into route-based pages (e.g., `HomePage`, `BookPage`, `PaymentPage`, `ProfilePage`), with shared layout and navigation.
- Extract salon search, payment form, and locale controls into dedicated components and hooks.
### Phase 1 Core MVP Features (Backend + Frontend)
- **Phone-first auth UX**
- Backend: reuse existing phone auth endpoints; ensure error messages and rate-limit responses are predictable and localized.
- Frontend:
- Build OTP-based login/registration screens that drive `/api/auth/phone/request/` and `/api/auth/phone/verify/`.
- Introduce an auth context (or similar) to store access/refresh tokens, current user profile, and handle logout.
- Defer social login beyond MVP, but keep API surface ready for it.
- **Booking search and creation**
- Backend is largely ready (booking validation and role-based access); review booking serializers in `[backend/apps/bookings/serializers.py](backend/apps/bookings/serializers.py)` to ensure they expose all fields needed for frontend booking forms.
- Frontend:
- Build a **booking flow**: pick a salon → choose service → select staff (optional) → select date/time slot (based on availability endpoints) → confirm booking.
- Add a “My bookings” page showing upcoming and past bookings, tied into the existing `/api/bookings/` endpoints.
- **Payments via Moyasar**
- Backend: confirm `create_payment_for_booking` contracts (inputs/outputs) are stable and documented.
- Frontend:
- Evolve the payments beta UI into a **post-booking payment step** that starts from a selected booking and guides the user into Moyasars hosted flow, then shows a status page.
- Handle callback/return from Moyasar (even if via manual redirect URL in MVP) and surface payment success/failure to the user.
- **Booking lifecycle notifications**
- Backend already sends notifications on booking create and status changes; align messaging templates with product UX and ensure localization strings exist.
- Frontend: surface notification results implicitly via booking status changes and explicit messages on the booking details page.
- **Localization foundations**
- Backend: ensure `UserLocaleMiddleware` and translation strings cover all user-visible errors in auth, bookings, payments, and notifications.
- Frontend: expand `en/ar-sa` translations to cover auth, booking, and payment flows; verify RTL layouts on the new screens.
- **Tests for critical flows**
- Backend: extend tests where needed to cover new booking/payment edge cases (e.g., tying booking status to payment status if/when introduced).
- Frontend: add Vitest tests for:
- Phone auth screen flows (request/verify success + errors).
- Booking flow (form validation, happy path, displaying server-side errors).
- Payment initiation from an existing booking.
### Phase 2 Manager Ops Lite (Post-MVP, partially covered now)
- **Salon and staff management UI**
- Use existing salon and staff models to build basic management pages for salon owners/managers (create/update services, staff, availability).
- **Calendar views and rescheduling**
- Provide calendar views for staff/managers to view daily/weekly bookings and reschedule or cancel within defined rules.
- **Reviews and ratings**
- Implement review submission and rating recalculation on the backend, with corresponding frontend components.
- **Reporting basics**
- Lightweight reports for managers (upcoming bookings, simple revenue summaries based on payment status) using existing payments data.
### Phase 3 Scale & Compliance (Later)
- **Audit logging** for admin actions and booking/payment state changes.
- **PDPL/GDPR retention policies** and data export tooling.
- **Observability**: structured logging, metrics, and basic dashboards for auth failures, OTP send failures, payment errors, and notification outcomes.
## Architecture Overview Diagram
A simplified view of the target MVP data flow:
```mermaid
flowchart LR
user["User (web/mobile)"] --> frontend["ReactFrontend"]
frontend --> api["DjangoAPI"]
api --> accounts["AccountsApp"]
api --> salons["SalonsApp"]
api --> bookings["BookingsApp"]
api --> payments["PaymentsApp"]
api --> notifications["NotificationsApp"]
accounts --> otpProviders["OtpProviders"]
notifications --> otpProviders
payments --> moyasar["MoyasarGateway"]
bookings --> notifications
bookings --> payments
salons --> bookings
```
This diagram clarifies current coupling and highlights where future refactors (e.g., a dedicated messaging service or payment orchestrator) could sit.
## Validation And Acceptance For This Plan
- The roadmap is accepted when:
- It clearly maps current backend and frontend capabilities to the Phase 1 MVP goals in `AGENTS.md`.
- It identifies the highest-risk design/architecture issues that could impede MVP reliability or future evolution.
- It provides a phased, concrete sequence of work packages that can be implemented via ExecPlans (e.g., booking notifications, Moyasar payments, phone auth UX).
- Each major feature area (auth, bookings, payments, notifications, localization, tests) should have or adopt an ExecPlan under `docs/execplans/` in line with `PLANS.md` before implementation begins.
+14
View File
@@ -0,0 +1,14 @@
# Frontend Notes (MVP Readiness)
## High-Level Takeaways
- `App.jsx` is monolithic and mixes search, payments, and locale controls; no routing exists yet.
- Domain logic (API payloads, validation, error handling) lives in UI components instead of hooks/services.
- Tests only cover hero copy and RTL behavior; search and payment flows are untested.
- Global styles are fragile (likely `::root` typo instead of `:root`).
- Auth token handling is ad hoc and should be replaced with a proper auth flow/context.
## Near-Term Focus
- Introduce routing and split into pages (home/search, auth, booking, payment, profile).
- Extract API logic into hooks/services to make testing and reuse easier.
- Add Vitest coverage for search, booking, and payment flows.
- Fix global CSS root selector and stabilize base layout styles.
+4545
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -13,12 +13,13 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0" "react-i18next": "^14.1.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1", "@testing-library/react": "^14.2.1",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vitest": "^1.3.1" "vitest": "^1.3.1"
+24 -90
View File
@@ -1,95 +1,29 @@
import { useEffect, useState } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useTranslation } from "react-i18next"; import MainLayout from "./layouts/MainLayout";
import { apiGet } from "./api/client"; import HomePage from "./pages/HomePage";
import { setLocale } from "./i18n"; import BookPage from "./pages/BookPage";
import PaymentPage from "./pages/PaymentPage";
import ProfilePage from "./pages/ProfilePage";
import BookingsPage from "./pages/BookingsPage";
import LoginPage from "./pages/LoginPage";
import SalonDetailPage from "./pages/SalonDetailPage";
import PaymentReturnPage from "./pages/PaymentReturnPage";
export default function App() { export default function App() {
const [salons, setSalons] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("idle");
const { t, i18n } = useTranslation();
useEffect(() => {
let ignore = false;
async function load() {
setStatus("loading");
try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch (error) {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
return ( return (
<div className="page"> <BrowserRouter>
<header className="hero"> <Routes>
<div className="hero-top"> <Route path="/" element={<MainLayout />}>
<p className="eyebrow">{t("hero.eyebrow")}</p> <Route index element={<HomePage />} />
<div className="locale-switch" aria-label={t("locale.label")}> <Route path="salon/:id" element={<SalonDetailPage />} />
<button <Route path="book" element={<BookPage />} />
type="button" <Route path="pay" element={<PaymentPage />} />
className={i18n.language === "ar-sa" ? "active" : ""} <Route path="pay/return" element={<PaymentReturnPage />} />
onClick={() => setLocale("ar-sa")} <Route path="bookings" element={<BookingsPage />} />
> <Route path="profile" element={<ProfilePage />} />
{t("locale.arabic")} <Route path="login" element={<LoginPage />} />
</button> </Route>
<button </Routes>
type="button" </BrowserRouter>
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
</div>
<h1>{t("hero.title")}</h1>
<p className="subtitle">{t("hero.subtitle")}</p>
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</header>
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && (
<p className="error">{t("results.error")}</p>
)}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<article className="card" key={salon.id}>
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
</article>
))}
</div>
</section>
</div>
); );
} }
+17 -5
View File
@@ -1,23 +1,35 @@
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 { AuthProvider } from "./contexts/AuthContext";
import i18n from "./i18n"; import i18n from "./i18n";
vi.mock("./api/client", () => ({
apiGet: vi.fn().mockResolvedValue([]),
apiPost: vi.fn()
}));
function TestWrapper({ children }) {
return <AuthProvider>{children}</AuthProvider>;
}
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 />, { wrapper: TestWrapper });
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 />, { wrapper: TestWrapper });
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");
}); });
}); });
+68 -9
View File
@@ -2,19 +2,78 @@ import { getActiveLocale } from "../i18n";
const API_BASE = import.meta.env.VITE_API_BASE || "/api"; const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function handleResponse(response) { export class ApiError extends Error {
if (!response.ok) { constructor(message, { status, body } = {}) {
const errorText = await response.text(); super(message);
throw new Error(errorText || `Request failed: ${response.status}`); this.name = "ApiError";
this.status = status;
this.body = body;
} }
return response.json();
} }
export async function apiGet(path) { async function handleResponse(response) {
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
// Ignore
}
if (!response.ok) {
const message =
(body?.detail && typeof body.detail === "string" ? body.detail : null) ||
text ||
`Request failed: ${response.status}`;
throw new ApiError(message, { status: response.status, body });
}
return body;
}
function baseHeaders() {
return {
"Accept-Language": getActiveLocale(),
};
}
export async function apiGet(path, token) {
const headers = { ...baseHeaders() };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, { headers });
return handleResponse(response);
}
export async function apiPost(path, body, token) {
const headers = {
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
headers: { method: "POST",
"Accept-Language": getActiveLocale(), headers,
}, body: body ? JSON.stringify(body) : undefined,
});
return handleResponse(response);
}
export async function apiPatch(path, body, token) {
const headers = {
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, {
method: "PATCH",
headers,
body: body ? JSON.stringify(body) : undefined,
}); });
return handleResponse(response); return handleResponse(response);
} }
+24
View File
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { setLocale } from "../i18n";
export default function LocaleSwitch() {
const { t, i18n } = useTranslation();
return (
<div className="locale-switch" aria-label={t("locale.label")}>
<button
type="button"
className={i18n.language === "ar-sa" ? "active" : ""}
onClick={() => setLocale("ar-sa")}
>
{t("locale.arabic")}
</button>
<button
type="button"
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
);
}
+94
View File
@@ -0,0 +1,94 @@
import { useTranslation } from "react-i18next";
import { usePaymentForm } from "../hooks/usePaymentForm";
export default function PaymentForm({ bookingId = "", token = "" }) {
const { t } = useTranslation();
const form = usePaymentForm(bookingId, token);
return (
<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={(e) => {
e.preventDefault();
form.submit();
}}
>
<label className="field">
<span>{t("payment.bookingId")}</span>
<input
type="number"
min="1"
value={form.bookingIdInput}
onChange={(e) => form.setBookingIdInput(e.target.value)}
placeholder="123"
required
/>
</label>
<label className="field">
<span>{t("payment.accessToken")}</span>
<input
type="password"
value={form.tokenInput}
onChange={(e) => form.setTokenInput(e.target.value)}
placeholder={t("payment.accessTokenPlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.sourceType")}</span>
<select
value={form.sourceType}
onChange={(e) => form.setSourceType(e.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={form.sourceValue}
onChange={(e) => form.setSourceValue(e.target.value)}
placeholder={t("payment.sourceValuePlaceholder")}
/>
</label>
<label className="field">
<span>{t("payment.callbackUrl")}</span>
<input
type="url"
value={form.callbackUrl}
onChange={(e) => form.setCallbackUrl(e.target.value)}
placeholder="https://example.com/payments/return"
/>
</label>
<div className="payments-actions">
<button type="submit" disabled={form.status === "loading"}>
{form.status === "loading"
? t("payment.processing")
: t("payment.payNow")}
</button>
<p className="helper">
{t("payment.idempotency")}: {form.idempotencyKey}
</p>
</div>
</form>
{form.status === "error" && form.error && (
<p className="error">{form.error}</p>
)}
{form.status === "ready" && form.result && (
<pre className="payment-result">{JSON.stringify(form.result, null, 2)}</pre>
)}
</section>
);
}
@@ -0,0 +1,23 @@
import { Navigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../contexts/AuthContext";
export default function ProtectedRoute({ children }) {
const { t } = useTranslation();
const { isAuthenticated, loading } = useAuth();
const location = useLocation();
if (loading) {
return (
<div className="auth-loading">
<p>{t("common.loading")}</p>
</div>
);
}
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
+22
View File
@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function SalonCard({ salon }) {
const { t } = useTranslation();
return (
<article className="card" data-testid="salon-card">
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
<Link to={`/salon/${salon.id}`} className="card-link">
{t("card.viewDetails")}
</Link>
</article>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { useSalonSearch } from "../hooks/useSalonSearch";
import SalonCard from "./SalonCard";
export function SearchInput({ value, onChange }) {
const { t } = useTranslation();
return (
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={t("hero.searchPlaceholder")}
/>
</div>
);
}
export default function SalonSearch({ query }) {
const { t } = useTranslation();
const { salons, status } = useSalonSearch(query);
return (
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && <p className="error">{t("results.error")}</p>}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<SalonCard key={salon.id} salon={salon} />
))}
</div>
</section>
);
}
@@ -0,0 +1,42 @@
import { render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { BrowserRouter } from "react-router-dom";
import SalonSearch from "./SalonSearch";
import i18n from "../i18n";
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
}));
const { apiGet } = await import("../api/client");
function renderWithRouter(ui) {
return render(<BrowserRouter>{ui}</BrowserRouter>);
}
describe("SalonSearch", () => {
beforeEach(async () => {
vi.clearAllMocks();
apiGet.mockResolvedValue([]);
await i18n.changeLanguage("en");
});
it("shows loading then empty when no results", async () => {
renderWithRouter(<SalonSearch query="test" />);
await waitFor(() => {
expect(apiGet).toHaveBeenCalledWith("/salons/?q=test");
});
expect(screen.getByText(/no salons found/i)).toBeInTheDocument();
});
it("shows salon cards when results returned", async () => {
apiGet.mockResolvedValue([
{ id: 1, name: "Salon A", city: "Riyadh", rating_avg: 4.5, phone_number: "+966500000000" },
]);
renderWithRouter(<SalonSearch query="salon" />);
await waitFor(() => {
expect(screen.getByText("Salon A")).toBeInTheDocument();
});
expect(screen.getByText("Riyadh")).toBeInTheDocument();
});
});
+95
View File
@@ -0,0 +1,95 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { apiGet, apiPost } from "../api/client";
const STORAGE_ACCESS = "auth_access";
const STORAGE_REFRESH = "auth_refresh";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [accessToken, setAccessToken] = useState(() => {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_ACCESS);
});
const [refreshToken, setRefreshToken] = useState(() => {
if (typeof window === "undefined") return null;
return localStorage.getItem(STORAGE_REFRESH);
});
const [loading, setLoading] = useState(true);
const persistTokens = useCallback((access, refresh) => {
setAccessToken(access);
setRefreshToken(refresh);
if (typeof window !== "undefined") {
if (access) localStorage.setItem(STORAGE_ACCESS, access);
else localStorage.removeItem(STORAGE_ACCESS);
if (refresh) localStorage.setItem(STORAGE_REFRESH, refresh);
else localStorage.removeItem(STORAGE_REFRESH);
}
}, []);
const logout = useCallback(() => {
setUser(null);
persistTokens(null, null);
}, [persistTokens]);
const login = useCallback((access, refresh, userData) => {
persistTokens(access, refresh);
setUser(userData);
}, [persistTokens]);
// Restore user from token on mount
useEffect(() => {
if (!accessToken) {
setLoading(false);
return;
}
apiGet("/auth/me/", accessToken)
.then((data) => {
setUser(data);
setLoading(false);
})
.catch(() => {
// Token invalid, try refresh
if (!refreshToken) {
logout();
setLoading(false);
return;
}
apiPost("/auth/token/refresh/", { refresh: refreshToken })
.then(({ access }) => {
persistTokens(access, refreshToken);
return apiGet("/auth/me/", access);
})
.then((data) => {
setUser(data);
})
.catch(() => {
logout();
})
.finally(() => {
setLoading(false);
});
});
}, [accessToken, refreshToken, logout, persistTokens]);
const value = {
user,
accessToken,
loading,
login,
logout,
isAuthenticated: !!user,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) {
throw new Error("useAuth must be used within AuthProvider");
}
return ctx;
}
+123
View File
@@ -0,0 +1,123 @@
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiPost } from "../api/client";
function generateIdempotencyKey() {
if (typeof crypto !== "undefined" && crypto.randomUUID) {
return crypto.randomUUID();
}
return `id-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
// Must match AuthContext's STORAGE_ACCESS so fallback finds the same token
const AUTH_TOKEN_KEY = "auth_access";
export function usePaymentForm(bookingId = "", token = "") {
// token: optional auth token from AuthContext; tokenInput: manual override from form
const { t } = useTranslation();
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
const [tokenInput, setTokenInput] = useState(() => {
if (token) return token;
if (typeof window !== "undefined") {
return localStorage.getItem(AUTH_TOKEN_KEY) || "";
}
return "";
});
const [sourceType, setSourceType] = useState("stcpay");
const [sourceValue, setSourceValue] = useState("");
const [callbackUrl, setCallbackUrl] = useState(() => {
if (typeof window !== "undefined") {
return `${window.location.origin}/pay/return`;
}
return "";
});
const [status, setStatus] = useState("idle");
const [result, setResult] = useState(null);
const [error, setError] = useState("");
const idempotencyKey = useMemo(generateIdempotencyKey, []);
// Persist token to localStorage when it changes
const setTokenInputAndPersist = (value) => {
setTokenInput(value);
if (typeof window !== "undefined") {
if (value) {
localStorage.setItem(AUTH_TOKEN_KEY, value);
} else {
localStorage.removeItem(AUTH_TOKEN_KEY);
}
}
};
async function submit() {
setStatus("loading");
setError("");
setResult(null);
if (!bookingIdInput) {
setStatus("error");
setError(t("payment.errors.bookingRequired"));
return;
}
const source = { type: sourceType };
if (sourceType === "stcpay") {
if (!sourceValue) {
setStatus("error");
setError(t("payment.errors.mobileRequired"));
return;
}
source.mobile = sourceValue;
}
if (sourceType === "token") {
if (!sourceValue) {
setStatus("error");
setError(t("payment.errors.tokenRequired"));
return;
}
source.token = sourceValue;
}
const payload = {
booking_id: Number(bookingIdInput),
provider: "moyasar",
idempotency_key: idempotencyKey,
source,
};
if (callbackUrl) {
payload.callback_url = callbackUrl;
}
// Use tokenInput (form state) so user edits are respected; token prop only initializes it
const authToken = tokenInput;
try {
const data = await apiPost("/payments/", payload, authToken || undefined);
setResult(data);
setStatus("ready");
if (data?.redirect_url) {
window.location.assign(data.redirect_url);
}
} catch (err) {
setStatus("error");
setError(err.message || t("payment.errors.generic"));
}
}
return {
bookingIdInput,
setBookingIdInput,
tokenInput,
setTokenInput: setTokenInputAndPersist,
sourceType,
setSourceType,
sourceValue,
setSourceValue,
callbackUrl,
setCallbackUrl,
idempotencyKey,
status,
result,
error,
submit,
};
}
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from "react";
import { apiGet } from "../api/client";
export function useSalonSearch(query) {
const [salons, setSalons] = useState([]);
const [status, setStatus] = useState("idle");
useEffect(() => {
let ignore = false;
async function load() {
setStatus("loading");
try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
return { salons, status };
}
+32 -2
View File
@@ -13,11 +13,41 @@
}, },
"card": { "card": {
"noDescription": "لا يوجد وصف بعد.", "noDescription": "لا يوجد وصف بعد.",
"phoneUnavailable": "الهاتف غير متوفر" "phoneUnavailable": "الهاتف غير متوفر",
"viewDetails": "عرض التفاصيل والحجز"
}, },
"locale": { "nav":{"home":"الرئيسية","book":"احجز","pay":"ادفع","profile":"الحساب","bookings":"حجوزاتي","login":"تسجيل الدخول","logout":"تسجيل الخروج"},"book":{"title":"احجزي خدمة","placeholder":"تدفق الحجز قريباً.","cta":"احجزي الآن","selectSalon":"اختاري صالوناً من الصفحة الرئيسية للحجز.","service":"الخدمة","staff":"الفريق","date":"التاريخ","time":"الوقت","notes":"ملاحظات","notesPlaceholder":"ملاحظات اختيارية","selectService":"اختاري الخدمة","selectStaff":"اختاري الفني","submit":"تأكيد الحجز","submitting":"جاري الحجز...","errors":{"fillAll":"يرجى تعبئة جميع الحقول.","invalidTime":"تاريخ أو وقت غير صالح.","generic":"فشل الحجز."}},"salon":{"services":"الخدمات","staff":"الفريق","unknownStaff":"موظف {{id}}"},"profile":{"title":"الحساب","placeholder":"الملف والحجوزات قريباً.","myBookings":"حجوزاتي","noContact":"لا توجد معلومات اتصال"},"bookings":{"title":"حجوزاتي","subtitle":"مواعيدك القادمة والسابقة.","empty":"لا توجد حجوزات بعد.","pay":"ادفع الآن"},"paymentReturn":{"title":"حالة الدفع","success":"تم الدفع بنجاح","successMessage":"تم إتمام الدفع. ستستلمين تأكيداً عبر SMS أو واتساب.","checkStatus":"تحققي من بريدك أو الصالون لحالة الدفع.","reference":"المرجع: {{id}}","viewBookings":"عرض حجوزاتي"},"auth":{"title":"تسجيل الدخول","subtitle":"أدخلي رقم جوالك لاستلام رمز التحقق.","phone":"رقم الجوال","channel":"الإرسال عبر","sms":"رسالة نصية","whatsapp":"واتساب","sendCode":"إرسال الرمز","sending":"جاري الإرسال...","verifyTitle":"أدخلي الرمز","verifySubtitle":"أرسلنا رمزاً إلى {{phone}}","code":"رمز التحقق","verify":"تحقق","verifying":"جاري التحقق...","back":"تغيير الرقم","errors":{"generic":"حدث خطأ.","retryAfter":"انتظري {{seconds}} ثانية قبل المحاولة مرة أخرى."}},"locale": {
"label": "اللغة", "label": "اللغة",
"arabic": "العربية", "arabic": "العربية",
"english": "الإنجليزية" "english": "الإنجليزية"
},
"common": {
"loading": "جاري التحميل..."
},
"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": "فشل طلب الدفع."
}
} }
} }
+32 -2
View File
@@ -13,11 +13,41 @@
}, },
"card": { "card": {
"noDescription": "No description yet.", "noDescription": "No description yet.",
"phoneUnavailable": "Phone unavailable" "phoneUnavailable": "Phone unavailable",
"viewDetails": "View details & book"
}, },
"locale": { "nav":{"home":"Home","book":"Book","pay":"Pay","profile":"Profile","bookings":"My bookings","login":"Sign in","logout":"Sign out"},"book":{"title":"Book a Service","placeholder":"Booking flow coming soon.","cta":"Book now","selectSalon":"Select a salon from the home page to book.","service":"Service","staff":"Staff","date":"Date","time":"Time","notes":"Notes","notesPlaceholder":"Optional notes","selectService":"Select service","selectStaff":"Select staff","submit":"Confirm booking","submitting":"Booking...","errors":{"fillAll":"Please fill all required fields.","invalidTime":"Invalid date or time.","generic":"Booking failed."}},"salon":{"services":"Services","staff":"Staff","unknownStaff":"Staff {{id}}"},"profile":{"title":"Profile","placeholder":"Profile and bookings coming soon.","myBookings":"My bookings","noContact":"No contact info"},"bookings":{"title":"My bookings","subtitle":"Your upcoming and past appointments.","empty":"No bookings yet.","pay":"Pay now"},"paymentReturn":{"title":"Payment status","success":"Payment successful","successMessage":"Your payment was completed. You will receive a confirmation by SMS or WhatsApp.","checkStatus":"Check your email or the salon for payment status.","reference":"Reference: {{id}}","viewBookings":"View my bookings"},"auth":{"title":"Sign in","subtitle":"Enter your phone number to receive a verification code.","phone":"Phone number","channel":"Send via","sms":"SMS","whatsapp":"WhatsApp","sendCode":"Send code","sending":"Sending...","verifyTitle":"Enter code","verifySubtitle":"We sent a code to {{phone}}","code":"Verification code","verify":"Verify","verifying":"Verifying...","back":"Change number","errors":{"generic":"Something went wrong.","retryAfter":"Please wait {{seconds}} seconds before trying again."}},"locale": {
"label": "Language", "label": "Language",
"arabic": "العربية", "arabic": "العربية",
"english": "English" "english": "English"
},
"common": {
"loading": "Loading..."
},
"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."
}
} }
} }
+45
View File
@@ -0,0 +1,45 @@
import { Outlet, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import LocaleSwitch from "../components/LocaleSwitch";
import { useAuth } from "../contexts/AuthContext";
export default function MainLayout() {
const { t } = useTranslation();
const { isAuthenticated, logout } = useAuth();
return (
<div className="page">
<header className="main-header">
<nav className="main-nav">
<Link to="/" className="nav-brand">
{t("nav.home")}
</Link>
<Link to="/book" className="nav-link">
{t("nav.book")}
</Link>
<Link to="/pay" className="nav-link">
{t("nav.pay")}
</Link>
<Link to="/profile" className="nav-link">
{t("nav.profile")}
</Link>
<Link to="/bookings" className="nav-link">
{t("nav.bookings")}
</Link>
{isAuthenticated ? (
<button type="button" className="nav-link nav-logout" onClick={logout}>
{t("nav.logout")}
</button>
) : (
<Link to="/login" className="nav-link">
{t("nav.login")}
</Link>
)}
</nav>
<LocaleSwitch />
</header>
<main>
<Outlet />
</main>
</div>
);
}
+4 -1
View File
@@ -1,11 +1,14 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import "./i18n"; import "./i18n";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<App /> <AuthProvider>
<App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );
+167
View File
@@ -0,0 +1,167 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet, apiPost } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
export default function BookPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const { accessToken } = useAuth();
const salonId = searchParams.get("salon");
const [salon, setSalon] = useState(null);
const [serviceId, setServiceId] = useState("");
const [staffId, setStaffId] = useState("");
const [date, setDate] = useState("");
const [time, setTime] = useState("");
const [notes, setNotes] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!salonId) return;
apiGet(`/salons/${salonId}/`)
.then(setSalon)
.catch(() => setSalon(null));
}, [salonId]);
if (!salonId) {
return (
<section className="book-page">
<h1>{t("book.title")}</h1>
<p>{t("book.selectSalon")}</p>
</section>
);
}
const selectedService = salon?.services?.find((s) => String(s.id) === serviceId);
const duration = selectedService?.duration_minutes || 0;
function computeEndTime(startISO) {
if (!startISO || !duration) return null;
const start = new Date(startISO);
const end = new Date(start.getTime() + duration * 60 * 1000);
return end.toISOString();
}
async function handleSubmit(e) {
e.preventDefault();
setError("");
if (!serviceId || !staffId || !date || !time) {
setError(t("book.errors.fillAll"));
return;
}
// Use Asia/Riyadh offset for backend (KSA)
const startISO = `${date}T${time}:00+03:00`;
const endISO = computeEndTime(startISO);
if (!endISO) {
setError(t("book.errors.invalidTime"));
return;
}
setLoading(true);
try {
const booking = await apiPost(
"/bookings/",
{
service: Number(serviceId),
staff: Number(staffId),
start_time: startISO,
end_time: endISO,
notes,
},
accessToken
);
navigate(`/pay?booking=${booking.id}`);
} catch (err) {
setError(err.message || t("book.errors.generic"));
} finally {
setLoading(false);
}
}
const content = (
<section className="book-page">
<h1>{t("book.title")}</h1>
{salon && <p className="book-salon">{salon.name}</p>}
{!salon ? (
<p>{t("results.loading")}</p>
) : (
<form onSubmit={handleSubmit} className="book-form">
<label className="field">
<span>{t("book.service")}</span>
<select
value={serviceId}
onChange={(e) => setServiceId(e.target.value)}
required
>
<option value="">{t("book.selectService")}</option>
{salon.services?.map((s) => (
<option key={s.id} value={s.id}>
{s.name} {s.duration_minutes} min, {s.price_amount} {s.currency}
</option>
))}
</select>
</label>
<label className="field">
<span>{t("book.staff")}</span>
<select
value={staffId}
onChange={(e) => setStaffId(e.target.value)}
required
>
<option value="">{t("book.selectStaff")}</option>
{salon.staff?.map((s) => (
<option key={s.id} value={s.id}>
{s.name || s.title || t("salon.unknownStaff", { id: s.id })}
</option>
))}
</select>
</label>
<label className="field">
<span>{t("book.date")}</span>
<input
type="date"
value={date}
onChange={(e) => setDate(e.target.value)}
required
/>
</label>
<label className="field">
<span>{t("book.time")}</span>
<input
type="time"
value={time}
onChange={(e) => setTime(e.target.value)}
required
/>
</label>
<label className="field">
<span>{t("book.notes")}</span>
<input
type="text"
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("book.notesPlaceholder")}
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? t("book.submitting") : t("book.submit")}
</button>
</form>
)}
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
import { getActiveLocale } from "../i18n/index";
function formatDateTime(iso, locale) {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleString(locale, {
dateStyle: "medium",
timeStyle: "short",
});
}
export default function BookingsPage() {
const { t } = useTranslation();
const { accessToken } = useAuth();
const [bookings, setBookings] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!accessToken) return;
apiGet("/bookings/", accessToken)
.then(setBookings)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [accessToken]);
const content = (
<section className="bookings-page">
<h1>{t("bookings.title")}</h1>
<p className="bookings-subtitle">{t("bookings.subtitle")}</p>
{loading && <p>{t("results.loading")}</p>}
{error && <p className="error">{error}</p>}
{!loading && !error && bookings.length === 0 && (
<p>{t("bookings.empty")}</p>
)}
{!loading && !error && bookings.length > 0 && (
<ul className="bookings-list">
{bookings.map((b) => (
<li key={b.id} className="booking-card">
<div className="booking-header">
<span className="booking-status">{b.status}</span>
<span className="booking-salon">{b.salon_name}</span>
</div>
<p className="booking-service">{b.service_name}</p>
<p className="booking-time">
{formatDateTime(b.start_time, getActiveLocale())} {formatDateTime(b.end_time, getActiveLocale())}
</p>
<p className="booking-price">
{b.price_amount} {b.currency}
</p>
<Link to={`/pay?booking=${b.id}`} className="booking-pay-link">
{t("bookings.pay")}
</Link>
</li>
))}
</ul>
)}
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+21
View File
@@ -0,0 +1,21 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { SearchInput, default as SalonSearch } from "../components/SalonSearch";
export default function HomePage() {
const { t } = useTranslation();
const [query, setQuery] = useState("");
return (
<>
<header className="hero">
<div className="hero-top">
<p className="eyebrow">{t("hero.eyebrow")}</p>
</div>
<h1>{t("hero.title")}</h1>
<p className="subtitle">{t("hero.subtitle")}</p>
<SearchInput value={query} onChange={setQuery} />
</header>
<SalonSearch query={query} />
</>
);
}
+137
View File
@@ -0,0 +1,137 @@
import { useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiPost, ApiError } from "../api/client";
import { useAuth } from "../contexts/AuthContext";
export default function LoginPage() {
const { t } = useTranslation();
const navigate = useNavigate();
const location = useLocation();
const { login } = useAuth();
const [step, setStep] = useState("phone");
const [phone, setPhone] = useState("");
const [channel, setChannel] = useState("sms");
const [requestId, setRequestId] = useState("");
const [code, setCode] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const from = location.state?.from?.pathname || "/";
async function handleRequestOtp(e) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiPost("/auth/phone/request/", {
phone_number: phone,
channel,
});
setRequestId(res.request_id);
setStep("verify");
} catch (err) {
const body = err instanceof ApiError ? err.body : null;
if (body?.retry_after_seconds) {
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
} else {
setError(err.message || t("auth.errors.generic"));
}
} finally {
setLoading(false);
}
}
async function handleVerify(e) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await apiPost("/auth/phone/verify/", {
request_id: requestId,
code,
});
login(res.access, res.refresh, res.user);
navigate(from, { replace: true });
} catch (err) {
const body = err instanceof ApiError ? err.body : null;
if (body?.retry_after_seconds) {
setError(t("auth.errors.retryAfter", { seconds: body.retry_after_seconds }));
} else {
setError(err.message || t("auth.errors.generic"));
}
} finally {
setLoading(false);
}
}
if (step === "phone") {
return (
<section className="auth-page">
<h1>{t("auth.title")}</h1>
<p className="auth-subtitle">{t("auth.subtitle")}</p>
<form onSubmit={handleRequestOtp} className="auth-form">
<label className="field">
<span>{t("auth.phone")}</span>
<input
type="tel"
value={phone}
onChange={(e) => setPhone(e.target.value)}
placeholder="+966512345678"
required
/>
</label>
<label className="field">
<span>{t("auth.channel")}</span>
<select value={channel} onChange={(e) => setChannel(e.target.value)}>
<option value="sms">{t("auth.sms")}</option>
<option value="whatsapp">{t("auth.whatsapp")}</option>
</select>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" disabled={loading}>
{loading ? t("auth.sending") : t("auth.sendCode")}
</button>
</form>
</section>
);
}
return (
<section className="auth-page">
<h1>{t("auth.verifyTitle")}</h1>
<p className="auth-subtitle">{t("auth.verifySubtitle", { phone })}</p>
<form onSubmit={handleVerify} className="auth-form">
<label className="field">
<span>{t("auth.code")}</span>
<input
type="text"
inputMode="numeric"
maxLength={6}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ""))}
placeholder="123456"
required
/>
</label>
{error && <p className="error">{error}</p>}
<div className="auth-actions">
<button type="submit" disabled={loading || code.length < 6}>
{loading ? t("auth.verifying") : t("auth.verify")}
</button>
<button
type="button"
className="auth-back"
onClick={() => {
setStep("phone");
setCode("");
setError("");
}}
>
{t("auth.back")}
</button>
</div>
</form>
</section>
);
}
+60
View File
@@ -0,0 +1,60 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { BrowserRouter } from "react-router-dom";
import LoginPage from "./LoginPage";
import { AuthProvider } from "../contexts/AuthContext";
import i18n from "../i18n";
vi.mock("../api/client", async (importOriginal) => {
const actual = await importOriginal();
return { ...actual, apiPost: vi.fn() };
});
const { apiPost } = await import("../api/client");
function renderLogin() {
return render(
<AuthProvider>
<BrowserRouter>
<LoginPage />
</BrowserRouter>
</AuthProvider>
);
}
describe("LoginPage", () => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage("en");
});
it("renders phone input and send code button", () => {
renderLogin();
expect(screen.getByLabelText(/phone number/i)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Send code" })).toBeInTheDocument();
});
it("shows verify step after successful OTP request", async () => {
apiPost.mockResolvedValueOnce({ request_id: "abc-123", expires_at: "2025-01-01T12:00:00Z" });
renderLogin();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await waitFor(() => {
expect(screen.getByLabelText(/verification code/i)).toBeInTheDocument();
});
});
it("shows error when OTP request fails", async () => {
apiPost.mockRejectedValueOnce(new Error("Rate limited"));
renderLogin();
fireEvent.change(screen.getByLabelText(/phone number/i), { target: { value: "+966512345678" } });
fireEvent.click(screen.getByRole("button", { name: "Send code" }));
await waitFor(() => {
expect(screen.getByText("Rate limited")).toBeInTheDocument();
});
});
});
+10
View File
@@ -0,0 +1,10 @@
import { useSearchParams } from "react-router-dom";
import PaymentForm from "../components/PaymentForm";
import { useAuth } from "../contexts/AuthContext";
export default function PaymentPage() {
const [searchParams] = useSearchParams();
const bookingIdFromUrl = searchParams.get("booking") || "";
const { accessToken } = useAuth();
return <PaymentForm bookingId={bookingIdFromUrl} token={accessToken || ""} />;
}
+26
View File
@@ -0,0 +1,26 @@
import { useSearchParams, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function PaymentReturnPage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const status = searchParams.get("status") || "";
const id = searchParams.get("id") || "";
const isSuccess = ["paid", "captured", "authorized"].includes(status.toLowerCase());
return (
<section className="payment-return">
<h1>{isSuccess ? t("paymentReturn.success") : t("paymentReturn.title")}</h1>
<p>
{isSuccess
? t("paymentReturn.successMessage")
: t("paymentReturn.checkStatus")}
</p>
{id && <p className="payment-return-id">{t("paymentReturn.reference", { id })}</p>}
<Link to="/profile" className="book-cta">
{t("paymentReturn.viewBookings")}
</Link>
</section>
);
}
+25
View File
@@ -0,0 +1,25 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { useAuth } from "../contexts/AuthContext";
import ProtectedRoute from "../components/ProtectedRoute";
export default function ProfilePage() {
const { t } = useTranslation();
const { user } = useAuth();
const content = (
<section className="profile-page">
<h1>{t("profile.title")}</h1>
{user && (
<p className="profile-phone">
{user.phone_number || user.email || t("profile.noContact")}
</p>
)}
<Link to="/bookings" className="book-cta">
{t("profile.myBookings")}
</Link>
</section>
);
return <ProtectedRoute>{content}</ProtectedRoute>;
}
+55
View File
@@ -0,0 +1,55 @@
import { useEffect, useState } from "react";
import { useParams, Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet } from "../api/client";
export default function SalonDetailPage() {
const { t } = useTranslation();
const { id } = useParams();
const [salon, setSalon] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
if (!id) return;
apiGet(`/salons/${id}/`)
.then(setSalon)
.catch((err) => setError(err.message))
.finally(() => setLoading(false));
}, [id]);
if (loading) return <p>{t("results.loading")}</p>;
if (error) return <p className="error">{error}</p>;
if (!salon) return null;
return (
<section className="salon-detail">
<h1>{salon.name}</h1>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
<h2>{t("salon.services")}</h2>
<ul className="service-list">
{salon.services?.map((s) => (
<li key={s.id}>
{s.name} {s.duration_minutes} min, {s.price_amount} {s.currency}
</li>
))}
</ul>
<h2>{t("salon.staff")}</h2>
<ul className="staff-list">
{salon.staff?.map((s) => (
<li key={s.id}>{s.name || s.title || t("salon.unknownStaff", { id: s.id })}</li>
))}
</ul>
<Link to={`/book?salon=${salon.id}`} className="book-cta">
{t("book.cta")}
</Link>
</section>
);
}
+273
View File
@@ -25,6 +25,42 @@ body {
padding: 48px 24px 80px; padding: 48px 24px 80px;
} }
.main-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 32px;
padding-bottom: 16px;
border-bottom: 1px solid #eadfd2;
}
.main-nav {
display: flex;
gap: 16px;
align-items: center;
}
.nav-brand,
.nav-link {
color: #1c1b1f;
text-decoration: none;
font-weight: 600;
}
.nav-brand:hover,
.nav-link:hover {
text-decoration: underline;
}
.nav-logout {
background: none;
border: none;
cursor: pointer;
font: inherit;
}
.hero { .hero {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -108,6 +144,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));
@@ -145,6 +273,151 @@ h1 {
font-size: 14px; font-size: 14px;
} }
.card-link {
display: inline-block;
margin-top: 8px;
color: #1c1b1f;
font-weight: 600;
text-decoration: none;
}
.card-link:hover {
text-decoration: underline;
}
.auth-page {
max-width: 400px;
margin: 0 auto;
}
.auth-form {
display: flex;
flex-direction: column;
gap: 16px;
margin-top: 24px;
}
.auth-subtitle {
color: #5c5a5f;
margin: 8px 0 0;
}
.auth-actions {
display: flex;
gap: 12px;
}
.auth-back {
background: transparent;
border: 1px solid #dad3ca;
color: #3c3a3f;
}
.auth-loading {
text-align: center;
padding: 48px;
}
.salon-detail {
margin-bottom: 32px;
}
.service-list,
.staff-list {
list-style: none;
padding: 0;
margin: 12px 0;
}
.service-list li,
.staff-list li {
padding: 8px 0;
border-bottom: 1px solid #eadfd2;
}
.book-cta {
display: inline-block;
margin-top: 24px;
padding: 12px 24px;
background: #1c1b1f;
color: white;
font-weight: 600;
text-decoration: none;
border-radius: 999px;
}
.book-cta:hover {
opacity: 0.9;
}
.book-form {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
margin-top: 24px;
}
.book-salon {
color: #5c5a5f;
margin: 4px 0 0;
}
.bookings-list {
list-style: none;
padding: 0;
margin: 24px 0 0;
}
.booking-card {
background: white;
padding: 20px;
border-radius: 16px;
box-shadow: 0 12px 24px rgba(21, 21, 21, 0.08);
margin-bottom: 16px;
}
.booking-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.booking-status {
text-transform: capitalize;
font-weight: 600;
}
.booking-service,
.booking-time,
.booking-price {
margin: 8px 0 0;
color: #5c5a5f;
}
.booking-pay-link {
display: inline-block;
margin-top: 12px;
font-weight: 600;
color: #1c1b1f;
}
.payment-return {
max-width: 480px;
margin: 0 auto;
text-align: center;
}
.payment-return-id {
font-size: 14px;
color: #5c5a5f;
}
.profile-phone {
margin: 8px 0 16px;
color: #5c5a5f;
}
.error { .error {
color: #b00020; color: #b00020;
} }
+1
View File
@@ -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"
} }