38 Commits

Author SHA1 Message Date
mohd 6428459313 failed tests btw 2026-03-14 23:42:21 +03:00
mohd 2a8b6a7b62 feat: added initial implementation 2026-03-14 23:30:56 +03:00
mohd 8b626a940e chore: condense all docs and markdown files 2026-03-14 15:11:40 +03:00
mohd f3811b7520 fix: catch race conditions when creating users 2026-03-14 15:11:19 +03:00
mohd 9787fb699a feat: deprecate email, pre-verify users + documentation 2026-03-14 14:40:52 +03:00
mohd ad711d1daf feat: IP & device rate limits 2026-03-14 01:07:26 +03:00
mohd 9b87eb74d7 chore: update docs 2026-03-14 01:06:42 +03:00
mohd eb88f23d28 chore: document auth fixes 2026-03-14 00:48:05 +03:00
mohd 0b76356169 fix: deprecate passwords, use phone auth source of truth 2026-03-14 00:47:31 +03:00
mohd c391a9b8e5 chore: update auth progress 2026-03-14 00:32:57 +03:00
mohd 5ece1036cd feat: DB constraints for phone auth 2026-03-14 00:31:20 +03:00
mohd 4026b94c3a feat: phone auth tests and fixes 2026-03-13 23:48:40 +03:00
mohd 38e5ece96f chore: auth gaps docs 2026-03-13 23:45:36 +03:00
mohd 5db211dda9 chore: less brittle tests 2026-03-13 23:26:09 +03:00
mohd c0846fe096 test: added auth contract test 2026-03-13 20:36:47 +03:00
mohd 560460dd84 Fix OTP localization test expectation 2026-03-13 16:51:26 +03:00
mohd c212acc504 Remove Authentica E2E test and expand OTP coverage 2026-03-13 16:49:29 +03:00
mohd 15ed5036d1 Remove dead Twilio tests and docs mentions 2026-03-13 16:46:21 +03:00
mohd 0c992404ea chore: removed unused otp providers 2026-03-13 16:25:26 +03:00
mohd d796d9e6a1 removed unviable e2e test 2026-03-13 16:21:25 +03:00
mohd 2ba0cfffc8 chore: adjust near-term focus 2026-03-13 16:11:30 +03:00
mohd 3f35f7dc17 Merge pull request 'chore: edited agents files' (#1) from agents into main
Reviewed-on: #1
2026-03-13 13:07:03 +00:00
mohd 07491063f5 chore: edited agents files 2026-03-13 16:02:52 +03:00
mohd b8218669c2 added claude.md 2026-03-02 00:58:00 +03:00
mohd 2305c3dc9d feat: add Arabic translations and fix frontend i18n gaps
- Add backend/locale/ar_SA/LC_MESSAGES/django.po with Arabic (ar-sa) translations
  for all 62 user-facing error/validation strings across accounts, bookings,
  payments, and notifications apps; compile to django.mo
- Add common.loading and salon.unknownStaff keys to both ar-sa.json and en.json
- ProtectedRoute: replace hardcoded "Loading..." with t("common.loading")
- BookPage, SalonDetailPage: replace `Staff ${s.id}` fallback with
  t("salon.unknownStaff", { id: s.id })
- BookingsPage: pass getActiveLocale() to toLocaleString so date/time
  format matches the active app language

All 35 backend tests and 7 frontend tests pass.

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 23:30:04 +03:00
mohd aa607b9b6e Fleshed out documentation 2026-02-28 17:41:00 +03:00
mohd 828cbcc822 Authentica OTP tests 2026-02-28 17:31:03 +03:00
mohd 4253f6f650 Added Authentica OTP 2026-02-28 16:58:50 +03:00
mohd a1da918f95 Enhance documentation, implement Twilio OTP delivery, and update payment gateway methods. Updated AGENTS.md and README.md for clarity on ExecPlans and architecture. Added Twilio as a dependency and implemented capture/refund methods in MoyasarGateway. Improved frontend routing with react-router-dom and added authentication context. Updated styles and localization files for better user experience. 2026-02-28 15:33:50 +03:00
mohd 86fd07c778 miscellaneous documentation 2026-02-28 15:33:18 +03:00
mohd ca2a6b58b6 Booking lifecycle notifications and status updates 2026-02-28 15:06:35 +03:00
mohd db36551211 Document payments sanity check and fix demo seed 2026-02-28 13:28:58 +03:00
mohd a150b18fe7 Wire payments UI and fix frontend tests 2026-02-28 13:15:41 +03:00
mohd f3c93f500e Implement Moyasar payments flow with webhooks 2026-02-28 13:01:12 +03:00
mohd d9767ff0a7 Add payments ExecPlan and set as active 2026-02-28 12:47:59 +03:00
111 changed files with 10489 additions and 782 deletions
+1
View File
@@ -17,3 +17,4 @@ dist/
# OS # OS
.DS_Store .DS_Store
backend/tmp_authentica_request_id.txt
+1
View File
@@ -0,0 +1 @@
venv
+28 -58
View File
@@ -1,67 +1,37 @@
# AGENTS.md # AGENTS.md
## Project Goal ## General
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. - Minimum tokens, skip grammar.
- Start every task by reading this file and `docs/README.md`.
## Current Plan (Roadmap) ## Goal
### Phase 1: Core MVP Reliability Build a reliable salon booking platform (Django + React) for KSA first, with clear path to scale.
- Phone-first auth with OTP (SMS/WhatsApp), rate limits, and social login.
- Booking integrity (availability, staff schedules, overlap prevention).
- Payments via Moyasar (payment creation, webhooks, reconciliation).
- Notifications for booking lifecycle.
- Localization foundations (i18n plumbing, RTL readiness, locale preferences).
- Tests for critical flows.
### Phase 2: Manager Ops ## Core Standards
- Salon/staff/service management. - Tests required for every feature/change.
- Calendar view + rescheduling/cancellation rules. - Booking/payment flows must be idempotent and auditable.
- Reviews/ratings with moderation and recompute. - Phone auth must be abuse-resistant (rate limits + safe verification).
- Reports (revenue, popular services, customer trends). - Keep business logic in services; avoid fat views.
- Full translation coverage and Arabic UX polish. - Use predictable API errors: HTTP status + `detail`.
### Phase 3: Scale & Compliance ## Active Navigation Path
- Audit logs and data export. - Docs index: `docs/README.md`
- PDPL/GDPR retention policy. - ExecPlan policy + active plan: `docs/PLANS.md`
- Observability and performance baselines. - Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
- Architecture snapshot: `docs/architecture.md`
- Known gaps: `docs/risks.md`
## Reliability Standards (NonNegotiable) ## Roadmap (compact)
- Every new feature ships with tests. - Phase 1: phone OTP auth, booking integrity, Moyasar payments, lifecycle notifications, i18n/RTL base, critical tests.
- Avoid introducing regressions without covering a fix or guardrail. - Phase 2: manager tools (catalog/staff/calendar/reports/reviews).
- Payment and booking flows must be idempotent and auditable. - Phase 3: audit/compliance/observability.
- Phone auth must be ratelimited and safe from abuse.
- Every change should be tested before completion; no exceptions.
## Testing Expectations ## Testing Expectations
### Backend - Backend: `pytest` + `pytest-django`, tests in `backend/apps/<app>/tests/`.
- Use `pytest` + `pytest-django`. - Frontend: `vitest` + Testing Library, cover search/booking/auth flows.
- Tests live beside apps (`apps/<app>/tests/`). - Backend tests run from `backend/` so `pytest.ini` resolves settings.
- Minimum coverage: auth, booking validation, payment state transitions.
### Frontend ## Collaboration Rules
- Use `vitest` + Testing Library. - Do not rewrite/delete unrelated work.
- Critical flows covered: search, booking form, auth state. - Do not run destructive git commands unless explicitly asked.
- Update docs when behavior changes (`docs/risks.md`, runbooks, ADR/ExecPlan as needed).
## Code Style & Practices
- Keep business logic in services (avoid fat views).
- Use explicit, readable model fields and serializers.
- Small, wellnamed functions > monolithic handlers.
- Prefer predictable error responses (HTTP status + `detail`).
## Known Gaps (Tracked)
- See `docs/risks.md` for current gaps/risks to address.
## Environment Notes
- Python is invoked as `python3`.
- A virtualenv is in use.
- DB: PostgreSQL in production, SQLite allowed for local dev.
- Backend tests must run with the venv active and `pytest-django` installed; run from `backend/` so `backend/pytest.ini` is picked up and `DJANGO_SETTINGS_MODULE` resolves.
## Collaboration Rules for Agents
- Dont delete or rewrite unrelated work.
- Avoid destructive git commands unless explicitly asked.
- Update `docs/risks.md` when adding or closing a significant gap.
- Keep README instructions current when tooling changes.
# 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`.
-154
View File
@@ -1,154 +0,0 @@
# Codex Execution Plans (ExecPlans):
This document describes the requirements for an execution plan ("ExecPlan"), a design document that a coding agent can follow to deliver a working feature or system change. Treat the reader as a complete beginner to this repository: they have only the current working tree and the single ExecPlan file you provide. There is no memory of prior plans and no external context.
## 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.
## How to use ExecPlans and PLANS.md
When authoring an executable specification (ExecPlan), follow PLANS.md _to the letter_. If it is not in your context, refresh your memory by reading the entire PLANS.md file. Be thorough in reading (and re-reading) source material to produce an accurate specification. When creating a spec, start from the skeleton and flesh it out as you do your research.
When implementing an executable specification (ExecPlan), do not prompt the user for "next steps"; simply proceed to the next milestone. Keep all sections up to date, add or split entries in the list at every stopping point to affirmatively state the progress made and next steps. Resolve ambiguities autonomously, and commit frequently.
When discussing an executable specification (ExecPlan), record decisions in a log in the spec for posterity; it should be unambiguously clear why any change to the specification was made. ExecPlans are living documents, and it should always be possible to restart from _only_ the ExecPlan and no other work.
When researching a design with challenging requirements or significant unknowns, use milestones to implement proof of concepts, "toy implementations", etc., that allow validating whether the user's proposal is feasible. Read the source code of libraries by finding or acquiring them, research deeply, and include prototypes to guide a fuller implementation.
## Requirements
NON-NEGOTIABLE REQUIREMENTS:
* Every ExecPlan must be fully self-contained. Self-contained means that in its current form it contains all knowledge and instructions needed for a novice to succeed.
* Every ExecPlan is a living document. Contributors are required to revise it as progress is made, as discoveries occur, and as design decisions are finalized. Each revision must remain fully self-contained.
* Every ExecPlan must enable a complete novice to implement the feature end-to-end without prior knowledge of this repo.
* Every ExecPlan must produce a demonstrably working behavior, not merely code changes to "meet a definition".
* Every ExecPlan must define every term of art in plain language or do not use it.
Purpose and intent come first. Begin by explaining, in a few sentences, why the work matters from a user's perspective: what someone can do after this change that they could not do before, and how to see it working. Then guide the reader through the exact steps to achieve that outcome, including what to edit, what to run, and what they should observe.
The agent executing your plan can list files, read files, search, run the project, and run tests. It does not know any prior context and cannot infer what you meant from earlier milestones. Repeat any assumption you rely on. Do not point to external blogs or docs; if knowledge is required, embed it in the plan itself in your own words. If an ExecPlan builds upon a prior ExecPlan and that file is checked in, incorporate it by reference. If it is not, you must include all relevant context from that plan.
## Formatting
Format and envelope are simple and strict. Each ExecPlan must be one single fenced code block labeled as `md` that begins and ends with triple backticks. Do not nest additional triple-backtick code fences inside; when you need to show commands, transcripts, diffs, or code, present them as indented blocks within that single fence. Use indentation for clarity rather than code fences inside an ExecPlan to avoid prematurely closing the ExecPlan's code fence. Use two newlines after every heading, use # and ## and so on, and correct syntax for ordered and unordered lists.
When writing an ExecPlan to a Markdown (.md) file where the content of the file *is only* the single ExecPlan, you should omit the triple backticks.
Write in plain prose. Prefer sentences over lists. Avoid checklists, tables, and long enumerations unless brevity would obscure meaning. Checklists are permitted only in the `Progress` section, where they are mandatory. Narrative sections must remain prose-first.
## Guidelines
Self-containment and plain language are paramount. If you introduce a phrase that is not ordinary English ("daemon", "middleware", "RPC gateway", "filter graph"), define it immediately and remind the reader how it manifests in this repository (for example, by naming the files or commands where it appears). Do not say "as defined previously" or "according to the architecture doc." Include the needed explanation here, even if you repeat yourself.
Avoid common failure modes. Do not rely on undefined jargon. Do not describe "the letter of a feature" so narrowly that the resulting code compiles but does nothing meaningful. Do not outsource key decisions to the reader. When ambiguity exists, resolve it in the plan itself and explain why you chose that path. Err on the side of over-explaining user-visible effects and under-specifying incidental implementation details.
Anchor the plan with observable outcomes. State what the user can do after implementation, the commands to run, and the outputs they should see. Acceptance should be phrased as behavior a human can verify ("after starting the server, navigating to [http://localhost:8080/health](http://localhost:8080/health) returns HTTP 200 with body OK") rather than internal attributes ("added a HealthCheck struct"). If a change is internal, explain how its impact can still be demonstrated (for example, by running tests that fail before and pass after, and by showing a scenario that uses the new behavior).
Specify repository context explicitly. Name files with full repository-relative paths, name functions and modules precisely, and describe where new files should be created. If touching multiple areas, include a short orientation paragraph that explains how those parts fit together so a novice can navigate confidently. When running commands, show the working directory and exact command line. When outcomes depend on environment, state the assumptions and provide alternatives when reasonable.
Be idempotent and safe. Write the steps so they can be run multiple times without causing damage or drift. If a step can fail halfway, include how to retry or adapt. If a migration or destructive operation is necessary, spell out backups or safe fallbacks. Prefer additive, testable changes that can be validated as you go.
Validation is not optional. Include instructions to run tests, to start the system if applicable, and to observe it doing something useful. Describe comprehensive testing for any new features or capabilities. Include expected outputs and error messages so a novice can tell success from failure. Where possible, show how to prove that the change is effective beyond compilation (for example, through a small end-to-end scenario, a CLI invocation, or an HTTP request/response transcript). State the exact test commands appropriate to the projects toolchain and how to interpret their results.
Capture evidence. When your steps produce terminal output, short diffs, or logs, include them inside the single fenced block as indented examples. Keep them concise and focused on what proves success. If you need to include a patch, prefer file-scoped diffs or small excerpts that a reader can recreate by following your instructions rather than pasting large blobs.
## Milestones
Milestones are narrative, not bureaucracy. If you break the work into milestones, introduce each with a brief paragraph that describes the scope, what will exist at the end of the milestone that did not exist before, the commands to run, and the acceptance you expect to observe. Keep it readable as a story: goal, work, result, proof. Progress and milestones are distinct: milestones tell the story, progress tracks granular work. Both must exist. Never abbreviate a milestone merely for the sake of brevity, do not leave out details that could be crucial to a future implementation.
Each milestone must be independently verifiable and incrementally implement the overall goal of the execution plan.
## Living plans and design decisions
* ExecPlans are living documents. As you make key design decisions, update the plan to record both the decision and the thinking behind it. Record all decisions in the `Decision Log` section.
* ExecPlans must contain and maintain a `Progress` section, a `Surprises & Discoveries` section, a `Decision Log`, and an `Outcomes & Retrospective` section. These are not optional.
* When you discover optimizer behavior, performance tradeoffs, unexpected bugs, or inverse/unapply semantics that shaped your approach, capture those observations in the `Surprises & Discoveries` section with short evidence snippets (test output is ideal).
* If you change course mid-implementation, document why in the `Decision Log` and reflect the implications in `Progress`. Plans are guides for the next contributor as much as checklists for you.
* At completion of a major task or the full plan, write an `Outcomes & Retrospective` entry summarizing what was achieved, what remains, and lessons learned.
# Prototyping milestones and parallel implementations
It is acceptable—-and often encouraged—-to include explicit prototyping milestones when they de-risk a larger change. Examples: adding a low-level operator to a dependency to validate feasibility, or exploring two composition orders while measuring optimizer effects. Keep prototypes additive and testable. Clearly label the scope as “prototyping”; describe how to run and observe results; and state the criteria for promoting or discarding the prototype.
Prefer additive code changes followed by subtractions that keep tests passing. Parallel implementations (e.g., keeping an adapter alongside an older path during migration) are fine when they reduce risk or enable tests to continue passing during a large migration. Describe how to validate both paths and how to retire one safely with tests. When working with multiple new libraries or feature areas, consider creating spikes that evaluate the feasibility of these features _independently_ of one another, proving that the external library performs as expected and implements the features we need in isolation.
## Skeleton of a Good ExecPlan
# <Short, action-oriented description>
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.
If PLANS.md file is checked into the repo, reference the path to that file here from the repository root and note that this document must be maintained in accordance with PLANS.md.
## Purpose / Big Picture
Explain in a few sentences what someone gains after this change and how they can see it working. State the user-visible behavior you will enable.
## Progress
Use a list with checkboxes to summarize granular steps. Every stopping point must be documented here, even if it requires splitting a partially completed task into two (“done” vs. “remaining”). This section must always reflect the actual current state of the work.
- [x] (2025-10-01 13:00Z) Example completed step.
- [ ] Example incomplete step.
- [ ] Example partially completed step (completed: X; remaining: Y).
Use timestamps to measure rates of progress.
## Surprises & Discoveries
Document unexpected behaviors, bugs, optimizations, or insights discovered during implementation. Provide concise evidence.
- Observation: …
Evidence: …
## Decision Log
Record every decision made while working on the plan in the format:
- Decision: …
Rationale: …
Date/Author: …
## Outcomes & Retrospective
Summarize outcomes, gaps, and lessons learned at major milestones or at completion. Compare the result against the original purpose.
## Context and Orientation
Describe the current state relevant to this task as if the reader knows nothing. Name the key files and modules by full path. Define any non-obvious term you will use. Do not refer to prior plans.
## Plan of Work
Describe, in prose, the sequence of edits and additions. For each edit, name the file and location (function, module) and what to insert or change. Keep it concrete and minimal.
## Concrete Steps
State the exact commands to run and where to run them (working directory). When a command generates output, show a short expected transcript so the reader can compare. This section must be updated as work proceeds.
## Validation and Acceptance
Describe how to start or exercise the system and what to observe. Phrase acceptance as behavior, with specific inputs and outputs. If tests are involved, say "run <projects test command> and expect <N> passed; the new test <name> fails before the change and passes after>".
## Idempotence and Recovery
If steps can be repeated safely, say so. If a step is risky, provide a safe retry or rollback path. Keep the environment clean after completion.
## Artifacts and Notes
Include the most important transcripts, diffs, or snippets as indented examples. Keep them concise and focused on what proves success.
## Interfaces and Dependencies
Be prescriptive. Name the libraries, modules, and services to use and why. Specify the types, traits/interfaces, and function signatures that must exist at the end of the milestone. Prefer stable names and paths such as `crate::module::function` or `package.submodule.Interface`. E.g.:
In crates/foo/planner.rs, define:
pub trait Planner {
fn plan(&self, observed: &Observed) -> Vec<Action>;
}
If you follow the guidance above, a single, stateless agent -- or a human novice -- can read your ExecPlan from top to bottom and produce a working, observable result. That is the bar: SELF-CONTAINED, SELF-SUFFICIENT, NOVICE-GUIDING, OUTCOME-FOCUSED.
When you revise a plan, you must ensure your changes are comprehensively reflected across all sections, including the living document sections, and you must write a note at the bottom of the plan describing the change and the reason why. ExecPlans must describe not just the what but the why for almost everything.
+24 -56
View File
@@ -1,63 +1,31 @@
# Salon Booking Platform # Salon
Scaffolded Django + React starter for a salon booking platform. KSA-first salon booking platform.
## Backend ## Quick start
Location: `backend/` ### Backend
1. `python3 -m venv venv && source venv/bin/activate`
2. `pip install -r backend/requirements.txt -r backend/requirements-dev.txt`
3. `cp backend/.env.example backend/.env`
4. `cd backend && python3 manage.py migrate && python3 manage.py runserver`
### Setup Optional demo data:
- `python3 manage.py seed_demo`
1. Create a virtualenv and install dependencies. Backend tests:
- `pip install -r backend/requirements.txt -r backend/requirements-dev.txt` - `cd backend && python3 -m pytest`
2. Copy `backend/.env.example` to `backend/.env` and adjust values. - external integrations only: `PYTEST_ADDOPTS='' python3 -m pytest -m external`
3. Run migrations and start the server.
### Demo data ### Frontend
1. `cd frontend && npm install`
2. `npm run dev`
3. tests: `npm run test`
After migrations, you can seed demo data: ## Docs map
- Agent rules: `AGENTS.md`
- `python manage.py seed_demo` - Docs index: `docs/README.md`
- Plans/ExecPlan policy: `docs/PLANS.md`
### Tests - Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
- Architecture: `docs/architecture.md`
- `pytest` - Risks: `docs/risks.md`
### Core API endpoints (current scaffold)
- `POST /api/auth/register/`
- `POST /api/auth/token/`
- `POST /api/auth/token/refresh/`
- `GET/PATCH /api/auth/me/`
- `POST /api/auth/otp/request/`
- `POST /api/auth/otp/verify/`
- `POST /api/auth/phone/request/`
- `POST /api/auth/phone/verify/`
- `POST /api/auth/social/<provider>/` (placeholder)
- `GET /api/salons/`
- `GET /api/salons/<id>/`
- `GET /api/salons/<id>/services/`
- `GET /api/salons/<id>/staff/`
- `GET /api/salons/<id>/reviews/`
- `GET/POST /api/bookings/`
- `GET /api/bookings/<id>/`
- `GET/POST /api/payments/`
## Frontend
Location: `frontend/`
### Setup
1. Install dependencies via `npm install`.
2. Run `npm run dev`.
### Tests
- `npm run test`
The dev server proxies `/api` to `http://localhost:8000`.
## Project Notes
- Known gaps and risks: `docs/risks.md`
+1
View File
@@ -19,3 +19,4 @@ UNIFONIC_WHATSAPP_SENDER=
MOYASAR_SECRET_KEY= MOYASAR_SECRET_KEY=
MOYASAR_PUBLISHABLE_KEY= MOYASAR_PUBLISHABLE_KEY=
MOYASAR_BASE_URL= MOYASAR_BASE_URL=
MOYASAR_WEBHOOK_SECRET=
+21
View File
@@ -0,0 +1,21 @@
# Backend Notes
## Current state
- Phone-first auth is canonical (`/api/auth/phone/request`, `/api/auth/phone/verify`).
- Password token endpoint `/api/auth/token/` is intentionally deprecated (`410`).
- Moyasar payment create/webhook flow implemented.
- Booking integrity checks + lifecycle notifications implemented.
## Run
- `cd backend`
- `python3 manage.py migrate`
- `python3 manage.py runserver`
## Test
- `cd backend && python3 -m pytest`
- external-only: `PYTEST_ADDOPTS='' python3 -m pytest -m external`
## Pointers
- Architecture: `docs/architecture.md`
- Active plan: `docs/execplans/auth-phone-first-hardening.md`
- Risks: `docs/risks.md`
+4 -4
View File
@@ -7,12 +7,12 @@ from apps.accounts.models import PhoneOTP, User
@admin.register(User) @admin.register(User)
class UserAdmin(DjangoUserAdmin): class UserAdmin(DjangoUserAdmin):
model = User model = User
list_display = ("email", "phone_number", "role", "is_staff", "is_phone_verified") list_display = ("phone_number", "email", "role", "is_staff", "is_phone_verified")
list_filter = ("role", "is_staff", "is_phone_verified") list_filter = ("role", "is_staff", "is_phone_verified")
ordering = ("email",) ordering = ("phone_number",)
search_fields = ("email", "phone_number") search_fields = ("email", "phone_number")
fieldsets = ( fieldsets = (
(None, {"fields": ("email", "password")}), (None, {"fields": ("phone_number", "password")}),
("Personal", {"fields": ("first_name", "last_name", "phone_number")}), ("Personal", {"fields": ("first_name", "last_name", "phone_number")}),
("Roles", {"fields": ("role", "is_phone_verified")}), ("Roles", {"fields": ("role", "is_phone_verified")}),
("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}), ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
@@ -21,7 +21,7 @@ class UserAdmin(DjangoUserAdmin):
add_fieldsets = ( add_fieldsets = (
(None, { (None, {
"classes": ("wide",), "classes": ("wide",),
"fields": ("email", "password1", "password2", "role"), "fields": ("phone_number", "password1", "password2", "role"),
}), }),
) )
@@ -0,0 +1,25 @@
# Generated by Django 6.0.3 on 2026-03-13 20:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0003_preferred_language"),
]
operations = [
migrations.AlterField(
model_name="user",
name="phone_number",
field=models.CharField(max_length=20, unique=True),
),
migrations.AddConstraint(
model_name="user",
constraint=models.CheckConstraint(
condition=models.Q(("phone_number__regex", r"^\+[1-9][0-9]{7,14}$")),
name="accounts_user_phone_e164_format",
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 6.0.3 on 2026-03-13 21:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('accounts', '0004_alter_user_groups_alter_user_phone_number_and_more'),
]
operations = [
migrations.AddField(
model_name='phoneotp',
name='device_signal',
field=models.CharField(blank=True, db_index=True, default='', max_length=64),
),
migrations.AddField(
model_name='phoneotp',
name='request_ip',
field=models.GenericIPAddressField(blank=True, db_index=True, null=True),
),
]
+30 -7
View File
@@ -4,6 +4,7 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
from django.db import models from django.db import models
from django.db.models import Q
from django.utils import timezone from django.utils import timezone
@@ -17,8 +18,8 @@ class UserRole(models.TextChoices):
class UserManager(BaseUserManager): class UserManager(BaseUserManager):
def create_user(self, email=None, password=None, **extra_fields): def create_user(self, email=None, password=None, **extra_fields):
phone_number = extra_fields.get("phone_number") phone_number = extra_fields.get("phone_number")
if not email and not phone_number: if not phone_number:
raise ValueError("Email or phone number is required") raise ValueError("Phone number is required")
if email: if email:
email = self.normalize_email(email) email = self.normalize_email(email)
user = self.model(email=email, **extra_fields) user = self.model(email=email, **extra_fields)
@@ -29,7 +30,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,12 +38,12 @@ 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):
email = models.EmailField(unique=True, null=True, blank=True) email = models.EmailField(unique=True, null=True, blank=True)
phone_number = models.CharField(max_length=20, unique=True, null=True, blank=True) phone_number = models.CharField(max_length=20, unique=True)
role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER) role = models.CharField(max_length=20, choices=UserRole.choices, default=UserRole.CUSTOMER)
first_name = models.CharField(max_length=150, blank=True) first_name = models.CharField(max_length=150, blank=True)
last_name = models.CharField(max_length=150, blank=True) last_name = models.CharField(max_length=150, blank=True)
@@ -59,10 +60,29 @@ 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
class Meta:
constraints = [
models.CheckConstraint(
name="accounts_user_phone_e164_format",
condition=Q(phone_number__regex=r"^\+[1-9][0-9]{7,14}$"),
),
]
@property
def display_name(self) -> str:
first = (self.first_name or "").strip()
last = (self.last_name or "").strip()
if first or last:
return f"{first} {last}".strip()
if self.email:
return self.email
return self.phone_number
def __str__(self): def __str__(self):
return self.email or self.phone_number or str(self.id) return self.display_name
class OtpChannel(models.TextChoices): class OtpChannel(models.TextChoices):
@@ -87,6 +107,9 @@ class PhoneOTP(models.Model):
verified_at = models.DateTimeField(null=True, blank=True) verified_at = models.DateTimeField(null=True, blank=True)
attempt_count = models.PositiveSmallIntegerField(default=0) attempt_count = models.PositiveSmallIntegerField(default=0)
max_attempts = models.PositiveSmallIntegerField(default=5) max_attempts = models.PositiveSmallIntegerField(default=5)
# Request metadata for abuse controls and support investigations.
request_ip = models.GenericIPAddressField(null=True, blank=True, db_index=True)
device_signal = models.CharField(max_length=64, blank=True, default="", db_index=True)
def is_expired(self): def is_expired(self):
return timezone.now() >= self.expires_at return timezone.now() >= self.expires_at
+2
View File
@@ -25,6 +25,7 @@ class UserSerializer(serializers.ModelSerializer):
class RegisterSerializer(serializers.ModelSerializer): class RegisterSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True, min_length=8) password = serializers.CharField(write_only=True, min_length=8)
phone_number = serializers.CharField(max_length=20, required=True)
class Meta: class Meta:
model = User model = User
@@ -61,6 +62,7 @@ class OTPVerifySerializer(serializers.Serializer):
class PhoneAuthRequestSerializer(serializers.Serializer): class PhoneAuthRequestSerializer(serializers.Serializer):
phone_number = serializers.CharField(max_length=20) phone_number = serializers.CharField(max_length=20)
channel = serializers.ChoiceField(choices=OtpChannel.choices) channel = serializers.ChoiceField(choices=OtpChannel.choices)
device_id = serializers.CharField(required=False, allow_blank=True, max_length=128, write_only=True)
email = serializers.EmailField(required=False, allow_null=True, allow_blank=True) email = serializers.EmailField(required=False, allow_null=True, allow_blank=True)
first_name = serializers.CharField(required=False, allow_blank=True) first_name = serializers.CharField(required=False, allow_blank=True)
last_name = serializers.CharField(required=False, allow_blank=True) last_name = serializers.CharField(required=False, allow_blank=True)
+213 -58
View File
@@ -1,8 +1,10 @@
import logging import logging
import os import os
import secrets import secrets
from datetime import timedelta from hashlib import sha256
import ipaddress
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
@@ -33,13 +35,33 @@ class OtpCooldownError(RuntimeError):
self.retry_after_seconds = retry_after_seconds self.retry_after_seconds = retry_after_seconds
class OtpIpRateLimitError(RuntimeError):
def __init__(self, retry_after_seconds: int):
super().__init__(_("Too many OTP requests from this IP. Try again later."))
self.retry_after_seconds = retry_after_seconds
class OtpDeviceRateLimitError(RuntimeError):
def __init__(self, retry_after_seconds: int):
super().__init__(_("Too many OTP requests from this device. Try again later."))
self.retry_after_seconds = retry_after_seconds
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:
@@ -49,70 +71,182 @@ class ConsoleOtpProvider(BaseOtpProvider):
logger.info("OTP WhatsApp to %s: %s", to_number, message) logger.info("OTP WhatsApp to %s: %s", to_number, message)
class TwilioOtpProvider(BaseOtpProvider):
class AuthenticaOtpProvider(BaseOtpProvider):
"""Authentica provider for OTP delivery (SMS/WhatsApp) plus custom SMS messaging."""
uses_provider_otp = True
def __init__(self) -> None: def __init__(self) -> None:
self.account_sid = os.getenv("TWILIO_ACCOUNT_SID") self.api_key = os.getenv("AUTHENTICA_API_KEY")
self.auth_token = os.getenv("TWILIO_AUTH_TOKEN") self.base_url = os.getenv("AUTHENTICA_BASE_URL", "https://api.authentica.sa")
self.from_number = os.getenv("TWILIO_FROM_NUMBER") self.timeout_seconds = float(os.getenv("AUTHENTICA_TIMEOUT_SECONDS", "10"))
self.whatsapp_from = os.getenv("TWILIO_WHATSAPP_FROM") self.sender_name = os.getenv("AUTHENTICA_SENDER_NAME")
def _assert_config(self) -> None: def _assert_config(self) -> None:
if not self.account_sid or not self.auth_token or not self.from_number: if not self.api_key:
raise ValueError(_("Twilio credentials are not configured")) 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: def send_sms(self, to_number: str, message: str) -> None:
self._assert_config() if not self.sender_name:
raise NotImplementedError(_("Twilio SMS adapter not implemented yet")) 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: def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config() raise ValueError(_("Authentica WhatsApp messaging is not supported"))
if not self.whatsapp_from:
raise ValueError(_("Twilio WhatsApp sender is not configured"))
raise NotImplementedError(_("Twilio WhatsApp adapter not implemented yet"))
class UnifonicOtpProvider(BaseOtpProvider):
def __init__(self) -> None:
self.app_sid = os.getenv("UNIFONIC_APP_SID")
self.sender_id = os.getenv("UNIFONIC_SENDER_ID")
self.whatsapp_sender = os.getenv("UNIFONIC_WHATSAPP_SENDER")
def _assert_config(self) -> None:
if not self.app_sid or not self.sender_id:
raise ValueError(_("Unifonic credentials are not configured"))
def send_sms(self, to_number: str, message: str) -> None:
self._assert_config()
raise NotImplementedError(_("Unifonic SMS adapter not implemented yet"))
def send_whatsapp(self, to_number: str, message: str) -> None:
self._assert_config()
if not self.whatsapp_sender:
raise ValueError(_("Unifonic WhatsApp sender is not configured"))
raise NotImplementedError(_("Unifonic WhatsApp adapter not implemented yet"))
PROVIDERS = { PROVIDERS = {
"console": ConsoleOtpProvider, "console": ConsoleOtpProvider,
"twilio": TwilioOtpProvider, "authentica": AuthenticaOtpProvider,
"unifonic": UnifonicOtpProvider,
} }
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))
def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpose.AUTH) -> OtpSendResult: def _compute_retry_after_seconds(oldest_recent, now, window_minutes: int, floor_seconds: int = 1) -> int:
if oldest_recent:
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds())
else:
retry_after = floor_seconds
return max(retry_after, floor_seconds)
def normalize_request_ip(raw_ip: str | None) -> str | None:
if not raw_ip:
return None
candidate = raw_ip.split(",")[0].strip()
try:
return str(ipaddress.ip_address(candidate))
except ValueError:
return None
def build_device_signal(device_id: str | None, user_agent: str | None, accept_language: str | None) -> str:
# Prefer explicit device id; fallback to passive headers for coarse signal.
source = (device_id or "").strip()
if not source:
source = f"{(user_agent or '').strip()}|{(accept_language or '').strip()}"
if not source.strip("|"):
return ""
return f"sha256:{sha256(source.encode('utf-8')).hexdigest()[:24]}"
def enforce_phone_auth_request_limits(request_ip: str | None, device_signal: str | None) -> None:
now = timezone.now()
window_minutes = getattr(settings, "PHONE_AUTH_RISK_WINDOW_MINUTES", 15)
window_start = now - timedelta(minutes=window_minutes)
ip_limit = getattr(settings, "PHONE_AUTH_IP_MAX_PER_WINDOW", 20)
device_limit = getattr(settings, "PHONE_AUTH_DEVICE_MAX_PER_WINDOW", 20)
if request_ip and ip_limit > 0:
ip_recent = PhoneOTP.objects.filter(
purpose=OtpPurpose.AUTH,
request_ip=request_ip,
created_at__gte=window_start,
)
if ip_recent.count() >= ip_limit:
oldest_recent = ip_recent.order_by("created_at").first()
raise OtpIpRateLimitError(
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes)
)
if device_signal and device_limit > 0:
device_recent = PhoneOTP.objects.filter(
purpose=OtpPurpose.AUTH,
device_signal=device_signal,
created_at__gte=window_start,
)
if device_recent.count() >= device_limit:
oldest_recent = device_recent.order_by("created_at").first()
raise OtpDeviceRateLimitError(
retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes)
)
def create_and_send_otp(
phone_number: str,
channel: str,
purpose: str = OtpPurpose.AUTH,
request_ip: str | None = None,
device_signal: str = "",
) -> OtpSendResult:
provider = get_provider() provider = get_provider()
now = timezone.now() now = timezone.now()
window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15) window_minutes = getattr(settings, "OTP_WINDOW_MINUTES", 15)
@@ -123,11 +257,9 @@ def create_and_send_otp(phone_number: str, channel: str, purpose: str = OtpPurpo
recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start) recent_qs = PhoneOTP.objects.filter(phone_number=phone_number, created_at__gte=window_start)
if recent_qs.count() >= max_per_window: if recent_qs.count() >= max_per_window:
oldest_recent = recent_qs.order_by("created_at").first() oldest_recent = recent_qs.order_by("created_at").first()
if oldest_recent: raise OtpRateLimitError(
retry_after = int((oldest_recent.created_at + timedelta(minutes=window_minutes) - now).total_seconds()) retry_after_seconds=_compute_retry_after_seconds(oldest_recent, now, window_minutes, cooldown_seconds)
else: )
retry_after = cooldown_seconds
raise OtpRateLimitError(retry_after_seconds=max(retry_after, cooldown_seconds))
latest = ( latest = (
PhoneOTP.objects.filter(phone_number=phone_number, channel=channel) PhoneOTP.objects.filter(phone_number=phone_number, channel=channel)
@@ -139,25 +271,36 @@ 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(),
request_ip=request_ip,
device_signal=device_signal,
) )
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 +312,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,129 @@
"""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"}
@patch("requests.post")
def test_authentica_request_failure_raises(mock_post):
mock_response = MagicMock(ok=False)
mock_response.json.return_value = {"detail": "fail"}
mock_post.return_value = mock_response
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
with pytest.raises(RuntimeError):
provider.send_otp("+966512345678", OtpChannel.SMS)
def test_authentica_send_sms_requires_sender_name():
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
with pytest.raises(ValueError):
provider.send_sms("+966512345678", "Hello")
def test_authentica_send_otp_rejects_unknown_channel():
with patch.dict(os.environ, {"AUTHENTICA_API_KEY": "api-key"}):
provider = AuthenticaOtpProvider()
with pytest.raises(ValueError):
provider.send_otp("+966512345678", "email")
@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
@@ -0,0 +1,53 @@
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
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_otp_request_whatsapp_ok(client):
response = client.post(
reverse("otp_request"),
{"phone_number": "0512345678", "channel": "whatsapp"},
content_type="application/json",
)
assert response.status_code == 201
data = response.json()
assert "request_id" in data
assert "expires_at" in data
@pytest.mark.django_db
def test_otp_verify_rejects_expired(client):
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.VERIFY,
provider="console",
code_hash="unused",
expires_at=timezone.now() - timedelta(minutes=1),
)
response = client.post(
reverse("otp_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_otp_request_invalid_phone_localized_ar(client):
response = client.post(
reverse("otp_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="ar-sa",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
+120 -5
View File
@@ -1,13 +1,128 @@
import pytest from datetime import timedelta
from django.test import override_settings from unittest.mock import patch
from apps.accounts.models import OtpChannel import pytest
from apps.accounts.services.otp import OtpRateLimitError, create_and_send_otp from django.contrib.auth.hashers import make_password
from django.test import override_settings
from django.utils import timezone
from apps.accounts.models import OtpChannel, OtpPurpose, PhoneOTP
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
# Once the max is reached, even a correct code must remain blocked.
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
# Do not lock this test to a specific increment policy after lockout.
assert otp.attempt_count >= otp.max_attempts
assert otp.verified_at is None
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=60)
def test_otp_cooldown_retry_after_seconds():
fixed_now = timezone.now()
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
# Align created_at with fixed time for deterministic cooldown.
PhoneOTP.objects.filter(id=result.request_id).update(created_at=fixed_now)
with pytest.raises(OtpCooldownError) as excinfo:
create_and_send_otp("+966512345678", OtpChannel.SMS)
assert excinfo.value.retry_after_seconds == 60
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console", OTP_MAX_PER_WINDOW=1, OTP_WINDOW_MINUTES=15, OTP_RESEND_COOLDOWN_SECONDS=0)
def test_otp_rate_limit_retry_after_seconds():
fixed_now = timezone.now()
with patch("apps.accounts.services.otp.timezone.now", return_value=fixed_now):
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
# Make the oldest OTP sit 10s before window expiry.
window_start = fixed_now - timedelta(minutes=15)
PhoneOTP.objects.filter(id=result.request_id).update(created_at=window_start + timedelta(seconds=10))
with pytest.raises(OtpRateLimitError) as excinfo:
create_and_send_otp("+966512345678", OtpChannel.SMS)
assert excinfo.value.retry_after_seconds == 10
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console", OTP_RESEND_COOLDOWN_SECONDS=1)
def test_otp_resend_after_cooldown_ok():
result = create_and_send_otp("+966512345678", OtpChannel.SMS)
# Force cooldown to be elapsed.
PhoneOTP.objects.filter(id=result.request_id).update(
created_at=timezone.now() - timedelta(seconds=5)
)
create_and_send_otp("+966512345678", OtpChannel.SMS)
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 2
@pytest.mark.django_db
def test_verify_otp_rejects_expired():
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel=OtpChannel.SMS,
purpose=OtpPurpose.AUTH,
provider="console",
code_hash=make_password("123456"),
expires_at=timezone.now() - timedelta(minutes=1),
)
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
assert otp.attempt_count == 0
assert otp.verified_at is None
@pytest.mark.django_db
def test_verify_otp_rejects_reuse_after_verified():
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(),
)
assert verify_otp(otp, "123456") is True
otp.refresh_from_db()
assert otp.attempt_count == 1
assert verify_otp(otp, "123456") is False
otp.refresh_from_db()
assert otp.attempt_count == 1
+84 -15
View File
@@ -1,31 +1,100 @@
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",
)
assert response.status_code == 201
request_id = response.json()["request_id"]
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None
assert str(otp.id) == 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
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
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_refresh_endpoint_still_works(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
request_response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
request_id = request_response.json()["request_id"]
verify_response = client.post(
reverse("phone_auth_verify"),
{"request_id": request_id, "code": "123456"},
content_type="application/json",
)
assert verify_response.status_code == 200
refresh = verify_response.json()["refresh"]
refresh_response = client.post(
reverse("token_refresh"),
{"refresh": refresh},
content_type="application/json", content_type="application/json",
) )
assert response.status_code == 201 assert refresh_response.status_code == 200
request_id = response.json()["request_id"] assert "access" in refresh_response.json()
otp = PhoneOTP.objects.filter(phone_number="+966512345678").order_by("-created_at").first()
assert otp is not None
assert str(otp.id) == request_id
bad = client.post( @pytest.mark.django_db
verify_url, @override_settings(OTP_PROVIDER="console")
{"request_id": request_id, "code": "000000"}, def test_phone_auth_verify_returns_404_when_user_removed(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
request_response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
request_id = request_response.json()["request_id"]
User.objects.filter(phone_number="+966512345678").delete()
verify_response = client.post(
reverse("phone_auth_verify"),
{"request_id": request_id, "code": "123456"},
content_type="application/json", content_type="application/json",
) )
assert bad.status_code == 400
assert User.objects.filter(phone_number="+966512345678").exists() assert verify_response.status_code == 404
@@ -0,0 +1,293 @@
from unittest.mock import patch
import pytest
from django.db import IntegrityError
from django.test import override_settings
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_creates_customer_for_new_phone(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"first_name": "Sara",
"last_name": "Ali",
"email": "sara@example.com",
},
content_type="application/json",
)
assert response.status_code == 201
data = response.json()
assert "request_id" in data
assert "expires_at" in data
user = User.objects.get(phone_number="+966512345678")
assert user.role == "customer"
assert user.is_phone_verified is False
otp = PhoneOTP.objects.get(id=data["request_id"])
assert otp.phone_number == "+966512345678"
assert otp.channel == "sms"
assert otp.purpose == OtpPurpose.AUTH
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_existing_phone_no_duplicate_user(client):
User.objects.create_user(
phone_number="+966512345678",
email="existing@example.com",
first_name="Existing",
)
before_count = User.objects.filter(phone_number="+966512345678").count()
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert response.status_code == 201
assert User.objects.filter(phone_number="+966512345678").count() == before_count
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_handles_duplicate_user_creation(client):
original_create_user = User.objects.create_user
otp_code = "123456"
def create_user_and_raise(*args, **kwargs):
original_create_user(*args, **kwargs)
raise IntegrityError("duplicate user")
with patch("apps.accounts.views.User.objects.create_user", side_effect=create_user_and_raise):
with patch("apps.accounts.services.otp.generate_code", return_value=otp_code):
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"first_name": "Sara",
"last_name": "Ali",
"email": "sara@example.com",
},
content_type="application/json",
)
assert response.status_code == 201
assert User.objects.filter(phone_number="+966512345678").count() == 1
assert PhoneOTP.objects.filter(phone_number="+966512345678").count() == 1
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_race_with_email_conflict(client):
original_create_user = User.objects.create_user
target_email = "race@example.com"
def create_conflict_user_then_raise(*args, **kwargs):
original_create_user(phone_number="+966500000002", email=target_email)
raise IntegrityError("email already claimed")
before_otp_count = PhoneOTP.objects.count()
with patch("apps.accounts.views.User.objects.create_user", side_effect=create_conflict_user_then_raise):
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"email": target_email,
},
content_type="application/json",
)
assert response.status_code == 400
assert "detail" in response.json()
assert User.objects.filter(phone_number="+966512345678").count() == 0
assert PhoneOTP.objects.count() == before_otp_count
@pytest.mark.django_db
@override_settings(OTP_PROVIDER="console")
def test_phone_auth_request_rejects_email_already_used(client):
User.objects.create_user(
phone_number="+966500000001",
email="taken@example.com",
)
before_otp_count = PhoneOTP.objects.count()
response = client.post(
reverse("phone_auth_request"),
{
"phone_number": "0512345678",
"channel": "sms",
"email": "taken@example.com",
},
content_type="application/json",
)
assert response.status_code == 400
assert "detail" in response.json()
assert User.objects.filter(phone_number="+966512345678").count() == 0
assert PhoneOTP.objects.count() == before_otp_count
@pytest.mark.django_db
def test_phone_auth_request_invalid_phone_localized_en(client):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="en",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "Phone number must be in E.164 format or a valid Saudi mobile"
@pytest.mark.django_db
def test_phone_auth_request_invalid_phone_localized_ar(client):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "123", "channel": "sms"},
content_type="application/json",
HTTP_ACCEPT_LANGUAGE="ar-sa",
)
assert response.status_code == 400
assert response.json()["phone_number"][0] == "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
@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_phone_auth_request_cooldown_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert second.status_code == 429
data = second.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=1,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=0,
)
def test_phone_auth_request_rate_limit_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
)
assert second.status_code == 429
data = second.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_MAX_PER_WINDOW=20,
OTP_WINDOW_MINUTES=15,
OTP_RESEND_COOLDOWN_SECONDS=0,
PHONE_AUTH_RISK_WINDOW_MINUTES=15,
PHONE_AUTH_IP_MAX_PER_WINDOW=2,
PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20,
)
def test_phone_auth_request_ip_throttle_returns_retry_after(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
first = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms"},
content_type="application/json",
REMOTE_ADDR="203.0.113.10",
)
assert first.status_code == 201
second = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345679", "channel": "sms"},
content_type="application/json",
REMOTE_ADDR="203.0.113.10",
)
assert second.status_code == 201
third = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345680", "channel": "sms"},
content_type="application/json",
REMOTE_ADDR="203.0.113.10",
)
assert third.status_code == 429
data = third.json()
assert "detail" in data
assert data["retry_after_seconds"] > 0
@pytest.mark.django_db
@override_settings(
OTP_PROVIDER="console",
OTP_RESEND_COOLDOWN_SECONDS=0,
OTP_MAX_PER_WINDOW=20,
PHONE_AUTH_IP_MAX_PER_WINDOW=20,
PHONE_AUTH_DEVICE_MAX_PER_WINDOW=20,
)
def test_phone_auth_request_persists_request_ip_and_device_signal(client):
with patch("apps.accounts.services.otp.generate_code", return_value="123456"):
response = client.post(
reverse("phone_auth_request"),
{"phone_number": "0512345678", "channel": "sms", "device_id": "device-abc"},
content_type="application/json",
REMOTE_ADDR="198.51.100.40",
HTTP_USER_AGENT="pytest-agent",
)
assert response.status_code == 201
otp = PhoneOTP.objects.get(id=response.json()["request_id"])
assert otp.request_ip == "198.51.100.40"
assert otp.device_signal
@@ -0,0 +1,107 @@
from unittest.mock import patch
import pytest
from django.db import IntegrityError
from django.urls import reverse
from apps.accounts.models import OtpPurpose, PhoneOTP, User
@pytest.mark.django_db
def test_create_user_rejects_email_only_identity():
# Phone-first invariant: do not allow creating users without phone_number.
with pytest.raises(ValueError):
User.objects.create_user(email="email-only@example.com")
@pytest.mark.django_db
def test_register_requires_phone_number(client):
# Public registration must keep phone as required identifier.
response = client.post(
reverse("register"),
{"email": "new@example.com", "password": "StrongPass123"},
content_type="application/json",
)
assert response.status_code == 400
assert "phone_number" in response.json()
@pytest.mark.django_db
def test_phone_auth_verify_rejects_verify_purpose_otp(client):
user = User.objects.create_user(phone_number="+966512345678")
otp = PhoneOTP.objects.create(
phone_number=user.phone_number,
channel="sms",
purpose=OtpPurpose.VERIFY,
provider="console",
code_hash="not-used",
expires_at=PhoneOTP.expiry_at(),
)
# Purpose boundary: /phone/verify must only accept auth OTP requests.
with patch("apps.accounts.views.verify_otp", return_value=True):
response = client.post(
reverse("phone_auth_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
def test_otp_verify_rejects_auth_purpose_otp(client):
otp = PhoneOTP.objects.create(
phone_number="+966512345678",
channel="sms",
purpose=OtpPurpose.AUTH,
provider="console",
code_hash="not-used",
expires_at=PhoneOTP.expiry_at(),
)
# Purpose boundary: /otp/verify must only accept verify OTP requests.
with patch("apps.accounts.views.verify_otp", return_value=True):
response = client.post(
reverse("otp_verify"),
{"request_id": str(otp.id), "code": "123456"},
content_type="application/json",
)
assert response.status_code == 400
@pytest.mark.django_db
def test_db_rejects_null_phone_number():
# DB invariant: phone_number is mandatory.
with pytest.raises(IntegrityError):
User.objects.create(email="null-phone@example.com")
@pytest.mark.django_db
def test_db_rejects_non_e164_phone_number():
# DB invariant: store normalized E.164 only.
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="0512345678")
@pytest.mark.django_db
def test_db_rejects_duplicate_phone_number():
User.objects.create_user(phone_number="+966512345678")
with pytest.raises(IntegrityError):
User.objects.create_user(phone_number="+966512345678")
@pytest.mark.django_db
def test_password_token_endpoint_is_disabled(client):
User.objects.create_user(phone_number="+966512345678", password="StrongPass123")
response = client.post(
reverse("token_obtain_pair"),
{"phone_number": "+966512345678", "password": "StrongPass123"},
content_type="application/json",
)
assert response.status_code == 410
assert "detail" in response.json()
@@ -0,0 +1,35 @@
import pytest
from apps.accounts.models import User
@pytest.mark.django_db
def test_display_name_prefers_full_name():
user = User.objects.create_user(
phone_number="+966500000001",
first_name="Sara",
last_name="Ali",
email="sara@example.com",
)
assert user.display_name == "Sara Ali"
assert str(user) == "Sara Ali"
@pytest.mark.django_db
def test_display_name_falls_back_to_email():
user = User.objects.create_user(
phone_number="+966500000002",
email="fallback@example.com",
)
assert user.display_name == "fallback@example.com"
@pytest.mark.django_db
def test_display_name_falls_back_to_phone_when_no_email():
user = User.objects.create_user(
phone_number="+966500000003",
)
assert user.display_name == "+966500000003"
+3 -2
View File
@@ -1,5 +1,5 @@
from django.urls import path from django.urls import path
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView from rest_framework_simplejwt.views import TokenRefreshView
from apps.accounts.views import ( from apps.accounts.views import (
MeView, MeView,
@@ -7,6 +7,7 @@ from apps.accounts.views import (
OTPVerifyView, OTPVerifyView,
PhoneAuthRequestView, PhoneAuthRequestView,
PhoneAuthVerifyView, PhoneAuthVerifyView,
PasswordTokenObtainDeprecatedView,
RegisterView, RegisterView,
SocialLoginPlaceholderView, SocialLoginPlaceholderView,
) )
@@ -14,7 +15,7 @@ from apps.accounts.views import (
urlpatterns = [ urlpatterns = [
path("register/", RegisterView.as_view(), name="register"), path("register/", RegisterView.as_view(), name="register"),
path("me/", MeView.as_view(), name="me"), path("me/", MeView.as_view(), name="me"),
path("token/", TokenObtainPairView.as_view(), name="token_obtain_pair"), path("token/", PasswordTokenObtainDeprecatedView.as_view(), name="token_obtain_pair"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
path("otp/request/", OTPRequestView.as_view(), name="otp_request"), path("otp/request/", OTPRequestView.as_view(), name="otp_request"),
path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"), path("otp/verify/", OTPVerifyView.as_view(), name="otp_verify"),
+72 -11
View File
@@ -1,5 +1,5 @@
from django.contrib.auth import get_user_model from django.contrib.auth import get_user_model
from django.shortcuts import get_object_or_404 from django.db import IntegrityError
from rest_framework import generics, permissions, status from rest_framework import generics, permissions, status
from rest_framework.response import Response from rest_framework.response import Response
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@@ -17,8 +17,13 @@ from apps.accounts.serializers import (
) )
from apps.accounts.services.otp import ( from apps.accounts.services.otp import (
OtpCooldownError, OtpCooldownError,
OtpDeviceRateLimitError,
OtpIpRateLimitError,
OtpRateLimitError, OtpRateLimitError,
build_device_signal,
create_and_send_otp, create_and_send_otp,
enforce_phone_auth_request_limits,
normalize_request_ip,
verify_otp, verify_otp,
) )
@@ -70,7 +75,10 @@ class OTPVerifyView(APIView):
serializer = OTPVerifySerializer(data=request.data) serializer = OTPVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"]) # Purpose isolation: verification endpoint accepts only verify-purpose OTPs.
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.VERIFY).first()
if not otp:
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
if not verify_otp(otp, data["code"]): if not verify_otp(otp, data["code"]):
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
@@ -91,6 +99,25 @@ class PhoneAuthRequestView(APIView):
data = serializer.validated_data data = serializer.validated_data
phone_number = data["phone_number"] phone_number = data["phone_number"]
email = data.get("email") or None email = data.get("email") or None
request_ip = normalize_request_ip(request.META.get("HTTP_X_FORWARDED_FOR") or request.META.get("REMOTE_ADDR"))
device_signal = build_device_signal(
data.get("device_id"),
request.META.get("HTTP_USER_AGENT"),
request.META.get("HTTP_ACCEPT_LANGUAGE"),
)
try:
enforce_phone_auth_request_limits(request_ip=request_ip, device_signal=device_signal)
except OtpIpRateLimitError as exc:
return Response(
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
except OtpDeviceRateLimitError as exc:
return Response(
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
status=status.HTTP_429_TOO_MANY_REQUESTS,
)
user = User.objects.filter(phone_number=phone_number).first() user = User.objects.filter(phone_number=phone_number).first()
if not user: if not user:
@@ -99,16 +126,33 @@ class PhoneAuthRequestView(APIView):
{"detail": _("Email already in use.")}, {"detail": _("Email already in use.")},
status=status.HTTP_400_BAD_REQUEST, status=status.HTTP_400_BAD_REQUEST,
) )
user = User.objects.create_user( try:
email=email, user = User.objects.create_user(
phone_number=phone_number, email=email,
first_name=data.get("first_name", ""), phone_number=phone_number,
last_name=data.get("last_name", ""), first_name=data.get("first_name", ""),
role="customer", last_name=data.get("last_name", ""),
) role="customer",
)
except IntegrityError:
user = User.objects.filter(phone_number=phone_number).first()
if not user:
# Another worker may have claimed this phone or email after our initial checks.
if email and User.objects.filter(email=email).exists():
return Response(
{"detail": _("Email already in use.")},
status=status.HTTP_400_BAD_REQUEST,
)
raise
try: try:
result = create_and_send_otp(phone_number, data["channel"], purpose=OtpPurpose.AUTH) result = create_and_send_otp(
phone_number,
data["channel"],
purpose=OtpPurpose.AUTH,
request_ip=request_ip,
device_signal=device_signal,
)
except OtpCooldownError as exc: except OtpCooldownError as exc:
return Response( return Response(
{"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds}, {"detail": str(exc), "retry_after_seconds": exc.retry_after_seconds},
@@ -133,7 +177,10 @@ class PhoneAuthVerifyView(APIView):
serializer = PhoneAuthVerifySerializer(data=request.data) serializer = PhoneAuthVerifySerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
otp = get_object_or_404(PhoneOTP, id=data["request_id"]) # Purpose isolation: login endpoint accepts only auth-purpose OTPs.
otp = PhoneOTP.objects.filter(id=data["request_id"], purpose=OtpPurpose.AUTH).first()
if not otp:
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
if not verify_otp(otp, data["code"]): if not verify_otp(otp, data["code"]):
return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST) return Response({"detail": _("Invalid or expired code")}, status=status.HTTP_400_BAD_REQUEST)
@@ -164,3 +211,17 @@ class SocialLoginPlaceholderView(APIView):
{"detail": _("Social login not configured yet. Add OAuth provider config.")}, {"detail": _("Social login not configured yet. Add OAuth provider config.")},
status=status.HTTP_501_NOT_IMPLEMENTED, status=status.HTTP_501_NOT_IMPLEMENTED,
) )
class PasswordTokenObtainDeprecatedView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
return Response(
{
"detail": _(
"Password login is deprecated. Use /api/auth/phone/request/ then /api/auth/phone/verify/."
)
},
status=status.HTTP_410_GONE,
)
+1 -1
View File
@@ -29,4 +29,4 @@ class Booking(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.customer.email} - {self.service.name}" return f"{self.customer.display_name} - {self.service.name}"
+63 -16
View File
@@ -1,5 +1,7 @@
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from apps.bookings.models import Booking from apps.bookings.models import Booking, BookingStatus
from apps.bookings.services import validate_booking_request from apps.bookings.services import validate_booking_request
from apps.salons.models import Service, StaffProfile from apps.salons.models import Service, StaffProfile
@@ -27,14 +29,33 @@ 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:
return None return None
first = obj.staff.user.first_name or "" return obj.staff.user.display_name
last = obj.staff.user.last_name or ""
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):
@@ -52,14 +73,40 @@ class BookingCreateSerializer(serializers.ModelSerializer):
def create(self, validated_data): def create(self, validated_data):
request = self.context["request"] request = self.context["request"]
service = validated_data["service"] service = validated_data["service"]
return Booking.objects.create( staff = validated_data.get("staff")
salon=service.salon, start_time = validated_data["start_time"]
customer=request.user, end_time = validated_data["end_time"]
service=service,
staff=validated_data.get("staff"), with transaction.atomic():
start_time=validated_data["start_time"], # Lock the staff row so concurrent booking requests for the same staff
end_time=validated_data["end_time"], # member are serialized. Without this, two requests that both pass the
notes=validated_data.get("notes", ""), # overlap check in validate() can race and both commit overlapping
price_amount=service.price_amount, # bookings. On SQLite (dev/tests) the FOR UPDATE clause is silently
currency=service.currency, # ignored but the transaction still serializes writes; PostgreSQL
) # (production) gets true row-level locking.
StaffProfile.objects.select_for_update().get(pk=staff.pk)
# Re-run the overlap check inside the lock so the check and the insert
# are atomic with respect to other writers.
overlap = Booking.objects.filter(
staff=staff,
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
start_time__lt=end_time,
end_time__gt=start_time,
).exists()
if overlap:
raise serializers.ValidationError(
{"start_time": _("Booking overlaps an existing appointment")}
)
return Booking.objects.create(
salon=service.salon,
customer=request.user,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
notes=validated_data.get("notes", ""),
price_amount=service.price_amount,
currency=service.currency,
)
@@ -12,9 +12,23 @@ from apps.salons.models import Salon, Service, StaffAvailability, StaffProfile
@pytest.fixture @pytest.fixture
def base_entities(): def base_entities():
owner = User.objects.create_user(email="owner@example.com", password="pass", role=UserRole.MANAGER) owner = User.objects.create_user(
customer = User.objects.create_user(email="customer@example.com", password="pass") email="owner@example.com",
staff_user = User.objects.create_user(email="staff@example.com", password="pass", role=UserRole.STAFF) password="pass",
role=UserRole.MANAGER,
phone_number="+966500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000003",
)
salon = Salon.objects.create( salon = Salon.objects.create(
owner=owner, owner=owner,
+12 -1
View File
@@ -1,7 +1,9 @@
from rest_framework import permissions, viewsets from rest_framework import permissions, viewsets
from apps.bookings.models import Booking from apps.bookings.models import Booking, BookingStatus
from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer from apps.bookings.serializers import BookingCreateSerializer, BookingSerializer
from apps.notifications.models import NotificationEvent
from apps.notifications.services import notify_booking_lifecycle, notify_on_status_change
class BookingViewSet(viewsets.ModelViewSet): class BookingViewSet(viewsets.ModelViewSet):
@@ -21,3 +23,12 @@ class BookingViewSet(viewsets.ModelViewSet):
if self.action == "create": if self.action == "create":
return BookingCreateSerializer return BookingCreateSerializer
return BookingSerializer return BookingSerializer
def perform_create(self, serializer):
booking = serializer.save()
notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CREATED)
def perform_update(self, serializer):
previous_status = self.get_object().status
booking = serializer.save()
notify_on_status_change(booking, previous_status)
+21
View File
@@ -0,0 +1,21 @@
from django.contrib import admin
from apps.notifications.models import Notification
@admin.register(Notification)
class NotificationAdmin(admin.ModelAdmin):
list_display = (
"id",
"event",
"channel",
"status",
"booking",
"recipient",
"phone_number",
"provider",
"sent_at",
"created_at",
)
list_filter = ("event", "channel", "status", "provider")
search_fields = ("phone_number", "message")
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.notifications"
@@ -0,0 +1,85 @@
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("bookings", "0001_initial"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="Notification",
fields=[
("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
("phone_number", models.CharField(blank=True, max_length=20)),
(
"channel",
models.CharField(
choices=[("sms", "SMS"), ("whatsapp", "WhatsApp")],
max_length=20,
),
),
(
"event",
models.CharField(
choices=[
("booking_created", "Booking Created"),
("booking_confirmed", "Booking Confirmed"),
("booking_cancelled", "Booking Cancelled"),
],
max_length=50,
),
),
(
"status",
models.CharField(
choices=[
("pending", "Pending"),
("sent", "Sent"),
("failed", "Failed"),
("skipped", "Skipped"),
],
default="pending",
max_length=20,
),
),
("provider", models.CharField(blank=True, max_length=50)),
("message", models.TextField(blank=True)),
("provider_payload", models.JSONField(blank=True, default=dict)),
("error_message", models.TextField(blank=True)),
("sent_at", models.DateTimeField(blank=True, null=True)),
("created_at", models.DateTimeField(auto_now_add=True)),
(
"booking",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.CASCADE,
related_name="notifications",
to="bookings.booking",
),
),
(
"recipient",
models.ForeignKey(
blank=True,
null=True,
on_delete=models.deletion.SET_NULL,
related_name="notifications",
to=settings.AUTH_USER_MODEL,
),
),
],
),
migrations.AddConstraint(
model_name="notification",
constraint=models.UniqueConstraint(
fields=("booking", "recipient", "event", "channel"),
name="uniq_notification_booking_event",
),
),
]
+64
View File
@@ -0,0 +1,64 @@
from django.conf import settings
from django.db import models
from apps.bookings.models import Booking
class NotificationChannel(models.TextChoices):
SMS = "sms", "SMS"
WHATSAPP = "whatsapp", "WhatsApp"
class NotificationEvent(models.TextChoices):
BOOKING_CREATED = "booking_created", "Booking Created"
BOOKING_CONFIRMED = "booking_confirmed", "Booking Confirmed"
BOOKING_CANCELLED = "booking_cancelled", "Booking Cancelled"
class NotificationStatus(models.TextChoices):
PENDING = "pending", "Pending"
SENT = "sent", "Sent"
FAILED = "failed", "Failed"
SKIPPED = "skipped", "Skipped"
class Notification(models.Model):
booking = models.ForeignKey(
Booking,
on_delete=models.CASCADE,
related_name="notifications",
null=True,
blank=True,
)
recipient = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.SET_NULL,
related_name="notifications",
null=True,
blank=True,
)
phone_number = models.CharField(max_length=20, blank=True)
channel = models.CharField(max_length=20, choices=NotificationChannel.choices)
event = models.CharField(max_length=50, choices=NotificationEvent.choices)
status = models.CharField(
max_length=20,
choices=NotificationStatus.choices,
default=NotificationStatus.PENDING,
)
provider = models.CharField(max_length=50, blank=True)
message = models.TextField(blank=True)
provider_payload = models.JSONField(default=dict, blank=True)
error_message = models.TextField(blank=True)
sent_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["booking", "recipient", "event", "channel"],
name="uniq_notification_booking_event",
)
]
def __str__(self) -> str:
return f"{self.event} to {self.phone_number or self.recipient_id}"
+171
View File
@@ -0,0 +1,171 @@
from __future__ import annotations
from dataclasses import dataclass
from django.conf import settings
from django.db import transaction
from django.utils import timezone, translation
from django.utils.translation import gettext_lazy as _
from apps.accounts.services.otp import PROVIDERS as OTP_PROVIDERS
from apps.bookings.models import Booking, BookingStatus
from apps.notifications.models import (
Notification,
NotificationChannel,
NotificationEvent,
NotificationStatus,
)
@dataclass
class NotificationSendResult:
status: str
payload: dict
error_message: str = ""
def _get_provider():
provider_key = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
provider_cls = OTP_PROVIDERS.get(provider_key)
if not provider_cls:
raise ValueError(_("Unknown notification provider: %(provider)s") % {"provider": provider_key})
return provider_cls(), provider_key
def _format_start_time(booking: Booking) -> str:
start_local = timezone.localtime(booking.start_time)
return start_local.strftime("%Y-%m-%d %H:%M")
def _build_message(booking: Booking, event: str) -> str:
start_text = _format_start_time(booking)
service_name = booking.service.name
salon_name = booking.salon.name
if event == NotificationEvent.BOOKING_CREATED:
return _(
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
) % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
if event == NotificationEvent.BOOKING_CONFIRMED:
return _(
"Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
) % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
if event == NotificationEvent.BOOKING_CANCELLED:
return _(
"Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
) % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
return _("Booking update for %(service)s at %(salon)s on %(start)s.") % {
"service": service_name,
"salon": salon_name,
"start": start_text,
}
def _send_message(phone_number: str, channel: str, message: str) -> NotificationSendResult:
provider, _ = _get_provider()
try:
if channel == NotificationChannel.SMS:
provider.send_sms(phone_number, message)
elif channel == NotificationChannel.WHATSAPP:
provider.send_whatsapp(phone_number, message)
else:
raise ValueError(_("Unsupported notification channel"))
except Exception as exc: # pragma: no cover - provider failures are environment specific
return NotificationSendResult(status=NotificationStatus.FAILED, payload={}, error_message=str(exc))
return NotificationSendResult(status=NotificationStatus.SENT, payload={}, error_message="")
def _notification_channel() -> str:
return getattr(settings, "NOTIFICATION_DEFAULT_CHANNEL", NotificationChannel.SMS)
def send_booking_notification(booking: Booking, recipient, event: str) -> Notification:
channel = _notification_channel()
phone_number = getattr(recipient, "phone_number", None) or ""
# Render the message in the recipient's preferred language.
with translation.override(getattr(recipient, "preferred_language", None)):
message = _build_message(booking, event)
with transaction.atomic():
notification, created = Notification.objects.get_or_create(
booking=booking,
recipient=recipient,
event=event,
channel=channel,
defaults={
"phone_number": phone_number,
"message": message,
},
)
if not created and notification.status == NotificationStatus.SENT:
return notification
if not phone_number:
# Record the skip for auditability when we cannot deliver.
notification.status = NotificationStatus.SKIPPED
notification.error_message = "Recipient has no phone number"
notification.save(update_fields=["status", "error_message"])
return notification
notification.phone_number = phone_number
notification.message = message
send_result = _send_message(phone_number, channel, message)
notification.status = send_result.status
notification.provider = getattr(settings, "NOTIFICATION_PROVIDER", settings.OTP_PROVIDER)
notification.provider_payload = send_result.payload
notification.error_message = send_result.error_message
notification.sent_at = timezone.now() if send_result.status == NotificationStatus.SENT else None
notification.save(
update_fields=[
"phone_number",
"message",
"status",
"provider",
"provider_payload",
"error_message",
"sent_at",
]
)
return notification
def notify_booking_lifecycle(booking: Booking, event: str) -> list[Notification]:
recipients = [booking.customer]
if booking.staff and booking.staff.user:
recipients.append(booking.staff.user)
notifications = []
for recipient in recipients:
notifications.append(send_booking_notification(booking, recipient, event))
return notifications
def notify_on_status_change(booking: Booking, previous_status: str) -> list[Notification]:
if booking.status == previous_status:
return []
# Only notify for lifecycle transitions we explicitly support today.
if booking.status == BookingStatus.CONFIRMED:
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CONFIRMED)
if booking.status == BookingStatus.CANCELLED:
return notify_booking_lifecycle(booking, NotificationEvent.BOOKING_CANCELLED)
return []
@@ -0,0 +1,121 @@
from datetime import timedelta
import pytest
from django.urls import reverse
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import User, UserRole
from apps.bookings.models import Booking, BookingStatus
from apps.notifications.models import Notification, NotificationEvent, NotificationStatus
from apps.salons.models import Salon, Service, StaffProfile
@pytest.fixture
def booking_payload():
owner = User.objects.create_user(
email="owner@example.com",
password="pass",
role=UserRole.MANAGER,
phone_number="+966500000001",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000002",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000003",
)
salon = Salon.objects.create(
owner=owner,
name="Main Salon",
description="",
address="123 King Rd",
city="Riyadh",
phone_number="0512345678",
)
service = Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=120,
currency="SAR",
)
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
start_time = timezone.now() + timedelta(days=1)
end_time = start_time + timedelta(minutes=60)
return {
"customer": customer,
"staff_user": staff_user,
"service": service,
"staff": staff,
"payload": {
"service": service.id,
"staff": staff.id,
"start_time": start_time.isoformat(),
"end_time": end_time.isoformat(),
"notes": "",
},
}
@pytest.mark.django_db
def test_booking_create_sends_notifications(booking_payload):
client = APIClient()
client.force_authenticate(user=booking_payload["customer"])
response = client.post(
reverse("booking-list"),
booking_payload["payload"],
content_type="application/json",
)
assert response.status_code == 201
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CREATED)
assert notifications.count() == 2
assert all(notification.status == NotificationStatus.SENT for notification in notifications)
@pytest.mark.django_db
def test_booking_status_change_sends_notifications_once(booking_payload):
client = APIClient()
client.force_authenticate(user=booking_payload["customer"])
response = client.post(
reverse("booking-list"),
booking_payload["payload"],
content_type="application/json",
)
assert response.status_code == 201
booking_id = Booking.objects.get(customer=booking_payload["customer"]).id
update_payload = {"status": BookingStatus.CONFIRMED}
client.force_authenticate(user=booking_payload["staff_user"])
response_update = client.patch(
reverse("booking-detail", args=[booking_id]),
update_payload,
content_type="application/json",
)
assert response_update.status_code == 200
notifications = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
assert notifications.count() == 2
response_repeat = client.patch(
reverse("booking-detail", args=[booking_id]),
update_payload,
content_type="application/json",
)
assert response_repeat.status_code == 200
notifications_repeat = Notification.objects.filter(event=NotificationEvent.BOOKING_CONFIRMED)
assert notifications_repeat.count() == 2
@@ -0,0 +1,73 @@
# Generated by Django 6.0.2 on 2026-02-28 09:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='payment',
name='authorized_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='captured_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='failed_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='idempotency_key',
field=models.UUIDField(blank=True, null=True, unique=True),
),
migrations.AddField(
model_name='payment',
name='paid_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='provider_payload',
field=models.JSONField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='refunded_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='status_updated_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='verified_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='payment',
name='voided_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AlterField(
model_name='payment',
name='external_id',
field=models.CharField(blank=True, max_length=200, null=True, unique=True),
),
migrations.AlterField(
model_name='payment',
name='status',
field=models.CharField(choices=[('initiated', 'Initiated'), ('created', 'Created'), ('authorized', 'Authorized'), ('captured', 'Captured'), ('paid', 'Paid'), ('failed', 'Failed'), ('refunded', 'Refunded'), ('voided', 'Voided'), ('verified', 'Verified')], default='initiated', max_length=20),
),
]
+16 -2
View File
@@ -15,21 +15,35 @@ class PaymentProvider(models.TextChoices):
class PaymentStatus(models.TextChoices): class PaymentStatus(models.TextChoices):
INITIATED = "initiated", "Initiated"
CREATED = "created", "Created" CREATED = "created", "Created"
AUTHORIZED = "authorized", "Authorized" AUTHORIZED = "authorized", "Authorized"
CAPTURED = "captured", "Captured" CAPTURED = "captured", "Captured"
PAID = "paid", "Paid"
FAILED = "failed", "Failed" FAILED = "failed", "Failed"
REFUNDED = "refunded", "Refunded" REFUNDED = "refunded", "Refunded"
VOIDED = "voided", "Voided"
VERIFIED = "verified", "Verified"
class Payment(models.Model): class Payment(models.Model):
booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments") booking = models.ForeignKey(Booking, on_delete=models.CASCADE, related_name="payments")
provider = models.CharField(max_length=50, choices=PaymentProvider.choices) provider = models.CharField(max_length=50, choices=PaymentProvider.choices)
status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.CREATED) status = models.CharField(max_length=20, choices=PaymentStatus.choices, default=PaymentStatus.INITIATED)
amount = models.DecimalField(max_digits=10, decimal_places=2) amount = models.DecimalField(max_digits=10, decimal_places=2)
currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR")) currency = models.CharField(max_length=10, default=getattr(settings, "DEFAULT_CURRENCY", "SAR"))
external_id = models.CharField(max_length=200, blank=True) external_id = models.CharField(max_length=200, null=True, blank=True, unique=True)
idempotency_key = models.UUIDField(null=True, blank=True, unique=True)
provider_payload = models.JSONField(null=True, blank=True)
metadata = models.JSONField(default=dict, blank=True) metadata = models.JSONField(default=dict, blank=True)
authorized_at = models.DateTimeField(null=True, blank=True)
captured_at = models.DateTimeField(null=True, blank=True)
paid_at = models.DateTimeField(null=True, blank=True)
failed_at = models.DateTimeField(null=True, blank=True)
refunded_at = models.DateTimeField(null=True, blank=True)
voided_at = models.DateTimeField(null=True, blank=True)
verified_at = models.DateTimeField(null=True, blank=True)
status_updated_at = models.DateTimeField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
+31 -12
View File
@@ -2,7 +2,7 @@ from rest_framework import serializers
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from apps.bookings.models import Booking from apps.bookings.models import Booking
from apps.payments.models import Payment, PaymentProvider, PaymentStatus from apps.payments.models import Payment, PaymentProvider
class PaymentSerializer(serializers.ModelSerializer): class PaymentSerializer(serializers.ModelSerializer):
@@ -18,7 +18,16 @@ class PaymentSerializer(serializers.ModelSerializer):
"amount", "amount",
"currency", "currency",
"external_id", "external_id",
"idempotency_key",
"metadata", "metadata",
"authorized_at",
"captured_at",
"paid_at",
"failed_at",
"refunded_at",
"voided_at",
"verified_at",
"status_updated_at",
"created_at", "created_at",
] ]
read_only_fields = fields read_only_fields = fields
@@ -27,23 +36,33 @@ class PaymentSerializer(serializers.ModelSerializer):
class PaymentCreateSerializer(serializers.ModelSerializer): class PaymentCreateSerializer(serializers.ModelSerializer):
booking_id = serializers.IntegerField(write_only=True) booking_id = serializers.IntegerField(write_only=True)
provider = serializers.ChoiceField(choices=PaymentProvider.choices) provider = serializers.ChoiceField(choices=PaymentProvider.choices)
idempotency_key = serializers.UUIDField(write_only=True)
source = serializers.JSONField(write_only=True, required=False)
callback_url = serializers.URLField(write_only=True, required=False)
class Meta: class Meta:
model = Payment model = Payment
fields = ["booking_id", "provider"] fields = ["booking_id", "provider", "idempotency_key", "source", "callback_url"]
def validate_booking_id(self, value): def validate_booking_id(self, value):
if not Booking.objects.filter(id=value).exists(): if not Booking.objects.filter(id=value).exists():
raise serializers.ValidationError(_("Booking not found")) raise serializers.ValidationError(_("Booking not found"))
return value return value
def create(self, validated_data): def validate(self, attrs):
booking = Booking.objects.get(id=validated_data["booking_id"]) provider = attrs.get("provider")
return Payment.objects.create( source = attrs.get("source")
booking=booking, if provider != PaymentProvider.MOYASAR:
provider=validated_data["provider"], raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
status=PaymentStatus.CREATED, if source is None:
amount=booking.price_amount, raise serializers.ValidationError({"source": _("Payment source is required")})
currency=booking.currency, source_type = source.get("type")
metadata={}, if not source_type:
) raise serializers.ValidationError({"source": _("Payment source type is required")})
if source_type == "creditcard":
raise serializers.ValidationError(
{"source": _("Card data must not be sent to the backend; use frontend tokenization")}
)
if source_type == "token" and not attrs.get("callback_url"):
raise serializers.ValidationError({"callback_url": _("Callback URL is required for token payments")})
return attrs
+105 -10
View File
@@ -2,21 +2,43 @@ import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Optional from typing import Optional
import requests
class PaymentGatewayError(RuntimeError):
def __init__(self, message: str, status_code: Optional[int] = None, payload: Optional[dict] = None) -> None:
super().__init__(message)
self.status_code = status_code
self.payload = payload or {}
@dataclass @dataclass
class PaymentInitResult: class PaymentInitResult:
external_id: str external_id: str
status: Optional[str]
redirect_url: Optional[str] redirect_url: Optional[str]
payload: dict
class BasePaymentGateway: class BasePaymentGateway:
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult: def create_payment(
self,
amount: int,
currency: str,
description: str,
source: dict,
callback_url: Optional[str],
given_id: str,
metadata: dict,
) -> PaymentInitResult:
raise NotImplementedError raise NotImplementedError
def capture_payment(self, external_id: str) -> None: def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
raise NotImplementedError raise NotImplementedError
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
raise NotImplementedError raise NotImplementedError
@@ -30,14 +52,87 @@ class MoyasarGateway(BasePaymentGateway):
if not self.secret_key or not self.publishable_key: if not self.secret_key or not self.publishable_key:
raise ValueError("Moyasar credentials are not configured") raise ValueError("Moyasar credentials are not configured")
def create_payment(self, amount: str, currency: str, description: str) -> PaymentInitResult: def create_payment(
self,
amount: int,
currency: str,
description: str,
source: dict,
callback_url: Optional[str],
given_id: str,
metadata: dict,
) -> PaymentInitResult:
self._assert_config() self._assert_config()
raise NotImplementedError("Moyasar gateway integration not implemented yet") url = f"{self.base_url}/v1/payments"
payload = {
"amount": amount,
"currency": currency,
"description": description,
"source": source,
"given_id": given_id,
"metadata": metadata,
}
if callback_url:
payload["callback_url"] = callback_url
def capture_payment(self, external_id: str) -> None: try:
self._assert_config() response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
raise NotImplementedError("Moyasar capture not implemented yet") except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar") from exc
def refund_payment(self, external_id: str, amount: Optional[str] = None) -> None: try:
data = response.json() if response.content else {}
except ValueError as exc:
raise PaymentGatewayError("Invalid response from Moyasar") from exc
if response.status_code not in (200, 201):
raise PaymentGatewayError(
"Moyasar returned an error",
status_code=response.status_code,
payload=data,
)
redirect_url = None
source_payload = data.get("source") or {}
if isinstance(source_payload, dict):
redirect_url = source_payload.get("transaction_url")
return PaymentInitResult(
external_id=data.get("id"),
status=data.get("status"),
redirect_url=redirect_url,
payload=data,
)
def capture_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Capture an authorized payment. Amount in minor units; omit for full capture."""
self._assert_config() self._assert_config()
raise NotImplementedError("Moyasar refund not implemented yet") url = f"{self.base_url}/v1/payments/{external_id}/capture"
payload = {} if amount is None else {"amount": amount}
try:
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar for capture") from exc
if response.status_code not in (200, 201):
data = response.json() if response.content else {}
raise PaymentGatewayError(
"Moyasar capture failed",
status_code=response.status_code,
payload=data,
)
def refund_payment(self, external_id: str, amount: Optional[int] = None) -> None:
"""Refund a paid/captured payment. Amount in minor units; omit for full refund."""
self._assert_config()
url = f"{self.base_url}/v1/payments/{external_id}/refund"
payload = {} if amount is None else {"amount": amount}
try:
response = requests.post(url, json=payload, auth=(self.secret_key, ""), timeout=10)
except requests.RequestException as exc:
raise PaymentGatewayError("Failed to reach Moyasar for refund") from exc
if response.status_code not in (200, 201):
data = response.json() if response.content else {}
raise PaymentGatewayError(
"Moyasar refund failed",
status_code=response.status_code,
payload=data,
)
+175
View File
@@ -0,0 +1,175 @@
from __future__ import annotations
from decimal import Decimal, ROUND_HALF_UP
from typing import Optional, Tuple
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from apps.bookings.models import Booking
from apps.payments.models import Payment, PaymentProvider, PaymentStatus
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
CURRENCY_DECIMALS = {
"SAR": 2,
"USD": 2,
"EUR": 2,
"GBP": 2,
"KWD": 3,
"BHD": 3,
"JOD": 3,
}
MoyasarAllowedSourceTypes = {"token", "stcpay", "applepay", "samsungpay"}
def _to_minor_units(amount: Decimal, currency: str) -> int:
decimals = CURRENCY_DECIMALS.get(currency.upper(), 2)
factor = Decimal("1") if decimals == 0 else Decimal(10) ** decimals
minor = (amount * factor).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(minor)
def _map_provider_status(status: Optional[str]) -> Optional[str]:
if not status:
return None
status = status.lower()
mapping = {
"initiated": PaymentStatus.INITIATED,
"authorized": PaymentStatus.AUTHORIZED,
"captured": PaymentStatus.CAPTURED,
"paid": PaymentStatus.PAID,
"failed": PaymentStatus.FAILED,
"refunded": PaymentStatus.REFUNDED,
"voided": PaymentStatus.VOIDED,
"verified": PaymentStatus.VERIFIED,
}
return mapping.get(status)
def _apply_status(payment: Payment, status: str) -> None:
now = timezone.now()
payment.status = status
payment.status_updated_at = now
if status == PaymentStatus.AUTHORIZED:
payment.authorized_at = now
elif status == PaymentStatus.CAPTURED:
payment.captured_at = now
elif status == PaymentStatus.PAID:
payment.paid_at = now
elif status == PaymentStatus.FAILED:
payment.failed_at = now
elif status == PaymentStatus.REFUNDED:
payment.refunded_at = now
elif status == PaymentStatus.VOIDED:
payment.voided_at = now
elif status == PaymentStatus.VERIFIED:
payment.verified_at = now
def create_payment_for_booking(
booking: Booking,
provider: str,
idempotency_key,
source: dict,
callback_url: Optional[str] = None,
) -> Tuple[Payment, bool, Optional[str]]:
if provider != PaymentProvider.MOYASAR:
raise serializers.ValidationError({"provider": _("Provider integration not implemented")})
existing = Payment.objects.filter(idempotency_key=idempotency_key).first()
if existing:
if existing.booking_id != booking.id or existing.provider != provider:
raise serializers.ValidationError({"idempotency_key": _("Idempotency key already used")})
return existing, False, existing.metadata.get("redirect_url")
source_type = (source or {}).get("type")
if source_type not in MoyasarAllowedSourceTypes:
raise serializers.ValidationError({"source": _("Unsupported payment source type")})
payment = Payment.objects.create(
booking=booking,
provider=provider,
status=PaymentStatus.INITIATED,
amount=booking.price_amount,
currency=booking.currency,
idempotency_key=idempotency_key,
metadata={"booking_id": booking.id},
)
_apply_status(payment, PaymentStatus.INITIATED)
payment.save(update_fields=["status", "status_updated_at"])
amount_minor = _to_minor_units(booking.price_amount, booking.currency)
description = f"Booking {booking.id}"
gateway = MoyasarGateway()
try:
result = gateway.create_payment(
amount=amount_minor,
currency=booking.currency,
description=description,
source=source,
callback_url=callback_url,
given_id=str(idempotency_key),
metadata={"booking_id": booking.id},
)
except PaymentGatewayError as exc:
_apply_status(payment, PaymentStatus.FAILED)
payment.metadata["gateway_error"] = {
"message": str(exc),
"status_code": exc.status_code,
"payload": exc.payload,
}
payment.save(update_fields=[
"status",
"status_updated_at",
"failed_at",
"metadata",
])
raise serializers.ValidationError({"detail": _("Payment provider error")}) from exc
if not result.external_id:
_apply_status(payment, PaymentStatus.FAILED)
payment.metadata["gateway_error"] = {"message": "Missing payment reference from provider"}
payment.save(update_fields=[
"status",
"status_updated_at",
"failed_at",
"metadata",
])
raise serializers.ValidationError({"detail": _("Payment provider error")})
payment.external_id = result.external_id
payment.provider_payload = result.payload
payment.metadata["redirect_url"] = result.redirect_url
mapped_status = _map_provider_status(result.status)
if mapped_status:
_apply_status(payment, mapped_status)
payment.save()
return payment, True, result.redirect_url
def apply_webhook_event(payment: Payment, event_type: str, payload: dict) -> bool:
mapping = {
"payment_authorized": PaymentStatus.AUTHORIZED,
"payment_captured": PaymentStatus.CAPTURED,
"payment_paid": PaymentStatus.PAID,
"payment_failed": PaymentStatus.FAILED,
"payment_faild": PaymentStatus.FAILED,
"payment_abandoned": PaymentStatus.FAILED,
"payment_refunded": PaymentStatus.REFUNDED,
"payment_voided": PaymentStatus.VOIDED,
"payment_verified": PaymentStatus.VERIFIED,
}
target_status = mapping.get(event_type)
if not target_status:
return False
if payment.status == target_status:
return False
_apply_status(payment, target_status)
payment.metadata["last_webhook"] = payload
payment.save()
return True
@@ -0,0 +1,54 @@
"""Tests for Moyasar capture and refund gateway methods."""
from unittest.mock import Mock, patch
import pytest
from apps.payments.services.gateway import MoyasarGateway, PaymentGatewayError
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_capture_calls_api(mock_post):
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "captured"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
gateway.capture_payment("pay_1")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert "pay_1/capture" in call_args[0][0]
assert call_args[1]["auth"] == ("sk_test", "")
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_refund_calls_api(mock_post):
mock_post.return_value = Mock(status_code=200, content=b"{}", json=lambda: {"id": "pay_1", "status": "refunded"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
gateway.refund_payment("pay_1")
mock_post.assert_called_once()
call_args = mock_post.call_args
assert "pay_1/refund" in call_args[0][0]
@patch("apps.payments.services.gateway.requests.post")
def test_moyasar_capture_raises_on_error(mock_post):
mock_post.return_value = Mock(status_code=400, content=b'{"message":"Invalid"}', json=lambda: {"message": "Invalid"})
with patch.dict("os.environ", {
"MOYASAR_SECRET_KEY": "sk_test",
"MOYASAR_PUBLISHABLE_KEY": "pk_test",
}):
gateway = MoyasarGateway()
with pytest.raises(PaymentGatewayError) as exc_info:
gateway.capture_payment("pay_1")
assert exc_info.value.status_code == 400
@@ -0,0 +1,182 @@
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,
phone_number="+966500000011",
)
customer = User.objects.create_user(
email="customer@example.com",
password="pass",
phone_number="+966500000012",
)
staff_user = User.objects.create_user(
email="staff@example.com",
password="pass",
role=UserRole.STAFF,
phone_number="+966500000013",
)
salon = Salon.objects.create(
owner=owner,
name="Main Salon",
description="",
address="123 King Rd",
city="Riyadh",
phone_number="0512345678",
)
service = Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=120,
currency="SAR",
)
staff = StaffProfile.objects.create(user=staff_user, salon=salon)
start_time = timezone.now() + timedelta(days=1)
end_time = start_time + timedelta(minutes=60)
booking = Booking.objects.create(
salon=salon,
customer=customer,
service=service,
staff=staff,
start_time=start_time,
end_time=end_time,
status=BookingStatus.PENDING,
price_amount=service.price_amount,
currency=service.currency,
notes="",
)
return customer, booking
def _mock_gateway_response(payment_id="pay_test", status="initiated"):
response = Mock()
response.status_code = 201
response.json.return_value = {
"id": payment_id,
"status": status,
"source": {"transaction_url": "https://moyasar.example/tx"},
}
response.content = b"{}"
return response
@pytest.mark.django_db
@patch("apps.payments.services.gateway.requests.post")
def test_create_payment_idempotency_returns_existing(mock_post, booking_entities, monkeypatch):
customer, booking = booking_entities
client = APIClient()
client.force_authenticate(user=customer)
monkeypatch.setenv("MOYASAR_SECRET_KEY", "sk_test")
monkeypatch.setenv("MOYASAR_PUBLISHABLE_KEY", "pk_test")
mock_post.return_value = _mock_gateway_response()
request_id = str(uuid.uuid4())
payload = {
"booking_id": booking.id,
"provider": PaymentProvider.MOYASAR,
"idempotency_key": request_id,
"source": {"type": "stcpay", "mobile": "0500000000"},
}
response = client.post(reverse("payment-list"), payload, content_type="application/json")
assert response.status_code == 201
response_repeat = client.post(reverse("payment-list"), payload, content_type="application/json")
assert response_repeat.status_code == 200
assert Payment.objects.count() == 1
@pytest.mark.django_db
def test_rejects_creditcard_source(booking_entities):
customer, booking = booking_entities
client = APIClient()
client.force_authenticate(user=customer)
payload = {
"booking_id": booking.id,
"provider": PaymentProvider.MOYASAR,
"idempotency_key": str(uuid.uuid4()),
"source": {"type": "creditcard", "number": "4111111111111111"},
}
response = client.post(reverse("payment-list"), payload, content_type="application/json")
assert response.status_code == 400
assert "source" in response.json()
@pytest.mark.django_db
def test_webhook_paid_updates_status(booking_entities, monkeypatch):
_, booking = booking_entities
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
payment = Payment.objects.create(
booking=booking,
provider=PaymentProvider.MOYASAR,
status=PaymentStatus.INITIATED,
amount=booking.price_amount,
currency=booking.currency,
external_id="pay_webhook",
metadata={},
)
payload = {
"type": "payment_paid",
"secret_token": "secret",
"data": {"id": "pay_webhook"},
}
client = APIClient()
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
assert response.status_code == 200
payment.refresh_from_db()
assert payment.status == PaymentStatus.PAID
assert payment.paid_at is not None
@pytest.mark.django_db
def test_webhook_invalid_secret_is_rejected(booking_entities, monkeypatch):
_, booking = booking_entities
monkeypatch.setenv("MOYASAR_WEBHOOK_SECRET", "secret")
Payment.objects.create(
booking=booking,
provider=PaymentProvider.MOYASAR,
status=PaymentStatus.INITIATED,
amount=booking.price_amount,
currency=booking.currency,
external_id="pay_webhook",
metadata={},
)
payload = {
"type": "payment_paid",
"secret_token": "wrong",
"data": {"id": "pay_webhook"},
}
client = APIClient()
response = client.post(reverse("payment-webhook"), payload, content_type="application/json")
assert response.status_code == 401
+2 -1
View File
@@ -1,11 +1,12 @@
from django.urls import include, path from django.urls import include, path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from apps.payments.views import PaymentViewSet from apps.payments.views import PaymentViewSet, payment_webhook
router = DefaultRouter() router = DefaultRouter()
router.register(r"", PaymentViewSet, basename="payment") router.register(r"", PaymentViewSet, basename="payment")
urlpatterns = [ urlpatterns = [
path("webhook/", payment_webhook, name="payment-webhook"),
path("", include(router.urls)), path("", include(router.urls)),
] ]
+48 -13
View File
@@ -1,10 +1,17 @@
from rest_framework import permissions, status, viewsets import logging
from rest_framework.response import Response import os
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import permissions, status, viewsets
from rest_framework.decorators import api_view, permission_classes
from rest_framework.response import Response
from apps.bookings.models import Booking from apps.bookings.models import Booking
from apps.payments.models import Payment from apps.payments.models import Payment, PaymentProvider
from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer from apps.payments.serializers import PaymentCreateSerializer, PaymentSerializer
from apps.payments.services.payments import apply_webhook_event, create_payment_for_booking
logger = logging.getLogger(__name__)
def user_can_access_booking(user, booking: Booking) -> bool: def user_can_access_booking(user, booking: Booking) -> bool:
@@ -41,14 +48,42 @@ class PaymentViewSet(viewsets.ModelViewSet):
booking = Booking.objects.get(id=serializer.validated_data["booking_id"]) booking = Booking.objects.get(id=serializer.validated_data["booking_id"])
if not user_can_access_booking(request.user, booking): if not user_can_access_booking(request.user, booking):
return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN) return Response({"detail": _("Not allowed")}, status=status.HTTP_403_FORBIDDEN)
payment = serializer.save() payment, created, redirect_url = create_payment_for_booking(
return Response( booking=booking,
{ provider=serializer.validated_data["provider"],
"detail": _("Payment record created. Provider integration pending."), idempotency_key=serializer.validated_data["idempotency_key"],
"payment_id": payment.id, source=serializer.validated_data["source"],
"amount": str(payment.amount), callback_url=serializer.validated_data.get("callback_url"),
"currency": payment.currency,
"status": payment.status,
},
status=status.HTTP_201_CREATED,
) )
response_data = PaymentSerializer(payment).data
response_data["redirect_url"] = redirect_url
response_data["created"] = created
return Response(response_data, status=status.HTTP_201_CREATED if created else status.HTTP_200_OK)
@api_view(["POST"])
@permission_classes([permissions.AllowAny])
def payment_webhook(request):
secret = os.getenv("MOYASAR_WEBHOOK_SECRET")
payload = request.data or {}
if not secret:
return Response({"detail": _("Webhook secret not configured")}, status=status.HTTP_500_INTERNAL_SERVER_ERROR)
if payload.get("secret_token") != secret:
return Response({"detail": _("Invalid webhook signature")}, status=status.HTTP_401_UNAUTHORIZED)
event_type = payload.get("type")
data = payload.get("data") or {}
external_id = data.get("id")
if not external_id:
return Response({"detail": _("Missing payment reference")}, status=status.HTTP_400_BAD_REQUEST)
payment = Payment.objects.filter(external_id=external_id, provider=PaymentProvider.MOYASAR).first()
if not payment:
logger.warning("Moyasar webhook for unknown payment %s", external_id)
return Response({"detail": _("Payment not found")}, status=status.HTTP_200_OK)
applied = apply_webhook_event(payment, event_type, payload)
if not applied:
return Response({"detail": _("Event ignored")}, status=status.HTTP_200_OK)
return Response({"detail": _("Webhook processed")}, status=status.HTTP_200_OK)
@@ -126,10 +126,10 @@ class Command(BaseCommand):
booking=booking, booking=booking,
provider=PaymentProvider.MOYASAR, provider=PaymentProvider.MOYASAR,
defaults={ defaults={
"status": PaymentStatus.CREATED, "status": PaymentStatus.INITIATED,
"amount": booking.price_amount, "amount": booking.price_amount,
"currency": booking.currency, "currency": booking.currency,
"external_id": "", "external_id": None,
"metadata": {"note": "Demo payment record"}, "metadata": {"note": "Demo payment record"},
}, },
) )
+6 -3
View File
@@ -56,7 +56,7 @@ class StaffProfile(models.Model):
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=True)
def __str__(self): def __str__(self):
return f"{self.user.email} - {self.salon.name}" return f"{self.user.display_name} - {self.salon.name}"
class StaffAvailability(models.Model): class StaffAvailability(models.Model):
@@ -84,7 +84,10 @@ class StaffAvailability(models.Model):
ordering = ["staff_id", "day_of_week", "start_time"] ordering = ["staff_id", "day_of_week", "start_time"]
def __str__(self): def __str__(self):
return f"{self.staff.user.email} {self.get_day_of_week_display()} {self.start_time}-{self.end_time}" return (
f"{self.staff.user.display_name} {self.get_day_of_week_display()} "
f"{self.start_time}-{self.end_time}"
)
class Review(models.Model): class Review(models.Model):
@@ -95,4 +98,4 @@ class Review(models.Model):
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"Review {self.rating} for {self.salon.name}" return f"Review {self.rating} by {self.customer.display_name} for {self.salon.name}"
+2 -6
View File
@@ -26,9 +26,7 @@ class StaffSerializer(serializers.ModelSerializer):
fields = ["id", "name", "title", "bio", "is_active"] fields = ["id", "name", "title", "bio", "is_active"]
def get_name(self, obj): def get_name(self, obj):
first = obj.user.first_name or "" return obj.user.display_name
last = obj.user.last_name or ""
return (first + " " + last).strip() or obj.user.email
class ReviewSerializer(serializers.ModelSerializer): class ReviewSerializer(serializers.ModelSerializer):
@@ -39,9 +37,7 @@ class ReviewSerializer(serializers.ModelSerializer):
fields = ["id", "rating", "comment", "created_at", "customer_name"] fields = ["id", "rating", "comment", "created_at", "customer_name"]
def get_customer_name(self, obj): def get_customer_name(self, obj):
first = obj.customer.first_name or "" return obj.customer.display_name
last = obj.customer.last_name or ""
return (first + " " + last).strip() or obj.customer.email
class SalonSerializer(serializers.ModelSerializer): class SalonSerializer(serializers.ModelSerializer):
@@ -0,0 +1,103 @@
from datetime import timedelta, time
import pytest
from django.utils import timezone
from apps.accounts.models import User, UserRole
from apps.bookings.models import Booking
from apps.bookings.serializers import BookingSerializer
from apps.salons.models import (
Salon,
Service,
StaffAvailability,
StaffProfile,
Review,
)
from apps.salons.serializers import ReviewSerializer, StaffSerializer
@pytest.mark.django_db
class TestDisplayNameFallbacks:
def _create_customer(self, phone_number):
return User.objects.create_user(phone_number=phone_number)
def _create_staff_user(self, phone_number):
return User.objects.create_user(phone_number=phone_number, role=UserRole.STAFF)
def _create_salon(self, owner):
return Salon.objects.create(
owner=owner,
name="Test Salon",
address="123 Main",
city="Riyadh",
)
def _create_service(self, salon):
return Service.objects.create(
salon=salon,
name="Haircut",
description="",
duration_minutes=60,
price_amount=200,
currency="SAR",
)
def test_staff_serializer_falls_back_to_phone(self):
owner = User.objects.create_user(phone_number="+966500000001", email="owner@example.com")
salon = self._create_salon(owner)
staff_user = self._create_staff_user(phone_number="+966500000002")
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
serializer = StaffSerializer(staff_profile)
assert serializer.data["name"] == "+966500000002"
def test_review_serializer_customer_name_uses_phone(self):
owner = User.objects.create_user(phone_number="+966500000003", email="owner2@example.com")
salon = self._create_salon(owner)
customer = self._create_customer(phone_number="+966500000004")
review = Review.objects.create(salon=salon, customer=customer, rating=5, comment="Great")
serializer = ReviewSerializer(review)
assert serializer.data["customer_name"] == "+966500000004"
assert "+966500000004" in str(review)
def test_booking_serializer_staff_name_and_str_use_phone(self):
owner = User.objects.create_user(phone_number="+966500000005", email="owner3@example.com")
salon = self._create_salon(owner)
staff_user = self._create_staff_user(phone_number="+966500000006")
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
service = self._create_service(salon)
customer = self._create_customer(phone_number="+966500000007")
start = timezone.now()
booking = Booking.objects.create(
salon=salon,
customer=customer,
service=service,
staff=staff_profile,
start_time=start,
end_time=start + timedelta(hours=1),
price_amount=service.price_amount,
currency=service.currency,
)
serializer = BookingSerializer(booking)
assert serializer.data["staff_name"] == "+966500000006"
assert "+966500000007" in str(booking)
def test_staff_model_str_uses_phone(self):
owner = User.objects.create_user(phone_number="+966500000008", email="owner4@example.com")
salon = self._create_salon(owner)
staff_user = self._create_staff_user(phone_number="+966500000009")
staff_profile = StaffProfile.objects.create(user=staff_user, salon=salon)
availability = StaffAvailability.objects.create(
staff=staff_profile,
day_of_week=0,
start_time=time(9, 0),
end_time=time(10, 0),
)
assert "+966500000009" in str(staff_profile)
assert "+966500000009" in str(availability)
Binary file not shown.
+251
View File
@@ -0,0 +1,251 @@
# Arabic (Saudi Arabia) translations for Salon booking platform.
# Copyright (C) 2026
#
msgid ""
msgstr ""
"Project-Id-Version: 1.0\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-03-02 00:45+0300\n"
"PO-Revision-Date: 2026-03-02 00:45+0300\n"
"Last-Translator: Claude\n"
"Language-Team: Arabic (Saudi Arabia)\n"
"Language: ar_SA\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
#: apps/accounts/services/otp.py:26
msgid "Too many OTP requests. Try again later."
msgstr "طلبات رمز التحقق كثيرة جداً. حاول مرة أخرى لاحقاً."
#: apps/accounts/services/otp.py:32
msgid "Please wait before requesting another code."
msgstr "يرجى الانتظار قبل طلب رمز آخر."
#: apps/accounts/services/otp.py:71
msgid "Twilio credentials are not configured"
msgstr "لم يتم تكوين بيانات اعتماد Twilio"
#: apps/accounts/services/otp.py:85
msgid "Twilio WhatsApp sender is not configured"
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Twilio"
#: apps/accounts/services/otp.py:100
msgid "Unifonic credentials are not configured"
msgstr "لم يتم تكوين بيانات اعتماد Unifonic"
#: apps/accounts/services/otp.py:104
msgid "Unifonic SMS adapter not implemented yet"
msgstr "محول SMS لـ Unifonic لم يتم تنفيذه بعد"
#: apps/accounts/services/otp.py:109
msgid "Unifonic WhatsApp sender is not configured"
msgstr "لم يتم تكوين مُرسِل WhatsApp لـ Unifonic"
#: apps/accounts/services/otp.py:110
msgid "Unifonic WhatsApp adapter not implemented yet"
msgstr "محول WhatsApp لـ Unifonic لم يتم تنفيذه بعد"
#: apps/accounts/services/otp.py:126
msgid "Authentica API key is not configured"
msgstr "لم يتم تكوين مفتاح API لـ Authentica"
#: apps/accounts/services/otp.py:149 apps/accounts/services/otp.py:162
msgid "Authentica request failed"
msgstr "فشل طلب Authentica"
#: apps/accounts/services/otp.py:159
#, python-format
msgid "Authentica request failed: %(status)s %(body)s"
msgstr "فشل طلب Authentica: %(status)s %(body)s"
#: apps/accounts/services/otp.py:168 apps/accounts/services/otp.py:276
msgid "Unsupported OTP channel"
msgstr "قناة رمز التحقق غير مدعومة"
#: apps/accounts/services/otp.py:179
#, python-format
msgid "Authentica verify failed: %(response)s"
msgstr "فشل التحقق بـ Authentica: %(response)s"
#: apps/accounts/services/otp.py:184
msgid "Authentica sender name is not configured"
msgstr "لم يتم تكوين اسم مُرسِل Authentica"
#: apps/accounts/services/otp.py:195
msgid "Authentica WhatsApp messaging is not supported"
msgstr "مراسلة WhatsApp غير مدعومة لـ Authentica"
#: apps/accounts/services/otp.py:209
#, python-format
msgid "Unknown OTP provider: %(provider)s"
msgstr "مزود رمز التحقق غير معروف: %(provider)s"
#: apps/accounts/services/otp.py:256
#, python-format
msgid "Your verification code is %(code)s. It expires in %(minutes)s minutes."
msgstr "رمز التحقق الخاص بك هو %(code)s. ينتهي في %(minutes)s دقيقة."
#: apps/accounts/services/phone.py:8
msgid "Phone number is required"
msgstr "رقم الهاتف مطلوب"
#: apps/accounts/services/phone.py:17
msgid "Invalid phone number format"
msgstr "تنسيق رقم الهاتف غير صالح"
#: apps/accounts/services/phone.py:28
msgid "Phone number must be in E.164 format or a valid Saudi mobile"
msgstr "يجب أن يكون رقم الهاتف بصيغة E.164 أو رقم جوال سعودي صالح"
#: apps/accounts/views.py:75 apps/accounts/views.py:138
msgid "Invalid or expired code"
msgstr "الرمز غير صالح أو منتهي الصلاحية"
#: apps/accounts/views.py:82
msgid "Phone verified"
msgstr "تم التحقق من رقم الهاتف"
#: apps/accounts/views.py:99
msgid "Email already in use."
msgstr "البريد الإلكتروني مستخدم بالفعل."
#: apps/accounts/views.py:142
msgid "User not found"
msgstr "المستخدم غير موجود"
#: apps/accounts/views.py:164
msgid "Social login not configured yet. Add OAuth provider config."
msgstr "لم يتم تكوين تسجيل الدخول الاجتماعي بعد. أضف إعداد مزود OAuth."
#: apps/bookings/serializers.py:54
msgid "Only staff or managers can confirm bookings."
msgstr "يمكن للموظفين والمدراء فقط تأكيد الحجوزات."
#: apps/bookings/serializers.py:56
msgid "Only staff or managers can complete bookings."
msgstr "يمكن للموظفين والمدراء فقط إكمال الحجوزات."
#: apps/bookings/serializers.py:58
msgid "You are not allowed to cancel this booking."
msgstr "لا يُسمح لك بإلغاء هذا الحجز."
#: apps/bookings/serializers.py:101 apps/bookings/services.py:49
msgid "Booking overlaps an existing appointment"
msgstr "يتداخل الحجز مع موعد قائم"
#: apps/bookings/services.py:13
msgid "Staff is required for booking"
msgstr "يجب تحديد موظف للحجز"
#: apps/bookings/services.py:16
msgid "Selected staff does not belong to this salon"
msgstr "الموظف المختار لا ينتمي إلى هذا الصالون"
#: apps/bookings/services.py:19
msgid "End time must be after start time"
msgstr "يجب أن يكون وقت الانتهاء بعد وقت البدء"
#: apps/bookings/services.py:23
msgid "End time must match service duration"
msgstr "يجب أن يتطابق وقت الانتهاء مع مدة الخدمة"
#: apps/bookings/services.py:40
msgid "Booking is outside staff availability"
msgstr "الحجز خارج أوقات توفر الموظف"
#: apps/notifications/services.py:31
#, python-format
msgid "Unknown notification provider: %(provider)s"
msgstr "مزود الإشعارات غير معروف: %(provider)s"
#: apps/notifications/services.py:47
#, python-format
msgid ""
"Your booking request is received for %(service)s at %(salon)s on %(start)s."
msgstr "تم استلام طلب حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:55
#, python-format
msgid "Your booking is confirmed for %(service)s at %(salon)s on %(start)s."
msgstr "تم تأكيد حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:63
#, python-format
msgid "Your booking was cancelled for %(service)s at %(salon)s on %(start)s."
msgstr "تم إلغاء حجزك لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:70
#, python-format
msgid "Booking update for %(service)s at %(salon)s on %(start)s."
msgstr "تحديث الحجز لخدمة %(service)s في %(salon)s بتاريخ %(start)s."
#: apps/notifications/services.py:85
msgid "Unsupported notification channel"
msgstr "قناة الإشعارات غير مدعومة"
#: apps/payments/serializers.py:49
msgid "Booking not found"
msgstr "الحجز غير موجود"
#: apps/payments/serializers.py:56 apps/payments/services/payments.py:79
msgid "Provider integration not implemented"
msgstr "تكامل المزود غير مُنفَّذ"
#: apps/payments/serializers.py:58
msgid "Payment source is required"
msgstr "مصدر الدفع مطلوب"
#: apps/payments/serializers.py:61
msgid "Payment source type is required"
msgstr "نوع مصدر الدفع مطلوب"
#: apps/payments/serializers.py:64
msgid "Card data must not be sent to the backend; use frontend tokenization"
msgstr "لا يجب إرسال بيانات البطاقة إلى الخادم؛ استخدم التحويل إلى رمز في الواجهة الأمامية"
#: apps/payments/serializers.py:67
msgid "Callback URL is required for token payments"
msgstr "رابط الاستجابة مطلوب لمدفوعات الرمز"
#: apps/payments/services/payments.py:84
msgid "Idempotency key already used"
msgstr "مفتاح الإيدمبوتنسي مستخدم بالفعل"
#: apps/payments/services/payments.py:89
msgid "Unsupported payment source type"
msgstr "نوع مصدر الدفع غير مدعوم"
#: apps/payments/services/payments.py:130
#: apps/payments/services/payments.py:141
msgid "Payment provider error"
msgstr "خطأ في مزود الدفع"
#: apps/payments/views.py:50
msgid "Not allowed"
msgstr "غير مسموح"
#: apps/payments/views.py:70
msgid "Webhook secret not configured"
msgstr "لم يتم تكوين رمز الـ webhook"
#: apps/payments/views.py:73
msgid "Invalid webhook signature"
msgstr "توقيع الـ webhook غير صالح"
#: apps/payments/views.py:79
msgid "Missing payment reference"
msgstr "مرجع الدفع مفقود"
#: apps/payments/views.py:84
msgid "Payment not found"
msgstr "لم يتم العثور على الدفعة"
#: apps/payments/views.py:88
msgid "Event ignored"
msgstr "تم تجاهل الحدث"
#: apps/payments/views.py:89
msgid "Webhook processed"
msgstr "تمت معالجة الـ webhook"
+3 -1
View File
@@ -1,4 +1,6 @@
[pytest] [pytest]
DJANGO_SETTINGS_MODULE = salon_api.settings DJANGO_SETTINGS_MODULE = salon_api.settings
python_files = tests.py test_*.py *_tests.py python_files = tests.py test_*.py *_tests.py
addopts = -q addopts = -q -m "not external"
markers =
external: hits real third-party services (requires explicit env to run)
+2
View File
@@ -4,3 +4,5 @@ djangorestframework-simplejwt>=5.3
django-cors-headers>=4.3 django-cors-headers>=4.3
psycopg[binary]>=3.1 psycopg[binary]>=3.1
python-dotenv>=1.0 python-dotenv>=1.0
requests>=2.31
twilio>=9.0
+16 -4
View File
@@ -1,4 +1,5 @@
import os import os
import sys
from pathlib import Path from pathlib import Path
from datetime import timedelta from datetime import timedelta
from urllib.parse import urlparse from urllib.parse import urlparse
@@ -26,6 +27,7 @@ INSTALLED_APPS = [
"apps.salons", "apps.salons",
"apps.bookings", "apps.bookings",
"apps.payments", "apps.payments",
"apps.notifications",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
@@ -77,11 +79,14 @@ def parse_database_url(database_url: str):
} }
DATABASE_URL = os.getenv("DATABASE_URL") running_tests = "PYTEST_CURRENT_TEST" in os.environ or any("pytest" in arg for arg in sys.argv)
if DATABASE_URL: test_database_url = os.getenv("TEST_DATABASE_URL")
parsed_db = parse_database_url(DATABASE_URL) database_url = os.getenv("DATABASE_URL")
if running_tests:
parsed_db = parse_database_url(test_database_url) if test_database_url else None
else: else:
parsed_db = None parsed_db = parse_database_url(database_url) if database_url else None
DATABASES = { DATABASES = {
"default": parsed_db "default": parsed_db
@@ -135,8 +140,15 @@ 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"))
PHONE_AUTH_RISK_WINDOW_MINUTES = int(os.getenv("PHONE_AUTH_RISK_WINDOW_MINUTES", "15"))
PHONE_AUTH_IP_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_IP_MAX_PER_WINDOW", "20"))
PHONE_AUTH_DEVICE_MAX_PER_WINDOW = int(os.getenv("PHONE_AUTH_DEVICE_MAX_PER_WINDOW", "20"))
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")
+58
View File
@@ -0,0 +1,58 @@
# ExecPlan Guide
ExecPlans are living implementation docs for complex features/refactors.
## Active ExecPlan
- `docs/execplans/auth-phone-first-hardening.md`
## Other ExecPlans
- `docs/execplans/booking-notifications.md` (completed)
- `docs/execplans/booking-integrity.md` (completed)
- `docs/execplans/payments-moyasar.md` (completed)
- `docs/execplans/arabic-localization.md` (foundations completed)
## When To Use
Create/update an ExecPlan when work is cross-app, risky, or multi-step.
## Required Sections (all ExecPlans)
- `Purpose / Big Picture`
- `Progress` (checkboxes with timestamps)
- `Surprises & Discoveries`
- `Decision Log`
- `Outcomes & Retrospective`
- `Context and Orientation`
- `Plan of Work`
- `Validation and Acceptance`
- `Idempotence and Recovery`
## Operating Rules
- Keep plan self-contained and repo-path specific.
- Update the plan as you implement; do not treat it as static.
- Acceptance must be observable (API response/test/user-visible behavior).
- Include exact commands and working directory for validation.
## Minimal Skeleton
Use this structure for new plans:
```
# <Feature Name>
## Purpose / Big Picture
## Progress
- [ ] ...
## Surprises & Discoveries
- Observation: ...
Evidence: ...
## Decision Log
- Decision: ...
Rationale: ...
Date/Author: ...
## Outcomes & Retrospective
## Context and Orientation
## Plan of Work
## Validation and Acceptance
## Idempotence and Recovery
```
+24
View File
@@ -0,0 +1,24 @@
# Docs Index
Use this file first.
## Start Here
- Repo setup: `README.md`
- Agent rules: `AGENTS.md`
- Plan rules + active plan: `docs/PLANS.md`
- Active ExecPlan: `docs/execplans/auth-phone-first-hardening.md`
- Architecture: `docs/architecture.md`
- Risks: `docs/risks.md`
## Map
- `docs/execplans/`: feature execution plans (living docs)
- `docs/adr/`: architecture decisions
- `docs/runbooks/`: operational response guides
- `docs/templates/`: ADR/runbook templates
- `docs/frontend-spec-requirements.md`: frontend technical specs + requirements (customer MVP)
## Update Rules (short)
- Behavior/flow changes: update architecture + affected runbook.
- Cross-cutting hard-to-reverse decision: add ADR.
- Significant risk added/closed: update `docs/risks.md`.
- Significant feature/refactor: update or add ExecPlan.
@@ -0,0 +1,25 @@
# ADR 0001: Synchronous External Calls For MVP
## Status
Accepted
## Context
OTP sends, booking notifications, and payment provider calls were needed quickly for MVP reliability. Running queue infrastructure early would add operational overhead.
## Decision
Keep external calls synchronous in request/response paths for MVP, with explicit timeouts and failure handling.
## Consequences
- Faster delivery, fewer moving pieces.
- Higher latency risk when providers are slow.
- Payment/OTP failures surface to clients immediately.
- Notification failures are recorded (`FAILED`) and monitored, not returned to client requests.
## Alternatives Considered
- Full queue (Celery/Redis): deferred.
- Hybrid queue for notifications only: valid future step when latency/throughput needs it.
## Related
- `docs/architecture.md`
- `docs/runbooks/auth_otp_failures.md`
- `docs/runbooks/payments_sanity_check.md`
+23
View File
@@ -0,0 +1,23 @@
# ADR 0002: Moyasar As Payment Gateway
## Status
Accepted
## Context
MVP requires KSA-focused payment support (SAR + local methods).
## Decision
Use Moyasar as the primary gateway for payment creation and webhook reconciliation.
## Consequences
- Strong KSA fit for MVP.
- External dependency on Moyasar uptime/API stability.
- Gateway abstraction can be expanded later for multi-provider support.
## Alternatives Considered
- Other regional gateways: deferred.
- Global-first provider: not selected for MVP priorities.
## Related
- `backend/apps/payments/services/gateway.py`
- `docs/runbooks/payments_sanity_check.md`
+24
View File
@@ -0,0 +1,24 @@
# ADR 0003: Authentica As Primary OTP Provider
## Status
Accepted
## Context
MVP auth is phone-first; production OTP delivery needed for KSA.
## Decision
Use Authentica as production OTP provider. Keep `console` provider for local development/tests.
## Consequences
- Production auth depends on Authentica credentials + uptime.
- Local development remains simple.
- Adding backup provider needs new adapter + runbook updates.
## Alternatives Considered
- Console-only: not viable for production.
- Multi-provider from day one: deferred for scope control.
## Related
- `backend/apps/accounts/services/otp.py`
- `backend/salon_api/settings.py`
- `docs/runbooks/auth_otp_failures.md`
+13
View File
@@ -0,0 +1,13 @@
# ADR Index
Use ADRs for cross-cutting, hard-to-reverse decisions.
## Existing ADRs
- `0001-synchronous-external-calls-mvp.md`
- `0002-moyasar-payment-gateway.md`
- `0003-authentica-otp-provider.md`
## Add New ADR
- Copy `docs/templates/adr.md`
- Use next numeric prefix (`0004`, `0005`, ...)
- Keep status updated (`Proposed`, `Accepted`, `Deprecated`, `Superseded`)
+42
View File
@@ -0,0 +1,42 @@
# Architecture Snapshot
Compact snapshot for current MVP reliability work.
## System
- Backend: Django + DRF (`backend/`)
- Frontend: React + Vite (`frontend/`)
- Primary market defaults: KSA (locale + phone patterns)
## Backend Domains
- `apps/accounts`: phone-first users, OTP flows, auth APIs.
- `apps/salons`: salons/services/staff/availability/reviews.
- `apps/bookings`: booking creation + status transitions + overlap/availability validation.
- `apps/payments`: Moyasar payment creation + webhook reconciliation + idempotency.
- `apps/notifications`: booking lifecycle notifications (customer/staff).
## Key Flows
1. Phone auth: `/api/auth/phone/request/` -> OTP issue, `/api/auth/phone/verify/` -> JWT.
2. Booking: `POST /api/bookings/` enforces staff, duration, availability, overlap.
3. Payment: `POST /api/payments/` creates Moyasar payment with idempotency key.
4. Webhook: `/api/payments/webhook/` maps provider events to internal payment status.
5. Notification: booking create/confirm/cancel dispatches SMS/WhatsApp records.
## Current Design Decisions
- External calls are synchronous for MVP (ADR 0001).
- Moyasar is payment provider (ADR 0002).
- Authentica is primary OTP provider (ADR 0003).
## Reliability State
- Implemented: booking integrity, payment idempotency+webhooks, lifecycle notifications, locale foundations.
- Open: full OAuth linking policy, broader i18n coverage, stronger observability/audit/compliance.
## Where To Extend Safely
- Add business rules in services modules, not views.
- Preserve idempotency keys/state transitions for booking/payment changes.
- Update runbooks + risks with every production-impacting behavior change.
## Reference Docs
- Plan policy + active plan: `docs/PLANS.md`
- Known gaps: `docs/risks.md`
- Runbooks: `docs/runbooks/README.md`
- ADRs: `docs/adr/README.md`
+19
View File
@@ -0,0 +1,19 @@
# Documentation Rules
## Principles
- Single source of truth per topic; link, do not duplicate.
- Update docs in same change set as code.
- Prefer observable behavior (what to run, what to see).
## Canonical Locations
- Architecture/system boundaries: `docs/architecture.md`
- Execution plans: `docs/execplans/` + policy in `docs/PLANS.md`
- Operational procedures: `docs/runbooks/`
- Cross-cutting decisions: `docs/adr/`
- Known gaps: `docs/risks.md`
## Minimum Review Checklist
- Docs touched or explicitly unchanged.
- Runbook updated for production-impacting flow changes.
- ADR added when decision is hard to reverse.
- Risks updated when a major gap opens/closes.
+26 -101
View File
@@ -1,120 +1,45 @@
# Arabic Localization Readiness (ar-sa First) # Arabic Localization Foundations
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. This ExecPlan follows `docs/PLANS.md`.
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 ## Purpose / Big Picture
Ship i18n plumbing for `ar-sa` first (locale selection, RTL behavior, user preference), with full translation breadth deferred.
After this change, the codebase has localization foundations in place: locale selection, right-to-left layout support, and a user language preference in the backend. Arabic is treated as a first-class locale for structure and behavior, but full translation coverage is intentionally deferred until core backend flows stabilize. You can see it working by starting the backend and frontend, switching the language to Arabic, and observing RTL layout, the page `dir="rtl"`, and the API responding with the correct `Content-Language` when sending `Accept-Language: ar-sa`.
## Progress ## Progress
- [x] (2026-02-27 00:00Z) Plan created.
- [x] (2026-02-27 00:00Z) Created initial ExecPlan for Arabic localization readiness. - [x] (2026-02-28 12:20Z) Backend locale middleware + user preference landed.
- [x] (2026-02-28 12:00Z) Added backend locale settings, LocaleMiddleware, user language preference, and user locale middleware. - [x] (2026-02-28 12:30Z) Frontend i18n + RTL switching landed.
- [x] (2026-02-28 12:10Z) Wrapped backend user-facing strings for future translation (no full catalog yet). - [x] (2026-02-28 12:40Z) Basic tests/docs updated.
- [x] (2026-02-28 12:20Z) Added frontend i18n, RTL support, language persistence, and minimal seed translations. - [ ] Complete translation coverage and broader RTL QA as features expand.
- [x] (2026-02-28 12:30Z) Added targeted backend and frontend tests for locale selection and RTL behavior.
- [x] (2026-02-28 12:40Z) Updated documentation and risks for staged localization.
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: none critical after implementation.
- Observation: None yet. Evidence: targeted locale/RTL tests passed for initial scope.
Evidence: No implementation work has started.
## Decision Log ## Decision Log
- Decision: `ar-sa` default locale with `en` fallback.
- Decision: Use `ar-sa` as the default locale with English as a fallback. Rationale: KSA-first product target.
Rationale: The product is KSA-focused and Arabic should be primary while keeping English for mixed audiences. Date/Author: 2026-02-27/Codex
Date/Author: 2026-02-27, Codex - Decision: deliver foundations first, defer full string coverage.
- Decision: Implement localization foundations now and defer full translation coverage. Rationale: reduce churn while core product flows are still evolving.
Rationale: Early i18n plumbing avoids future refactors, while delaying full translation prevents churn as features evolve. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex
- Decision: Use lower-case `ar-sa` for locale identifiers in code and storage.
Rationale: Django language codes are lower-case; standardizing avoids mismatches between backend and frontend.
Date/Author: 2026-02-28, Codex
- Decision: Persist user language preference on the `User` model and fall back to `Accept-Language` for anonymous requests.
Rationale: This provides consistent localized behavior for logged-in users while respecting browser preferences for guests.
Date/Author: 2026-02-27, Codex
- Decision: Localize both backend API messages and frontend UI strings.
Rationale: A partial localization would create mismatched language experiences and confuse users.
Date/Author: 2026-02-27, Codex
- Decision: Use `i18next` + `react-i18next` with a small custom locale selection helper rather than a detection plugin.
Rationale: The project is small and can avoid extra dependencies while still meeting locale selection requirements.
Date/Author: 2026-02-27, Codex
## Outcomes & Retrospective ## Outcomes & Retrospective
Foundations complete; translation completeness remains open.
Localization foundations are now in place across backend and frontend, with user preference support, RTL layout, minimal Arabic strings, and basic tests. Full translation coverage and broader RTL QA remain as future work once core flows stabilize.
## Context and Orientation ## Context and Orientation
- Backend locale settings/middleware: `backend/salon_api/settings.py`, `backend/apps/accounts/middleware.py`
The backend is a Django + DRF app in `backend/` with settings in `backend/salon_api/settings.py`. The frontend is a Vite + React app in `frontend/` with the entrypoint at `frontend/src/main.jsx` and global styles in `frontend/src/styles.css`. Localization foundations now exist: Django `LocaleMiddleware` is configured and `apps/accounts/middleware.py` applies user preferences, while the frontend initializes `i18next` in `frontend/src/i18n/index.js` and sets `lang`/`dir` on the root element. User-facing strings have begun to be wrapped for translation, but full Arabic translation coverage remains pending. - Frontend i18n/RTL: `frontend/src/i18n/`, `frontend/src/main.jsx`, `frontend/src/styles.css`
## Plan of Work ## Plan of Work
Next phase: expand string coverage and run end-to-end RTL checks on all added routes/components.
First, add Django locale support. Update `backend/salon_api/settings.py` to define `LANGUAGE_CODE="ar-sa"`, `LANGUAGES` with Arabic and English, `LOCALE_PATHS` pointing to `backend/locale`, and add `django.middleware.locale.LocaleMiddleware` to `MIDDLEWARE` after `SessionMiddleware`. Create `backend/apps/accounts/middleware.py` with `UserLocaleMiddleware` that activates `request.user.preferred_language` after `AuthenticationMiddleware` and sets the response `Content-Language` header. Add a `preferred_language` field to `backend/apps/accounts/models.py` and expose it via `backend/apps/accounts/serializers.py` so `/api/auth/me/` can read and update it.
Next, wrap all user-facing backend strings in translation wrappers. Use `from django.utils.translation import gettext_lazy as _` in serializers and models, and `gettext` in runtime view responses. Cover custom messages in `apps/accounts`, `apps/bookings`, `apps/payments`, and `apps/salons`. Do not translate the backend catalog yet; full Arabic API messages are a later milestone once core flows stabilize. Update or add tests that confirm language selection by user preference and `Accept-Language` headers.
Then, add frontend localization. Introduce an `frontend/src/i18n/` module that sets up `i18next` with `en` and `ar-sa` resource files. Update `frontend/src/main.jsx` to initialize i18n before rendering `App`, set `document.documentElement.lang` and `dir` whenever language changes, and persist the selected locale to local storage. Update `frontend/src/api/client.js` to include the `Accept-Language` header using the active locale. Replace hard-coded UI strings in `frontend/src/App.jsx` with `t(...)` keys and add minimal Arabic translations for the current UI.
Finally, make the UI RTL-safe. Update `frontend/src/styles.css` to use logical properties (`margin-inline`, `padding-inline`, `text-align: start`) where relevant, add `:dir(rtl)` overrides for layout if needed, and add an Arabic-capable font such as `Noto Sans Arabic` to the font stack. Validate end-to-end behavior by running the backend and frontend, switching language, and confirming the UI renders RTL and API responses match the selected locale. Full translation coverage remains a later milestone.
## Concrete Steps
Run these commands from the repository root (`/home/kopernikus/kshkool/Salon`).
1. Add backend locale middleware, settings, and `preferred_language` field, then create a migration.
- Update `backend/salon_api/settings.py`, `backend/apps/accounts/models.py`, and add `backend/apps/accounts/middleware.py`.
- Run:
python3 backend/manage.py makemigrations accounts
2. (Deferred) Generate and compile Arabic translations for the backend when full translation coverage is ready.
- Run:
python3 backend/manage.py makemessages -l ar --ignore frontend --ignore node_modules
python3 backend/manage.py compilemessages
- Edit `backend/locale/ar/LC_MESSAGES/django.po` to translate the newly wrapped strings.
3. Add frontend i18n resources and wire them into the app.
- Update `frontend/package.json`, `frontend/src/main.jsx`, `frontend/src/api/client.js`, `frontend/src/App.jsx`, and create `frontend/src/i18n/index.js` plus translation JSON files.
4. Run tests and verify behavior.
- Backend:
python3 -m pytest
- Frontend:
cd frontend
npm run test
## Validation and Acceptance ## Validation and Acceptance
- Backend: locale header behavior matches user preference/`Accept-Language`.
Backend acceptance is achieved when `Accept-Language` and user preference change the response language header. For example, an OTP error should carry `Content-Language: ar-sa` even if the message text remains English until translations are added: - Frontend: language toggle sets `lang` + `dir`, persists across refresh.
- Commands:
$ curl -s -H "Accept-Language: ar-sa" -X POST http://localhost:8000/api/auth/otp/request/ -H "Content-Type: application/json" -d '{"phone_number":"123","channel":"sms"}' - `cd backend && python3 -m pytest`
{"phone_number":["Phone number is required"]} - `cd frontend && npm run test`
Frontend acceptance is achieved when the page renders Arabic text, the root element uses `dir="rtl"`, and the UI remains readable. You should be able to toggle language, reload, and still see Arabic due to stored preference. Running `npm run dev` and visiting the page should show Arabic UI strings when the selected locale is `ar-sa`.
## Idempotence and Recovery ## Idempotence and Recovery
Locale config is additive and safe to reapply; translation catalogs can be regenerated.
The locale settings and middleware changes are safe to apply multiple times. Translation commands can be rerun; `makemessages` updates catalogs and `compilemessages` rebuilds `.mo` files. If a translation file is corrupted, re-run `makemessages` and re-apply translations. The migration adding `preferred_language` is additive and reversible via standard Django migration rollback.
## Artifacts and Notes
Expected header behavior after implementing locale selection:
Content-Language: ar-sa
Example local storage entry for the frontend:
localStorage["locale"] = "ar-sa"
## Interfaces and Dependencies
Add backend localization dependencies by using Djangos built-in translation system and middleware (`django.middleware.locale.LocaleMiddleware`) and a new `apps.accounts.middleware.UserLocaleMiddleware` to enforce user preference. The `User` model gains a language preference field:
preferred_language = models.CharField(max_length=10, choices=settings.LANGUAGES, default=settings.LANGUAGE_CODE)
Frontend dependencies must include `i18next` and `react-i18next`. The i18n setup should live in `frontend/src/i18n/index.js`, exporting an initialized i18n instance. The API client in `frontend/src/api/client.js` must attach `Accept-Language` to every request based on the active locale.
Plan Maintenance Note: Initial plan created on 2026-02-27 to scope Arabic localization readiness across backend and frontend. Updated on 2026-02-28 to stage localization work (foundations now, full translations later).
@@ -0,0 +1,48 @@
# Phone-first Auth Hardening
This ExecPlan follows `docs/PLANS.md`.
## Purpose / Big Picture
Keep phone OTP as canonical login surface, preserve phone-first identity across serializers/admin/UI-facing strings, and lock regression tests around this contract.
## Progress
- [x] (2026-03-14 12:00 UTC) Plan created with test-first scope.
- [x] (2026-03-14 13:55 UTC) Added tests for display fallback + phone auth error contracts.
- [x] (2026-03-14 14:30 UTC) Implemented `User.display_name`, serializer/admin updates, and docs updates.
- [ ] Expand tests for OAuth linking policy and remaining phone-first invariants.
## Surprises & Discoveries
- Observation: JWT test key warning appears in suite.
Evidence: `InsecureKeyLengthWarning` during accounts/salons pytest runs.
## Decision Log
- Decision: Pre-create user on phone request; verify on phone verify.
Rationale: deterministic onboarding lifecycle.
Date/Author: 2026-03-14/Codex
- Decision: Add `User.display_name` and reuse everywhere.
Rationale: stable fallback for phone-only accounts.
Date/Author: 2026-03-14/Codex
## Outcomes & Retrospective
Core phone-first hardening landed and tests pass for implemented scope. Remaining work is mainly policy (OAuth linking/conflict) plus extra invariants coverage.
## Context and Orientation
- Auth endpoints: `backend/apps/accounts/views.py`
- User model/admin: `backend/apps/accounts/models.py`, `backend/apps/accounts/admin.py`
- Cross-app display paths: `backend/apps/salons/`, `backend/apps/bookings/`
## Plan of Work
1. Keep adding invariant tests first.
2. Finalize OAuth linking/conflict policy and enforce in auth services.
3. Update docs/runbooks/risks with final contract.
## Validation and Acceptance
From `backend/`:
- `python3 -m pytest backend/apps/accounts/tests backend/apps/salons/tests`
Acceptance:
- Phone auth endpoints remain canonical and stable.
- Display paths show phone-first labels when email absent.
- New invariant tests pass.
## Idempotence and Recovery
Auth hardening changes are additive and test-gated. Roll back by app-level revert if a contract regression is detected.
+26 -102
View File
@@ -1,121 +1,45 @@
# Booking Integrity (Availability, Schedules, Overlap Prevention) # Booking Integrity
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. This ExecPlan follows `docs/PLANS.md`.
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 ## Purpose / Big Picture
Reject invalid booking windows and staff double-booking; accept only schedule-valid requests.
After this change, bookings cannot be created in invalid time windows or in ways that double-book staff. A manager can rely on the system to prevent overlapping appointments and to enforce staff working hours. You can see it working by attempting to create a booking outside a staff members availability window or that overlaps an existing confirmed booking and receiving a clear validation error; creating a booking that fits availability and does not overlap should succeed.
## Progress ## Progress
- [x] (2026-02-28 13:05Z) Plan created.
- [x] (2026-02-28 13:05Z) Created ExecPlan for booking integrity (availability, schedules, overlap prevention). - [x] (2026-02-28 13:30Z) Validation service implemented (duration/availability/overlap).
- [x] (2026-02-28 13:25Z) Added staff availability model, admin registration, and manual migration. - [x] (2026-02-28 13:45Z) Tests added.
- [x] (2026-02-28 13:30Z) Introduced booking validation service for duration, schedule, and overlap checks. - [x] (2026-02-28 13:50Z) Risks updated.
- [x] (2026-02-28 13:32Z) Updated booking create serializer to require staff and enforce validation rules.
- [x] (2026-02-28 13:45Z) Added backend tests covering overlap prevention, availability windows, and duration validation.
- [x] (2026-02-28 13:50Z) Updated `docs/risks.md` to reflect closed booking-integrity gaps.
## Surprises & Discoveries ## Surprises & Discoveries
- Observation: `makemigrations` unavailable in one environment due missing Django.
- Observation: Django is not installed in the environment, so `makemigrations` could not run. Evidence: import error during initial migration step.
Evidence: `ImportError: Couldn't import Django` when running `python3 backend/manage.py makemigrations salons`.
## Decision Log ## Decision Log
- Decision: require `staff` on booking creation.
- Decision: Require `staff` on booking creation to enforce schedule and overlap rules deterministically. Rationale: no deterministic schedule validation without staff.
Rationale: Without an assigned staff member, the system cannot guarantee schedule integrity. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex - Decision: no-availability-records => open schedule.
- Decision: Treat staff availability as open-ended if no availability records exist for that staff member. Rationale: backward compatibility while enabling stricter config when data exists.
Rationale: This avoids breaking existing workflows while enabling explicit schedule enforcement when configured. Date/Author: 2026-02-28/Codex
Date/Author: 2026-02-28, Codex
- Decision: Enforce that `end_time - start_time` matches the service duration in minutes.
Rationale: Prevents inconsistent bookings and ensures predictable slot lengths.
Date/Author: 2026-02-28, Codex
- Decision: Add the `StaffAvailability` migration manually instead of using `makemigrations`.
Rationale: Django was unavailable in the environment; a manual migration keeps schema changes explicit and reviewable.
Date/Author: 2026-02-28, Codex
## Outcomes & Retrospective ## Outcomes & Retrospective
Completed. Booking integrity checks are active with tests for core failure modes.
Booking integrity is now enforced via staff availability checks, duration validation, and overlap prevention, with test coverage for each rule. This closes the highest-risk booking integrity gap in `docs/risks.md`, while timezone and business-hours enforcement remain future work.
## Context and Orientation ## Context and Orientation
- Validation entrypoint: `backend/apps/bookings/services.py`
Booking creation is implemented in `backend/apps/bookings/serializers.py` (`BookingCreateSerializer`) and routed via `backend/apps/bookings/views.py` in a DRF `ModelViewSet`. The booking model lives in `backend/apps/bookings/models.py`, while staff information is in `backend/apps/salons/models.py` as `StaffProfile`. There is no current scheduling model and no overlap validation. This plan introduces a staff availability model and a dedicated booking validation service to keep business logic out of views, in line with project standards. - Create serializer: `backend/apps/bookings/serializers.py`
- Staff availability model: `backend/apps/salons/models.py`
## Plan of Work ## Plan of Work
Completed; remaining future policy work is timezone/business-hours specifics.
First, add a staff availability model in `backend/apps/salons/models.py`. Create a `StaffAvailability` model with a foreign key to `StaffProfile`, a day-of-week integer (0-6), and start/end times (as `TimeField`). Use an `is_active` boolean to allow disabling entries without deleting them. Register the model in `backend/apps/salons/admin.py` for basic management. Create and apply a migration in the salons app.
Next, add a booking validation service in `backend/apps/bookings/services.py`. The service should expose a function like `validate_booking_request(service, staff, start_time, end_time)` that raises `serializers.ValidationError` or a custom domain error translated into DRF validation errors. It should check:
- `staff` is required and belongs to the same salon as the service.
- `start_time < end_time` and duration matches `service.duration_minutes`.
- Staff availability: if availability records exist for the staff and day-of-week, ensure the booking window is fully inside one availability window with `is_active=True`.
- Overlap: prevent any booking for the same staff with status in `pending` or `confirmed` that overlaps the requested window; `cancelled` and `completed` bookings should not block.
Then, update `BookingCreateSerializer` in `backend/apps/bookings/serializers.py` to call the validation service and to require `staff`. Keep `create` unchanged beyond relying on validated data.
Finally, add tests in `backend/apps/bookings/tests/test_booking_integrity.py`. Cover these cases:
- Reject bookings with no staff assigned.
- Reject bookings where `end_time` precedes `start_time`.
- Reject bookings where duration does not match `service.duration_minutes`.
- Reject bookings outside staff availability when availability records exist.
- Allow bookings when no availability records exist.
- Reject overlapping bookings for the same staff with `pending` or `confirmed` status; allow overlaps with `cancelled` or `completed` bookings.
Update `docs/risks.md` to mark booking integrity gaps as addressed once tests pass.
## Concrete Steps
Run these commands from the repository root (`/home/m7md/kshkool/Salon`).
1. Add staff availability model and migration.
- Edit `backend/apps/salons/models.py` and `backend/apps/salons/admin.py`.
- Run:
python3 backend/manage.py makemigrations salons
2. Add booking validation service and update serializer.
- Create `backend/apps/bookings/services.py` and update `backend/apps/bookings/serializers.py`.
3. Add tests.
- Create `backend/apps/bookings/tests/test_booking_integrity.py`.
4. Run tests.
- Backend:
python3 -m pytest
## Validation and Acceptance ## Validation and Acceptance
From `backend/`:
- Attempting to create a booking without a staff member returns HTTP 400 with a clear validation error. - `python3 -m pytest backend/apps/bookings/tests`
- Creating a booking outside availability returns HTTP 400 with a clear validation error. Acceptance:
- Creating a booking overlapping an existing pending/confirmed booking for the same staff returns HTTP 400. - Invalid duration/availability/overlap cases return 400.
- Creating a booking within an availability window and without overlap returns HTTP 201. - Valid windows return 201.
- Running `python3 -m pytest` passes, and the new booking-integrity tests fail before the changes and pass after.
## Idempotence and Recovery ## Idempotence and Recovery
Validation is stateless; schema change is additive and reversible by standard migrations.
Model and serializer changes are additive and safe to reapply. If a migration needs to be re-run, it can be rolled back using standard Django migration rollback and re-applied. The validation service is pure and can be iterated without impacting data. If availability rules are too strict, disabling availability entries will effectively remove the constraint without deleting data.
## Artifacts and Notes
Example overlap query used in validation:
Booking.objects.filter(
staff=staff,
status__in=[BookingStatus.PENDING, BookingStatus.CONFIRMED],
start_time__lt=end_time,
end_time__gt=start_time,
)
## Interfaces and Dependencies
- `backend/apps/salons/models.py` must define a new `StaffAvailability` model with fields: `staff` (FK), `day_of_week` (0-6), `start_time`, `end_time`, `is_active`.
- `backend/apps/bookings/services.py` must define `validate_booking_request(service, staff, start_time, end_time)`.
- `backend/apps/bookings/serializers.py` must call the validation service and require `staff` on create.
Plan Maintenance Note: Created on 2026-02-28 to implement booking integrity (availability, schedules, overlap prevention) as the next Phase 1 reliability step.
Plan Maintenance Note (Update): Marked milestones complete and recorded the manual migration decision after implementing booking integrity and tests on 2026-02-28.
+43
View File
@@ -0,0 +1,43 @@
# Booking Lifecycle Notifications
This ExecPlan follows `docs/PLANS.md`.
## Purpose / Big Picture
Notify customer and staff on booking created/confirmed/cancelled with auditable notification rows.
## Progress
- [x] (2026-02-28 17:05Z) Plan + scope finalized.
- [x] (2026-02-28 17:40Z) Notification app + providers + booking wiring implemented.
- [x] (2026-02-28 18:05Z) Tests added for create/status-change/no-duplicate behavior.
- [x] (2026-02-28 18:10Z) Risks/docs updated.
## Surprises & Discoveries
- Observation: status field had been read-only for update flow.
Evidence: `PATCH /api/bookings/<id>` validation failure before serializer update.
## Decision Log
- Decision: persist notification records for every lifecycle send attempt.
Rationale: auditability + support traceability.
Date/Author: 2026-02-28/Codex
- Decision: reuse OTP provider abstraction for notification channels.
Rationale: avoid duplicated provider integration.
Date/Author: 2026-02-28/Codex
## Outcomes & Retrospective
Completed. Booking lifecycle notifications are live with idempotent records and test coverage.
## Context and Orientation
- Notification domain: `backend/apps/notifications/`
- Booking integration points: `backend/apps/bookings/views.py`
## Plan of Work
Completed; future work is provider hardening/monitoring only.
## Validation and Acceptance
From `backend/`:
- `python3 -m pytest backend/apps/notifications/tests`
Acceptance:
- Create/confirm/cancel each emit customer+staff notification rows once per event/channel.
## Idempotence and Recovery
Uniqueness constraints prevent duplicate event/channel sends per recipient/booking; retries update existing rows safely.
+46
View File
@@ -0,0 +1,46 @@
# Payments Integration (Moyasar)
This ExecPlan follows `docs/PLANS.md`.
## Purpose / Big Picture
Create payments idempotently, reconcile webhook states safely, and keep payment history auditable.
## Progress
- [x] (2026-02-28 14:35Z) Plan created.
- [x] (2026-02-28 15:55Z) Payment creation + webhook processing implemented.
- [x] (2026-02-28 16:10Z) Tests for create/idempotency/webhooks implemented.
- [x] (2026-02-28 16:20Z) Risks updated.
## Surprises & Discoveries
- Observation: missing `requests` dependency blocked gateway calls initially.
Evidence: `ModuleNotFoundError: requests`.
## Decision Log
- Decision: enforce request-level idempotency key for payment create.
Rationale: prevent duplicate charges on retries.
Date/Author: 2026-02-28/Codex
- Decision: persist provider + webhook payloads.
Rationale: payment auditability/debuggability.
Date/Author: 2026-02-28/Codex
## Outcomes & Retrospective
Completed for MVP core flow (create + webhook). Remaining work: richer operational monitoring and optional admin capture/refund APIs.
## Context and Orientation
- Payment model/services/views: `backend/apps/payments/`
- Booking dependency: `backend/apps/bookings/`
## Plan of Work
Completed for current scope.
## Validation and Acceptance
From `backend/`:
- `python3 -m pytest backend/apps/payments/tests`
Acceptance:
- New key => new payment.
- Same key => existing payment reused.
- Valid webhook transitions state idempotently.
- Invalid webhook auth rejected.
## Idempotence and Recovery
Idempotency key guards create path; webhook replay is safe.
+216
View File
@@ -0,0 +1,216 @@
# Frontend Technical Specs and Requirements (MVP)
## Purpose
Define the implementation contract for the React frontend against the current backend REST API so the customer flows (auth, search, booking, payment, profile) can ship reliably for KSA.
## Scope
In scope:
- Customer web app (React + Vite) for Phase 1 flows.
- API integration contracts for current backend endpoints.
- UX/error/loading behavior and test requirements.
- i18n/RTL and KSA timezone handling.
Out of scope (Phase 2+):
- Manager/staff admin dashboards.
- Advanced reporting/reviews moderation tools.
- Full observability product dashboards.
## Product and Platform Constraints
- Market default: KSA first.
- Default locale: `ar-sa`; fallback: `en`.
- Timezone baseline: `Asia/Riyadh` (`+03:00`).
- API error contract: HTTP status + `detail` where applicable.
- Booking/payment operations must avoid duplicate side effects.
## UX Priorities (Primary Contract)
- First screen for authenticated and guest users MUST be a feed of nearby/available salons.
- The feed is the default home experience and primary entry to booking.
- Main customer navigation MUST be bottom tabs (mobile-first), not header-first navigation.
- Bottom tabs MUST include at minimum:
- Home/Feed
- Bookings
- Profile
- Optional tabs (when implemented) may include Search/Explore and Payments, but must not displace Home/Feed as default.
## Frontend Architecture Requirements
## Runtime and Stack
- React 18 + Vite.
- React Router (`BrowserRouter`) for route navigation.
- `i18next` + `react-i18next` for translations and direction switching.
- `fetch` wrapper in `src/api/client.js` as single API boundary.
- Auth/session state in `AuthContext`.
## Route Map (Customer)
- `/` home feed (nearby/available salons) + search/filter.
- `/salon/:id` salon detail.
- `/login` phone OTP login.
- `/book?salon=<id>` booking creation.
- `/pay?booking=<id>` payment initiation.
- `/pay/return` payment callback/return surface.
- `/bookings` customer booking history.
- `/profile` customer profile summary.
## Module Boundaries
- `src/api/`: all HTTP logic, standardized errors.
- `src/contexts/`: auth/session lifecycle only.
- `src/hooks/`: domain-side UI logic (`useSalonSearch`, `usePaymentForm`).
- `src/pages/`: route-level composition.
- `src/components/`: reusable presentation and guarded wrappers.
- `src/i18n/`: locale dictionaries and locale/direction state.
## Functional Requirements
### FR-1 Phone-First Authentication
- Login MUST use:
- `POST /api/auth/phone/request/`
- `POST /api/auth/phone/verify/`
- Password auth endpoint (`/api/auth/token/`) MUST NOT be used (returns 410).
- Login request form MUST collect:
- `phone_number` (accept KSA local or E.164 input)
- `channel` (`sms` or `whatsapp`)
- optional: `device_id` (recommended for abuse controls)
- Verify step MUST submit `request_id` + 6-digit `code`.
- On success, frontend MUST persist `access` and `refresh` tokens and user payload.
### FR-2 Session Restore and Token Refresh
- On app boot, if `access` exists:
- call `GET /api/auth/me/`.
- If `401`/token invalid:
- call `POST /api/auth/token/refresh/` once.
- retry `GET /api/auth/me/` with new access token.
- If refresh fails, frontend MUST clear tokens and require re-login.
### FR-3 Salon Discovery
- Home MUST render a salon feed by default on first load.
- Feed data MUST come from `GET /api/salons/` and support query params for discovery.
- Home search MUST call `GET /api/salons/?q=<query>`.
- Search SHOULD support additional filters when UI is added:
- `city`
- `service`
- Nearby/available ranking can be client-side initially, but server response MUST remain source of truth.
- Result cards MUST link to `/salon/:id`.
### FR-4 Salon Detail
- Detail page MUST call `GET /api/salons/:id/`.
- UI MUST render:
- salon base info
- services (duration, amount, currency)
- staff list
- optional reviews/photos if present
- CTA MUST deep-link to booking flow with salon id.
### FR-5 Booking Creation
- Booking page MUST require authenticated user.
- Create booking with `POST /api/bookings/` using:
- `service`
- `staff` (required)
- `start_time`
- `end_time`
- optional `notes`
- `end_time` MUST match service duration exactly.
- Datetime submitted to backend MUST include explicit offset (`+03:00` for KSA baseline).
- On success (`201`), frontend MUST navigate to payment flow with booking id.
### FR-6 Booking History
- `/bookings` MUST call authenticated `GET /api/bookings/`.
- List MUST show booking id, status, salon/service labels, datetime, and price.
- Datetime rendering MUST use active locale formatting.
### FR-7 Payment Initiation (Idempotent)
- Payment submission MUST call `POST /api/payments/`.
- Payload requirements:
- `booking_id` (number)
- `provider` = `moyasar`
- `idempotency_key` (UUID)
- `source` object with supported type (`stcpay`, `token`, `applepay`, `samsungpay`)
- `callback_url` required for `source.type=token`
- Frontend MUST disable duplicate submits while request is in-flight.
- Same payment attempt retry MUST reuse the same `idempotency_key`.
- New attempt MUST generate a new key.
- If response includes `redirect_url`, frontend MUST redirect.
### FR-8 Payment Return Handling
- `/pay/return` MUST parse query params:
- `status`
- `id`
- Success statuses shown as success UX: `paid`, `captured`, `authorized`.
- Non-success statuses MUST show neutral/pending/failure guidance and link to profile/bookings.
### FR-9 Locale and Direction
- App MUST allow switching between `ar-sa` and `en`.
- Locale switch MUST:
- persist preference in local storage
- set `<html lang>`
- set `<html dir>` (`rtl` for `ar-sa`, `ltr` for `en`)
- API calls MUST include `Accept-Language` header with active locale.
## API Contract Requirements
| Endpoint | Auth | Request | Success | Error handling |
|---|---|---|---|---|
| `POST /api/auth/phone/request/` | No | `phone_number`, `channel`, optional profile fields | `201` with `request_id`, `expires_at` | `429` may include `retry_after_seconds`; show wait message |
| `POST /api/auth/phone/verify/` | No | `request_id`, `code` | `200` with `access`, `refresh`, `user` | `400` invalid/expired code |
| `POST /api/auth/token/refresh/` | No | `refresh` | `200` with new `access` | logout on failure |
| `GET /api/auth/me/` | Bearer | - | `200` user payload | `401` triggers refresh flow |
| `GET /api/salons/` | No | `q`, optional `city`, `service` | `200` list | show localized generic fetch error |
| `GET /api/salons/:id/` | No | - | `200` detail object | show detail/fallback |
| `POST /api/bookings/` | Bearer | booking payload | `201` booking | `400` field validation errors |
| `GET /api/bookings/` | Bearer | - | `200` list | auth + generic errors |
| `POST /api/payments/` | Bearer | payment payload | `201` created or `200` reused idempotent record | `400/403` with details; never auto-retry with new key |
## Error and State Handling Requirements
- API wrapper MUST throw structured errors with:
- HTTP status
- parsed response body
- best message (`detail` first, fallback to response text)
- For validation objects (`{field: [msg]}`), UI SHOULD render first field message near form and keep raw object in debug logs.
- For `429` with `retry_after_seconds`, UI MUST display server-provided cooldown.
- All mutating forms MUST expose:
- idle/loading/error/success states
- submit button disabled while loading
## Security and Abuse-Resistance Requirements
- Use Bearer access token for authenticated endpoints only.
- Include optional `device_id` during phone auth request to strengthen backend abuse controls.
- Never send raw card PAN/CVV data to backend; use tokenized sources only.
- On logout, clear user and both tokens from memory + storage.
## Accessibility and UX Requirements
- All interactive controls MUST have accessible labels.
- Auth/booking/payment forms MUST be keyboard usable.
- Error text MUST be visible and associated with active form context.
- Layout MUST remain usable on mobile widths (`>=320px`) and desktop.
## Non-Functional Requirements
- Reliability: no duplicate payment submission side effects for one attempt.
- Consistency: API errors surfaced predictably and localized where available.
- Maintainability: domain behavior in hooks/services, not route components.
- Extensibility: route/module structure must support manager/staff pages later without rewrite.
## Test Requirements (Frontend)
- Test stack: `vitest` + Testing Library.
- Required coverage for release:
- phone login request + verify success/failure + 429 cooldown message
- auth restore and refresh-token fallback
- protected route redirect behavior
- salon search loading/empty/results/error
- booking form validation + API error mapping + success redirect
- payment form source validation + idempotency key reuse on retry + redirect behavior
- locale switching persists and sets `lang`/`dir`
- bookings list rendering and localized datetime output
Run:
- `cd frontend && npm run test`
## Definition of Done (Frontend)
- All FR requirements implemented for in-scope routes.
- API integrations match endpoint/payload contract above.
- No use of deprecated password login API.
- All listed frontend tests pass.
- `ar-sa` and `en` UX verified on mobile + desktop.
## Known Dependencies and Open Decisions
- OAuth/social-linking policy is not finalized; keep social login UI hidden for now.
- Cancellation and refund policies are not finalized; do not ship irreversible customer actions until policy finalization.
- Detailed business-hours/timezone policy beyond current backend validation remains open; keep KSA-offset submission and avoid client-side assumptions that override server validation.
+18 -23
View File
@@ -1,31 +1,26 @@
# Risks And Gaps # Risks And Gaps
This file tracks known gaps and risks to address in future iterations. Open items only; remove resolved duplicates.
## Security And Auth ## Auth
- Phone normalization is KSA-focused and minimal; broaden for multi-country use. - KSA-focused phone normalization; multi-country strategy pending.
- OTP protections are basic; add device fingerprinting and IP throttling if needed. - Phone auth abuse controls need production tuning (IP/device thresholds).
- OTP provider adapters (Twilio/Unifonic) are scaffolds only; no real SMS/WhatsApp delivery yet. - Social login/OAuth linking policy still undefined (collision/merge rules).
- Social login is a placeholder. - JWT test warning exists for short test signing key (`InsecureKeyLengthWarning`).
- USERNAME_FIELD is still `email` while email can be null; verify admin/login flows.
## Booking Integrity ## Booking
- Availability checks and overlap prevention are now enforced for staff bookings. - No explicit timezone/business-hours policy beyond current availability checks.
- No timezone handling or business hours enforcement. - Cancellation policy and refund policy not finalized.
- No cancellation rules or refund logic.
## Payments ## Payments
- Payment integration is not implemented. Current API only stores records and a Moyasar scaffold exists. - Core Moyasar flow works; admin capture/refund endpoints not exposed yet.
- Webhook handling and payment status reconciliation missing. - Monitoring/alerting for webhook failures is still basic.
- Idempotency handling for payment creation missing.
## Data And UX ## Localization
- Ratings are not recalculated from reviews. - Foundations exist (`en`, `ar-sa`, RTL), but translation coverage is incomplete.
- No image upload or storage strategy for photos. - RTL QA across all future pages still pending.
- No notifications (email/SMS) beyond OTP scaffolding.
- Localization foundations are in progress; full Arabic translation coverage and RTL QA are still pending.
## Ops And Compliance ## Ops/Compliance
- No audit logs for admin actions. - No full audit log strategy for privileged actions.
- No multi-tenant isolation or data export tooling. - No PDPL/GDPR retention policy or data export workflow.
- No GDPR/PDPL data retention policies defined. - No formal observability baseline (metrics/SLO dashboards).
+8
View File
@@ -0,0 +1,8 @@
# Runbooks Index
Runbooks for production-impacting flows:
- `docs/runbooks/auth_otp_failures.md`
- `docs/runbooks/booking_failures.md`
- `docs/runbooks/payments_sanity_check.md`
Rule: if a new production flow is added, add or update a runbook in same change.
+34
View File
@@ -0,0 +1,34 @@
# Runbook: Auth OTP Failures
## Symptoms
- Users do not receive OTP.
- `/api/auth/otp/request` or `/api/auth/phone/request` fails.
- `/api/auth/otp/verify` or `/api/auth/phone/verify` shows invalid/expired unexpectedly.
## Impact
Users cannot sign in/verify phone; booking/payment flows may block.
## Quick Checks
- Confirm `OTP_PROVIDER` in `backend/salon_api/settings.py`.
- Check OTP provider credentials in `backend/.env`.
- Check app logs for provider/timeouts/rate-limit errors.
- Validate OTP rate-limit settings:
- `OTP_MAX_PER_WINDOW`
- `OTP_WINDOW_MINUTES`
- `OTP_RESEND_COOLDOWN_SECONDS`
- `PHONE_AUTH_IP_MAX_PER_WINDOW`
- `PHONE_AUTH_DEVICE_MAX_PER_WINDOW`
## Mitigation
1. Fix env/config mismatch; restart API.
2. If provider outage, use `console` only in non-prod.
3. If abuse spike/false positives, tune IP/device thresholds.
4. Verify server clock and `OTP_EXPIRY_MINUTES`.
## Escalation
- Roll back recent auth changes if correlated with deployment.
- Escalate to Authentica with request IDs + timestamps.
## References
- OTP logic: `backend/apps/accounts/services/otp.py`
- Risks: `docs/risks.md`
+28
View File
@@ -0,0 +1,28 @@
# Runbook: Booking Failures
## Symptoms
- `POST /api/bookings/` fails (400/500).
- Booking status update fails.
- Booking missing/incorrect in listing.
## Impact
Customers cannot book; staff schedule integrity degrades; dependent flows break.
## Quick Checks
- Validate payload: `service`, `staff`, `start_time`, `end_time`.
- Check logs for validation/integrity errors.
- Confirm staff availability + overlap expectations.
- If notifications expected, confirm provider config + notification rows.
## Mitigation
1. Reproduce with known test data.
2. Inspect booking validation service and serializer permissions.
3. Confirm timezone assumptions for failing case.
4. If regression after deploy, roll back booking-related change.
## Escalation
Share booking id, user id, timestamps, and failing payload/response with engineering.
## References
- Booking logic: `backend/apps/bookings/`
- Notification logic: `backend/apps/notifications/`
+37
View File
@@ -0,0 +1,37 @@
# Runbook: Payments Sanity Check (Local Mock)
Validate payment create + webhook reconciliation without hitting Moyasar.
## Preconditions
- Venv + backend deps installed.
- DB migrated.
- Run from repo root unless noted.
## Steps
1. Start local mock server on `127.0.0.1:8001` exposing `POST /v1/payments`.
2. Seed data:
- `source venv/bin/activate`
- `cd backend`
- `python3 manage.py migrate`
- `python3 manage.py seed_demo`
3. Run API with mock settings:
- `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`
4. Generate JWT in shell (demo user) and store as `<ACCESS>`.
5. Create payment:
- `POST /api/payments/` with `booking_id`, `provider=moyasar`, `idempotency_key`, valid source.
6. Send paid webhook:
- `POST /api/payments/webhook/` with `{"type":"payment_paid","secret_token":"whsec","data":{"id":"<external_id>"}}`
7. Verify `GET /api/payments/` shows status `paid` and `paid_at` set.
## Expected Results
- Create payment returns `status=initiated` + provider `external_id` + `redirect_url`.
- Webhook returns `{"detail":"Webhook processed"}`.
- Payment transitions to `paid` idempotently.
## Edge Checks
- Wrong/missing webhook secret -> `401`.
- Reused idempotency key -> same payment reused, no duplicate charge.
- Unsupported sources rejected by validation.
## Cleanup
Stop Django + mock processes.
+19
View File
@@ -0,0 +1,19 @@
# ADR <NNNN>: <Title>
## Status
Proposed | Accepted | Deprecated | Superseded
## Context
Problem, constraints, and forces.
## Decision
Chosen option.
## Consequences
Positive/negative impact, including ops impact.
## Alternatives Considered
Viable options and rejection reason.
## Related
Relevant docs/code/runbooks/PRs.
+8
View File
@@ -0,0 +1,8 @@
# Runbook: <Title>
## Symptoms
## Impact
## Quick Checks
## Mitigation Steps
## Rollback / Escalation
## References
+16
View File
@@ -0,0 +1,16 @@
# Frontend Notes
## Current state
- React/Vite app with i18n (`en`, `ar-sa`) and RTL switching.
- Still light on routing/domain separation; critical flow test coverage needs expansion.
## Run
- `cd frontend && npm install`
- `npm run dev`
## Test
- `npm run test`
## Pointers
- Architecture: `docs/architecture.md`
- Risks: `docs/risks.md`
+4545
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -13,12 +13,13 @@
"i18next": "^23.11.5", "i18next": "^23.11.5",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-i18next": "^14.1.0" "react-i18next": "^14.1.0",
"react-router-dom": "^7.13.1"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"@testing-library/jest-dom": "^6.4.2", "@testing-library/jest-dom": "^6.4.2",
"@testing-library/react": "^14.2.1", "@testing-library/react": "^14.2.1",
"@vitejs/plugin-react": "^4.2.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.0", "vite": "^5.0.0",
"vitest": "^1.3.1" "vitest": "^1.3.1"
+24 -90
View File
@@ -1,95 +1,29 @@
import { useEffect, useState } from "react"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import { useTranslation } from "react-i18next"; import MainLayout from "./layouts/MainLayout";
import { apiGet } from "./api/client"; import HomePage from "./pages/HomePage";
import { setLocale } from "./i18n"; import BookPage from "./pages/BookPage";
import PaymentPage from "./pages/PaymentPage";
import ProfilePage from "./pages/ProfilePage";
import BookingsPage from "./pages/BookingsPage";
import LoginPage from "./pages/LoginPage";
import SalonDetailPage from "./pages/SalonDetailPage";
import PaymentReturnPage from "./pages/PaymentReturnPage";
export default function App() { export default function App() {
const [salons, setSalons] = useState([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState("idle");
const { t, i18n } = useTranslation();
useEffect(() => {
let ignore = false;
async function load() {
setStatus("loading");
try {
const data = await apiGet(`/salons/?q=${encodeURIComponent(query)}`);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch (error) {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
return ( return (
<div className="page"> <BrowserRouter>
<header className="hero"> <Routes>
<div className="hero-top"> <Route path="/" element={<MainLayout />}>
<p className="eyebrow">{t("hero.eyebrow")}</p> <Route index element={<HomePage />} />
<div className="locale-switch" aria-label={t("locale.label")}> <Route path="salon/:id" element={<SalonDetailPage />} />
<button <Route path="book" element={<BookPage />} />
type="button" <Route path="pay" element={<PaymentPage />} />
className={i18n.language === "ar-sa" ? "active" : ""} <Route path="pay/return" element={<PaymentReturnPage />} />
onClick={() => setLocale("ar-sa")} <Route path="bookings" element={<BookingsPage />} />
> <Route path="profile" element={<ProfilePage />} />
{t("locale.arabic")} <Route path="login" element={<LoginPage />} />
</button> </Route>
<button </Routes>
type="button" </BrowserRouter>
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
</div>
<h1>{t("hero.title")}</h1>
<p className="subtitle">{t("hero.subtitle")}</p>
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</div>
</header>
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && (
<p className="error">{t("results.error")}</p>
)}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<article className="card" key={salon.id}>
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
</article>
))}
</div>
</section>
</div>
); );
} }
+17 -5
View File
@@ -1,23 +1,35 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import App from "./App.jsx"; import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import i18n from "./i18n"; import i18n from "./i18n";
vi.mock("./api/client", () => ({
apiGet: vi.fn().mockResolvedValue([]),
apiPost: vi.fn()
}));
function TestWrapper({ children }) {
return <AuthProvider>{children}</AuthProvider>;
}
describe("App", () => { describe("App", () => {
it("renders the hero copy", async () => { it("renders the hero copy", async () => {
await i18n.changeLanguage("en"); await i18n.changeLanguage("en");
render(<App />); render(<App />, { wrapper: TestWrapper });
expect( expect(
screen.getByText("Find, compare, and book top salons near you.") await screen.findByText("Find, compare, and book top salons near you.")
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it("switches to Arabic and sets RTL direction", async () => { it("switches to Arabic and sets RTL direction", async () => {
await i18n.changeLanguage("en"); await i18n.changeLanguage("en");
render(<App />); render(<App />, { wrapper: TestWrapper });
fireEvent.click(screen.getByRole("button", { name: "العربية" })); const arabicButton = screen.getByRole("button", { name: "العربية" });
fireEvent.click(arabicButton);
await waitFor(() => { await waitFor(() => {
expect(document.documentElement.dir).toBe("rtl"); expect(document.documentElement.dir).toBe("rtl");
}); });
expect(screen.getByText("الصالونات")).toBeInTheDocument(); expect(arabicButton).toHaveClass("active");
}); });
}); });
+68 -9
View File
@@ -2,19 +2,78 @@ import { getActiveLocale } from "../i18n";
const API_BASE = import.meta.env.VITE_API_BASE || "/api"; const API_BASE = import.meta.env.VITE_API_BASE || "/api";
async function handleResponse(response) { export class ApiError extends Error {
if (!response.ok) { constructor(message, { status, body } = {}) {
const errorText = await response.text(); super(message);
throw new Error(errorText || `Request failed: ${response.status}`); this.name = "ApiError";
this.status = status;
this.body = body;
} }
return response.json();
} }
export async function apiGet(path) { async function handleResponse(response) {
const text = await response.text();
let body = null;
try {
body = text ? JSON.parse(text) : null;
} catch {
// Ignore
}
if (!response.ok) {
const message =
(body?.detail && typeof body.detail === "string" ? body.detail : null) ||
text ||
`Request failed: ${response.status}`;
throw new ApiError(message, { status: response.status, body });
}
return body;
}
function baseHeaders() {
return {
"Accept-Language": getActiveLocale(),
};
}
export async function apiGet(path, token) {
const headers = { ...baseHeaders() };
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, { headers });
return handleResponse(response);
}
export async function apiPost(path, body, token) {
const headers = {
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, { const response = await fetch(`${API_BASE}${path}`, {
headers: { method: "POST",
"Accept-Language": getActiveLocale(), headers,
}, body: body ? JSON.stringify(body) : undefined,
});
return handleResponse(response);
}
export async function apiPatch(path, body, token) {
const headers = {
...baseHeaders(),
"Content-Type": "application/json",
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(`${API_BASE}${path}`, {
method: "PATCH",
headers,
body: body ? JSON.stringify(body) : undefined,
}); });
return handleResponse(response); return handleResponse(response);
} }
+24
View File
@@ -0,0 +1,24 @@
import { useTranslation } from "react-i18next";
import { setLocale } from "../i18n";
export default function LocaleSwitch() {
const { t, i18n } = useTranslation();
return (
<div className="locale-switch" aria-label={t("locale.label")}>
<button
type="button"
className={i18n.language === "ar-sa" ? "active" : ""}
onClick={() => setLocale("ar-sa")}
>
{t("locale.arabic")}
</button>
<button
type="button"
className={i18n.language === "en" ? "active" : ""}
onClick={() => setLocale("en")}
>
{t("locale.english")}
</button>
</div>
);
}
@@ -0,0 +1,20 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import LocaleSwitch from "./LocaleSwitch";
import i18n from "../i18n";
describe("LocaleSwitch", () => {
beforeEach(async () => {
localStorage.clear();
await i18n.changeLanguage("en");
});
it("persists locale and sets html lang/dir", async () => {
render(<LocaleSwitch />);
fireEvent.click(screen.getByRole("button", { name: "العربية" }));
await waitFor(() => {
expect(document.documentElement.lang).toBe("ar-sa");
});
expect(document.documentElement.dir).toBe("rtl");
expect(localStorage.getItem("locale")).toBe("ar-sa");
});
});
+86
View File
@@ -0,0 +1,86 @@
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.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>
<option value="samsungpay">{t("payment.sources.samsungpay")}</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,92 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import PaymentForm from "./PaymentForm";
import i18n from "../i18n";
vi.mock("../api/client", () => ({
apiPost: vi.fn(),
}));
const { apiPost } = await import("../api/client");
describe("PaymentForm", () => {
beforeEach(async () => {
vi.clearAllMocks();
await i18n.changeLanguage("en");
Object.defineProperty(globalThis, "crypto", {
value: { randomUUID: () => "uuid-1" },
configurable: true,
});
});
it("validates source details for stc pay", async () => {
render(<PaymentForm bookingId="12" />);
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
expect(
await screen.findByText("Mobile number is required for stc pay.")
).toBeInTheDocument();
});
it("reuses idempotency key on retry", async () => {
apiPost.mockRejectedValueOnce(new Error("fail"));
apiPost.mockResolvedValueOnce({ id: "ok" });
render(<PaymentForm bookingId="12" />);
fireEvent.change(screen.getByLabelText("Source value"), {
target: { value: "+966500000000" },
});
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
await screen.findByText("fail");
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
await waitFor(() => {
expect(apiPost).toHaveBeenCalledTimes(2);
});
const firstPayload = apiPost.mock.calls[0][1];
const secondPayload = apiPost.mock.calls[1][1];
expect(firstPayload.idempotency_key).toBe("uuid-1");
expect(secondPayload.idempotency_key).toBe("uuid-1");
});
it("redirects when response includes redirect_url", async () => {
const assign = vi.fn();
Object.defineProperty(window.location, "assign", {
value: assign,
configurable: true,
});
apiPost.mockResolvedValueOnce({ redirect_url: "https://pay.test/redirect" });
render(<PaymentForm bookingId="12" />);
fireEvent.change(screen.getByLabelText("Source value"), {
target: { value: "+966500000000" },
});
fireEvent.click(screen.getByRole("button", { name: "Pay now" }));
await waitFor(() => {
expect(assign).toHaveBeenCalledWith("https://pay.test/redirect");
});
});
it("disables submit while loading", async () => {
let resolveRequest;
apiPost.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRequest = resolve;
})
);
render(<PaymentForm bookingId="12" />);
fireEvent.change(screen.getByLabelText("Source value"), {
target: { value: "+966500000000" },
});
const button = screen.getByRole("button", { name: "Pay now" });
fireEvent.click(button);
expect(button).toBeDisabled();
resolveRequest({ id: "done" });
await waitFor(() => {
expect(button).not.toBeDisabled();
});
});
});
@@ -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,33 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import ProtectedRoute from "./ProtectedRoute";
import { AuthProvider } from "../contexts/AuthContext";
function renderProtected(initialEntries = ["/protected"]) {
return render(
<AuthProvider>
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route
path="/protected"
element={
<ProtectedRoute>
<div>Secret</div>
</ProtectedRoute>
}
/>
<Route path="/login" element={<div>Login</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
);
}
describe("ProtectedRoute", () => {
it("redirects unauthenticated users to login", async () => {
renderProtected();
await waitFor(() => {
expect(screen.getByText("Login")).toBeInTheDocument();
});
});
});
+22
View File
@@ -0,0 +1,22 @@
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
export default function SalonCard({ salon }) {
const { t } = useTranslation();
return (
<article className="card" data-testid="salon-card">
<div className="card-header">
<h3>{salon.name}</h3>
<span className="rating">{salon.rating_avg} / 5</span>
</div>
<p>{salon.description || t("card.noDescription")}</p>
<div className="meta">
<span>{salon.city}</span>
<span>{salon.phone_number || t("card.phoneUnavailable")}</span>
</div>
<Link to={`/salon/${salon.id}`} className="card-link">
{t("card.viewDetails")}
</Link>
</article>
);
}
+37
View File
@@ -0,0 +1,37 @@
import { useTranslation } from "react-i18next";
import { useSalonSearch } from "../hooks/useSalonSearch";
import SalonCard from "./SalonCard";
export function SearchInput({ value, onChange }) {
const { t } = useTranslation();
return (
<div className="search">
<input
type="text"
placeholder={t("hero.searchPlaceholder")}
value={value}
onChange={(e) => onChange(e.target.value)}
aria-label={t("hero.searchPlaceholder")}
/>
</div>
);
}
export default function SalonSearch({ query }) {
const { t } = useTranslation();
const { salons, status } = useSalonSearch(query);
return (
<section className="results">
<h2>{t("results.title")}</h2>
{status === "loading" && <p>{t("results.loading")}</p>}
{status === "error" && <p className="error">{t("results.error")}</p>}
{status === "ready" && salons.length === 0 && <p>{t("results.empty")}</p>}
<div className="grid">
{salons.map((salon) => (
<SalonCard key={salon.id} salon={salon} />
))}
</div>
</section>
);
}
@@ -0,0 +1,50 @@
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();
});
it("shows error state when api fails", async () => {
apiGet.mockRejectedValueOnce(new Error("boom"));
renderWithRouter(<SalonSearch query="fail" />);
await waitFor(() => {
expect(screen.getByText(/unable to load salons/i)).toBeInTheDocument();
});
});
});
+102
View File
@@ -0,0 +1,102 @@
import { createContext, useCallback, useContext, useEffect, useState } from "react";
import { apiGet, apiPost, ApiError } 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((err) => {
const status = ApiError && err instanceof ApiError ? err.status : err?.status;
const message = typeof err?.message === "string" ? err.message : "";
const isUnauthorized = status === 401 || message.includes("401");
if (!isUnauthorized) {
setLoading(false);
return;
}
// 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,70 @@
import { render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { AuthProvider, useAuth } from "./AuthContext";
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
const { apiGet, apiPost } = await import("../api/client");
function AuthProbe() {
const { user, isAuthenticated, loading } = useAuth();
if (loading) return <div>Loading</div>;
return (
<div>
<div>auth:{isAuthenticated ? "yes" : "no"}</div>
<div>user:{user ? user.name : "none"}</div>
</div>
);
}
describe("AuthContext", () => {
beforeEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
it("restores session with refresh fallback", async () => {
localStorage.setItem("auth_access", "expired");
localStorage.setItem("auth_refresh", "refresh-token");
apiGet.mockRejectedValueOnce(new Error("401"));
apiPost.mockResolvedValueOnce({ access: "new-access" });
apiGet.mockResolvedValueOnce({ name: "Sara" });
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("auth:yes")).toBeInTheDocument();
});
expect(screen.getByText("user:Sara")).toBeInTheDocument();
expect(apiPost).toHaveBeenCalledWith("/auth/token/refresh/", { refresh: "refresh-token" });
expect(apiGet).toHaveBeenCalledWith("/auth/me/", "new-access");
});
it("clears tokens on refresh failure", async () => {
localStorage.setItem("auth_access", "expired");
localStorage.setItem("auth_refresh", "refresh-token");
apiGet.mockRejectedValueOnce(new Error("401"));
apiPost.mockRejectedValueOnce(new Error("refresh failed"));
render(
<AuthProvider>
<AuthProbe />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByText("auth:no")).toBeInTheDocument();
});
expect(localStorage.getItem("auth_access")).toBeNull();
expect(localStorage.getItem("auth_refresh")).toBeNull();
});
});
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { apiPost, ApiError } 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 = "") {
const { t } = useTranslation();
const [bookingIdInput, setBookingIdInput] = useState(bookingId);
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 idempotencyRef = useRef(null);
if (!idempotencyRef.current) {
idempotencyRef.current = generateIdempotencyKey();
}
const [idempotencyKey, setIdempotencyKey] = useState(idempotencyRef.current);
const lastBookingId = useRef(bookingId);
useEffect(() => {
if (lastBookingId.current !== bookingIdInput) {
lastBookingId.current = bookingIdInput;
const nextKey = generateIdempotencyKey();
idempotencyRef.current = nextKey;
setIdempotencyKey(nextKey);
}
}, [bookingIdInput]);
function getFirstFieldError(body) {
if (!body || typeof body !== "object") return "";
for (const value of Object.values(body)) {
if (Array.isArray(value) && value.length) return value[0];
if (typeof value === "string") return value;
}
return "";
}
async function submit() {
if (status === "loading") return;
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;
}
if (!callbackUrl) {
setStatus("error");
setError(t("payment.errors.callbackRequired"));
return;
}
source.token = sourceValue;
}
const payload = {
booking_id: Number(bookingIdInput),
provider: "moyasar",
idempotency_key: idempotencyRef.current,
source,
};
if (callbackUrl) {
payload.callback_url = callbackUrl;
}
try {
const fallbackToken =
token ||
(typeof window !== "undefined"
? localStorage.getItem(AUTH_TOKEN_KEY) || ""
: "");
const data = await apiPost("/payments/", payload, fallbackToken || undefined);
setResult(data);
setStatus("ready");
if (data?.redirect_url) {
window.location.assign(data.redirect_url);
}
} catch (err) {
if (ApiError && err instanceof ApiError && err.body) {
const fieldMessage = getFirstFieldError(err.body);
if (fieldMessage) {
console.warn("Payment validation error", err.body);
setStatus("error");
setError(fieldMessage);
return;
}
}
const message =
(typeof err?.message === "string" && err.message) || String(err) || "";
setStatus("error");
setError(message || t("payment.errors.generic"));
}
}
return {
bookingIdInput,
setBookingIdInput,
sourceType,
setSourceType,
sourceValue,
setSourceValue,
callbackUrl,
setCallbackUrl,
idempotencyKey,
status,
result,
error,
submit,
};
}
+38
View File
@@ -0,0 +1,38 @@
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 params = new URLSearchParams();
if (query) {
params.set("q", query);
}
const path = params.toString() ? `/salons/?${params.toString()}` : "/salons/";
const data = await apiGet(path);
if (!ignore) {
setSalons(data);
setStatus("ready");
}
} catch {
if (!ignore) {
setStatus("error");
}
}
}
load();
return () => {
ignore = true;
};
}, [query]);
return { salons, status };
}
+108 -1
View File
@@ -13,11 +13,118 @@
}, },
"card": { "card": {
"noDescription": "لا يوجد وصف بعد.", "noDescription": "لا يوجد وصف بعد.",
"phoneUnavailable": "الهاتف غير متوفر" "phoneUnavailable": "الهاتف غير متوفر",
"viewDetails": "عرض التفاصيل والحجز"
},
"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}} ثانية قبل المحاولة مرة أخرى."
},
"deviceId": "معرّف الجهاز (اختياري)",
"deviceIdPlaceholder": "معرّف الجهاز"
}, },
"locale": { "locale": {
"label": "اللغة", "label": "اللغة",
"arabic": "العربية", "arabic": "العربية",
"english": "الإنجليزية" "english": "الإنجليزية"
},
"common": {
"loading": "جاري التحميل..."
},
"payment": {
"title": "المدفوعات (تجريبي)",
"subtitle": "إرسال عملية دفع عبر Moyasar لحجز موجود.",
"badge": "المدفوعات",
"bookingId": "رقم الحجز",
"sourceType": "نوع المصدر",
"sourceValue": "قيمة المصدر",
"sourceValuePlaceholder": "رقم الجوال أو الرمز",
"callbackUrl": "رابط العودة",
"payNow": "ادفع الآن",
"processing": "جارٍ المعالجة...",
"idempotency": "مفتاح التكرار",
"sources": {
"stcpay": "stc pay (جوال)",
"token": "دفع عبر رمز",
"applepay": "Apple Pay",
"samsungpay": "Samsung Pay"
},
"errors": {
"bookingRequired": "رقم الحجز مطلوب.",
"mobileRequired": "رقم الجوال مطلوب لـ stc pay.",
"tokenRequired": "الرمز مطلوب للدفع عبر الرمز.",
"generic": "فشل طلب الدفع.",
"callbackRequired": "رابط العودة مطلوب لعمليات الدفع عبر الرمز."
}
} }
} }
+108 -1
View File
@@ -13,11 +13,118 @@
}, },
"card": { "card": {
"noDescription": "No description yet.", "noDescription": "No description yet.",
"phoneUnavailable": "Phone unavailable" "phoneUnavailable": "Phone unavailable",
"viewDetails": "View details & book"
},
"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."
},
"deviceId": "Device ID (optional)",
"deviceIdPlaceholder": "Device identifier"
}, },
"locale": { "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",
"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",
"samsungpay": "Samsung 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.",
"callbackRequired": "Callback URL is required for token payments."
}
} }
} }
+46
View File
@@ -0,0 +1,46 @@
import { Outlet, Link, NavLink } 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">
<div className="header-row">
<Link to="/" className="nav-brand">
{t("nav.home")}
</Link>
<div className="header-actions">
{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>
)}
<LocaleSwitch />
</div>
</div>
</header>
<main>
<Outlet />
</main>
<nav className="bottom-nav" aria-label={t("nav.home")}>
<NavLink to="/" end className="tab-link">
{t("nav.home")}
</NavLink>
<NavLink to="/bookings" className="tab-link">
{t("nav.bookings")}
</NavLink>
<NavLink to="/profile" className="tab-link">
{t("nav.profile")}
</NavLink>
</nav>
</div>
);
}
+4 -1
View File
@@ -1,11 +1,14 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App.jsx"; import App from "./App.jsx";
import { AuthProvider } from "./contexts/AuthContext";
import "./i18n"; import "./i18n";
import "./styles.css"; import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")).render( ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode> <React.StrictMode>
<App /> <AuthProvider>
<App />
</AuthProvider>
</React.StrictMode> </React.StrictMode>
); );
+199
View File
@@ -0,0 +1,199 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { apiGet, apiPost, ApiError } 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 formatWithOffset(date, offsetMinutes) {
const utcMs = date.getTime() + date.getTimezoneOffset() * 60 * 1000;
const target = new Date(utcMs + offsetMinutes * 60 * 1000);
const pad = (value) => String(value).padStart(2, "0");
const year = target.getUTCFullYear();
const month = pad(target.getUTCMonth() + 1);
const day = pad(target.getUTCDate());
const hours = pad(target.getUTCHours());
const minutes = pad(target.getUTCMinutes());
const seconds = pad(target.getUTCSeconds());
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}+03:00`;
}
function computeEndTime(startISO) {
if (!startISO || !duration) return null;
const start = new Date(startISO);
if (Number.isNaN(start.getTime())) return null;
const end = new Date(start.getTime() + duration * 60 * 1000);
return formatWithOffset(end, 180);
}
function getFirstFieldError(body) {
if (!body || typeof body !== "object") return "";
for (const value of Object.values(body)) {
if (Array.isArray(value) && value.length) return value[0];
if (typeof value === "string") return value;
}
return "";
}
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;
}
if (loading) 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) {
if (err instanceof ApiError && err.body) {
const fieldMessage = getFirstFieldError(err.body);
if (fieldMessage) {
console.warn("Booking validation error", err.body);
setError(fieldMessage);
return;
}
}
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>;
}
+100
View File
@@ -0,0 +1,100 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import BookPage from "./BookPage";
import { AuthProvider } from "../contexts/AuthContext";
import i18n from "../i18n";
vi.mock("../components/ProtectedRoute", () => ({
default: ({ children }) => <>{children}</>,
}));
vi.mock("../api/client", () => ({
apiGet: vi.fn(),
apiPost: vi.fn(),
}));
const { apiGet, apiPost } = await import("../api/client");
function renderBook(initialEntries = ["/book?salon=1"]) {
return render(
<AuthProvider>
<MemoryRouter initialEntries={initialEntries}>
<Routes>
<Route path="/book" element={<BookPage />} />
<Route path="/pay" element={<div>Pay Page</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
);
}
const salonFixture = {
id: 1,
name: "Riyadh Salon",
services: [
{ id: 10, name: "Cut", duration_minutes: 60, price_amount: 120, currency: "SAR" },
],
staff: [{ id: 99, name: "Mona" }],
};
describe("BookPage", () => {
beforeEach(async () => {
vi.clearAllMocks();
apiGet.mockResolvedValue(salonFixture);
await i18n.changeLanguage("en");
});
it("validates required fields", async () => {
renderBook();
await screen.findByText("Riyadh Salon");
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
expect(screen.getByText("Please fill all required fields.")).toBeInTheDocument();
});
it("submits booking and redirects to payment", async () => {
apiPost.mockResolvedValueOnce({ id: 55 });
renderBook();
await screen.findByText("Riyadh Salon");
fireEvent.change(screen.getByLabelText("Service"), { target: { value: "10" } });
fireEvent.change(screen.getByLabelText("Staff"), { target: { value: "99" } });
fireEvent.change(screen.getByLabelText("Date"), { target: { value: "2026-03-14" } });
fireEvent.change(screen.getByLabelText("Time"), { target: { value: "10:30" } });
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
await waitFor(() => {
expect(screen.getByText("Pay Page")).toBeInTheDocument();
});
expect(apiPost).toHaveBeenCalledWith(
"/bookings/",
expect.objectContaining({
service: 10,
staff: 99,
start_time: "2026-03-14T10:30:00+03:00",
end_time: expect.any(String),
}),
null
);
const payload = apiPost.mock.calls[0][1];
const startMs = new Date(payload.start_time).getTime();
const endMs = new Date(payload.end_time).getTime();
expect(endMs - startMs).toBe(60 * 60 * 1000);
});
it("shows API error message", async () => {
apiPost.mockRejectedValueOnce(new Error("Booking failed"));
renderBook();
await screen.findByText("Riyadh Salon");
fireEvent.change(screen.getByLabelText("Service"), { target: { value: "10" } });
fireEvent.change(screen.getByLabelText("Staff"), { target: { value: "99" } });
fireEvent.change(screen.getByLabelText("Date"), { target: { value: "2026-03-14" } });
fireEvent.change(screen.getByLabelText("Time"), { target: { value: "10:30" } });
fireEvent.click(screen.getByRole("button", { name: "Confirm booking" }));
await waitFor(() => {
expect(screen.getByText("Booking failed")).toBeInTheDocument();
});
});
});

Some files were not shown because too many files have changed in this diff Show More