Commit 04f46767 authored by DevPilot's avatar DevPilot

docs(sayd-mobile): add full planning suite for the member app proposal

Four-pillar decomposition (membership/money, activities, events, gate/invitations)
with data model, API spec, UX/motion system, and delivery plan.
parent 808c63d1
# Sayd Mobile — Product & Architecture Overview
> Planning suite for the نادي الصيد المصري member mobile app.
> Companion to the commercial proposal (`../عرض تطبيق الاعضاء.md`).
## Document map
| Doc | Contents |
|-----|----------|
| `00-overview.md` | This file — vision, principles, scope, architecture |
| `01-feature-catalog.md` | **The core deliverable.** Every pillar exploded into numbered, executable features |
| `02-data-model.md` | Postgres schema, DDL, state machines |
| `03-api-spec.md` | Full REST surface, auth, error contract |
| `04-app-ux-motion.md` | Screen inventory, navigation, and the motion/animation design system |
| `05-portal-infra-delivery.md` | Staff portal screens, Heroku infra, CI/CD, sprint plan, open questions |
## Repositories
| Repo | Purpose |
|------|---------|
| `root/sayd-mobile-app` | Flutter client (iOS 14+, Android 8+), Arabic-first RTL |
| `root/sayd-mobile-portal` | PHP 8.1+ backend API + staff web portal, Postgres, Heroku |
---
## 1. What we are actually selling
A **member-facing mobile app** backed by a **deliberately flat** management portal.
The backend's entire responsibility is:
1. Hold a read-optimized copy of member / family / dues / activity data (fed by Excel upload, then maintained through simple screens).
2. Take money and emit **invoices and receipts**.
3. Issue and verify **QR codes** (gate entry + guest invitations + event tickets).
4. Show **primitive reports** — a collections dashboard and CSV exports. Nothing more.
It is **not** an ERP. No general ledger, no journal entries, no HR, no inventory, no procurement, no POS,
no approval-chain workflow engine. The ClubPHP ERP is referenced in these docs only where it teaches us
what a real club workflow looks like (installment interest, medical-cert clearance levels, invitation
quotas). It is **not** an architectural template and none of its code or schema is reused.
The differentiator we are selling is not feature count — it is that **the member-facing experience is
exceptional**: fast, Arabic-native, and genuinely beautifully animated. Budget the craft accordingly
(see `04-app-ux-motion.md`).
---
## 2. Product principles
These are binding. When a new feature request arrives, test it against these before saying yes.
1. **Flat, not ERP.** If a feature needs double-entry accounting, multi-step approval chains, or
role-permission matrices beyond ~5 staff roles — it is Phase 2. Say so explicitly rather than
half-building it.
2. **Excel in, receipt out.** Every dataset must be re-importable from a CSV template. Even after the
portal grows CRUD screens, bulk re-upload stays a first-class path — that is the club's actual muscle
memory and our migration safety net.
3. **One phone number = one identity.** No passwords, no self-registration, no staff account activation.
The mobile number on the uploaded roster *is* the authentication substrate.
4. **Every money path ends in the same receipt object.** Renewal, installment, fine, activity fee, event
ticket, extra invitation — six sources, one payment engine, one receipt. Never let a pillar grow its
own payment integration.
5. **Every person is independently addressable.** The member, the spouse, and *each child* has their own
status, own QR, own medical certificate, own attendance record. Never model the family as one unit
with one state.
6. **Motion budget goes to moments, not to lists.** Balance reveals, payment success, QR pulse, medical
status transitions, card flips — these get lavish treatment. List screens stay instant and legible.
7. **Offline-tolerant where it hurts.** The gate QR must render with no connectivity. Statement and card
data must render from cache. Payment must never be attempted offline.
8. **Idempotent by default.** Billing jobs, payment intents, and CSV imports all re-run safely. This is
non-negotiable — it is the difference between a trusted system and a support nightmare.
---
## 3. Scope boundary (restated so it cannot drift)
### In scope — Phase 1
The four pillars in `01-feature-catalog.md`, plus the cross-cutting systems (auth, payments, ingestion,
notifications, reporting).
### Out of scope — Phase 2 candidates
Facility/court/pool booking · in-club wallet & stored balance · restaurant/cafeteria ordering ·
chalets & seasonal village bookings · support ticketing · achievements/gamification · tournament brackets
& live match center · full accounting · HR & payroll · inventory & procurement · in-club POS.
**Specific creep risks to watch** (each is a real gravitational pull from inside a Phase-1 pillar):
- Gate access → *facility booking*. Scanning a QR at a gate is not the same product as reserving a court.
- Coach evaluation → *full athlete performance management*. Show the evaluation; don't build the analytics suite.
- Paid events → *generic e-commerce*. Events have fixed inventory and a roster; that's it.
- Collections dashboard → *BI tool*. Six revenue lines and a CSV export. Stop there.
---
## 4. Architecture at a glance
```
┌──────────────────────────┐ ┌──────────────────────────────┐
│ Flutter app (member) │ │ Gate scanner (web, tablet) │
│ iOS 14+ / Android 8+ │ │ PWA, camera, offline queue │
└───────────┬──────────────┘ └───────────────┬──────────────┘
│ HTTPS/JSON, Bearer token │
└──────────────┬─────────────────────────┘
┌──────────────────────────────────────────┐
│ PHP 8.1+ API (sayd-mobile-portal) │
│ • /api/v1/* member API │
│ • /api/gate/* scan API (low latency) │
│ • /portal/* staff web UI (HTML/CSS/JS)│
│ • /jobs/* scheduled billing + jobs │
└───────┬──────────────────────┬───────────┘
│ │
┌───────▼────────┐ ┌────────▼─────────────────────────┐
│ PostgreSQL │ │ External: payment gateway, │
│ (Heroku PG) │ │ FCM/APNs push, SMS provider, │
└────────────────┘ │ S3-compatible object storage │
└──────────────────────────────────┘
```
**Stack decisions and why:**
| Layer | Choice | Rationale |
|-------|--------|-----------|
| Mobile | Flutter | One codebase, both stores, and the animation primitives we need for the "sexy" mandate are first-class. |
| Backend | PHP 8.1+, no framework | Matches the team's existing muscle memory (ClubPHP is hand-rolled PHP). A flat backend does not justify a framework's weight. Plain PDO + a thin router + a template layer. |
| DB | PostgreSQL | Heroku-native. We rely on JSONB (custom event forms, config blobs), partial indexes, and proper `timestamptz` — all things MySQL handles worse. |
| Host | Heroku | Managed, fast to ship, review apps for staging. Accepted trade-off: dyno cold starts and egress region (see infra doc for the Egypt-latency note). |
| Storage | S3-compatible | Medical certs, receipts, event images, member photos. Never on the dyno filesystem — it's ephemeral. |
| Portal UI | HTML/CSS/JS, no SPA framework | ~15 staff screens, mostly forms and tables. A build step would cost more than it returns. |
---
## 5. The four pillars
| # | Pillar | Promise | Catalog section |
|---|--------|---------|-----------------|
| 1 | **Membership & Money** | See my and my family's membership status, dues, installments and fines — and pay them. | F1 |
| 2 | **Sports Activities** | Browse and join activities, upload medical certificates, track attendance and coach evaluations. | F2 |
| 3 | **Club Life & Paid Events** | Read club news, and book & pay for paid events (trips, Umrah, etc.). | F3 |
| 4 | **Gate & Invitations** | Enter via personal QR (mine or any child's), and buy extra guest invitations past my monthly quota. | F4 |
| — | **Cross-cutting** | Auth, payments, ingestion, notifications, reporting, platform. | F5–F9 |
---
## 6. How to read the feature catalog
Every feature carries an ID (`F1.2.3`), a priority, and acceptance criteria.
| Priority | Meaning |
|----------|---------|
| **P0** | Ship-blocking. The pillar is not deliverable without it. |
| **P1** | Required for a credible launch. Cut only under schedule emergency, with the club's sign-off. |
| **P2** | High-value polish. Ship if the schedule holds. |
| **P3** | Explicitly deferred to Phase 2 — listed so it is recorded, not forgotten. |
Estimates are in **engineer-days** and assume one senior full-stack engineer plus one Flutter engineer
working in parallel. They cover build + self-test, not QA cycles or client review latency.
# Sayd Mobile — Feature Catalog
Every pillar decomposed into the complementary features required to make it work in production.
IDs are stable and referenced from the API, data model, and delivery plan docs.
Priorities: **P0** ship-blocking · **P1** required for credible launch · **P2** polish if schedule holds ·
**P3** explicitly Phase 2.
Estimates are engineer-days (build + self-test only).
---
# F1 — Pillar 1: Membership & Money
> *"العضو يقدر يشوف بيانات عضويته وبيانات عضوية أولاده، حالة العضوية، والمتأخرات أو المدفوعات، أقساط الشهر ده والأقساط الجاية، غرامات"*
## F1.1 — Identity & family graph
The single most load-bearing model in the system. Get this wrong and all four pillars inherit the damage.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.1.1 | **Person model** — one row per human (member, spouse, child), each with own status, DOB, gender, photo, national ID. Not a "member with embedded children" blob. | P0 | 2 |
| F1.1.2 | **Household model** — links persons to one membership. Roles: `primary`, `spouse`, `child`, `dependent`. A person belongs to exactly one household in Phase 1. | P0 | 1 |
| F1.1.3 | **Relationship metadata**`relationship` (son/daughter/wife/husband), `child_order`, `join_date`, `classification` (included in membership vs. added-for-a-fee). Drives fee logic and card variants. | P0 | 1 |
| F1.1.4 | **Per-person status** — each person independently `active / grace / suspended / expired / frozen`. A suspended child must not suspend the parent. | P0 | 1 |
| F1.1.5 | **Guardian/payer designation** — which adult(s) may view and pay for a given child. Defaults to the primary member. | P0 | 1 |
| F1.1.6 | **Visibility scopes** — per-link boolean flags: `can_view_financials`, `can_view_medical`, `can_view_evaluations`, `can_view_attendance`, `can_pay`. Ship the model + defaults now even if the toggle UI lands later — retrofitting permission scoping into a live app is brutal, and divorced/separated households are a certainty at this club's size. | P0 | 2 |
| F1.1.7 | **Age derivation** — computed age in years/months from DOB at query time, never stored stale. Drives activity eligibility (F2.1.4) and guest pricing (F4.7.3). | P0 | 0.5 |
| F1.1.8 | **Aging-out rule** — a child crossing the club's dependent age ceiling changes classification and may owe a separate membership. Detect and flag; do not auto-charge. | P1 | 1.5 |
| F1.1.9 | **Person photo management** — upload/replace via portal or app, stored in object storage, served through a signed-URL CDN path. Required for the membership card and gate scan verification. | P0 | 1.5 |
| F1.1.10 | **Deceased / transferred / waived membership handling** — read-only historical states so a transferred membership doesn't vanish from the app mid-season. Display-only; the transfer *process* stays in the club's hands. | P2 | 1 |
**Acceptance:** A household with 1 member + 1 spouse + 3 children renders 5 independently-statused persons;
suspending one child changes nothing for the other four.
## F1.2 — Membership card & status
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.2.1 | **Digital membership card** — photo, name (AR), membership number, membership type, status badge, expiry date, club branding. The app's hero object. | P0 | 2 |
| F1.2.2 | **Family switcher** — segmented control / horizontal card carousel to switch the whole app context between household persons. Persisted across sessions. | P0 | 2 |
| F1.2.3 | **Status state machine** — explicit, testable transitions with named triggers. `active → grace` (due date passed), `grace → suspended` (grace window exhausted), `suspended → active` (payment), `* → frozen` (staff action), `active → expired` (renewal lapsed past cutoff). No implicit status derivation scattered across queries. | P0 | 2 |
| F1.2.4 | **Configurable grace window** — per membership type, in days. Drives both the badge and the gate decision (F4.2.2). | P0 | 1 |
| F1.2.5 | **Status explainer sheet** — tapping the badge explains *why* ("مستحق منذ 12 يوم — لديك 8 أيام سماح") and what to do. Kills a large share of support calls. | P1 | 1 |
| F1.2.6 | **Card flip → QR** — the card flips to reveal the person's gate QR (F4.1). One gesture from app-open to gate-ready. | P0 | 1.5 |
| F1.2.7 | **Offline card render** — card + QR render from encrypted local cache with no network. Shows a "last synced" stamp when stale. | P0 | 2 |
| F1.2.8 | **Add to Apple/Google Wallet** | P3 | — |
## F1.3 — Statement of account
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.3.1 | **Unified charge ledger** — one table, one screen, six charge types: `annual_renewal`, `installment`, `fine`, `activity_fee`, `event_ticket`, `extra_invitation`. Never build per-type screens. | P0 | 3 |
| F1.3.2 | **Three tabs: مستحق الآن / قادم / متأخر** — due-now, upcoming, overdue. Counts and totals per tab. | P0 | 2 |
| F1.3.3 | **Per-person filter** — "show only Youssef's charges" via the family switcher; plus an "all family" aggregate mode. | P0 | 1 |
| F1.3.4 | **Household total owed** — the number on the home screen. Sum across all persons the viewer has `can_view_financials` on. | P0 | 1 |
| F1.3.5 | **Charge detail sheet** — description, amount, due date, period covered, status, linked person, linked entity (which activity / which event / which violation), receipt link if paid. | P0 | 1.5 |
| F1.3.6 | **Overdue aging** — 1–30 / 31–60 / 61–90 / 90+ buckets with day counts on each line. Feeds the portal's collections dashboard (F9.1). | P0 | 1 |
| F1.3.7 | **Payment history** — chronological list of every payment with receipt access, filterable by person and date range. | P0 | 1.5 |
| F1.3.8 | **Statement export** — PDF and Excel of the full account, emailed or shared. The club's members *will* ask for this for reimbursement and record-keeping. | P1 | 2 |
| F1.3.9 | **Running balance & credit** — handles overpayment producing a credit balance that auto-applies to the next charge. Must be visible, not silent. | P1 | 2 |
| F1.3.10 | **Empty / all-clear state** — a genuinely delightful "لا مستحقات عليك" state. This is most members' most common view; do not leave it as a blank list. | P1 | 1 |
## F1.4 — Installments
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.4.1 | **Installment plan model** — total, down payment, interest rate, months, monthly amount, start date, status. Mirrors real club practice (the reference ERP runs plans at a 22% default rate with grace months). | P0 | 2 |
| F1.4.2 | **Schedule generation** — N dated rows with principal/interest split and remaining-balance-after. Generated once at plan creation, immutable thereafter except for payment status. | P0 | 2 |
| F1.4.3 | **Plan detail screen** — progress ring (paid/total), next due, full schedule list with per-row status. | P0 | 2 |
| F1.4.4 | **Pay one installment** | P0 | 1 |
| F1.4.5 | **Pay several installments at once** — multi-select rows, one checkout. | P1 | 1.5 |
| F1.4.6 | **Early settlement** — pay the remaining balance in full. **Must recompute interest** per club policy (unearned interest is typically dropped) — this is a distinct endpoint returning a settlement quote the member confirms before paying, never a naive sum of remaining rows. | P1 | 2.5 |
| F1.4.7 | **Grace months** — plans may defer the first N months' interest or payment entirely. Schedule generator must support it. | P1 | 1 |
| F1.4.8 | **Partial payment against an installment** — allowed only where club policy permits; per-charge-type config, not global. | P1 | 1.5 |
| F1.4.9 | **Installment reminders** — T-7, T-3, T-0, and overdue escalation pushes per row (F8.3). | P0 | 1 |
| F1.4.10 | **Member-initiated plan request** — member with a large due asks to split it; creates a request in a staff queue with the proposed terms. Not auto-approved. | P2 | 2 |
| F1.4.11 | **Cheque-backed plans** — display only (some plans are secured by post-dated cheques); the app shows cheque status, it does not manage cheques. | P3 | — |
## F1.5 — Fines & violations
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.5.1 | **Fine list** — amount, reason, date imposed, status (`imposed / paid / appealed / waived / cancelled`), linked person. | P0 | 1.5 |
| F1.5.2 | **Violation detail** — the narrative behind the fine: what happened, when, where, evidence photo if the club attached one. Members dispute fines they don't understand; showing the reason reduces appeals. | P0 | 1 |
| F1.5.3 | **Pay a fine** — via the shared payment engine. | P0 | 0.5 |
| F1.5.4 | **Appeal a fine** — free text + optional photo attachment, submitted to a staff queue. Deliberately thin: a status field and a staff response, no SLA engine, no multi-step approval. | P1 | 2 |
| F1.5.5 | **Appeal status tracking + push on decision**`submitted → under_review → accepted / rejected` with the staff's note shown to the member. | P1 | 1 |
| F1.5.6 | **Gate-block policy flag** — whether an unpaid fine blocks gate entry. **Config-driven and evaluated independently from membership status** (F4.2.2). These are two different questions and conflating them in the scan logic will produce wrong denials. | P0 | 1 |
| F1.5.7 | **Suspension penalties** — some violations carry a date-ranged suspension rather than (or alongside) a fine. Must suspend gate access for exactly that window, then auto-restore. | P1 | 2 |
| F1.5.8 | **Fine history** — including waived and cancelled, for the member's own records. | P2 | 0.5 |
## F1.6 — Payment engine *(shared by F1, F2, F3, F4 — build once)*
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.6.1 | **Gateway abstraction layer** — a driver interface (`authorize`, `capture`, `refund`, `status`, `webhook`) with one concrete implementation in Phase 1 (Paymob / Fawry / Kashier). Egyptian club gateway contracts get renegotiated constantly; a hardcoded integration is a guaranteed rewrite. | P0 | 3 |
| F1.6.2 | **Payment intent + idempotency** — every "pay" tap creates a persisted intent with a client-generated idempotency key *before* the gateway is called. A dropped connection can never double-charge or double-credit. Non-negotiable. | P0 | 3 |
| F1.6.3 | **Webhook handler** — signature-verified, replay-safe, out-of-order-safe. The webhook, not the app's return trip, is the source of truth for payment success. | P0 | 2.5 |
| F1.6.4 | **Multi-charge checkout (cart)** — pay a renewal + a child's activity fee + an extra invitation in one gateway transaction, reconciled internally into separate charge settlements and receipt lines. | P0 | 3 |
| F1.6.5 | **Card payments** | P0 | 1 |
| F1.6.6 | **Mobile wallet payments** (Vodafone Cash etc.) | P0 | 1 |
| F1.6.7 | **Fawry / reference-code payments** — generate a code the member pays at any kiosk; charge settles on webhook. Critical in Egypt — a meaningful share of members will not enter card details in an app. | P1 | 2 |
| F1.6.8 | **Saved cards (tokenization)** — store the gateway token, never the PAN. | P2 | 2 |
| F1.6.9 | **Autopay opt-in** — recurring charge on the billing date, with explicit consent UI, a visible "next charge" notice, and a one-tap off switch. Trust-sensitive: treat as its own mini-project. **Gate on gateway capability** (see open questions). | P2 | 4 |
| F1.6.10 | **Bank-transfer proof upload** — member uploads a transfer receipt; staff confirms; charge settles. Manual but common. | P2 | 2 |
| F1.6.11 | **Failure handling** — typed failure reasons (insufficient funds, 3DS abandoned, gateway timeout) with a retry path. Never a dead end. | P0 | 1.5 |
| F1.6.12 | **Staff-recorded offline payment** — cash at the counter, recorded in the portal, appears in the app statement. Keeps the app the single source of truth even for non-digital payments. | P0 | 1.5 |
| F1.6.13 | **Refund & void** — staff-initiated, tied to the original payment, reverses the charge settlement and issues a credit note. | P1 | 2.5 |
| F1.6.14 | **Reconciliation view** — daily gateway settlement vs. recorded payments, with a drift report. The single biggest driver of long-term trust in self-service payment; without it, discrepancies get discovered by accident months later. | P0 | 3 |
| F1.6.15 | **Payment limits & fraud guards** — max amount per transaction, velocity limits per member, suspicious-pattern flagging. | P1 | 1.5 |
## F1.7 — Invoices & receipts
> The founder's framing: *"السيستم ده المفروض يطلع لهم في الآخر فواتير بتاعت المدفوعات وبس"* — invoices and
> receipts are the backend's core output. Treat this as a headline feature, not plumbing.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.7.1 | **Receipt object** — sequential receipt number, date, payer, lines (one per settled charge), method, total, amount-in-words in Arabic, club header. | P0 | 2 |
| F1.7.2 | **Sequential numbering** — gapless per series per year, generated transactionally. Gaps trigger audit questions at Egyptian clubs. | P0 | 1.5 |
| F1.7.3 | **PDF generation** — Arabic RTL, correct shaping, club logo, QR of the receipt ID for staff verification. | P0 | 3 |
| F1.7.4 | **In-app receipt archive** — every receipt, searchable, shareable (WhatsApp/email), re-downloadable forever. | P0 | 1.5 |
| F1.7.5 | **Instant receipt on success** — appears in the success animation flow (F4 of the motion spec), not after a refresh. | P0 | 1 |
| F1.7.6 | **Emailed receipt** — where an email exists on file. | P1 | 1 |
| F1.7.7 | **VAT handling** — configurable rate and per-charge-type taxability, with a compliant tax line. Off by default; enable per the club's tax position. | P1 | 2 |
| F1.7.8 | **Credit note** — for refunds/voids, referencing the original receipt. | P1 | 1.5 |
| F1.7.9 | **Pro-forma invoice** — issued *before* payment for members who need one for corporate reimbursement. | P2 | 1.5 |
| F1.7.10 | **Staff receipt re-print with print count** — audit trail of reprints. | P2 | 1 |
## F1.8 — Billing cycle & renewals
> *"يدفع الاشتراك أو تجديد الاشتراك أول كل شهر يوم واحد في الشهر"* — day 1 is a **billing cycle date**, not a
> one-day-only payment window. Two distinct mechanics: charge *generation* and payment *collection*.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.8.1 | **Monthly generation job** — on day 1, generate the cycle's charges from active plans and active activity subscriptions. Idempotent: re-running the job, or a member already holding that period's charge, must never duplicate. | P0 | 3 |
| F1.8.2 | **Configurable cycle anchor** — day-of-month is config, not a hardcoded `1`. Clubs change this. | P0 | 0.5 |
| F1.8.3 | **Payment window & grace cutoff** — pay from day 1 through day N penalty-free; late-fee rule applies after. Per-charge-type config. | P0 | 2 |
| F1.8.4 | **Late-fee rule engine** — flat amount or percentage, one-time or per-period, with a cap. Applied by a job, not on read, so the number never changes while the member is looking at it. | P1 | 2.5 |
| F1.8.5 | **Day-1 notification burst** — push to every member with a charge that cycle, throttled to avoid provider rate limits; SMS fallback for app-inactive members. | P0 | 2 |
| F1.8.6 | **T-3 upcoming reminder** — before day 1, so the member isn't surprised. | P0 | 1 |
| F1.8.7 | **Annual renewal charge** — the yearly membership fee, generated on the membership anniversary or the club's fixed fiscal date, with its own reminder cadence (T-30, T-7, T-0). | P0 | 2 |
| F1.8.8 | **Proration** — mid-cycle joins and mid-cycle activity enrollments charge a partial period. | P1 | 2 |
| F1.8.9 | **Job observability** — every run logs rows generated/skipped/failed, surfaced in the portal. A silent billing job is an unacceptable risk. | P0 | 1.5 |
| F1.8.10 | **Dry-run mode** — preview what the job *would* generate before it commits. Used every cycle for the first months of live operation. | P1 | 1.5 |
## F1.9 — Discounts & adjustments
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F1.9.1 | **Discount model** — percentage or fixed, scoped to a charge type, a person, or a membership type, with validity dates. | P1 | 2 |
| F1.9.2 | **Regulatory / statutory discounts** — legally-mandated categories the club must honour (the reference ERP models these separately from commercial discounts, and so should we). | P1 | 1.5 |
| F1.9.3 | **Sibling discount** — Nth child at a reduced activity rate. Extremely common club policy. | P1 | 2 |
| F1.9.4 | **Early-payment discount** — pay before day X of the cycle, get Y% off. A direct lever on the collection-rate KPI this whole product is sold on. | P2 | 1.5 |
| F1.9.5 | **Staff manual adjustment** — credit or debit a member's account with a mandatory reason, fully audit-logged. | P1 | 1.5 |
| F1.9.6 | **Exemption** — waive a charge entirely with a reason and an approver. | P1 | 1 |
| F1.9.7 | **Discount transparency in-app** — show the member the original price, the discount line, and the final amount. Never just a lower number with no explanation. | P1 | 1 |
---
# F2 — Pillar 2: Sports Activities
> *"يقدر يشوف الأنشطة الرياضية، واشتراكات أولاده فيها، يرفع الشهادات الطبية عشان يتقبلوا يلعبوا، يشوف الحضور والغياب، وتقييمات المدرب"*
## F2.1 — Catalog & discovery
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.1.1 | **Discipline → program → group hierarchy** — sport (swimming) → program (swimming, ages 6-9, beginner) → group (Sat/Mon 4pm, Coach Ahmed). The member browses disciplines and enrolls into groups. | P0 | 2 |
| F2.1.2 | **Catalog screen** — discipline cards with icon/photo, program list with age bracket, sessions-per-week, monthly fee, and seats-left. Publicly priced — the proposal explicitly calls out that hidden pricing kills enrollment. | P0 | 3 |
| F2.1.3 | **Program detail** — description, what's included, required equipment, coach profile, schedule preview, capacity, fees broken down (registration + monthly + card/form fees). | P0 | 2 |
| F2.1.4 | **Auto age-eligibility filter** — browsing "for Youssef (7)" shows only programs he qualifies for, with ineligible ones either hidden or shown greyed with the reason. Uses computed age (F1.1.7). | P0 | 1.5 |
| F2.1.5 | **Gender restriction handling** — programs may be male/female/mixed. | P0 | 0.5 |
| F2.1.6 | **Real-time capacity** — seats-left decrements at enrollment, not on a nightly batch. Popular groups (swimming, karate) *will* oversell otherwise. | P0 | 2 |
| F2.1.7 | **Member vs. non-member pricing** — display the correct rate for the viewer. | P0 | 0.5 |
| F2.1.8 | **Search & filter** — by discipline, day of week, time of day, age, coach. | P1 | 2 |
| F2.1.9 | **Coach profile** — photo, bio, certifications, disciplines, rating (F2.8.2). Parents choose coaches, not just time slots. | P1 | 1.5 |
| F2.1.10 | **Recommended-for-you strip** — programs matching a child's age and history. Cheap conversion lever. | P2 | 1.5 |
| F2.1.11 | **Trial session booking** — one free/discounted trial before committing to a month. Strong conversion tool; needs its own attendance treatment. | P2 | 3 |
## F2.2 — Enrollment
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.2.1 | **Enrollment flow** — program → person → group/time-slot → fee summary → medical-cert gate → pay → confirmation with first session date. | P0 | 4 |
| F2.2.2 | **Person picker** — enroll self or any household child; blocks persons already enrolled in that program. | P0 | 1 |
| F2.2.3 | **Fee composition** — one-time registration fee + card fee + form fee + first month's subscription, itemised before payment. Nasty surprises at checkout are the #1 abandonment cause. | P0 | 1.5 |
| F2.2.4 | **Medical-certificate precondition** — enrollment checks for a valid cert and either blocks, or allows enrollment with a **grace deadline** (club-configurable), after which the enrollment is suspended. See F2.3. | P0 | 2 |
| F2.2.5 | **Multi-child / multi-program cart** — a parent enrolling 3 kids in swimming should not repeat the flow 3 times. Batch add, one checkout, one receipt with multiple lines. | P1 | 3 |
| F2.2.6 | **Waitlist** — when a group is full, join a waitlist with a visible position. | P1 | 2.5 |
| F2.2.7 | **Waitlist seat offer** — seat frees up → push to the next person → short claim window (e.g. 24h) → auto-passes to the next if unclaimed. This is a shared primitive: **reuse it for events** (F3.3.6). | P1 | 3 |
| F2.2.8 | **Enrollment confirmation** — in-app + push, with schedule, location, coach, what to bring, first session date. | P0 | 1 |
| F2.2.9 | **Capacity race safety** — two parents claiming the last seat simultaneously must not both succeed. Transactional seat reservation with a short hold during checkout. | P0 | 2 |
| F2.2.10 | **Enrollment hold during payment** — seat is held while the member completes payment, released on abandonment/timeout. | P1 | 1.5 |
## F2.3 — Medical certificates *(explicitly requested; the operational heart of this pillar)*
> The reason this exists: children cannot legally train without valid clearance, and today nobody tracks
> expiry proactively. The value is the **expiry engine**, not the upload button.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.3.1 | **Upload flow** — pick person → pick certificate type → camera or file → optional exam date / doctor / clinic → submit. Multi-page (photo of a 2-page cert) must work. | P0 | 3 |
| F2.3.2 | **Certificate types**`recreational`, `academy`, `international` — different validity periods and different clearance requirements. A child may need a different cert for swimming than for a combat sport. | P0 | 1.5 |
| F2.3.3 | **Multiple concurrent certs per person** — do **not** model one cert per person. Different activities demand different clearances, and they expire on different dates. | P0 | 1.5 |
| F2.3.4 | **Status lifecycle**`pending → approved / rejected / conditional`, plus `expired` by time. Every transition pushes to the member. | P0 | 2 |
| F2.3.5 | **Clearance level**`full` / `conditional (with restriction notes)` / `unfit`. A coach must be able to see *why* a child is conditionally cleared before letting them train. Surfaced to the parent too. | P0 | 1.5 |
| F2.3.6 | **Rejection reason** — mandatory, member-visible, actionable ("الصورة غير واضحة — أعد الرفع"). | P0 | 0.5 |
| F2.3.7 | **Expiry engine** — computes expiry from exam date + validity period (per cert type), or honours an explicit expiry on the document. Nightly job flips expired certs and cascades to enrollment status. | P0 | 2.5 |
| F2.3.8 | **Expiry warnings** — T-30, T-14, T-7, T-0 pushes: *"شهادة يوسف الطبية تنتهي خلال ١٤ يوم"*. This single feature is most of the pillar's real-world value. | P0 | 1.5 |
| F2.3.9 | **Enrollment blocking on expiry** — expired cert suspends the child's participation (configurable: hard block vs. warn-and-grace). Must be visible in the app *before* the child shows up to a session. | P0 | 2 |
| F2.3.10 | **Grace deadline on new enrollment** — enroll now, submit the cert within N days. Countdown shown in-app; auto-suspend on breach. | P1 | 2 |
| F2.3.11 | **Staff review queue** — the portal's most-used screen for this pillar: pending certs with document viewer, approve / reject / conditional + notes, keyboard-driven for speed. | P0 | 4 |
| F2.3.12 | **"Expiring soon" staff view** — proactively chase renewals rather than discovering lapses at the poolside. | P1 | 1.5 |
| F2.3.13 | **Certificate history per person** — including superseded and expired ones, for audit. | P1 | 1 |
| F2.3.14 | **Secure document storage** — medical documents are sensitive PII: private bucket, signed short-lived URLs, access-logged, never publicly addressable. | P0 | 2 |
| F2.3.15 | **Re-upload / renew flow** — one tap from the expiry warning straight into a pre-filled upload for the same person and cert type. | P1 | 1 |
## F2.4 — Attendance
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.4.1 | **Attendance record** — per person, per session date, status `present / absent / late / excused / makeup`, with optional check-in/out times. | P0 | 2 |
| F2.4.2 | **Attendance history screen** — calendar or list view per enrollment, colour-coded, filterable by month. | P0 | 2.5 |
| F2.4.3 | **Attendance rate** — % this month / this season, presented motivationally (a ring, a streak), not as a data table. This is a parent-facing reassurance feature, not analytics. | P1 | 1.5 |
| F2.4.4 | **Advance absence notice** — parent marks "Youssef will miss Tuesday". Informational to the coach; not a formal leave-request workflow. Feeds the coach's roster view. | P1 | 2 |
| F2.4.5 | **Makeup credits** — excused absences may earn a makeup credit, with an expiry. Shown as "لديك حصة تعويضية واحدة". | P1 | 2.5 |
| F2.4.6 | **Makeup booking** — spend a credit against an open slot in the same or another eligible group, capacity-checked. | P2 | 3 |
| F2.4.7 | **Coach attendance entry** — mobile-friendly web view (coaches do not need a native app in Phase 1): load a group's roster, mark everyone in under a minute, submit. Must work on a phone at poolside on bad wifi — optimistic UI with a retry queue. | P0 | 4 |
| F2.4.8 | **Absence-streak alert** — 3 consecutive absences notifies the parent and flags the coach. Early churn signal and a genuine duty-of-care feature. | P2 | 1.5 |
| F2.4.9 | **Attendance-linked gate correlation** | P3 | — |
## F2.5 — Evaluations & progress
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.5.1 | **Evaluation model** — per person, per discipline, per period: criterion scores, overall score, skill level, strengths, weaknesses, coach notes. | P0 | 2 |
| F2.5.2 | **Configurable criteria per discipline** — swimming is scored differently from karate. Criteria carry weights and max scores. | P1 | 2 |
| F2.5.3 | **Parent-visible flag** — coaches draft internally; only submitted/approved evaluations reach the parent. Never expose drafts. | P0 | 1 |
| F2.5.4 | **Evaluation screen** — the emotional peak of this pillar for a parent. Scores as an animated radar/bar reveal, coach's written notes, skill-level badge. Deserves real design investment. | P0 | 3 |
| F2.5.5 | **Progress over time** — the same criteria across successive evaluations as a trend line. "هل ابني بيتحسن؟" is the actual question being asked. | P1 | 2.5 |
| F2.5.6 | **Skill-level progression** — beginner → intermediate → advanced, with a celebratory moment on promotion (push + in-app animation). | P1 | 1.5 |
| F2.5.7 | **Coach evaluation entry** — web view for coaches: pick group, pick player, score criteria, write notes, save draft, submit. | P0 | 3 |
| F2.5.8 | **Evaluation reminders to coaches** — monthly/periodic nudge, else this feature quietly dies in month two. | P1 | 1 |
| F2.5.9 | **Group-change recommendation** — a coach's evaluation may recommend moving a child up a group; surfaces to staff and (optionally) the parent. | P2 | 1.5 |
| F2.5.10 | **Fitness tests / measurements** | P3 | — |
## F2.6 — Schedule
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.6.1 | **Weekly training schedule** — per enrollment: day, time, duration, facility/location, coach. | P0 | 2 |
| F2.6.2 | **Family calendar** — all household persons' sessions merged into one week view. A parent with 3 kids in 4 activities needs exactly this. | P1 | 3 |
| F2.6.3 | **Next session card** — home-screen widget: "التمرين القادم: السباحة، غدًا ٤:٠٠م، حمام ٢". | P1 | 1 |
| F2.6.4 | **Schedule change notification** — coach or staff changes a slot → push to every affected parent. | P0 | 1.5 |
| F2.6.5 | **Session cancellation** — cancel a single session (weather, facility closure) with a reason and a push; optionally auto-issues makeup credits. | P1 | 2 |
| F2.6.6 | **Blackout dates / club holidays** — no sessions generated, calendar shows the closure. | P1 | 1.5 |
| F2.6.7 | **Add to device calendar** | P2 | 1 |
| F2.6.8 | **Session reminder** — T-2h push, opt-outable. | P2 | 1 |
## F2.7 — Subscription lifecycle
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.7.1 | **Monthly activity subscription** — the recurring charge, generated by the day-1 billing job (F1.8.1) for every active enrollment. | P0 | 2 |
| F2.7.2 | **Renewal (auto-generate + pay)** — the app's "renew" action against the generated charge. | P0 | 1.5 |
| F2.7.3 | **Non-payment consequence** — configurable: grace, then suspend participation. Must be visible to the coach's roster, not just the billing system. | P0 | 2 |
| F2.7.4 | **Pause / freeze** — travel, injury, exam season. Paused months are skipped by the billing job. Requires a paused-months model, not a delete. | P1 | 2.5 |
| F2.7.5 | **Group transfer request** — move to another slot/group. Auto-approve if capacity exists, else queue for staff. | P1 | 2.5 |
| F2.7.6 | **Withdrawal / cancellation** — with a notice-period rule and clear messaging on what is/isn't refundable. | P1 | 2 |
| F2.7.7 | **Re-enrollment** — a previously withdrawn person rejoins without re-paying the one-time registration fee (config). | P2 | 1.5 |
| F2.7.8 | **Season handling** — programs with defined season start/end; auto-expire enrollments at season end with a renewal prompt. | P1 | 2 |
## F2.8 — Coach interaction
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F2.8.1 | **Coach profile view** (member-facing) | P1 | 1 |
| F2.8.2 | **Rate the coach** — 1–5 stars + optional comment, periodically or after a session. Cheap to build, high goodwill, and gives the club a quality signal it has never had. | P2 | 2 |
| F2.8.3 | **Rating moderation** — staff can hide abusive content; ratings are aggregate-visible to the club, not publicly to other members in Phase 1. | P2 | 1.5 |
| F2.8.4 | **Coach → parent broadcast** — a coach messages their group's parents ("bring a towel Saturday"). One-way, no inbox, no threading — resist building a chat product. | P2 | 2.5 |
| F2.8.5 | **Two-way parent–coach messaging** | P3 | — |
---
# F3 — Pillar 3: Club Life & Paid Events
> *"يقدر يشوف الـ events بتاعت النادي والـ blogs، ويقدر يشوف الـ events المدفوعة، زي رحلات حج وعمرة، ويدفعها ويشترك فيها"*
Two distinct things that must not be merged into one feed: **news** (free, informational) and
**paid events** (bookable inventory with money attached).
## F3.1 — News & announcements
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F3.1.1 | **News feed** — chronological cards: cover image, title, category, date, excerpt. | P0 | 2 |
| F3.1.2 | **Article detail** — rich text (headings, bold, lists, images), share sheet. | P0 | 2 |
| F3.1.3 | **Categories** — announcement / facility / schedule / match / general, with colour coding and filtering. | P0 | 1 |
| F3.1.4 | **Push on publish** — with per-category opt-out in notification preferences. | P0 | 1 |
| F3.1.5 | **Scheduled publishing** — write now, publish at a set time. | P1 | 1 |
| F3.1.6 | **Pinned / urgent announcement** — a banner at the top of the home screen for critical notices (pool closure, schedule change). | P1 | 1.5 |
| F3.1.7 | **Rich media** — image galleries and embedded video links. | P2 | 2 |
| F3.1.8 | **Saved / bookmarked articles** | P2 | 1 |
| F3.1.9 | **Comments, likes, social graph****explicitly not building.** This is a bulletin board. Scope creep here burns budget for zero collections impact. | P3 | — |
## F3.2 — Paid event catalog
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F3.2.1 | **Event model** — title, description, cover, category, date range, venue, capacity, registration window, pricing, refund policy. Structurally distinct from news. | P0 | 2.5 |
| F3.2.2 | **Event categories** — trip / religious travel (Hajj & Umrah) / tournament / social / workshop / camp. Different categories surface different required fields (F3.4). | P0 | 1 |
| F3.2.3 | **Event listing** — upcoming events with price-from, dates, seats-left, and a registration-closing countdown. | P0 | 2 |
| F3.2.4 | **Event detail** — full description, itinerary, what's included/excluded, terms, gallery, organiser contact, map. | P0 | 2.5 |
| F3.2.5 | **Pricing tiers** — adult / child / member / guest / early-bird, each with its own price and its own seat allocation. A family trip prices differently per attendee type; a single price field will not survive first contact. | P0 | 2.5 |
| F3.2.6 | **Registration window** — opens/closes at set times; hard-enforced server-side, with a visible countdown. | P0 | 1 |
| F3.2.7 | **Capacity per tier + overall** — 40 seats total, max 15 children. | P1 | 1.5 |
| F3.2.8 | **Multi-date / multi-session events** | P2 | 2 |
## F3.3 — Booking & payment
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F3.3.1 | **Booking flow** — event → select attendees → per-attendee tier → required info (F3.4) → price summary → pay → ticket. | P0 | 4 |
| F3.3.2 | **Household attendee selection** — an Umrah trip is a household decision; select self + spouse + specific children in one booking. | P0 | 2 |
| F3.3.3 | **External guest attendees** — bring a non-member relative, priced at the guest tier. | P1 | 2 |
| F3.3.4 | **Seat hold during checkout** — reserve the seats while payment completes; release on abandonment. Same primitive as F2.2.10. | P0 | 1.5 |
| F3.3.5 | **Installment-eligible events** — a big-ticket trip may allow 2–3 payments. **Reuse F1.4's installment engine**; do not invent event-specific payment logic. | P1 | 2 |
| F3.3.6 | **Event waitlist** — reuse F2.2.7's waitlist primitive wholesale. | P1 | 1 |
| F3.3.7 | **Deposit + balance** — pay a deposit to secure a seat, balance due by a deadline; auto-release the seat on default. | P2 | 3 |
| F3.3.8 | **Refund policy engine** — per event: refundable until N days before, then partial, then none. Shown **before** payment, enforced at cancellation. | P1 | 2.5 |
| F3.3.9 | **Member-initiated cancellation** — subject to F3.3.8, producing a refund request for staff action. | P1 | 2 |
| F3.3.10 | **Booking modification** — add or remove an attendee post-booking, with price delta settled either way. | P2 | 3 |
## F3.4 — Event requirements & custom forms
> An Umrah trip needs passport data, a sports camp needs a consent form, a trip needs an emergency contact.
> Hardcoding fields per category does not scale; a small form engine does.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F3.4.1 | **Per-event custom form schema** — JSONB-defined fields (text, date, select, file, checkbox) with validation rules and required flags. | P1 | 3.5 |
| F3.4.2 | **Per-attendee form responses** — each attendee answers independently (each traveller has their own passport). | P1 | 2 |
| F3.4.3 | **Required document upload** — passport scan, consent form, photo — per attendee, reviewable by staff. | P1 | 2.5 |
| F3.4.4 | **Document review** — staff approves/rejects attendee documents; blocks final confirmation until complete. | P1 | 2 |
| F3.4.5 | **Waiver / terms acceptance** — versioned text, recorded acceptance with timestamp. Legally meaningful for trips and physical activities. | P1 | 1.5 |
| F3.4.6 | **Rooming / dietary preferences** — free-form via the same form engine. | P2 | — |
## F3.5 — Tickets & check-in
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F3.5.1 | **Digital ticket** — per attendee, with its own QR in a **separate namespace from the gate QR** (F4.1). An event ticket scans at the event, never at the club gate; conflating namespaces is a security hole. | P0 | 2 |
| F3.5.2 | **Ticket detail** — event info, attendee name, tier, booking reference, QR. | P0 | 1 |
| F3.5.3 | **Event check-in scanning** — staff scans tickets at the event; marks attendance; prevents double-entry. Reuses the gate scanner client (F4.3) in a different mode. | P1 | 2 |
| F3.5.4 | **Attendee roster export** — who's registered, who's paid, who's waitlisted, with their form responses. This is what the club operationally *needs*: the bus headcount, the Umrah manifest. | P0 | 2 |
| F3.5.5 | **Event reminders** — T-7, T-1, day-of pushes with practical details (meeting point, departure time). | P1 | 1.5 |
| F3.5.6 | **Ticket transfer** to another member | P3 | — |
## F3.6 — Post-event
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F3.6.1 | **Event photo gallery** — published after the event; drives engagement and next-event bookings. | P2 | 2 |
| F3.6.2 | **Post-event feedback survey** — reuses the form engine (F3.4.1). | P2 | 1.5 |
| F3.6.3 | **Past events archive** — with the member's own booking/receipt history. | P1 | 1 |
---
# F4 — Pillar 4: Gate Access & Invitations
> *"يقدر يدخل من على البوابة بـ QR code، إما بتاعه أو بتاع أولاده، كل واحد من أولاده بـ QR code، ويقدر يشتري QR codes دعوات لو خلص العدد بتاعه في الشهر"*
## F4.1 — Personal gate QR
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.1.1 | **Per-person QR** — the member **and every child and spouse individually**, never one shared family code. Required for accurate headcount and for per-person rules (a suspended child must not block the parent, and vice versa). | P0 | 2 |
| F4.1.2 | **Rotating token** — TOTP-style, signed, rotating on a short interval (30–60s). A static QR is a screenshot-and-share leak, which is precisely the revenue hole the club is trying to close. | P0 | 3.5 |
| F4.1.3 | **Offline generation** — the QR must render and verify with no connectivity at the phone. Device holds a server-issued secret (rotated periodically when online) and derives codes locally; the server verifies the derivation. Gate wifi at a club this size cannot be assumed. | P0 | 4 |
| F4.1.4 | **Clock-skew tolerance** — accept a ± window of time steps; handle phones with wrong clocks gracefully rather than denying a paid-up member at the gate. | P0 | 1.5 |
| F4.1.5 | **Secret provisioning & rotation** — issued at login, rotated on a schedule and on device change; revoked on logout or suspension. | P0 | 2 |
| F4.1.6 | **QR screen UX** — full-brightness auto-boost, large target, a live rotation indicator, person name + photo shown alongside so gate staff can eyeball a match. | P0 | 2 |
| F4.1.7 | **Fast access** — QR reachable in one gesture from app open (card flip, F1.2.6) and ideally from a home-screen shortcut. Members use this daily, in a queue, sometimes in sun glare. | P0 | 1.5 |
| F4.1.8 | **Fallback manual code** — a short numeric code gate staff can key in if the camera fails. Essential operational escape hatch. | P1 | 1.5 |
| F4.1.9 | **Wallet / NFC pass** | P3 | — |
## F4.2 — Access decision engine
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.2.1 | **Scan endpoint** — verify token → resolve person → evaluate eligibility → log → respond. Hard budget: **p95 < 500ms**. There is a physical queue behind every scan. | P0 | 3 |
| F4.2.2 | **Independent eligibility checks** — membership status, fine block (F1.5.6), suspension window (F1.5.7), and (if enabled) medical status for activity areas — evaluated as **separate named rules**, each producing its own denial reason. Never collapse these into one boolean. | P0 | 3 |
| F4.2.3 | **Human-legible denial reasons** — "منتهية منذ ١٢ يوم — يراجع مكتب العضوية", not a red X. Gate staff must not have to guess or phone the office. | P0 | 1.5 |
| F4.2.4 | **Grace-period allowance** — members inside the grace window enter, but the scanner shows an amber "متأخر — يرجى السداد" nudge. Collections lever at the exact moment of highest leverage. | P1 | 1.5 |
| F4.2.5 | **Anti-passback** — the same QR cannot enter twice without an exit, within a window. Prevents pass-back-over-the-fence sharing. Requires exit scanning (see open questions). | P2 | 2.5 |
| F4.2.6 | **Access points / gates** — multiple named gates, each scan tagged with its origin. | P1 | 1 |
| F4.2.7 | **Zone rules** — some areas (pool, gym) may require additional checks (valid medical cert). Same engine, extra rules per access point. | P2 | 2.5 |
| F4.2.8 | **Manual staff override** — let a person in despite a denial, with a mandatory reason, fully logged. Reality demands this; making it auditable is the answer, not forbidding it. | P1 | 1.5 |
| F4.2.9 | **Emergency open-gate mode** — bypass all checks during an evacuation, loudly flagged and time-boxed. | P2 | 1 |
## F4.3 — Gate scanner client
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.3.1 | **Scanner web app (PWA)** — camera view, runs on cheap Android tablets, which is realistically what a gate post will have. Installable, full-screen, kiosk-friendly. | P0 | 4 |
| F4.3.2 | **Large pass/fail feedback** — full-screen green/red, person photo + name, denial reason, plus an audible tone. Readable at arm's length in sunlight by staff who are not looking closely. | P0 | 2 |
| F4.3.3 | **Offline scan queue** — if the gate loses connectivity, verify what can be verified locally, queue the log, sync on reconnect. Fail-open vs. fail-closed is a **club policy decision** — implement both, config-selected. | P0 | 3.5 |
| F4.3.4 | **Manual code entry** — the F4.1.8 fallback. | P1 | 1 |
| F4.3.5 | **Scanner auth** — gate devices authenticate as devices, not as staff logins; revocable per device. | P0 | 1.5 |
| F4.3.6 | **Mode switch** — gate entry / gate exit / event check-in (F3.5.3) / guest entry. One client, four modes. | P1 | 1.5 |
| F4.3.7 | **Hardware turnstile integration** | P3 | — |
## F4.4 — Access log
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.4.1 | **Full scan log** — person, timestamp, gate, direction, granted/denied, reason, device, staff override if any. The operational audit trail the club currently does not have at all. | P0 | 1.5 |
| F4.4.2 | **Log viewer** — searchable by person, date, gate, result. Settles the inevitable "my son says he couldn't get in" call in seconds. | P1 | 2 |
| F4.4.3 | **Member-facing entry history** — "your last 30 entries". Transparency, and it surfaces sharing abuse to the member themselves. | P2 | 1 |
| F4.4.4 | **Live occupancy** — who is in the club right now, derived from entry/exit pairs. **Depends on exit scanning existing** — flag as a hardware dependency to confirm, not assume. | P2 | 2 |
| F4.4.5 | **Peak-hours report** — entries by hour/day. Primitive, chart-only, no drill-down. | P2 | 1.5 |
## F4.5 — Invitation quota
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.5.1 | **Quota policy per membership type** — free invitations per cycle, price per extra, purchase cap, child-invitation pricing, validity window. Config-driven, matching CSV template #5. | P0 | 2 |
| F4.5.2 | **Quota ledger** — issued / used / expired / remaining this cycle, per membership. Auditable, not a decrementing counter. | P0 | 2 |
| F4.5.3 | **Cycle reset** — resets on the same monthly anchor as billing (F1.8.2). Unused invitations expire; they do not roll over (unless configured to). | P0 | 1 |
| F4.5.4 | **Quota display** — "٣ من ٥ دعوات متبقية هذا الشهر" prominently on the invitations screen. | P0 | 1 |
| F4.5.5 | **Per-membership-type overrides** — VIP/honorary tiers get different quotas. | P1 | 1 |
| F4.5.6 | **Staff quota grant** — one-off extra invitations granted by staff, with a reason and audit log. | P1 | 1 |
## F4.6 — Issuing invitations
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.6.1 | **Issue flow** — guest name (+ optional phone / national ID), guest type (adult/child), visit date → generates a guest QR. | P0 | 2.5 |
| F4.6.2 | **Guest QR** — single-use or single-day, date-scoped, in its own namespace (not the member namespace, not the event namespace). | P0 | 2 |
| F4.6.3 | **Share the invitation** — send the QR via WhatsApp/SMS so the guest arrives holding it. Removes the "meet me at the gate" friction entirely. | P0 | 1.5 |
| F4.6.4 | **Validity window** — valid only on its date (or a configured window); auto-expires after. | P0 | 1 |
| F4.6.5 | **Revoke an invitation** — cancel an unused invitation and reclaim the quota slot. | P1 | 1 |
| F4.6.6 | **Guest scan & entry log** — tagged as a guest entry so invitation revenue reports as its own line (F9.1). | P0 | 1.5 |
| F4.6.7 | **Guest history** — who the member has invited, when, whether they showed. | P2 | 1 |
| F4.6.8 | **Guest blacklist** — staff can bar a specific national ID/phone from being invited. | P2 | 1.5 |
| F4.6.9 | **Guest-count-per-invitation** — one invitation may cover a party of N (club policy). | P2 | 1.5 |
## F4.7 — Buying extra invitations
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F4.7.1 | **Purchase flow** — quota exhausted → "شراء دعوات إضافية" → quantity → price → pay (shared engine) → invitations credited immediately. Direct plug for revenue the proposal names as currently leaking at the gate. | P0 | 2.5 |
| F4.7.2 | **Purchase cap** — maximum extra invitations per cycle, per policy. | P0 | 0.5 |
| F4.7.3 | **Differential guest pricing** — adult vs. child invitation pricing (template #5 already carries a child column). The purchase flow must ask guest type, not just quantity. | P0 | 1 |
| F4.7.4 | **Bundle pricing** — buy 5, get a discount. | P2 | 1 |
| F4.7.5 | **Purchased-invitation receipt** — same receipt object as everything else (F1.7.1). | P0 | 0.5 |
| F4.7.6 | **Revenue reporting** — extra-invitation income as its own line on the collections dashboard, already shown in the proposal's mock. | P0 | 1 |
---
# F5 — Auth & identity platform
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F5.1 | **OTP login by mobile number** — matched against the uploaded roster; no self-registration. | P0 | 3 |
| F5.2 | **OTP delivery** — SMS provider with a failover provider. Egyptian SMS delivery is unreliable enough that a single provider is a real launch risk. | P0 | 2 |
| F5.3 | **OTP security** — 2-minute expiry, 5-attempt lockout, request rate-limiting per number and per IP, single-use codes. | P0 | 2 |
| F5.4 | **Token issuance** — long-lived refresh + short-lived access token; refresh rotates on use. | P0 | 2 |
| F5.5 | **Single active device per membership** — the proposal commits to this to prevent account sharing. Needs a graceful "تم فتح حسابك على جهاز آخر" notice and easy re-auth, **not** a silent kick. | P0 | 2.5 |
| F5.6 | **Device registry** — device id, model, OS, push token, last seen; staff-visible for support. | P1 | 1.5 |
| F5.7 | **Biometric app lock** — Face ID / fingerprint to reopen the app. Financial data warrants it. | P1 | 1.5 |
| F5.8 | **Session revocation** — staff can force-logout a membership (lost phone, dispute). | P1 | 1 |
| F5.9 | **Number-change flow** — a member whose phone number changed must be re-linked by staff in the portal, verified against national ID. The single most common real-world lockout, and it has no self-service path by design. | P0 | 2 |
| F5.10 | **Multiple adults on one membership** — the member and spouse may each want the app on their own phone. F5.5's single-device rule is per **person**, not per household — model this explicitly or the spouse is locked out forever. | P0 | 2 |
| F5.11 | **Impersonation for support** — staff views a member's app state read-only, heavily audit-logged. | P2 | 2 |
---
# F6 — Data ingestion (the six CSV templates)
> The portal-not-ERP pitch rests entirely on this engine. It deserves more engineering care than its
> single storyboard screen implies.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F6.1 | **Six templates** — members, dues, activity catalog, activity subscribers, invitations policy, training schedules. Downloadable, documented, with example rows. | P0 | 2 |
| F6.2 | **Column auto-detection** — fuzzy-match incoming headers to expected fields regardless of order or exact wording; show the mapping for confirmation. | P0 | 3.5 |
| F6.3 | **Manual mapping override** — when auto-detection is unsure, staff maps columns by hand. | P0 | 2 |
| F6.4 | **Validation engine** — phone format, duplicate keys, invalid/impossible dates, missing required fields, referential integrity (an activity subscriber referencing a non-existent activity code), type coercion. Row-level errors with row numbers. | P0 | 4 |
| F6.5 | **Diff preview before commit** — "142 updated · 6 new · 3 flagged". Nothing touches the database until a human approves the diff. This is the trust anchor of the whole ingestion story. | P0 | 3.5 |
| F6.6 | **Idempotent upsert by natural key** — membership number, activity code. Re-uploading the same file updates rather than duplicating. | P0 | 2.5 |
| F6.7 | **Partial commit** — import the valid rows, export the rejected ones as a fix-and-re-upload file. Never all-or-nothing on a 2,000-row file. | P0 | 2 |
| F6.8 | **Import history & rollback** — every import logged with who/when/counts; rollback of the last import within a window. | P1 | 3 |
| F6.9 | **Encoding & format robustness** — UTF-8/Windows-1256, xlsx and csv, Arabic text, Arabic-Indic numerals, Excel's date mangling. This *will* eat days if not planned for. | P0 | 2.5 |
| F6.10 | **Large-file handling** — chunked/queued processing with progress, not a synchronous request that dies on a Heroku 30s timeout. | P0 | 2.5 |
| F6.11 | **Scheduled/recurring import** — drop a file, auto-import on a schedule. | P2 | 2 |
| F6.12 | **Export in the same shape** — export current data as the same template, so edit-and-re-upload is a supported round trip. | P1 | 2 |
---
# F7 — Notifications
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F7.1 | **Push infrastructure** — FCM + APNs, token registration/refresh, delivery receipts. | P0 | 3 |
| F7.2 | **SMS fallback** — for members who haven't opened the app (the proposal's "smart alert" explicitly promises this). | P0 | 2 |
| F7.3 | **Notification preferences** — per category (billing / activities / medical / events / news / gate), per channel. Ship the model day one. | P0 | 2 |
| F7.4 | **Template engine** — Arabic templates with variable substitution, editable in the portal without a deploy. | P0 | 2.5 |
| F7.5 | **Trigger catalog** — see table below. | P0 | 4 |
| F7.6 | **Delivery log** — what was sent to whom, when, through which channel, delivered or failed. | P0 | 1.5 |
| F7.7 | **Bulk campaigns** — staff sends to a segment (overdue > 30 days, a specific membership type, a specific activity's parents). | P1 | 3 |
| F7.8 | **Rate limiting & quiet hours** — no billing pushes at 2am; cap per member per day. | P1 | 1.5 |
| F7.9 | **In-app notification centre** — history of everything sent to this member, with deep links. | P1 | 2.5 |
| F7.10 | **Deep linking** — a notification opens the exact charge / cert / event, not the home screen. | P0 | 2 |
### Trigger catalog (minimum viable set)
| Trigger | Channel | Timing |
|---------|---------|--------|
| Upcoming charge | push | T-3 days before cycle |
| Cycle charges generated | push + SMS fallback | Day 1 |
| Charge overdue | push + SMS | T+1, T+7, T+30 |
| Payment succeeded | push | immediate |
| Payment failed | push | immediate |
| Annual renewal due | push + SMS | T-30, T-7, T-0 |
| Fine imposed | push | immediate |
| Fine appeal decided | push | immediate |
| Medical cert approved / rejected | push | immediate |
| Medical cert expiring | push | T-30, T-14, T-7, T-0 |
| Enrollment confirmed | push | immediate |
| Waitlist seat offered | push + SMS | immediate (claim window) |
| Session cancelled / rescheduled | push | immediate |
| New evaluation published | push | immediate |
| Skill level promotion | push | immediate |
| 3 consecutive absences | push | on detection |
| Event registration confirmed | push | immediate |
| Event reminder | push | T-7, T-1, day-of |
| News published | push | on publish (per category opt-in) |
| Invitation quota reset | push | cycle start |
| Gate access denied | push | immediate (optional) |
---
# F8 — Reporting (deliberately primitive)
> The brief is explicit: *"تقارير بدائية خالص"* — but beautiful. Resist building a BI tool.
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F8.1 | **Collections dashboard** — collected this month, outstanding, collection rate, % paid via app, month-over-month deltas. Already storyboarded in the proposal. | P0 | 4 |
| F8.2 | **Revenue by line** — all six charge types in one table (renewals, installments, fines, activity fees, event tickets, extra invitations). One true revenue picture, not four disconnected numbers. | P0 | 2 |
| F8.3 | **Top overdue members** — with amount and days late, one click to send a reminder. | P0 | 1.5 |
| F8.4 | **Smart alerts** — e.g. "214 members have dues and have never opened the app → send SMS". The proposal shows this; it's a genuine collections lever. | P1 | 2 |
| F8.5 | **Activity enrollment report** — headcount and revenue per program/group, capacity utilisation. | P1 | 2 |
| F8.6 | **Medical compliance report** — how many active players lack a valid certificate. A liability dashboard the club currently has no way to produce. | P1 | 1.5 |
| F8.7 | **Gate traffic report** — entries per day/hour, member vs. guest split. | P2 | 1.5 |
| F8.8 | **Event performance** — bookings, revenue, fill rate per event. | P2 | 1.5 |
| F8.9 | **CSV/Excel export on every report** — the pressure valve that stops every ad-hoc request from becoming a feature request. | P0 | 1.5 |
| F8.10 | **Scheduled email reports** — weekly collections summary to the board. | P2 | 2 |
| F8.11 | **Custom report builder** | P3 | — |
---
# F9 — Platform foundations
| ID | Feature | Pri | Est |
|----|---------|-----|-----|
| F9.1 | **Arabic-first RTL** — full RTL layout, Arabic typography, correct text shaping, Arabic-Indic numeral option, Hijri date display alongside Gregorian where relevant. Not a translation layer — the app is Arabic-native. | P0 | 3 |
| F9.2 | **Offline cache** — card, QR, statement, schedule render from encrypted local cache with a "last synced" indicator. | P0 | 3.5 |
| F9.3 | **Optimistic UI + retry queue** — writes (attendance marking, absence notice) feel instant and reconcile in the background. | P1 | 2.5 |
| F9.4 | **Error taxonomy** — typed, Arabic, actionable error messages. Never a raw HTTP code in front of a member. | P0 | 2 |
| F9.5 | **Crash & error reporting** — Sentry or equivalent, both client and server. | P0 | 1.5 |
| F9.6 | **Analytics** — funnel instrumentation on the flows that matter: login, payment, enrollment, QR open. Needed to prove the collections-rate claim the proposal sells on. | P1 | 2 |
| F9.7 | **Feature flags** — dark-launch and per-club toggles for policy-driven features (fine-blocks-gate, autopay, etc.). | P1 | 2 |
| F9.8 | **Force-update mechanism** — a minimum supported app version, enforced with a blocking screen. Essential once payment logic ships. | P0 | 1 |
| F9.9 | **Maintenance mode** — a graceful in-app notice during deploys/migrations. | P1 | 1 |
| F9.10 | **Audit log** — every staff mutation (payment recorded, cert approved, fine waived, discount applied) with actor, timestamp, before/after. | P0 | 2.5 |
| F9.11 | **Accessibility** — dynamic type, contrast, screen-reader labels on the Arabic UI. | P1 | 2 |
| F9.12 | **Data export (club-owned)** — full Excel export of the club's data on demand, as the proposal promises. | P0 | 2 |
| F9.13 | **App store presence** — listings, screenshots, privacy declarations, review-guideline compliance under the club's own developer accounts. | P0 | 3 |
| F9.14 | **Onboarding tour** — first-run walkthrough for a member base that is not uniformly app-literate. | P1 | 2 |
| F9.15 | **In-app help & club contact** — FAQ plus a one-tap call/WhatsApp to the membership office. Deflects the support load that would otherwise land on the club by phone. | P1 | 1.5 |
---
# Effort roll-up
| Area | P0 | P1 | P2 | Total (P0+P1) |
|------|----|----|----|---------------|
| F1 Membership & Money | ~62 | ~34 | ~10 | **~96** |
| F2 Sports Activities | ~54 | ~40 | ~16 | **~94** |
| F3 Events & News | ~26 | ~28 | ~10 | **~54** |
| F4 Gate & Invitations | ~40 | ~17 | ~15 | **~57** |
| F5 Auth | ~16 | ~6 | ~2 | **~22** |
| F6 Ingestion | ~25 | ~7 | ~2 | **~32** |
| F7 Notifications | ~19 | ~9 | — | **~28** |
| F8 Reporting | ~9 | ~7 | ~7 | **~16** |
| F9 Platform | ~19 | ~13 | — | **~32** |
| | | | | **≈ 431 engineer-days** |
**Reading this honestly:** ~431 engineer-days of P0+P1 against a 12-week calendar is ~4–5 engineers running
at full parallelism with zero slack. That is the real shape of the scope once every complementary feature
is on the table — and it is why the commercial proposal's 3-pillar, 12-week framing is the *right* thing to
sign. See `05-portal-infra-delivery.md` §4 for how this maps onto a defensible phasing, and which P1 items
are the honest cut-line if the club wants all four pillars inside twelve weeks.
# Sayd Mobile — Data Model (PostgreSQL)
Target: PostgreSQL 14+ on Heroku. Conventions used throughout:
- `bigint generated always as identity` primary keys.
- `timestamptz` everywhere — never naive timestamps. App timezone is `Africa/Cairo`; storage is UTC.
- `numeric(15,2)` for money. **Never** float.
- `text` + `check` constraints for evolving enumerations; native `enum` types only where the domain is
genuinely closed and stable.
- `jsonb` for genuinely variable structures (custom event forms, policy blobs, audit diffs) — never as a
dumping ground for fields we were too lazy to model.
- Soft delete via `archived_at timestamptz` (null = live), not a boolean.
- Every mutable table carries `created_at`, `updated_at`, `created_by`, `updated_by`.
---
## 1. Reference & configuration
```sql
create table branches (
id bigint generated always as identity primary key,
code text not null unique,
name_ar text not null,
name_en text,
is_active boolean not null default true,
created_at timestamptz not null default now()
);
create table membership_types (
id bigint generated always as identity primary key,
code text not null unique, -- working, honorary, foreign, ...
name_ar text not null,
annual_fee numeric(15,2) not null default 0,
grace_days integer not null default 0, -- F1.2.4
dependent_age_ceiling integer, -- F1.1.8
is_active boolean not null default true
);
-- Key/value app config, editable in the portal without a deploy.
create table app_config (
key text primary key,
value jsonb not null,
description text,
updated_at timestamptz not null default now(),
updated_by bigint
);
-- Seeded keys: billing.cycle_anchor_day, billing.grace_cutoff_day,
-- gate.fine_blocks_entry, gate.offline_policy (fail_open|fail_closed),
-- medical.block_on_expiry, invitations.rollover_enabled, tax.vat_rate ...
create table feature_flags ( -- F9.7
key text primary key,
enabled boolean not null default false,
notes text,
updated_at timestamptz not null default now()
);
```
---
## 2. Identity & family graph *(F1.1)*
The critical decision: **`persons` is the unit of identity**, not `members`. A membership is a container;
every human in it — member, spouse, each child — is an independently statused row. Every downstream
feature (QR, medical cert, attendance, charge) hangs off `person_id`.
```sql
create type person_status as enum ('active','grace','suspended','expired','frozen','deceased');
create table memberships (
id bigint generated always as identity primary key,
membership_number text not null unique,
branch_id bigint not null references branches(id),
membership_type_id bigint not null references membership_types(id),
status person_status not null default 'active',
join_date date,
expiry_date date, -- annual renewal horizon
grace_days_override integer, -- null => inherit from type
notes text,
archived_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table persons (
id bigint generated always as identity primary key,
membership_id bigint not null references memberships(id) on delete restrict,
role text not null check (role in ('primary','spouse','child','dependent')),
full_name_ar text not null,
full_name_en text,
national_id text,
date_of_birth date not null,
gender text check (gender in ('male','female')),
relationship text, -- son, daughter, wife, husband
child_order integer,
classification text default 'included'
check (classification in ('included','added_paid','guest')),
phone_mobile text, -- login identity for adults (F5.1)
email text,
photo_path text,
status person_status not null default 'active', -- F1.1.4, independent per person
status_reason text,
join_date date,
archived_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index persons_national_id_uq
on persons (national_id) where national_id is not null and archived_at is null;
create index persons_membership_idx on persons (membership_id) where archived_at is null;
create index persons_phone_idx on persons (phone_mobile) where phone_mobile is not null;
-- Who may see/do what for whom. Ships day one even if the toggle UI comes later (F1.1.6).
create table person_guardianships (
id bigint generated always as identity primary key,
guardian_person_id bigint not null references persons(id) on delete cascade,
ward_person_id bigint not null references persons(id) on delete cascade,
can_view_financials boolean not null default true,
can_view_medical boolean not null default true,
can_view_evaluations boolean not null default true,
can_view_attendance boolean not null default true,
can_pay boolean not null default true,
is_primary_contact boolean not null default false,
created_at timestamptz not null default now(),
unique (guardian_person_id, ward_person_id),
check (guardian_person_id <> ward_person_id)
);
```
Age is **always derived**, never stored (F1.1.7):
```sql
create or replace function person_age_years(dob date, at_date date default current_date)
returns integer language sql immutable as $$
select extract(year from age(at_date, dob))::int;
$$;
```
---
## 3. Auth *(F5)*
```sql
create table otp_requests (
id bigint generated always as identity primary key,
phone text not null,
code_hash text not null, -- never store the plaintext OTP
person_id bigint references persons(id),
attempts integer not null default 0,
max_attempts integer not null default 5,
expires_at timestamptz not null,
consumed_at timestamptz,
ip inet,
created_at timestamptz not null default now()
);
create index otp_phone_idx on otp_requests (phone, created_at desc);
create table devices (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id) on delete cascade,
device_uid text not null,
platform text check (platform in ('ios','android','web')),
model text,
os_version text,
app_version text,
push_token text,
gate_secret bytea, -- F4.1.5, rotating TOTP seed for offline QR
secret_rotated_at timestamptz,
is_active boolean not null default true,
last_seen_at timestamptz,
created_at timestamptz not null default now(),
unique (person_id, device_uid)
);
-- Single active device per PERSON (not per household) — F5.5 + F5.10.
create unique index devices_one_active_per_person
on devices (person_id) where is_active;
create table auth_tokens (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id) on delete cascade,
device_id bigint references devices(id) on delete cascade,
refresh_hash text not null unique,
expires_at timestamptz not null,
revoked_at timestamptz,
rotated_from bigint references auth_tokens(id),
created_at timestamptz not null default now()
);
```
---
## 4. The unified charge ledger *(F1.3)*
One table for all six revenue types. This is what makes "one statement screen" and "one collections
dashboard" possible instead of six parallel implementations.
```sql
create type charge_type as enum (
'annual_renewal','installment','fine','activity_fee','event_ticket','extra_invitation','adjustment'
);
create type charge_status as enum ('pending','partial','paid','waived','cancelled','overdue');
create table charges (
id bigint generated always as identity primary key,
membership_id bigint not null references memberships(id),
person_id bigint not null references persons(id), -- who the charge is FOR
type charge_type not null,
description_ar text not null,
amount numeric(15,2) not null check (amount >= 0),
discount_amount numeric(15,2) not null default 0,
late_fee_amount numeric(15,2) not null default 0,
tax_amount numeric(15,2) not null default 0,
total_amount numeric(15,2) not null
generated always as (amount - discount_amount + late_fee_amount + tax_amount) stored,
paid_amount numeric(15,2) not null default 0,
status charge_status not null default 'pending',
due_date date not null,
period_start date, -- which month/season this covers
period_end date,
-- polymorphic backlink to whatever generated it
source_type text, -- enrollment | installment_schedule | fine | event_booking | invitation_purchase
source_id bigint,
billing_run_id bigint, -- F1.8.9 traceability
idempotency_key text, -- F1.8.1 — stops duplicate generation
archived_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index charges_idem_uq on charges (idempotency_key) where idempotency_key is not null;
create index charges_person_status_idx on charges (person_id, status) where archived_at is null;
create index charges_due_idx on charges (due_date) where status in ('pending','partial','overdue');
create index charges_membership_idx on charges (membership_id, due_date desc);
```
**Aging** (F1.3.6) is a view, not stored — it changes daily:
```sql
create view v_charge_aging as
select c.*,
case when c.status in ('paid','waived','cancelled') then null
when c.due_date >= current_date then 0
else (current_date - c.due_date) end as days_overdue,
case when c.status in ('paid','waived','cancelled') or c.due_date >= current_date then 'current'
when current_date - c.due_date <= 30 then '1_30'
when current_date - c.due_date <= 60 then '31_60'
when current_date - c.due_date <= 90 then '61_90'
else '90_plus' end as aging_bucket
from charges c where c.archived_at is null;
```
---
## 5. Installments *(F1.4)*
```sql
create table installment_plans (
id bigint generated always as identity primary key,
membership_id bigint not null references memberships(id),
person_id bigint not null references persons(id),
source_type text, -- what is being financed
source_id bigint,
total_amount numeric(15,2) not null,
down_payment numeric(15,2) not null default 0,
interest_rate numeric(5,2) not null default 0,
grace_months integer not null default 0,
months integer not null check (months > 0),
monthly_payment numeric(15,2) not null,
total_with_interest numeric(15,2) not null,
start_date date not null,
status text not null default 'active'
check (status in ('active','completed','settled_early','defaulted','cancelled')),
settled_at timestamptz,
created_at timestamptz not null default now()
);
create table installment_schedule (
id bigint generated always as identity primary key,
plan_id bigint not null references installment_plans(id) on delete cascade,
seq integer not null,
due_date date not null,
amount numeric(15,2) not null,
principal numeric(15,2) not null,
interest numeric(15,2) not null default 0,
remaining_after numeric(15,2) not null default 0,
charge_id bigint references charges(id), -- created by the billing job when due
status text not null default 'scheduled'
check (status in ('scheduled','due','paid','waived','cancelled')),
paid_at timestamptz,
unique (plan_id, seq)
);
```
Early settlement (F1.4.6) is a **quote**, never a naive sum — unearned interest is dropped per club policy:
```sql
create table settlement_quotes (
id bigint generated always as identity primary key,
plan_id bigint not null references installment_plans(id),
principal_due numeric(15,2) not null,
interest_due numeric(15,2) not null,
discount numeric(15,2) not null default 0, -- unearned interest waived
total_due numeric(15,2) not null,
expires_at timestamptz not null, -- quotes go stale
accepted_at timestamptz,
created_at timestamptz not null default now()
);
```
---
## 6. Fines & violations *(F1.5)*
```sql
create table violations (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id),
occurred_on date not null,
description_ar text not null,
location text,
evidence_path text,
reported_by bigint,
created_at timestamptz not null default now()
);
create table fines (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id),
violation_id bigint references violations(id),
amount numeric(15,2) not null default 0,
penalty_type text not null default 'fine'
check (penalty_type in ('fine','suspension','both','warning')),
suspension_from date, -- F1.5.7
suspension_to date,
charge_id bigint references charges(id),
status text not null default 'imposed'
check (status in ('imposed','paid','appealed','waived','cancelled')),
blocks_gate boolean not null default false, -- F1.5.6, resolved from policy at creation
created_at timestamptz not null default now()
);
create index fines_active_suspension_idx on fines (person_id, suspension_from, suspension_to)
where penalty_type in ('suspension','both') and status = 'imposed';
create table fine_appeals (
id bigint generated always as identity primary key,
fine_id bigint not null references fines(id) on delete cascade,
submitted_by bigint not null references persons(id),
reason_ar text not null,
attachment_path text,
status text not null default 'submitted'
check (status in ('submitted','under_review','accepted','rejected')),
staff_response_ar text,
decided_by bigint,
decided_at timestamptz,
created_at timestamptz not null default now()
);
```
---
## 7. Payments, receipts, invoices *(F1.6, F1.7)*
The pipeline is `payment_intents → payments → payment_allocations → receipts`. Allocations are what let
one gateway transaction settle several charges (F1.6.4).
```sql
create table payment_intents (
id bigint generated always as identity primary key,
membership_id bigint not null references memberships(id),
initiated_by bigint not null references persons(id),
idempotency_key text not null unique, -- F1.6.2, client-generated
amount numeric(15,2) not null,
charge_ids bigint[] not null, -- the cart
gateway text not null,
gateway_ref text,
status text not null default 'created'
check (status in ('created','pending','succeeded','failed','expired','cancelled')),
failure_code text,
failure_reason_ar text,
expires_at timestamptz,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create table payments (
id bigint generated always as identity primary key,
intent_id bigint references payment_intents(id),
membership_id bigint not null references memberships(id),
amount numeric(15,2) not null,
method text not null
check (method in ('card','wallet','fawry','bank_transfer','cash','cheque','credit')),
gateway text,
gateway_ref text,
paid_at timestamptz not null default now(),
recorded_by bigint, -- staff id when offline (F1.6.12)
is_offline boolean not null default false,
voided_at timestamptz,
void_reason text,
created_at timestamptz not null default now()
);
create unique index payments_gateway_ref_uq
on payments (gateway, gateway_ref) where gateway_ref is not null;
create table payment_allocations (
id bigint generated always as identity primary key,
payment_id bigint not null references payments(id) on delete cascade,
charge_id bigint not null references charges(id),
amount numeric(15,2) not null check (amount > 0),
created_at timestamptz not null default now()
);
create table receipts (
id bigint generated always as identity primary key,
receipt_number text not null unique, -- gapless per series/year (F1.7.2)
series text not null default 'MAIN',
payment_id bigint not null references payments(id),
membership_id bigint not null references memberships(id),
amount numeric(15,2) not null,
amount_words_ar text,
pdf_path text,
issued_at timestamptz not null default now(),
voided_at timestamptz,
print_count integer not null default 0
);
create table receipt_sequences ( -- gapless allocation under concurrency
series text not null,
year integer not null,
last_value bigint not null default 0,
primary key (series, year)
);
create table credit_notes ( -- F1.7.8
id bigint generated always as identity primary key,
credit_number text not null unique,
original_receipt_id bigint not null references receipts(id),
amount numeric(15,2) not null,
reason_ar text not null,
issued_by bigint,
issued_at timestamptz not null default now()
);
```
**Gateway webhooks** (F1.6.3) are stored before processing so replays and out-of-order delivery are safe:
```sql
create table gateway_webhooks (
id bigint generated always as identity primary key,
gateway text not null,
event_id text not null,
payload jsonb not null,
signature_ok boolean not null,
processed_at timestamptz,
process_error text,
received_at timestamptz not null default now(),
unique (gateway, event_id)
);
```
---
## 8. Discounts *(F1.9)*
```sql
create table discounts (
id bigint generated always as identity primary key,
code text unique,
name_ar text not null,
kind text not null check (kind in ('percentage','fixed')),
value numeric(15,2) not null,
scope_charge_type charge_type, -- null = any
scope_membership_type_id bigint references membership_types(id),
category text not null default 'commercial'
check (category in ('commercial','regulatory','sibling','early_payment','staff')),
valid_from date,
valid_to date,
max_uses integer,
is_active boolean not null default true
);
create table discount_applications (
id bigint generated always as identity primary key,
discount_id bigint not null references discounts(id),
charge_id bigint not null references charges(id) on delete cascade,
amount numeric(15,2) not null,
applied_by bigint,
reason_ar text,
created_at timestamptz not null default now()
);
```
---
## 9. Sports activities *(F2)*
```sql
create table disciplines (
id bigint generated always as identity primary key,
code text not null unique,
name_ar text not null,
icon text,
category text,
sort_order integer not null default 0,
is_active boolean not null default true
);
create table programs (
id bigint generated always as identity primary key,
code text not null unique,
discipline_id bigint not null references disciplines(id),
name_ar text not null,
description_ar text,
age_from integer,
age_to integer,
gender_restriction text check (gender_restriction in ('male','female')),
skill_level text,
sessions_per_week integer not null default 2,
session_minutes integer not null default 60,
monthly_fee_member numeric(15,2) not null default 0,
monthly_fee_nonmember numeric(15,2) not null default 0,
registration_fee numeric(15,2) not null default 0,
required_cert_type text, -- F2.3.2
is_active boolean not null default true,
archived_at timestamptz
);
create table coaches (
id bigint generated always as identity primary key,
code text not null unique,
full_name_ar text not null,
phone text,
photo_path text,
bio_ar text,
certifications jsonb,
is_active boolean not null default true
);
create table groups (
id bigint generated always as identity primary key,
code text not null unique,
program_id bigint not null references programs(id),
coach_id bigint references coaches(id),
name_ar text not null,
max_capacity integer not null default 20,
current_count integer not null default 0, -- maintained transactionally (F2.1.6)
season_start date,
season_end date,
status text not null default 'active'
check (status in ('active','paused','completed','cancelled')),
archived_at timestamptz,
check (current_count >= 0 and current_count <= max_capacity)
);
create table group_schedule (
id bigint generated always as identity primary key,
group_id bigint not null references groups(id) on delete cascade,
day_of_week smallint not null check (day_of_week between 0 and 6),
start_time time not null,
end_time time not null,
location_ar text,
is_active boolean not null default true
);
create table enrollments (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id),
group_id bigint not null references groups(id),
status text not null default 'pending_payment'
check (status in ('pending_payment','active','paused','suspended','transferred','withdrawn','completed')),
enrolled_on date not null default current_date,
left_on date,
medical_grace_deadline date, -- F2.3.10
paused_periods jsonb not null default '[]'::jsonb, -- ["2026-07","2026-08"] (F2.7.4)
transferred_to_group_id bigint references groups(id),
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);
create unique index enrollments_active_uq on enrollments (person_id, group_id)
where status in ('pending_payment','active','paused','suspended');
```
### Medical certificates *(F2.3)* — the expiry engine is the point
```sql
create table medical_certificates (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id) on delete cascade,
cert_type text not null default 'recreational'
check (cert_type in ('recreational','academy','international')),
file_paths text[] not null, -- multi-page (F2.3.1)
exam_date date,
expiry_date date, -- explicit, or derived (F2.3.7)
validity_months integer,
doctor_name text,
clinic_name text,
status text not null default 'pending'
check (status in ('pending','approved','rejected','conditional','expired','superseded')),
clearance_level text check (clearance_level in ('full','conditional','unfit')), -- F2.3.5
restrictions_ar text,
rejection_reason_ar text,
reviewed_by bigint,
reviewed_at timestamptz,
created_at timestamptz not null default now()
);
-- Multiple concurrent certs per person are intentional (F2.3.3) — no unique index on person_id.
create index medcert_person_idx on medical_certificates (person_id, status);
create index medcert_expiry_idx on medical_certificates (expiry_date)
where status in ('approved','conditional');
```
### Attendance, evaluations, makeups *(F2.4, F2.5)*
```sql
create table attendance (
id bigint generated always as identity primary key,
enrollment_id bigint not null references enrollments(id) on delete cascade,
person_id bigint not null references persons(id),
group_id bigint not null references groups(id),
session_date date not null,
schedule_id bigint references group_schedule(id),
status text not null default 'present'
check (status in ('present','absent','late','excused','makeup')),
check_in_at timestamptz,
excuse_reason_ar text,
recorded_by bigint,
created_at timestamptz not null default now(),
unique (enrollment_id, session_date, schedule_id)
);
create table evaluation_criteria (
id bigint generated always as identity primary key,
discipline_id bigint not null references disciplines(id),
name_ar text not null,
max_score integer not null default 10,
weight numeric(4,2) not null default 1.00,
sort_order integer not null default 0,
is_active boolean not null default true
);
create table evaluations (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id),
group_id bigint not null references groups(id),
coach_id bigint references coaches(id),
period_start date,
period_end date,
overall_score numeric(5,2),
skill_level text,
strengths_ar text,
weaknesses_ar text,
notes_ar text,
parent_visible boolean not null default false, -- F2.5.3, drafts never leak
status text not null default 'draft'
check (status in ('draft','submitted','published')),
published_at timestamptz,
created_at timestamptz not null default now()
);
create table evaluation_scores (
id bigint generated always as identity primary key,
evaluation_id bigint not null references evaluations(id) on delete cascade,
criterion_id bigint not null references evaluation_criteria(id),
score numeric(5,2) not null,
note_ar text,
unique (evaluation_id, criterion_id)
);
create table makeup_credits (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id),
enrollment_id bigint not null references enrollments(id),
earned_for_date date not null,
expires_on date not null,
status text not null default 'available'
check (status in ('available','booked','used','expired','cancelled')),
booked_group_id bigint references groups(id),
booked_date date,
created_at timestamptz not null default now()
);
```
### Shared waitlist primitive *(F2.2.7, reused by events F3.3.6)*
```sql
create table waitlist_entries (
id bigint generated always as identity primary key,
subject_type text not null check (subject_type in ('group','event')),
subject_id bigint not null,
person_id bigint not null references persons(id),
position integer not null,
status text not null default 'waiting'
check (status in ('waiting','offered','claimed','expired','cancelled')),
offered_at timestamptz,
claim_expires_at timestamptz,
created_at timestamptz not null default now(),
unique (subject_type, subject_id, person_id)
);
```
---
## 10. News & events *(F3)*
```sql
create table news_articles (
id bigint generated always as identity primary key,
title_ar text not null,
body_ar text not null,
excerpt_ar text,
category text not null default 'general'
check (category in ('announcement','facility','schedule','match','general')),
cover_path text,
is_pinned boolean not null default false,
status text not null default 'draft' check (status in ('draft','scheduled','published')),
publish_at timestamptz,
published_at timestamptz,
created_by bigint,
created_at timestamptz not null default now()
);
create table events (
id bigint generated always as identity primary key,
code text not null unique,
title_ar text not null,
description_ar text,
category text not null
check (category in ('trip','religious_travel','tournament','social','workshop','camp')),
cover_path text,
venue_ar text,
starts_at timestamptz not null,
ends_at timestamptz,
registration_opens_at timestamptz,
registration_closes_at timestamptz,
total_capacity integer,
booked_count integer not null default 0,
refund_policy jsonb not null default '{}'::jsonb, -- F3.3.8
form_schema jsonb, -- F3.4.1
allows_installments boolean not null default false,
allows_guests boolean not null default false,
status text not null default 'draft'
check (status in ('draft','published','closed','cancelled','completed')),
created_at timestamptz not null default now()
);
create table event_tiers ( -- F3.2.5
id bigint generated always as identity primary key,
event_id bigint not null references events(id) on delete cascade,
code text not null,
name_ar text not null,
price numeric(15,2) not null,
capacity integer,
booked_count integer not null default 0,
applies_to text check (applies_to in ('adult','child','member','guest','any')),
unique (event_id, code)
);
create table event_bookings (
id bigint generated always as identity primary key,
event_id bigint not null references events(id),
membership_id bigint not null references memberships(id),
booked_by bigint not null references persons(id),
reference text not null unique,
total_amount numeric(15,2) not null,
status text not null default 'pending_payment'
check (status in ('pending_payment','confirmed','cancelled','refunded','waitlisted')),
cancelled_at timestamptz,
refund_amount numeric(15,2),
created_at timestamptz not null default now()
);
create table event_attendees (
id bigint generated always as identity primary key,
booking_id bigint not null references event_bookings(id) on delete cascade,
person_id bigint references persons(id), -- null => external guest (F3.3.3)
guest_name_ar text,
guest_national_id text,
tier_id bigint not null references event_tiers(id),
price numeric(15,2) not null,
form_responses jsonb not null default '{}'::jsonb, -- F3.4.2
documents_status text not null default 'not_required'
check (documents_status in ('not_required','pending','approved','rejected')),
ticket_code text unique, -- F3.5.1, own namespace
checked_in_at timestamptz,
check (person_id is not null or guest_name_ar is not null)
);
```
---
## 11. Gate access & invitations *(F4)*
```sql
create table access_points (
id bigint generated always as identity primary key,
code text not null unique,
name_ar text not null,
branch_id bigint references branches(id),
direction text not null default 'both' check (direction in ('in','out','both')),
zone_rules jsonb not null default '{}'::jsonb, -- F4.2.7
is_active boolean not null default true
);
create table gate_devices ( -- F4.3.5, devices authenticate as devices
id bigint generated always as identity primary key,
code text not null unique,
access_point_id bigint references access_points(id),
token_hash text not null,
mode text not null default 'gate_in'
check (mode in ('gate_in','gate_out','event_checkin','guest')),
is_active boolean not null default true,
last_seen_at timestamptz
);
create table access_log ( -- F4.4.1
id bigint generated always as identity primary key,
person_id bigint references persons(id),
invitation_id bigint,
event_attendee_id bigint,
access_point_id bigint references access_points(id),
gate_device_id bigint references gate_devices(id),
direction text not null check (direction in ('in','out')),
subject_kind text not null check (subject_kind in ('member','guest','event')),
granted boolean not null,
denial_code text,
denial_reason_ar text,
override_by bigint, -- F4.2.8
override_reason text,
scanned_at timestamptz not null default now(),
synced_at timestamptz -- null while queued offline (F4.3.3)
);
create index access_log_person_idx on access_log (person_id, scanned_at desc);
create index access_log_point_idx on access_log (access_point_id, scanned_at desc);
```
### Invitations *(F4.5–F4.7)*
```sql
create table invitation_policies ( -- CSV template #5
id bigint generated always as identity primary key,
membership_type_id bigint not null references membership_types(id),
free_per_cycle integer not null default 0,
extra_price_adult numeric(15,2) not null default 0,
extra_price_child numeric(15,2) not null default 0,
max_extra_per_cycle integer not null default 0,
validity_days integer not null default 1,
guests_per_invitation integer not null default 1,
rollover_enabled boolean not null default false,
unique (membership_type_id)
);
-- A ledger, not a counter (F4.5.2) — every grant and consumption is auditable.
create table invitation_ledger (
id bigint generated always as identity primary key,
membership_id bigint not null references memberships(id),
cycle text not null, -- 'YYYY-MM'
entry_type text not null
check (entry_type in ('free_grant','purchase','staff_grant','issue','revoke','expire')),
quantity integer not null, -- +grant / -consume
guest_kind text check (guest_kind in ('adult','child')),
charge_id bigint references charges(id), -- for purchases
reason text,
created_by bigint,
created_at timestamptz not null default now()
);
create index invitation_ledger_cycle_idx on invitation_ledger (membership_id, cycle);
create table invitations (
id bigint generated always as identity primary key,
membership_id bigint not null references memberships(id),
issued_by bigint not null references persons(id),
guest_name_ar text not null,
guest_phone text,
guest_national_id text,
guest_kind text not null default 'adult' check (guest_kind in ('adult','child')),
guest_count integer not null default 1,
visit_date date not null,
qr_code text not null unique, -- own namespace (F4.6.2)
status text not null default 'issued'
check (status in ('issued','used','expired','revoked')),
used_at timestamptz,
revoked_at timestamptz,
created_at timestamptz not null default now()
);
create index invitations_visit_idx on invitations (visit_date, status);
create table guest_blacklist ( -- F4.6.8
id bigint generated always as identity primary key,
national_id text,
phone text,
reason_ar text not null,
created_by bigint,
created_at timestamptz not null default now()
);
```
---
## 12. Notifications *(F7)*
```sql
create table notification_templates (
id bigint generated always as identity primary key,
trigger_key text not null unique, -- 'charge.overdue', 'medcert.expiring'
channel text not null check (channel in ('push','sms','email')),
title_ar text,
body_ar text not null, -- {{person_name}}, {{amount}} ...
is_active boolean not null default true,
updated_at timestamptz not null default now()
);
create table notification_preferences (
person_id bigint not null references persons(id) on delete cascade,
category text not null
check (category in ('billing','activities','medical','events','news','gate')),
push_enabled boolean not null default true,
sms_enabled boolean not null default true,
primary key (person_id, category)
);
create table notification_queue (
id bigint generated always as identity primary key,
person_id bigint not null references persons(id),
trigger_key text not null,
channel text not null,
payload jsonb not null default '{}'::jsonb,
deep_link text, -- F7.10
scheduled_for timestamptz not null default now(),
status text not null default 'queued'
check (status in ('queued','sent','failed','skipped','cancelled')),
attempts integer not null default 0,
sent_at timestamptz,
error text,
dedupe_key text,
created_at timestamptz not null default now()
);
create unique index notif_dedupe_uq on notification_queue (dedupe_key) where dedupe_key is not null;
create index notif_pending_idx on notification_queue (scheduled_for) where status = 'queued';
```
---
## 13. Ingestion *(F6)*
```sql
create table import_batches (
id bigint generated always as identity primary key,
template text not null
check (template in ('members','dues','activities','subscribers','invitations','schedules')),
file_name text not null,
file_path text not null,
column_mapping jsonb not null default '{}'::jsonb, -- F6.2 / F6.3
total_rows integer not null default 0,
valid_rows integer not null default 0,
new_rows integer not null default 0,
updated_rows integer not null default 0,
error_rows integer not null default 0,
status text not null default 'uploaded'
check (status in ('uploaded','validating','previewed','committing','committed','failed','rolled_back')),
uploaded_by bigint,
committed_at timestamptz,
created_at timestamptz not null default now()
);
create table import_rows (
id bigint generated always as identity primary key,
batch_id bigint not null references import_batches(id) on delete cascade,
row_number integer not null,
raw jsonb not null,
normalized jsonb,
action text check (action in ('insert','update','skip','error')),
target_table text,
target_id bigint,
errors jsonb not null default '[]'::jsonb,
created_at timestamptz not null default now()
);
create index import_rows_batch_idx on import_rows (batch_id, action);
```
---
## 14. Operations: billing runs & audit
```sql
create table billing_runs ( -- F1.8.9
id bigint generated always as identity primary key,
cycle text not null, -- 'YYYY-MM'
run_type text not null check (run_type in ('monthly','annual','late_fee','dry_run')),
is_dry_run boolean not null default false, -- F1.8.10
charges_created integer not null default 0,
charges_skipped integer not null default 0,
errors_count integer not null default 0,
log jsonb not null default '[]'::jsonb,
started_at timestamptz not null default now(),
finished_at timestamptz,
status text not null default 'running'
check (status in ('running','completed','failed'))
);
create table audit_log ( -- F9.10
id bigint generated always as identity primary key,
actor_type text not null check (actor_type in ('staff','member','system','gate_device')),
actor_id bigint,
action text not null,
entity_table text not null,
entity_id bigint,
before jsonb,
after jsonb,
ip inet,
created_at timestamptz not null default now()
);
create index audit_entity_idx on audit_log (entity_table, entity_id, created_at desc);
```
---
## 15. Staff accounts (kept minimal — this is not an ERP)
```sql
create table staff_users (
id bigint generated always as identity primary key,
username text not null unique,
full_name_ar text not null,
password_hash text not null,
role text not null
check (role in ('admin','finance','activities','medical','gate','readonly')),
branch_id bigint references branches(id),
is_active boolean not null default true,
last_login_at timestamptz,
created_at timestamptz not null default now()
);
```
Six fixed roles, no permission matrix, no role builder. If the club needs finer control than this, that is
a Phase 2 conversation — not a reason to grow an authorization engine inside a flat backend.
---
## 16. Indexing & performance notes
- **Gate scan path (F4.2.1, p95 < 500ms)** is the only genuinely latency-critical query. It must touch at
most: `devices` (secret lookup) → `persons``memberships` → one partial-index probe on active
suspensions/blocking fines. Denormalise an `access_state` cache column onto `persons`
(`active|grace|blocked`, refreshed by the billing/suspension jobs) rather than joining four tables at
the turnstile.
- `charges` is the hottest table. The three partial indexes above cover the statement screen, the
collections dashboard, and the billing job respectively.
- `access_log` and `audit_log` grow without bound — plan monthly partitioning by `scanned_at`/`created_at`
before year two, and archive to object storage.
- Every "list" endpoint is keyset-paginated on `(created_at, id)`. No `OFFSET` pagination on member-facing
lists.
# Sayd Mobile — API Specification
Three distinct surfaces, deliberately separated because they have different auth models, latency budgets,
and threat profiles:
| Surface | Base | Consumer | Auth |
|---------|------|----------|------|
| Member API | `/api/v1` | Flutter app | Bearer access token (person-scoped) |
| Gate API | `/api/gate` | Gate scanner PWA | Device token (`X-Gate-Token`) |
| Portal | `/portal` | Staff web UI | Session cookie + CSRF |
---
## 1. Conventions
**Request/response:** JSON, UTF-8. `Accept-Language: ar` default.
**Success envelope**
```json
{ "data": { }, "meta": { } }
```
**Error envelope** — every error is typed and carries an Arabic member-facing message (F9.4).
```json
{
"error": {
"code": "CHARGE_ALREADY_PAID",
"message_ar": "تم سداد هذا المستحق بالفعل",
"message_en": "This charge has already been paid",
"details": { "charge_id": 4471 }
}
}
```
**Status codes:** `200` ok · `201` created · `400` validation · `401` unauthenticated ·
`403` forbidden (incl. guardianship scope failures) · `404` not found · `409` conflict (capacity race,
duplicate idempotency key with different payload) · `422` business-rule rejection · `429` rate-limited ·
`503` maintenance mode.
**Pagination:** keyset. `?limit=20&cursor=<opaque>``meta.next_cursor`.
**Idempotency:** all POSTs that move money or consume inventory require
`Idempotency-Key: <uuid>`. Replaying a key returns the original result.
**Versioning:** URL-versioned (`/api/v1`). `X-App-Version` header on every request drives force-update
(F9.8) — server may respond `426 Upgrade Required`.
**Person scoping:** the token identifies a person. Any `person_id` in a path or body must be either the
token's own person or a ward via `person_guardianships` with the relevant `can_view_*` / `can_pay` flag.
Enforced centrally in middleware, never per-controller.
---
## 2. Auth *(F5)*
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/v1/auth/otp/request` | Send OTP to a mobile number |
| `POST` | `/api/v1/auth/otp/verify` | Exchange OTP for tokens |
| `POST` | `/api/v1/auth/refresh` | Rotate refresh token |
| `POST` | `/api/v1/auth/logout` | Revoke tokens + deactivate device |
| `GET` | `/api/v1/auth/me` | Current person + household summary |
| `POST` | `/api/v1/auth/device` | Register/update device + push token |
```http
POST /api/v1/auth/otp/request
{ "phone": "+201001234567" }
200 { "data": { "request_id": "...", "expires_in": 120, "masked_phone": "+2010****4567" } }
404 PHONE_NOT_REGISTERED → "هذا الرقم غير مسجل في بيانات النادي"
429 OTP_RATE_LIMITED → "برجاء الانتظار قبل طلب كود جديد"
```
```http
POST /api/v1/auth/otp/verify
{ "request_id": "...", "code": "4921", "device": { "uid": "...", "platform": "ios", "model": "iPhone 13" } }
200 {
"data": {
"access_token": "...", "expires_in": 900,
"refresh_token": "...",
"person": { "id": 12, "name_ar": "...", "role": "primary" },
"gate_secret": "base32...", // F4.1.5 — provisioned at login for offline QR
"gate_secret_period": 30
}
}
401 OTP_INVALID / OTP_EXPIRED / OTP_MAX_ATTEMPTS
409 DEVICE_CONFLICT → "حسابك مفتوح على جهاز آخر" + { "can_force": true }
```
`DEVICE_CONFLICT` is resolvable by re-posting with `"force_device": true`, which deactivates the previous
device and pushes a notice to it (F5.5) — never a silent kick.
---
## 3. Household & profile *(F1.1, F1.2)*
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/household` | All persons the caller can see, with status + permissions |
| `GET` | `/api/v1/persons/{id}` | One person: card data, status, expiry |
| `GET` | `/api/v1/persons/{id}/card` | Membership card payload (cacheable) |
| `PATCH` | `/api/v1/persons/{id}` | Update own contact fields (phone change is staff-only, F5.9) |
| `POST` | `/api/v1/persons/{id}/photo` | Upload/replace photo |
| `GET` | `/api/v1/persons/{id}/status` | Status + reason + what-to-do (F1.2.5) |
```http
GET /api/v1/household
200 { "data": {
"membership": { "number": "10247", "type_ar": "عامل", "expiry_date": "2026-12-31" },
"persons": [
{ "id": 12, "name_ar": "عمرو مصطفى", "role": "primary", "status": "active",
"photo_url": "...", "age": 44,
"permissions": { "view_financials": true, "pay": true } },
{ "id": 15, "name_ar": "يوسف عمرو", "role": "child", "status": "grace",
"status_reason_ar": "مستحق منذ ١٢ يوم — باقي ٨ أيام سماح", "age": 7,
"permissions": { "view_financials": true, "view_medical": true, "pay": true } }
],
"totals": { "outstanding": 6350.00, "due_now": 1200.00, "overdue": 5150.00 }
} }
```
---
## 4. Statement, charges, installments, fines *(F1.3–F1.5)*
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/charges` | Statement. `?person_id=&status=&type=&bucket=due_now\|upcoming\|overdue` |
| `GET` | `/api/v1/charges/{id}` | Charge detail incl. source entity + receipt |
| `GET` | `/api/v1/statement/summary` | Tab counts and household totals |
| `GET` | `/api/v1/statement/export` | PDF/Excel statement (F1.3.8) |
| `GET` | `/api/v1/installment-plans` | Plans for the household |
| `GET` | `/api/v1/installment-plans/{id}` | Plan + full schedule |
| `POST` | `/api/v1/installment-plans/{id}/settlement-quote` | Early-settlement quote (F1.4.6) |
| `POST` | `/api/v1/installment-plans/request` | Member asks to split a charge (F1.4.10) |
| `GET` | `/api/v1/fines` | Fines + violation detail |
| `POST` | `/api/v1/fines/{id}/appeal` | Submit an appeal (F1.5.4) |
| `GET` | `/api/v1/fines/{id}/appeal` | Appeal status + staff response |
| `GET` | `/api/v1/payments` | Payment history |
| `GET` | `/api/v1/receipts` / `/{id}` / `/{id}.pdf` | Receipt archive (F1.7.4) |
```http
GET /api/v1/charges?bucket=overdue&person_id=15
200 { "data": [
{ "id": 4471, "type": "activity_fee", "description_ar": "اشتراك السباحة — مارس ٢٠٢٦",
"person": { "id": 15, "name_ar": "يوسف عمرو" },
"amount": 1200.00, "discount_amount": 120.00, "late_fee_amount": 50.00,
"total_amount": 1130.00, "paid_amount": 0,
"due_date": "2026-03-01", "status": "overdue",
"days_overdue": 12, "aging_bucket": "1_30",
"source": { "type": "enrollment", "id": 88, "label_ar": "السباحة — مجموعة أ" } }
], "meta": { "total_amount": 5150.00, "count": 4 } }
```
```http
POST /api/v1/installment-plans/44/settlement-quote
201 { "data": {
"quote_id": 902, "principal_due": 8000.00, "interest_due": 1760.00,
"discount": 1100.00, "total_due": 8660.00,
"expires_at": "2026-03-14T12:00:00Z",
"explanation_ar": "تم إسقاط الفوائد غير المستحقة عند السداد المبكر"
} }
```
---
## 5. Payments *(F1.6, F1.7)*
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/v1/payments/intents` | Create intent for a cart of charges |
| `GET` | `/api/v1/payments/intents/{id}` | Poll intent status |
| `POST` | `/api/v1/payments/intents/{id}/cancel` | Abandon, release any held inventory |
| `GET` | `/api/v1/payments/methods` | Enabled methods for this club |
| `POST` | `/api/v1/payments/webhooks/{gateway}` | **Gateway → server.** Unauthenticated, signature-verified |
| `POST` | `/api/v1/payments/bank-transfer` | Upload transfer proof (F1.6.10) |
| `GET` | `/api/v1/payments/autopay` / `PUT` / `DELETE` | Autopay consent management (F1.6.9) |
```http
POST /api/v1/payments/intents
Idempotency-Key: 8f2c...
{ "charge_ids": [4471, 4472], "method": "card", "return_url": "saydapp://payment/return" }
201 { "data": {
"intent_id": 5510, "amount": 2330.00, "status": "pending",
"gateway": "paymob",
"checkout": { "kind": "redirect", "url": "https://..." },
"expires_at": "2026-03-13T12:20:00Z"
} }
422 CHARGE_ALREADY_PAID | CHARGE_NOT_PAYABLE | AMOUNT_BELOW_MINIMUM
409 IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_PAYLOAD
```
For Fawry (F1.6.7) the response is a reference code instead of a redirect:
```json
"checkout": { "kind": "reference", "code": "8823014", "expires_at": "...", "instructions_ar": "..." }
```
**The webhook is the source of truth.** The app's return trip only triggers a poll; it never marks a
payment successful. Webhook processing is: verify signature → persist raw (`gateway_webhooks`) → dedupe on
`(gateway, event_id)` → allocate to charges in a transaction → issue receipt → enqueue push. Every step
idempotent.
---
## 6. Sports activities *(F2)*
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/disciplines` | Discipline list for the catalog |
| `GET` | `/api/v1/programs` | `?discipline_id=&person_id=&day=&gender=` — auto age-filters when `person_id` given (F2.1.4) |
| `GET` | `/api/v1/programs/{id}` | Detail + fee breakdown + coach + schedule preview |
| `GET` | `/api/v1/programs/{id}/groups` | Groups with live seats-left (F2.1.6) |
| `GET` | `/api/v1/coaches/{id}` | Coach profile |
| `POST` | `/api/v1/enrollments/quote` | Price a proposed enrollment before committing |
| `POST` | `/api/v1/enrollments` | Enroll (holds a seat, returns charge + intent hint) |
| `GET` | `/api/v1/enrollments` | Household enrollments |
| `GET` | `/api/v1/enrollments/{id}` | Detail: schedule, attendance summary, latest evaluation |
| `POST` | `/api/v1/enrollments/{id}/pause` | Pause (F2.7.4) |
| `POST` | `/api/v1/enrollments/{id}/transfer` | Request group change (F2.7.5) |
| `POST` | `/api/v1/enrollments/{id}/withdraw` | Withdraw (F2.7.6) |
| `POST` | `/api/v1/waitlist` / `DELETE /{id}` / `POST /{id}/claim` | Shared waitlist (F2.2.7) |
```http
POST /api/v1/enrollments/quote
{ "group_id": 31, "person_id": 15 }
200 { "data": {
"eligible": true,
"fees": [
{ "code": "registration", "label_ar": "رسوم التسجيل", "amount": 500.00, "one_time": true },
{ "code": "monthly", "label_ar": "اشتراك مارس (محسوب بالتناسب)", "amount": 800.00, "prorated": true }
],
"discounts": [ { "label_ar": "خصم الأخ الثاني", "amount": -80.00 } ],
"total": 1220.00,
"medical": { "required": true, "status": "missing",
"policy": "grace", "grace_days": 14,
"message_ar": "يمكن الاشتراك الآن ورفع الشهادة الطبية خلال ١٤ يوم" },
"seats_left": 3
} }
422 NOT_AGE_ELIGIBLE | GENDER_RESTRICTED | ALREADY_ENROLLED | MEDICAL_REQUIRED_BLOCKING
409 GROUP_FULL → { "waitlist_available": true, "position_would_be": 4 }
```
`POST /api/v1/enrollments` places a **transactional seat hold** (F2.2.9/F2.2.10) with a TTL, returns the
created charge, and the client proceeds to `/payments/intents`. Hold expiry releases the seat and cancels
the charge.
### Medical certificates *(F2.3)*
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/persons/{id}/medical-certificates` | All certs incl. history |
| `POST` | `/api/v1/persons/{id}/medical-certificates` | Multipart upload (multi-page) |
| `GET` | `/api/v1/medical-certificates/{id}` | Detail + clearance + restrictions |
| `GET` | `/api/v1/medical-certificates/{id}/file/{n}` | Signed short-lived URL (F2.3.14) |
| `DELETE` | `/api/v1/medical-certificates/{id}` | Withdraw a *pending* upload only |
```http
GET /api/v1/persons/15/medical-certificates
200 { "data": [
{ "id": 77, "cert_type": "academy", "status": "approved", "clearance_level": "conditional",
"restrictions_ar": "يُمنع الغطس العميق", "exam_date": "2026-01-10",
"expiry_date": "2026-07-10", "days_until_expiry": 119,
"reviewed_at": "2026-01-12T09:00:00Z" }
] }
```
### Attendance, evaluations, schedule
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/enrollments/{id}/attendance` | `?from=&to=` + rate summary |
| `POST` | `/api/v1/enrollments/{id}/absence-notice` | Advance absence notice (F2.4.4) |
| `GET` | `/api/v1/persons/{id}/makeup-credits` | Available credits |
| `POST` | `/api/v1/makeup-credits/{id}/book` | Spend a credit on a slot (F2.4.6) |
| `GET` | `/api/v1/persons/{id}/evaluations` | Published evaluations only |
| `GET` | `/api/v1/evaluations/{id}` | Criterion scores + notes + trend |
| `GET` | `/api/v1/schedule` | `?person_id=&from=&to=` — omit `person_id` for the family calendar (F2.6.2) |
| `POST` | `/api/v1/coaches/{id}/rating` | Rate a coach (F2.8.2) |
---
## 7. News & events *(F3)*
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/news` | `?category=` feed |
| `GET` | `/api/v1/news/{id}` | Article |
| `GET` | `/api/v1/events` | `?category=&status=upcoming` |
| `GET` | `/api/v1/events/{id}` | Detail: tiers, capacity, refund policy, form schema |
| `POST` | `/api/v1/events/{id}/bookings/quote` | Price attendees before committing |
| `POST` | `/api/v1/events/{id}/bookings` | Create booking (holds seats) |
| `GET` | `/api/v1/bookings` / `/{id}` | Member's bookings |
| `PATCH` | `/api/v1/bookings/{id}` | Modify attendees (F3.3.10) |
| `POST` | `/api/v1/bookings/{id}/cancel` | Cancel per refund policy (F3.3.9) |
| `POST` | `/api/v1/bookings/{id}/attendees/{aid}/documents` | Upload required docs (F3.4.3) |
| `GET` | `/api/v1/bookings/{id}/tickets` | Tickets with QR codes (F3.5.1) |
```http
POST /api/v1/events/9/bookings/quote
{ "attendees": [
{ "person_id": 12, "tier_code": "adult" },
{ "person_id": 13, "tier_code": "adult" },
{ "person_id": 15, "tier_code": "child" },
{ "guest_name_ar": "خالة منى", "tier_code": "guest" }
] }
200 { "data": {
"lines": [ { "label_ar": "عمرو مصطفى — بالغ", "amount": 45000.00 }, ... ],
"total": 152000.00,
"installments_available": true,
"installment_options": [ { "months": 3, "monthly": 50666.67 } ],
"required_documents": [ { "code": "passport", "label_ar": "صورة جواز السفر", "per_attendee": true } ],
"refund_policy_ar": "استرداد كامل حتى ٣٠ يوم قبل السفر، ٥٠٪ حتى ١٤ يوم، لا يوجد استرداد بعدها",
"seats_left": 6
} }
```
---
## 8. Gate QR & invitations *(F4)*
### Member-side
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/persons/{id}/gate-token` | Server-generated QR payload (online path) |
| `POST` | `/api/v1/gate/secret/rotate` | Rotate the device's offline seed (F4.1.5) |
| `GET` | `/api/v1/persons/{id}/access-history` | Member's own entries (F4.4.3) |
| `GET` | `/api/v1/invitations` | Issued invitations + quota ledger |
| `GET` | `/api/v1/invitations/quota` | Free/used/remaining this cycle + extra pricing |
| `POST` | `/api/v1/invitations` | Issue an invitation (consumes quota) |
| `POST` | `/api/v1/invitations/{id}/revoke` | Revoke, reclaim quota (F4.6.5) |
| `POST` | `/api/v1/invitations/purchase/quote` | Price extra invitations |
| `POST` | `/api/v1/invitations/purchase` | Buy extras → returns a charge to pay (F4.7.1) |
```http
GET /api/v1/invitations/quota
200 { "data": {
"cycle": "2026-03",
"free_total": 5, "used": 3, "remaining": 2,
"resets_on": "2026-04-01",
"extra": { "price_adult": 150.00, "price_child": 75.00,
"max_per_cycle": 10, "purchased": 0, "can_purchase": 10 }
} }
```
```http
POST /api/v1/invitations
Idempotency-Key: ...
{ "guest_name_ar": "أحمد سمير", "guest_phone": "+2010...", "guest_kind": "adult", "visit_date": "2026-03-20" }
201 { "data": { "id": 331, "qr_code": "INV.331.<sig>", "qr_image_url": "...",
"visit_date": "2026-03-20", "expires_at": "2026-03-20T23:59:59+02:00",
"share_text_ar": "دعوة لدخول نادي الصيد يوم ٢٠ مارس..." } }
422 QUOTA_EXHAUSTED → { "can_purchase": true, "extra_price": 150.00 }
422 GUEST_BLACKLISTED
```
### Gate-side *(latency-critical, p95 < 500ms — F4.2.1)*
| Method | Path | Purpose |
|--------|------|---------|
| `POST` | `/api/gate/scan` | The access decision |
| `POST` | `/api/gate/scan/batch` | Flush a queued offline batch (F4.3.3) |
| `POST` | `/api/gate/override` | Staff override with reason (F4.2.8) |
| `GET` | `/api/gate/bootstrap` | Device config + offline verification keys |
| `POST` | `/api/gate/manual-code` | Manual numeric fallback (F4.1.8) |
```http
POST /api/gate/scan
X-Gate-Token: <device token>
{ "code": "MBR.15.842193", "direction": "in", "scanned_at": "2026-03-13T16:04:11Z" }
200 { "data": {
"granted": true,
"subject": { "kind": "member", "person_id": 15, "name_ar": "يوسف عمرو",
"photo_url": "...", "membership_number": "10247" },
"advisory": { "level": "warning", "message_ar": "متأخر ١٢ يوم — يرجى السداد" }, // F4.2.4
"log_id": 99812
} }
200 { "data": {
"granted": false,
"subject": { "kind": "member", "person_id": 15, "name_ar": "يوسف عمرو", "photo_url": "..." },
"denial": { "code": "MEMBERSHIP_EXPIRED",
"message_ar": "العضوية منتهية منذ ١٢ يوم — يراجع مكتب العضوية" },
"override_allowed": true
} }
```
Denial codes are a **closed, named set**, each mapping to its own rule (F4.2.2) — never a generic failure:
`MEMBERSHIP_EXPIRED` · `MEMBERSHIP_SUSPENDED` · `PERSON_SUSPENDED` · `UNPAID_FINE_BLOCK` ·
`SUSPENSION_PERIOD_ACTIVE` · `MEDICAL_REQUIRED_FOR_ZONE` · `INVITATION_EXPIRED` · `INVITATION_USED` ·
`INVITATION_WRONG_DATE` · `GUEST_BLACKLISTED` · `TOKEN_INVALID` · `TOKEN_EXPIRED` · `ANTI_PASSBACK`.
**QR payload format** — namespaced so a member code can never be replayed as a ticket or an invitation:
```
MBR.<person_id>.<totp> member gate entry (rotating, offline-derivable — F4.1.2/F4.1.3)
INV.<invitation_id>.<sig> guest invitation (single-use, date-scoped)
EVT.<attendee_id>.<sig> event ticket (single-use, event-scoped)
```
---
## 9. Notifications *(F7)*
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/api/v1/notifications` | In-app notification centre (F7.9) |
| `POST` | `/api/v1/notifications/{id}/read` | Mark read |
| `GET` | `/api/v1/notifications/preferences` | Per-category channel prefs |
| `PUT` | `/api/v1/notifications/preferences` | Update prefs |
---
## 10. Portal & jobs (server-side)
Staff endpoints mirror the member surface with wider scope and live under `/portal/*` with session auth.
The screens they back are listed in `05-portal-infra-delivery.md` §1.
Scheduled jobs (Heroku Scheduler / worker dyno):
| Job | Cadence | Feature |
|-----|---------|---------|
| `billing:generate-cycle` | daily 00:15, acts on the anchor day | F1.8.1 |
| `billing:annual-renewals` | daily | F1.8.7 |
| `billing:late-fees` | daily | F1.8.4 |
| `charges:mark-overdue` | daily | F1.3.6 |
| `medical:expire-and-warn` | daily | F2.3.7, F2.3.8 |
| `enrollments:suspend-unpaid` | daily | F2.7.3 |
| `invitations:cycle-reset` | monthly on anchor | F4.5.3 |
| `waitlist:process-offers` | every 15 min | F2.2.7 |
| `notifications:dispatch` | every minute | F7.5 |
| `payments:reconcile` | daily | F1.6.14 |
| `holds:release-expired` | every 5 min | F2.2.10, F3.3.4 |
| `access-state:refresh` | every 15 min | gate scan cache (data model §16) |
Every job writes a `billing_runs`-style record or a structured log line with counts. **No silent jobs**
a billing job that fails quietly is the worst failure mode this system has.
---
## 11. Rate limits
| Surface | Limit |
|---------|-------|
| `auth/otp/request` | 3 / 15 min per phone; 20 / hour per IP |
| `auth/otp/verify` | 5 attempts per request, then invalidate |
| Member API general | 120 req/min per token |
| `payments/intents` | 10 / hour per membership |
| `gate/scan` | 600 req/min per device (a busy gate is legitimately fast) |
| Webhooks | unlimited, signature-gated, deduped |
---
## 12. Security notes
- TLS everywhere; HSTS on the portal.
- Access tokens 15 min, refresh 60 days with rotation and reuse detection.
- Medical documents and receipts served **only** via short-lived signed URLs; the bucket is private and
every access is logged (F2.3.14).
- Gate device tokens are revocable per device and rotate on a schedule.
- Webhook endpoints verify HMAC signatures and reject anything older than a 5-minute window.
- PII (national ID, phone, medical documents) is never written to application logs.
- All staff mutations write to `audit_log` (F9.10) — enforced at the data-access layer, not left to
individual controllers to remember.
# Sayd Mobile — App UX, Design System & Motion
> The brief's hardest requirement: *"لازم تكون very extremely sexy and craft and animated."*
> This document is where that gets specified rather than hoped for.
The app is Arabic-native RTL. Every layout, icon direction, animation origin, and gesture direction is
authored RTL-first — not mirrored from an LTR design at the end.
---
## 1. Design system
Inherited from the proposal's existing brand (`css/styles.css`) so the app, the pitch deck, and the portal
read as one product.
### Brand tokens
| Token | Value | Use |
|-------|-------|-----|
| `--ink` | `#07142B` | Deepest navy — dark surfaces, text on light |
| `--ink-2` | `#0C2247` | Dark-mode chart/card surface |
| `--navy` | `#123566` | Primary brand |
| `--navy-soft` | `#1B4A8C` | Elevated navy |
| `--gold` | `#C9A227` | Accent — membership card, premium moments |
| `--gold-2` | `#E8CC6A` | Gold highlight / shimmer |
| `--sky` | `#3E8CE0` | Interactive primary, links, focus |
| `--mint` | `#16B981` | Positive / paid / present |
| `--rose` | `#E0556B` | Negative / overdue / absent |
| `--paper``--paper-3` | `#FFFFFF` / `#F7F8FB` / `#EFF2F7` | Light surfaces |
| `--txt` / `--txt-2` / `--txt-3` | `#0E1A2B` / `#4A5A72` / `#7C8AA0` | Primary / secondary / muted ink |
**Type:** `Cairo` for headings and numerals, `IBM Plex Sans Arabic` for body. Arabic-Indic numerals are a
user preference (`٣٬٢٥٠` vs `3,250`), defaulting to Western per the club's existing receipts.
**Radii:** 8 / 14 / 22 / 32px. **Shadows:** the proposal's three-tier `--sh-s/m/l`.
**Spacing:** 4px base grid. Touch targets ≥ 48×48.
### Status colors (reserved — never reused as a series color)
| Role | Hex | Maps to |
|------|-----|---------|
| good | `#0ca30c` | active · paid · present · approved |
| warning | `#fab219` | grace · due soon · cert expiring |
| serious | `#ec835a` | overdue · conditional clearance |
| critical | `#d03b3b` | suspended · expired · rejected · unfit |
These ship **with an icon and a label, never as color alone**`warning` and `serious` sit below 3:1 on the
light surface by design, so the icon+label pairing carries the meaning.
---
## 2. Chart & data-display system
Most of what this app shows is **not a chart**. Resist the urge. The rules below were validated, not
eyeballed.
### Categorical palette (hard cap: 3 series)
| Slot | Hue | Light | Dark (on `#0C2247`) |
|------|-----|-------|---------------------|
| 1 | sky | `#3E8CE0` | `#3E8CE0` |
| 2 | gold | `#C9A227` | `#B08A1E` |
| 3 | mint | `#16B981` | `#12A06F` |
Validated all-pairs in both modes (worst CVD ΔE 9.0 light / 8.3 dark, normal-vision 17.3 / 15.9 — clear of
the ≥8 and ≥15 floors). **Assigned in fixed order, never cycled, never reassigned on filter.**
A 4th series is never a new hue — fold the tail into "أخرى" or facet into small multiples.
**Light-mode relief rule:** gold (2.42:1) and mint (2.53:1) fall below 3:1 on white, so any chart using
them ships **visible direct labels or a table view**. This is an obligation, not a suggestion.
**Sequential** (magnitude): one hue, blue, light→dark — `#cde2fb · #9ec5f4 · #6da7ec · #3987e5 · #2a78d6 ·
#256abf · #184f95`. For ordinal (discrete, ordered) use, start no lighter than `#86b6ef` on light.
**Diverging** (above/below target): sky ↔ rose with a **neutral gray midpoint** (`#f0efec` light,
`#383835` dark). Never a hue at the midpoint.
### Form assignments
| What | Form | Why not the obvious thing |
|------|------|---------------------------|
| Collections headline (collected / outstanding / rate / % via app) | **KPI row of stat tiles** — value + delta + sparkline; the lead number as a **hero figure** ≥48px | Not a grouped bar chart. Four headline numbers are four numbers. |
| Attendance rate | **Meter** — a single ratio against a limit, same-ramp track | Not a donut. A 2-slice pie is the classic wrong answer. |
| Coach evaluation criteria | **Horizontal bar**, sequential one hue, criteria sorted by score, direct-labeled | **Not a radar/spider chart.** Radar distorts magnitude by area, makes ordering an arbitrary visual artifact, and is unreadable at phone width. Bars answer "which skill is weakest?" instantly — the actual question a parent asks. |
| Progress over time (same criterion across evaluations) | **Line**, single series, 2px, ≥8px markers, direct-label the last point, no legend | A single series needs no legend box — the title names it. |
| Revenue by charge type (6 types) | **Table** with an inline magnitude bar per row | Six meaningful classes is past the comfortable color budget. A table reads exactly, and exports. |
| Payment progress on an installment plan | **Meter** / progress ring | — |
| Gate traffic by hour | **Column**, sequential | — |
### Mark specs
Thin marks · 4px rounded data-ends anchored to the baseline · 2px lines · ≥8px markers · a 2px
surface-colored gap between adjacent fills and a 2px surface ring on overlapping marks · recessive
grid and axes · **selective** direct labels, never a number on every point · values and labels wear text
tokens, never the series color.
**One axis, always.** Never a dual-axis chart. Two measures of different scale become two charts.
### Interaction
Charts in the portal ship a hover layer by default — crosshair + tooltip on lines, per-mark tooltip on
bars. Filters sit in one row above the charts. On mobile, tap-to-reveal replaces hover, with hit targets
larger than the marks.
### Accessibility
≥2 series → legend always present, and ≤4 series are also direct-labeled, so identity is never carried by
color alone. Every chart has a table view. Dark mode uses its own validated steps from the same ramps —
never an automatic flip.
---
## 3. Screen inventory
### Onboarding & auth
1. **Splash** — logo draw-on, then crossfade
2. **Phone entry** — country prefix, large numeric field
3. **OTP entry** — 4 boxes, auto-advance, auto-read SMS, live resend countdown
4. **Device conflict** — "حسابك مفتوح على جهاز آخر" with a force-switch action
5. **First-run tour** — 3 cards: dues, QR, activities
### Home
6. **Home** — greeting, family switcher, membership card (hero), household outstanding, next session card, pinned announcement, quick actions (pay · QR · invite)
### Membership & money
7. **Membership card detail** — full card, flip to QR
8. **Statement** — three tabs (مستحق الآن / قادم / متأخر), per-person filter
9. **Charge detail** — breakdown, source, receipt
10. **Installment plan** — progress ring + schedule list
11. **Early settlement quote**
12. **Fines list** · 13. **Fine detail + appeal** · 14. **Appeal status**
15. **Cart / checkout** — selected charges, total, method picker
16. **Payment method** — card · wallet · Fawry reference · bank transfer
17. **Payment processing** — the waiting state (see §4.5)
18. **Payment success** — the app's biggest moment
19. **Payment failure** — typed reason + retry
20. **Receipt** — shareable, downloadable
21. **Receipt archive** · 22. **Payment history**
### Activities
23. **Catalog** — discipline grid
24. **Program list** — filtered by selected child's age
25. **Program detail** — fees, coach, schedule, capacity
26. **Group picker** — time slots with seats-left
27. **Enrollment quote** — itemized fees + medical status
28. **Waitlist join / claim**
29. **My enrollments** · 30. **Enrollment detail**
31. **Attendance history** — calendar + rate meter
32. **Absence notice**
33. **Makeup credits** · 34. **Makeup booking**
35. **Evaluations list** · 36. **Evaluation detail** — the emotional peak (§4.6)
37. **Progress trend**
38. **Schedule** — week view, per-person or whole family
39. **Coach profile** · 40. **Rate coach**
### Medical
41. **Certificates list** — per person, status + days-to-expiry
42. **Upload flow** — person → type → capture/pick → details → submit
43. **Certificate detail** — status, clearance, restrictions, rejection reason
44. **Expiry warning interstitial**
### Club life
45. **News feed** · 46. **Article detail**
47. **Events list** · 48. **Event detail** — tiers, capacity, refund policy
49. **Attendee selection** · 50. **Event custom form** · 51. **Document upload**
52. **Booking summary** · 53. **My bookings** · 54. **Ticket (QR)**
### Gate & invitations
55. **My QR** — full-screen, rotating, brightness-boosted
56. **Invitations** — quota ring, issued list
57. **Issue invitation** · 58. **Invitation created + share**
59. **Buy extra invitations** · 60. **Access history**
### Account
61. **Profile** · 62. **Notification preferences** · 63. **Notification centre**
64. **Help & FAQ** · 65. **Contact club** · 66. **Settings** (language, numerals, biometric lock, theme)
---
## 4. Motion system
### 4.1 Principles
1. **Motion explains, then delights.** Every animation answers "where did this come from / where did it
go". Decorative motion with no spatial logic is cut.
2. **Choreograph, don't animate everything at once.** Staggered entrances (40–60ms apart) read as crafted;
simultaneous entrances read as cheap.
3. **RTL-native origins.** Forward navigation pushes **right-to-left**; back reverses. Sheets rise. Nothing
is a mirrored afterthought.
4. **Interruptible.** Every animation is cancellable and reversible mid-flight. A user who taps back at
200ms must never wait out a 600ms sequence.
5. **Respect the system.** `MediaQuery.disableAnimations` / reduce-motion collapses everything to a 120ms
crossfade. Non-negotiable for accessibility.
6. **60fps or cut it.** Any animation that can't hold frame budget on a mid-range Android is redesigned,
not shipped janky. The target device is a 3-year-old Samsung, not a flagship.
### 4.2 Duration & curve scale
| Token | Duration | Curve | Use |
|-------|----------|-------|-----|
| `instant` | 90ms | `easeOut` | Toggles, checkbox, ripple |
| `quick` | 160ms | `easeOutCubic` | Buttons, chips, small state change |
| `base` | 240ms | `easeOutCubic` | Cards, list items, tab switch |
| `move` | 320ms | `easeInOutCubic` | Page transitions, sheets |
| `expressive` | 480ms | `easeOutBack` (overshoot 1.05) | Card flip, hero reveal |
| `celebrate` | 900ms | custom spring (stiffness 180, damping 18) | Payment success, promotion |
Stagger step: **50ms**. Max stagger chain: 6 items, then the rest fade as a block.
### 4.3 Signature moments (the motion budget goes here)
**M1 — Membership card entrance.** On home load: card rises 24px with a fade, gold foil edge sweeps
diagonally once (600ms), settles. The card carries a subtle parallax tied to device gyroscope (±6°
max) — a premium-object cue, disabled under reduce-motion and on low-power mode.
**M2 — Card flip to QR (F1.2.6).** 3D flip on the Y axis, 480ms `expressive`, with a real perspective
matrix and a specular sweep at the halfway point. Screen brightness ramps to max over the same 480ms so
the QR is gate-ready by the time the flip lands.
**M3 — QR rotation pulse (F4.1.2).** A hairline progress arc traces the QR's perimeter over the rotation
period (30s). At rotation, the code cross-dissolves with a 1.02 scale-pop over 200ms. Never a jarring
swap — the member must trust the code is live, not broken.
**M4 — Balance reveal.** The outstanding figure counts up from zero over 700ms with an ease-out, digits
rolling odometer-style. Currency symbol fades in last. If the balance is **zero**, this becomes the
all-clear state (§4.7).
**M5 — Payment success (the biggest moment).** Sequence, total ~1.6s:
1. Processing spinner morphs into a checkmark path draw (400ms)
2. A gold radial bloom expands from the check and dissipates (500ms, overlapping)
3. Amount and receipt number rise into place, staggered 50ms
4. The paid charge row in the background list crossfades from `serious` to `good` and slides out
5. Receipt card slides up from the bottom with a soft settle
Haptic: `mediumImpact` at the checkmark, `lightImpact` at the receipt settle. This is the moment that
makes members pay in-app again next month — it earns the budget.
**M6 — Evaluation reveal (F2.5.4).** Criterion bars grow from the baseline, staggered 60ms, `easeOutCubic`
over 500ms; the overall score counts up in parallel; the skill-level badge scale-pops with a spring at the
end. If the level increased since the last evaluation, a promotion ribbon unfurls with a haptic.
**M7 — Medical status transition (F2.3.4).** Status chip morphs between states with a color crossfade plus
an icon path morph (pending hourglass → check / cross / exclamation), 320ms. Approval adds a single
mint ripple from the chip.
**M8 — Gate scan feedback** (scanner PWA, not the app). Full-screen flood of `good`/`critical`, 120ms in,
hold, 400ms out. Person photo scales in from 0.9. Denial reason types in. Audible tone distinct per
outcome — gate staff often hear before they look.
**M9 — Invitation issued.** The generated QR "prints" — a mask wipes top-to-bottom over 500ms with a
subtle paper texture — then the share sheet rises. Quota ring decrements with an animated arc.
**M10 — Pull-to-refresh.** Custom: the club crest draws its stroke path as the pull progresses, spins on
release, dissolves on completion. Small, but it's the gesture members repeat most.
### 4.4 Navigation transitions
| From → To | Transition |
|-----------|-----------|
| Tab → Tab | Crossfade 160ms + 8px vertical lift on the incoming content |
| List → Detail | **Shared-element**: the row's card morphs into the detail header (320ms `move`) |
| Any → Modal sheet | Slide up 320ms `easeOutCubic`, scrim fades to 40%, background scales to 0.96 |
| Any → Full-screen (QR, camera) | Scale-from-origin 280ms |
| Forward (RTL) | Incoming enters from the **left**, outgoing exits **right** at 30% parallax |
| Back | Exact reverse; swipe-from-right-edge is interactive and follows the finger |
### 4.5 Loading & waiting
- **Skeletons, never spinners**, for content that has a known shape (statement rows, catalog cards).
Skeletons shimmer at 1.4s period, low contrast.
- **Payment processing** is the one place a determinate, narrated wait is right: "جارٍ تأكيد الدفع…"
with staged copy at 3s ("لحظات…") and 8s ("قد يستغرق الأمر وقتًا أطول قليلًا"). Never a bare spinner on
a money screen.
- **Optimistic writes** (absence notice, mark-read) apply instantly with a quiet undo affordance.
### 4.6 Empty, error & all-clear states
Every empty state is an illustrated, animated moment — not a centered gray sentence:
- **No dues (`F1.3.10`)** — the highest-traffic empty state. A gold check with a slow ambient shimmer,
"لا مستحقات عليك — كل شيء مسدد", and the next renewal date as reassurance.
- **No activities yet** — animated discipline icons drifting, with a "تصفّح الأنشطة" CTA.
- **No invitations left** — the quota ring at full, with the buy-extra CTA as the natural next step.
- **Offline** — a persistent slim banner, not a blocking screen; cached content stays interactive with a
"آخر تحديث" stamp.
- **Errors** — Arabic, specific, actionable, with a retry. Never a code.
### 4.7 Performance guardrails
- Animate only `transform` and `opacity`. No layout-triggering animation.
- `RepaintBoundary` around the card, the QR, and any independently-animating widget.
- Precache hero images and the crest before first paint.
- Shader warm-up on first launch to kill Skia jank on the first animation.
- Frame budget enforced in CI with an integration test asserting no dropped frames in the M1/M2/M5
sequences on a mid-tier profile.
- Every animation controller disposed — animation leaks are the #1 cause of Flutter memory growth.
---
## 5. Flutter implementation notes
- **State:** Riverpod. Repository layer per domain, hard-separated from widgets.
- **Navigation:** `go_router` with typed routes; deep links (F7.10) map 1:1 to route names.
- **Offline (F9.2):** Drift (SQLite) mirror of card, statement, schedule and QR seed. Encrypted at rest.
Repositories are cache-first with background revalidation.
- **Gate QR (F4.1.3):** TOTP derived locally from the device seed; no network on the critical path.
Seed in `flutter_secure_storage`, never in shared prefs.
- **Money:** `Decimal`, never `double`. Formatting through one central formatter with the Arabic-numerals
preference applied at the edge.
- **RTL:** `Directionality.rtl` at the root; all padding uses `EdgeInsetsDirectional`; icons that imply
direction get `matchTextDirection`.
- **Animations:** prefer explicit `AnimationController` + `AnimatedBuilder` for the signature moments
(M1–M10) so they're interruptible; implicit widgets are fine for micro-states.
- **Localization:** ARB files, Arabic as the source locale (not a translation target). English is a
stretch, not a Phase-1 commitment.
- **Testing:** golden tests on the card, statement rows, and evaluation chart in both themes; integration
tests on the auth → pay → receipt path.
# Sayd Mobile — Portal, Infrastructure & Delivery
---
## 1. Staff portal screens
PHP-rendered HTML/CSS/JS. No SPA framework — ~18 screens of forms and tables do not justify a build step.
Progressive enhancement: everything works without JS, JS makes it pleasant.
Six fixed roles (`admin · finance · activities · medical · gate · readonly`). No permission matrix, no role
builder — that's an ERP feature and we are explicitly not building one.
### Dashboard & collections
| # | Screen | Role | Feature |
|---|--------|------|---------|
| P1 | **Collections dashboard** — hero figure + KPI row (collected · outstanding · collection rate · % paid via app), revenue-by-type table, top-overdue list, smart alerts | finance, admin | F8.1–F8.4 |
| P2 | **Revenue detail** — drill into one charge type, date-ranged, exportable | finance | F8.2 |
### Members & money
| # | Screen | Role | Feature |
|---|--------|------|---------|
| P3 | **Member search & list** — by number, name, phone, national ID, status | all | F1.1 |
| P4 | **Member detail** — household, per-person status, full statement, payment history, receipts, gate log | all | F1.1–F1.3 |
| P5 | **Record offline payment** — cash at the counter, issues the same receipt object | finance | F1.6.12 |
| P6 | **Charge editor** — manual charge, adjustment, waiver, discount application (reason mandatory, audit-logged) | finance | F1.9.5, F1.9.6 |
| P7 | **Installment plans** — create, view, settle early, mark defaulted | finance | F1.4 |
| P8 | **Fines & violations** — impose, attach evidence, waive | admin | F1.5 |
| P9 | **Appeal queue** — pending appeals, decide with a note (pushes to member) | admin | F1.5.4 |
| P10 | **Payment reconciliation** — gateway settlement vs recorded payments, drift report | finance | F1.6.14 |
| P11 | **Refunds & credit notes** | finance | F1.6.13 |
### Activities
| # | Screen | Role | Feature |
|---|--------|------|---------|
| P12 | **Programs & pricing** — CRUD, fees, age brackets, capacity | activities | F2.1 |
| P13 | **Groups & schedules** — slots, coach assignment, capacity, season | activities | F2.6 |
| P14 | **Enrollments** — roster per group, status, transfers, waitlist pressure | activities | F2.2, F2.7 |
| P15 | **Medical review queue** — the most-used screen in this pillar: document viewer, approve/reject/conditional + notes, keyboard-driven | medical | F2.3.11 |
| P16 | **Expiring certificates** — chase renewals proactively | medical | F2.3.12 |
| P17 | **Coach attendance entry** — mobile-optimised, one group in under a minute, offline-tolerant | coach | F2.4.7 |
| P18 | **Coach evaluation entry** — criteria scoring, draft/submit | coach | F2.5.7 |
### Club life
| # | Screen | Role | Feature |
|---|--------|------|---------|
| P19 | **News editor** — write, schedule, publish, pin | admin | F3.1 |
| P20 | **Event editor** — tiers, capacity, registration window, refund policy, custom form builder | admin | F3.2, F3.4.1 |
| P21 | **Event roster** — attendees, payment status, documents, waitlist, **export** (the bus manifest) | admin | F3.5.4 |
### Gate & invitations
| # | Screen | Role | Feature |
|---|--------|------|---------|
| P22 | **Invitation policy** — per membership type: quota, extra pricing, caps, validity | admin | F4.5.1 |
| P23 | **Gate access log** — searchable by person/date/gate/result | gate, admin | F4.4.2 |
| P24 | **Gate devices** — register, revoke, set mode | admin | F4.3.5 |
| P25 | **Guest blacklist** | admin | F4.6.8 |
### Operations
| # | Screen | Role | Feature |
|---|--------|------|---------|
| P26 | **Data import** — upload → auto-map → validate → **diff preview** → commit; history and rollback | admin | F6 |
| P27 | **Billing runs** — history, counts, errors, dry-run trigger | finance, admin | F1.8.9, F1.8.10 |
| P28 | **Notification campaigns** — segment builder, template picker, send, delivery log | admin | F7.7 |
| P29 | **Notification templates** — edit Arabic copy without a deploy | admin | F7.4 |
| P30 | **Settings & policy** — billing anchor, grace days, fine-blocks-gate, medical policy, VAT | admin | app_config |
| P31 | **Staff users** | admin | — |
| P32 | **Audit log viewer** | admin | F9.10 |
### Gate scanner (separate PWA)
| # | Screen | Feature |
|---|--------|---------|
| G1 | **Scan view** — camera, full-screen pass/fail, photo, denial reason, audible tone | F4.3.1, F4.3.2 |
| G2 | **Manual code entry** | F4.1.8 |
| G3 | **Override** — with mandatory reason | F4.2.8 |
| G4 | **Offline queue status** | F4.3.3 |
---
## 2. Infrastructure
### Heroku topology
| Component | Plan (launch) | Notes |
|-----------|---------------|-------|
| `web` dyno | Standard-1X ×2 | PHP-FPM + nginx buildpack. Two dynos minimum — one dyno means a restart is an outage. |
| `worker` dyno | Standard-1X ×1 | Notification dispatch, import processing, PDF generation, webhook retries |
| `scheduler` | Heroku Scheduler | The cron table in `03-api-spec.md` §10 |
| Postgres | Standard-0 | 64GB, 400 connections, PITR. **Not** a hobby tier — hobby has no backups worth the name. |
| Redis | Premium-0 | Queue backend, rate limiting, gate access-state cache |
| Object storage | S3 (or Cloudflare R2) | Medical docs, receipts, photos, event images, import files |
| Papertrail / Logtail | — | Log aggregation; Heroku's own retention is too short |
| Sentry | — | Client + server error tracking |
### Environments
| Env | Purpose |
|-----|---------|
| `sayd-prod` | Production, on the club's domain |
| `sayd-staging` | Mirror of prod, anonymised data copy, used for club UAT |
| Review apps | Auto-spun per merge request, torn down on merge |
### Known constraints & how we handle them
- **Region latency.** Heroku's nearest region to Egypt is EU (Ireland/Frankfurt). Expect ~60–90ms RTT.
This is fine for every surface except the gate scan (p95 < 500ms budget). Mitigations: the denormalised
`access_state` cache column, a Redis lookup on the hot path, aggressive connection pooling via PgBouncer,
and the offline-verification path (F4.1.3) which removes the network from the critical path entirely.
**If the club later requires sub-100ms gate response under load, an in-club edge cache is the Phase-2
answer** — flag it now rather than discovering it at the turnstile.
- **Ephemeral filesystem.** Nothing is ever written to the dyno disk. All uploads stream to object storage.
- **30-second request timeout.** Imports, PDF batch generation, and bulk notification sends are queued to
the worker, never handled synchronously (F6.10).
- **Dyno restarts (~daily).** All jobs are idempotent and resumable; nothing holds in-memory state.
- **Connection limits.** PgBouncer buildpack in transaction-pooling mode from day one.
### Data residency
The proposal offers "hosting inside Egypt or Europe". Heroku means **Europe**. If the club's board requires
in-Egypt residency, that is a hosting-model change (a VPS in an Egyptian DC, or a local cloud), not a code
change — the app is portable. Raise this before contract signature, not after.
---
## 3. CI/CD & engineering practice
**Pipeline (GitLab CI):**
1. Lint — PHP CodeSniffer (PSR-12), `dart analyze`
2. Static analysis — PHPStan level 6, Flutter analyzer with strict lints
3. Tests — PHPUnit (domain + API contract), Flutter unit/widget/golden
4. Migration check — migrations apply cleanly to a fresh DB **and** roll back
5. Build — Flutter iOS/Android artifacts on tags
6. Deploy — auto to staging on `main`, manual gate to production
7. Post-deploy — smoke tests against the health endpoint
**Non-negotiables:**
- **Migrations are forward-only in production**, reversible in dev. Every migration reviewed for
lock behaviour — an `ALTER TABLE` on `charges` at 100k rows must not take the site down.
- **Every money path has a test.** Payment allocation, receipt numbering under concurrency, idempotency
replay, webhook out-of-order delivery. These are the tests that matter; UI tests are secondary.
- **Seed + fixture data** that mirrors real club shapes (a household with 3 kids in 4 activities, an
overdue installment plan, an expiring certificate) so every developer works against realistic data.
- **Feature flags** (F9.7) for anything policy-driven, so the club can toggle behaviour without a deploy.
---
## 4. Delivery plan
### The honest scope picture
`01-feature-catalog.md` totals **≈431 engineer-days** of P0+P1. Against a 12-week calendar that implies
4–5 engineers at full parallelism with zero slack — which is not a plan, it's a hope.
Three ways to make this real. **Recommendation: Option B.**
| Option | Shape | Trade-off |
|--------|-------|-----------|
| **A** | All four pillars, 12 weeks, 5 engineers | Highest risk. Any slip lands on the store-submission week, which has no give. |
| **B** ✅ | **P0 across all four pillars + the highest-value P1s, 12 weeks, 4 engineers**, with a named P1 backlog shipped in weeks 13–18 under the maintenance agreement | Club gets all four pillars working at launch; polish lands shortly after. Honest and defensible. |
| **C** | Original 3-pillar proposal scope, 12 weeks, 3 engineers; events/news as Phase 2 | Safest, but drops a pillar the founder explicitly wants. |
### Option B — week by week
| Weeks | Workstream | Deliverable |
|-------|-----------|-------------|
| **1–2** | Foundations | Templates finalised with the club · identity & family model (F1.1) · Postgres schema · auth skeleton (F5.1–F5.4) · gateway abstraction stubbed (F1.6.1) · CI/CD + environments live |
| **3–5** | Portal + ingestion | Ingestion engine complete with validation and diff preview (F6) · member/charge screens · collections dashboard · pricing · invitation policy · coach schedule upload. **Milestone: club uploads real data.** *(matches proposal payment #2)* |
| **4–7** | App core *(parallel)* | OTP auth · membership card + family switcher · statement/installments/fines · full payment flow · receipts. **Pillar 1 complete.** |
| **6–9** | Activities | Catalog · enrollment · medical certificates + review queue + expiry engine · attendance (incl. coach entry) · evaluations · schedule. **Pillar 2 complete.** |
| **8–10** | Club life | News feed · event catalog · booking + custom forms + documents · tickets. **Pillar 3 complete.** |
| **10** | Gate & invitations | Rotating offline QR · scan endpoint · scanner PWA · quota ledger · issue + purchase. **Pillar 4 complete.** |
| **11** | Hardening & pilot | Closed pilot: 50 members, 3 coaches, 1 gate. Proves the day-1 billing job, gate latency under a real queue, and payment reconciliation. Load and security testing. |
| **12** | Launch | Staff training (2 days) · member comms material · store submission · go-live |
| **13–18** | Post-launch P1 | Named backlog: multi-charge cart polish, waitlist automation, makeup booking, autopay, statement export, campaigns, bulk tooling |
### Critical path
`Identity model → ingestion engine → payment engine → everything else.`
Nothing meaningful ships until real member data is in the system and money can move. Weeks 1–5 are
therefore the highest-risk stretch, and the ingestion milestone in week 5 is the true go/no-go gate.
### Team
| Role | Allocation |
|------|-----------|
| Backend (PHP/Postgres) | 2 |
| Flutter | 1.5 |
| Design (UI + motion) | 0.5, front-loaded weeks 1–6 |
| QA | 0.5, ramping from week 7 |
| PM / club liaison | 0.25 throughout |
### Risk register
| Risk | Likelihood | Impact | Mitigation |
|------|-----------|--------|------------|
| Member data is messier than expected | **High** | High | The ingestion engine is built to absorb mess (fuzzy mapping, partial commit, fix-and-reupload). Get a real file in week 1, not week 5. |
| Payment gateway onboarding delays | Medium | **High** | Start the merchant-account process in week 1. Build against the sandbox; the abstraction layer means a gateway switch is days, not weeks. |
| Store review rejection | Medium | High | Submit in week 11, not 12. Payment-flow and privacy declarations reviewed against guidelines early. |
| Gate hardware/network reality differs from assumption | Medium | Medium | Offline-first QR design removes the dependency. Site-survey the gates in week 2. |
| SMS deliverability | Medium | Medium | Two providers wired from day one (F5.2). |
| Scope creep from an enthusiastic club | **High** | High | The out-of-scope list in `00-overview.md` §3 is contractual, not advisory. Every addition is a change order. |
| Club decision latency (pricing, policy) | High | Medium | The §5 questions are answered in week 1 workshops with named owners, or defaults are assumed in writing. |
---
## 5. Open questions to close in week 1
These are answered in the kickoff workshop with a named club owner, or we proceed on a **written assumed
default**. None of them blocks starting; all of them block finishing the feature they touch.
| # | Question | Blocks | Assumed default if unanswered |
|---|----------|--------|-------------------------------|
| 1 | Does gate hardware support **exit** scanning, or entry only? | F4.4.4 live occupancy, F4.2.5 anti-passback | Entry only; occupancy deferred |
| 2 | Is there a **late-fee rule** today, or are fines imposed ad hoc? | F1.8.4 | Staff-triggered, no automatic late fee |
| 3 | Do medical certificates have a **fixed validity period** by policy, or per-document? | F2.3.7 expiry automation | 6 months from exam date |
| 4 | **Refund policy** for events — fixed no-refund, or case-by-case? | F3.3.8, F3.3.9 | Per-event policy, staff-approved refunds |
| 5 | Does the chosen gateway support **tokenised recurring charges** in Egypt today? | F1.6.9 autopay | Autopay drops to Phase 2 |
| 6 | Does an **unpaid fine block gate entry**? | F1.5.6, F4.2.2 | No — fines don't block entry |
| 7 | **Invitation quota** — exact numbers per membership type, rollover yes/no? | F4.5.1 | 5/month adult, no rollover |
| 8 | **Billing anchor day** — is it truly the 1st for every charge type? | F1.8.2 | Day 1 |
| 9 | **Dependent age ceiling** — when does a child need their own membership? | F1.1.8 | 21 |
| 10 | **VAT** — is the club VAT-registered, and which charges are taxable? | F1.7.7 | Not registered; VAT off |
| 11 | **Data residency** — does the board require in-Egypt hosting? | Infra §2 | Europe (Heroku) |
| 12 | **Multi-branch** — one site or several? | F-wide `branch_id` usage | Single branch, column retained |
| 13 | Can the **spouse have their own app login**? | F5.10 | Yes — per-person device binding |
| 14 | Are **non-members** allowed to enroll in activities? | F2.1.7 pricing | Members only in Phase 1 |
| 15 | Who owns the **App Store / Play developer accounts**? | F9.13 | Club owns; we publish on their behalf |
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment