Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de8cbfec23 | |||
| b8218669c2 | |||
| 2305c3dc9d | |||
| ef60218c4c | |||
| 8018710d31 | |||
| 229975c612 | |||
| aa607b9b6e | |||
| 828cbcc822 | |||
| 4253f6f650 | |||
| a1da918f95 | |||
| 86fd07c778 | |||
| ca2a6b58b6 | |||
| db36551211 | |||
| a150b18fe7 | |||
| f3c93f500e | |||
| d9767ff0a7 |
@@ -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
|
||||||
@@ -17,3 +17,4 @@ dist/
|
|||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
backend/tmp_authentica_request_id.txt
|
||||||
|
|||||||
@@ -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, well‑named functions > monolithic handlers.
|
- Small, well‑named 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`.
|
||||||
|
|||||||
@@ -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`)
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
|
|||||||
MOYASAR_SECRET_KEY=
|
MOYASAR_SECRET_KEY=
|
||||||
MOYASAR_PUBLISHABLE_KEY=
|
MOYASAR_PUBLISHABLE_KEY=
|
||||||
MOYASAR_BASE_URL=
|
MOYASAR_BASE_URL=
|
||||||
|
MOYASAR_WEBHOOK_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,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.
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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}"
|
||||||
@@ -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
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
# Generated by Django 6.0.2 on 2026-02-28 09:54
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('payments', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='authorized_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='captured_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='failed_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='idempotency_key',
|
||||||
|
field=models.UUIDField(blank=True, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='paid_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='provider_payload',
|
||||||
|
field=models.JSONField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='refunded_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status_updated_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='verified_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='payment',
|
||||||
|
name='voided_at',
|
||||||
|
field=models.DateTimeField(blank=True, null=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='external_id',
|
||||||
|
field=models.CharField(blank=True, max_length=200, null=True, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='payment',
|
||||||
|
name='status',
|
||||||
|
field=models.CharField(choices=[('initiated', 'Initiated'), ('created', 'Created'), ('authorized', 'Authorized'), ('captured', 'Captured'), ('paid', 'Paid'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('voided', 'Voided'), ('verified', 'Verified')], default='initiated', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices):
|
|||||||
|
|
||||||
|
|
||||||
class PaymentStatus(models.TextChoices):
|
class PaymentStatus(models.TextChoices):
|
||||||
|
INITIATED = "initiated", "Initiated"
|
||||||
CREATED = "created", "Created"
|
CREATED = "created", "Created"
|
||||||
AUTHORIZED = "authorized", "Authorized"
|
AUTHORIZED = "authorized", "Authorized"
|
||||||
CAPTURED = "captured", "Captured"
|
CAPTURED = "captured", "Captured"
|
||||||
|
PAID = "paid", "Paid"
|
||||||
FAILED = "failed", "Failed"
|
FAILED = "failed", "Failed"
|
||||||
REFUNDED = "refunded", "Refunded"
|
REFUNDED = "refunded", "Refunded"
|
||||||
|
VOIDED = "voided", "Voided"
|
||||||
|
VERIFIED = "verified", "Verified"
|
||||||
|
|
||||||
|
|
||||||
class Payment(models.Model):
|
class Payment(models.Model):
|
||||||
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
|
||||||
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
|
||||||
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED)
|
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.INITIATED)
|
||||||
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
amount = models.DecimalField(max_digits=10, decimal_places=2)
|
||||||
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
|
||||||
external_id = models.CharField(max_length=200, blank=True)
|
external_id = models.CharField(max_length=200, null=True, blank=True, unique=True)
|
||||||
|
idempotency_key = models.UUIDField(null=True, blank=True, unique=True)
|
||||||
|
provider_payload = models.JSONField(null=True, blank=True)
|
||||||
metadata = models.JSONField(default=dict, blank=True)
|
metadata = models.JSONField(default=dict, blank=True)
|
||||||
|
authorized_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
captured_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
paid_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
failed_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
refunded_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
voided_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
verified_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
status_updated_at = models.DateTimeField(null=True, blank=True)
|
||||||
created_at = models.DateTimeField(auto_now_add=True)
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from rest_framework import serializers
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
|
|
||||||
|
|
||||||
class PaymentSerializer(serializers.ModelSerializer):
|
class PaymentSerializer(serializers.ModelSerializer):
|
||||||
@@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
"amount",
|
"amount",
|
||||||
"currency",
|
"currency",
|
||||||
"external_id",
|
"external_id",
|
||||||
|
"idempotency_key",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"authorized_at",
|
||||||
|
"captured_at",
|
||||||
|
"paid_at",
|
||||||
|
"failed_at",
|
||||||
|
"refunded_at",
|
||||||
|
"voided_at",
|
||||||
|
"verified_at",
|
||||||
|
"status_updated_at",
|
||||||
"created_at",
|
"created_at",
|
||||||
]
|
]
|
||||||
read_only_fields = fields
|
read_only_fields = fields
|
||||||
@@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer):
|
|||||||
class PaymentCreateSerializer(serializers.ModelSerializer):
|
class PaymentCreateSerializer(serializers.ModelSerializer):
|
||||||
booking_id = serializers.IntegerField(write_only=True)
|
booking_id = serializers.IntegerField(write_only=True)
|
||||||
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
provider = serializers.ChoiceField(choices=PaymentProvider.choices)
|
||||||
|
idempotency_key = serializers.UUIDField(write_only=True)
|
||||||
|
source = serializers.JSONField(write_only=True, required=False)
|
||||||
|
callback_url = serializers.URLField(write_only=True, required=False)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Payment
|
model = Payment
|
||||||
fields = ["booking_id", "provider"]
|
fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"]
|
||||||
|
|
||||||
def validate_booking_id(self, value):
|
def validate_booking_id(self, value):
|
||||||
if not Booking.objects.filter(id=value).exists():
|
if not Booking.objects.filter(id=value).exists():
|
||||||
raise serializers.ValidationError(_("Booking not found"))
|
raise serializers.ValidationError(_("Booking not found"))
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def create(self, validated_data):
|
def validate(self, attrs):
|
||||||
booking = Booking.objects.get(id=validated_data["booking_id"])
|
provider = attrs.get("provider")
|
||||||
return Payment.objects.create(
|
source = attrs.get("source")
|
||||||
booking=booking,
|
if provider != PaymentProvider.MOYASAR:
|
||||||
provider=validated_data["provider"],
|
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
|
||||||
status=PaymentStatus.CREATED,
|
if source is None:
|
||||||
amount=booking.price_amount,
|
raise serializers.ValidationError({"source": _("Payment source is required")})
|
||||||
currency=booking.currency,
|
source_type = source.get("type")
|
||||||
metadata={},
|
if not source_type:
|
||||||
)
|
raise serializers.ValidationError({"source": _("Payment source type is required")})
|
||||||
|
if source_type == "creditcard":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"source": _("Card data must not be sent to the backend; use frontend tokenization")}
|
||||||
|
)
|
||||||
|
if source_type == "token" and not attrs.get("callback_url"):
|
||||||
|
raise serializers.ValidationError({"callback_url": _("Callback URL is required for token payments")})
|
||||||
|
return attrs
|
||||||
|
|||||||
@@ -2,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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,11 +1,12 @@
|
|||||||
from django.urls import include, path
|
from django.urls import include, path
|
||||||
from rest_framework.routers import DefaultRouter
|
from rest_framework.routers import DefaultRouter
|
||||||
|
|
||||||
from apps.payments.views import PaymentViewSet
|
from apps.payments.views import PaymentViewSet, payment_webhook
|
||||||
|
|
||||||
router = DefaultRouter()
|
router = DefaultRouter()
|
||||||
router.register(r"", PaymentViewSet, basename="payment")
|
router.register(r"", PaymentViewSet, basename="payment")
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
|
path("webhook/", payment_webhook, name="payment-webhook"),
|
||||||
path("", include(router.urls)),
|
path("", include(router.urls)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,17 @@
|
|||||||
from rest_framework import permissions, status, viewsets
|
import logging
|
||||||
from rest_framework.response import Response
|
import os
|
||||||
|
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
from rest_framework import permissions, status, viewsets
|
||||||
|
from rest_framework.decorators import api_view, permission_classes
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
from apps.bookings.models import Booking
|
from apps.bookings.models import Booking
|
||||||
from apps.payments.models import Payment
|
from apps.payments.models import Payment, PaymentProvider
|
||||||
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
|
||||||
|
from apps.payments.services.payments import apply_webhook_event, create_payment_for_booking
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def user_can_access_booking(user, booking: Booking) -> bool:
|
def user_can_access_booking(user, booking: Booking) -> bool:
|
||||||
@@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet):
|
|||||||
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
|
||||||
if not user_can_access_booking(request.user, booking):
|
if not user_can_access_booking(request.user, booking):
|
||||||
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
|
||||||
payment = serializer.save()
|
payment, created, redirect_url = create_payment_for_booking(
|
||||||
return Response(
|
booking=booking,
|
||||||
{
|
provider=serializer.validated_data["provider"],
|
||||||
"detail": _("Payment record created. Provider integration pending."),
|
idempotency_key=serializer.validated_data["idempotency_key"],
|
||||||
"payment_id": payment.id,
|
source=serializer.validated_data["source"],
|
||||||
"amount": str(payment.amount),
|
callback_url=serializer.validated_data.get("callback_url"),
|
||||||
"currency": payment.currency,
|
|
||||||
"status": payment.status,
|
|
||||||
},
|
|
||||||
status=status.HTTP_201_CREATED,
|
|
||||||
)
|
)
|
||||||
|
response_data = PaymentSerializer(payment).data
|
||||||
|
response_data["redirect_url"] = redirect_url
|
||||||
|
response_data["created"] = created
|
||||||
|
return Response(response_data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
|
||||||
|
|
||||||
|
|
||||||
|
@api_view(["POST"])
|
||||||
|
@permission_classes([permissions.AllowAny])
|
||||||
|
def payment_webhook(request):
|
||||||
|
secret = os.getenv("MOYASAR_WEBHOOK_SECRET")
|
||||||
|
payload = request.data or {}
|
||||||
|
if not secret:
|
||||||
|
return Response({"detail": _("Webhook secret not configured")}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
if payload.get("secret_token") != secret:
|
||||||
|
return Response({"detail": _("Invalid webhook signature")}, status=status.HTTP_401_UNAUTHORIZED)
|
||||||
|
|
||||||
|
event_type = payload.get("type")
|
||||||
|
data = payload.get("data") or {}
|
||||||
|
external_id = data.get("id")
|
||||||
|
if not external_id:
|
||||||
|
return Response({"detail": _("Missing payment reference")}, status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
|
||||||
|
payment = Payment.objects.filter(external_id=external_id, provider=PaymentProvider.MOYASAR).first()
|
||||||
|
if not payment:
|
||||||
|
logger.warning("Moyasar webhook for unknown payment %s", external_id)
|
||||||
|
return Response({"detail": _("Payment not found")}, status=status.HTTP_200_OK)
|
||||||
|
|
||||||
|
applied = apply_webhook_event(payment, event_type, payload)
|
||||||
|
if not applied:
|
||||||
|
return Response({"detail": _("Event ignored")}, status=status.HTTP_200_OK)
|
||||||
|
return Response({"detail": _("Webhook processed")}, status=status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -126,10 +126,10 @@ class Command(BaseCommand):
|
|||||||
booking=booking,
|
booking=booking,
|
||||||
provider=PaymentProvider.MOYASAR,
|
provider=PaymentProvider.MOYASAR,
|
||||||
defaults={
|
defaults={
|
||||||
"status": PaymentStatus.CREATED,
|
"status": PaymentStatus.INITIATED,
|
||||||
"amount": booking.price_amount,
|
"amount": booking.price_amount,
|
||||||
"currency": booking.currency,
|
"currency": booking.currency,
|
||||||
"external_id": "",
|
"external_id": None,
|
||||||
"metadata": {"note": "Demo payment record"},
|
"metadata": {"note": "Demo payment record"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
Binary file not shown.
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -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`, ...).
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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 recipient’s 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 recipient’s 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 booking’s 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.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Payments Integration (Moyasar, Webhooks, Idempotency)
|
||||||
|
|
||||||
|
This ExecPlan is a living document. The sections `Progress`, `Surprises & Discoveries`, `Decision Log`, and `Outcomes & Retrospective` must be kept up to date as work proceeds.
|
||||||
|
|
||||||
|
The requirements for ExecPlans live in `PLANS.md` at the repository root. This document must be maintained in accordance with that file.
|
||||||
|
|
||||||
|
## Purpose / Big Picture
|
||||||
|
|
||||||
|
After this change, the backend can create Moyasar payments, track their state transitions, and reconcile them via webhooks in an idempotent and auditable way. A user can create a booking payment and see it progress from initiated to paid or failed. You can see it working by creating a payment, receiving a webhook callback that marks it as paid, and observing the payment record transition with a recorded provider reference and idempotency key.
|
||||||
|
|
||||||
|
## Progress
|
||||||
|
|
||||||
|
- [x] (2026-02-28 14:35Z) Created ExecPlan for payments integration (Moyasar + webhooks + idempotency).
|
||||||
|
- [x] (2026-02-28 15:05Z) Inspected payments models/endpoints and aligned naming with Moyasar scaffolding.
|
||||||
|
- [x] (2026-02-28 15:20Z) Defined payment state model extensions and idempotency tracking fields.
|
||||||
|
- [x] (2026-02-28 15:40Z) Implemented payment creation service and API endpoint with provider gateway.
|
||||||
|
- [x] (2026-02-28 15:55Z) Implemented webhook endpoint with secret verification and status mapping.
|
||||||
|
- [x] (2026-02-28 16:10Z) Added tests for creation, idempotency, and webhook reconciliation.
|
||||||
|
- [x] (2026-02-28 16:20Z) Updated `docs/risks.md` to close payment integration gaps once tested.
|
||||||
|
|
||||||
|
## Surprises & Discoveries
|
||||||
|
|
||||||
|
- Observation: The payments gateway needed an HTTP client dependency, so `requests` was added to backend requirements.
|
||||||
|
Evidence: `ModuleNotFoundError: No module named 'requests'` when running migrations after adding gateway calls.
|
||||||
|
|
||||||
|
## Decision Log
|
||||||
|
|
||||||
|
- Decision: Model payment state transitions as explicit status changes with audit-friendly timestamps.
|
||||||
|
Rationale: Payment flows must be auditable and deterministic under retries.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Require idempotency keys on payment creation requests.
|
||||||
|
Rationale: Prevents duplicate charges when clients retry.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Use a dedicated webhook endpoint with signature verification.
|
||||||
|
Rationale: Ensures authenticity of provider callbacks and protects state integrity.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
- Decision: Store provider payloads and webhook payloads on the payment record for auditability.
|
||||||
|
Rationale: Helps trace payment transitions without introducing a separate event table yet.
|
||||||
|
Date/Author: 2026-02-28, Codex
|
||||||
|
|
||||||
|
## Outcomes & Retrospective
|
||||||
|
|
||||||
|
Payment creation, idempotency handling, and webhook reconciliation are implemented for Moyasar. Tests cover creation, idempotency, and webhook status transitions, reducing the largest Phase 1 reliability gap. Refund/capture operations remain future work if required.
|
||||||
|
|
||||||
|
## Context and Orientation
|
||||||
|
|
||||||
|
Payments live in `backend/apps/payments/` with current models and API endpoints. The system currently stores payment records but does not integrate with Moyasar or reconcile webhooks. Booking flows live in `backend/apps/bookings/` and should link to payments. The project standards require business logic in services and predictable error responses.
|
||||||
|
|
||||||
|
## Plan of Work
|
||||||
|
|
||||||
|
First, review existing payment models and endpoints to avoid breaking field names. Identify whether `Payment` includes a reference to `Booking`, a `provider_reference`, and a status field. If any are missing, add them along with timestamps for `initiated_at`, `paid_at`, and `failed_at`. Create a migration for the new fields. Ensure status choices include at least `initiated`, `pending`, `paid`, `failed`, and `refunded` if refunds are in scope.
|
||||||
|
|
||||||
|
Next, introduce idempotency tracking. Add a `idempotency_key` field to the payment model (unique, indexed) and validate that payment creation requests require it. If a request repeats with the same key, return the existing payment without creating a new provider charge.
|
||||||
|
|
||||||
|
Then, implement the Moyasar payment creation service in `backend/apps/payments/services.py`. The service should build the provider request using amount, currency, description, and return URLs, and persist the `provider_reference` (payment id returned by Moyasar). Store the full provider response in a JSON field for audit if available.
|
||||||
|
|
||||||
|
Add a dedicated API endpoint for payment creation in `backend/apps/payments/views.py` and `backend/apps/payments/urls.py`. It should:
|
||||||
|
|
||||||
|
- Require authentication.
|
||||||
|
- Validate booking ownership and amount.
|
||||||
|
- Require `idempotency_key`.
|
||||||
|
- Call the service to create the provider payment.
|
||||||
|
- Return the payment record plus any provider redirect URL if applicable.
|
||||||
|
|
||||||
|
Then, implement the webhook endpoint (`/api/payments/webhook/`) with signature verification using Moyasar’s secret. It should parse the event, locate the payment by `provider_reference`, apply an idempotent state transition, and record timestamps. Unknown events should be logged but return 200 to avoid retries if possible.
|
||||||
|
|
||||||
|
Finally, add tests in `backend/apps/payments/tests/`:
|
||||||
|
|
||||||
|
- Creating a payment succeeds and stores provider reference.
|
||||||
|
- Creating with the same idempotency key returns the original record.
|
||||||
|
- Webhook for `paid` updates status and timestamp.
|
||||||
|
- Webhook with invalid signature is rejected.
|
||||||
|
- Webhook is idempotent (replay does not change state or duplicate logs).
|
||||||
|
|
||||||
|
Update `docs/risks.md` to mark payment integration gaps as addressed.
|
||||||
|
|
||||||
|
## Concrete Steps
|
||||||
|
|
||||||
|
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
|
||||||
|
|
||||||
|
1. Inspect payments models and endpoints.
|
||||||
|
- Read `backend/apps/payments/models.py`, `backend/apps/payments/views.py`, and `backend/apps/payments/serializers.py`.
|
||||||
|
|
||||||
|
2. Add fields for provider reference, status timestamps, and idempotency.
|
||||||
|
- Update `backend/apps/payments/models.py` and create a migration.
|
||||||
|
- Run:
|
||||||
|
python3 backend/manage.py makemigrations payments
|
||||||
|
|
||||||
|
3. Implement services and endpoints.
|
||||||
|
- Add `backend/apps/payments/services.py`.
|
||||||
|
- Update serializers and views accordingly.
|
||||||
|
|
||||||
|
4. Add webhook endpoint and signature verification.
|
||||||
|
- Update `backend/apps/payments/urls.py` and `backend/apps/payments/views.py`.
|
||||||
|
|
||||||
|
5. Add tests.
|
||||||
|
- Create `backend/apps/payments/tests/test_payments_flow.py`.
|
||||||
|
|
||||||
|
6. Run tests.
|
||||||
|
- Backend:
|
||||||
|
source venv/bin/activate
|
||||||
|
cd backend
|
||||||
|
python3 -m pytest
|
||||||
|
|
||||||
|
## Validation and Acceptance
|
||||||
|
|
||||||
|
- Creating a payment with a new idempotency key returns HTTP 201 and a provider reference.
|
||||||
|
- Creating the same payment with the same idempotency key returns HTTP 200/201 with the original payment (no new provider request).
|
||||||
|
- A valid webhook updates the payment status to `paid` and sets `paid_at`.
|
||||||
|
- An invalid webhook signature returns HTTP 400/401 and does not mutate data.
|
||||||
|
- `python3 -m pytest` passes with the new payments tests.
|
||||||
|
|
||||||
|
## Idempotence and Recovery
|
||||||
|
|
||||||
|
Payment creation is safe to retry with idempotency keys. Webhook processing is idempotent and can be replayed safely. If a payment status change is applied incorrectly, it can be corrected manually via admin and will be documented in audit fields.
|
||||||
|
|
||||||
|
## Artifacts and Notes
|
||||||
|
|
||||||
|
Example idempotency pattern:
|
||||||
|
|
||||||
|
existing = Payment.objects.filter(idempotency_key=key).first()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
Example overlap-safe webhook logic:
|
||||||
|
|
||||||
|
if payment.status == PaymentStatus.PAID:
|
||||||
|
return
|
||||||
|
payment.mark_paid()
|
||||||
|
|
||||||
|
## Interfaces and Dependencies
|
||||||
|
|
||||||
|
- `backend/apps/payments/models.py` must include fields: `provider_reference`, `idempotency_key`, `status`, `initiated_at`, `paid_at`, `failed_at`, and (optionally) `provider_payload` (JSON).
|
||||||
|
- `backend/apps/payments/services.py` must define `create_payment_for_booking(booking, idempotency_key, request_data)` and `verify_webhook_signature(request)`.
|
||||||
|
- `backend/apps/payments/views.py` must expose `PaymentCreateAPIView` and `payment_webhook` with signature verification.
|
||||||
|
|
||||||
|
Plan Maintenance Note: Created on 2026-02-28 to implement Moyasar payments with idempotency and webhook reconciliation as the next Phase 1 reliability milestone.
|
||||||
|
Plan Maintenance Note (Update): Marked steps complete and recorded dependency and audit decisions after implementing payments and tests on 2026-02-28.
|
||||||
+7
-6
@@ -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.
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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`.
|
||||||
@@ -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/`.
|
||||||
@@ -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`.
|
||||||
Vendored
+25
@@ -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.
|
||||||
Vendored
+29
@@ -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.
|
||||||
@@ -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 Moyasar’s 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.
|
||||||
|
|
||||||
@@ -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.
|
||||||
Generated
+4545
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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": "فشل طلب الدفع."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 || ""} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
|
globals: true,
|
||||||
environment: "jsdom",
|
environment: "jsdom",
|
||||||
setupFiles: "./src/test/setupTests.js"
|
setupFiles: "./src/test/setupTests.js"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user