# Architecture

## Shape

```
   Sales            Supervisor              Finance                 Admin
     |                   |                     |                      |
     +-------------------+---------------------+----------------------+
                                 |
                        Web app (mobile-first PWA)
                     REST API  /api/*   (role-enforced)
                                 |
                    +------------+------------+
                    |                         |
             SQLite database            File store
        facilities, contracts,        contracts, IDs,
        deployments, invoices,        receipts, invoices
        payments, commissions,
        users, activity_log
                    ^
                    |
        ETL: spreadsheets + Business Central exports
```

One process, no external dependencies. `node server/index.js` serves both the API and the
static app. Node 22.5+ provides `node:sqlite`, so there is nothing to install.

---

## The pipeline is derived, never typed

A facility's `stage` is computed from evidence, in this precedence order:

```
paid       paid > 0 and paid >= invoiced (or >= agreed price)
part_paid  paid > 0
invoiced   invoiced > 0
deployed   at least one deployment with status deployed/in_progress
contracted a verified signed contract exists
pipeline   manually moved (e.g. deployment planned)
lead       default
lost       manually closed out
```

`server/rules.js → deriveStage()` is the single implementation, used by the API and the ETL.
This is why nobody can "type" a facility into Paid — the money facts have to exist.

---

## Commission engine

`recomputeCommissions(db, facilityId)` is **idempotent**: it rebuilds a facility's ledger from
current facts and the active rate table. It is called after every mutation that could change
entitlement (contract upload, payment, deployment change, rule change).

Per facility it emits, at most one row per `(user, kind)`:

| Entitlement | Unlocked by |
|---|---|
| Sales `business_development` 5,000 | verified contract **and** payment received |
| Deployer `facility_commission` 5,000 | their deployment `status = deployed` |
| Deployer `business_development` 5,000 | they are the sales owner, contract + payment |
| Supervisor `facility_commission` 1,000/2,000/2,500 | deployment of their associate completed |
| Supervisor `business_development` 5,000 | they are the sales owner, contract + payment |

Statuses: `accrued` (event happened, gate not met) → `payable` (gate met) → `paid`.
Rows already `paid` are **never rewritten** — the engine preserves history. Rows that lose their
justification become `void` rather than being deleted.

Every row stores `unlock_reason`, so finance can see exactly why money is owed.

---

## Data model

| Table | Purpose |
|---|---|
| `users` | one row per person; `role` ∈ admin / sales / supervisor / deployer / finance / viewer. Holds M-PESA + bank payout fields. |
| `pin_reset_requests` | "Forgot PIN?" requests: what the person typed, which account it matched (if any), and whether an admin has handled it. |
| `sessions` | bearer session tokens (httpOnly cookie). |
| `facilities` | the spine. Name, level, FID, county, stage, agreed price, monthly price, term, sales owner. |
| `stage_events` | append-only record of pipeline movement. |
| `contracts` | signed contract records (number, date, amount, verification status). |
| `documents` | every upload: signed contract, client/owner ID, company registration, receipt, invoice scan. |
| `deployments` | facility × person × supervisor, with discipline and status. |
| `invoices` | invoices raised, from the app or imported from Business Central. `match_state` flags unmatched rows. |
| `payments` | money received, with mode (mpesa/bank/cheque/cash), reference, receipt, and computed `months_paid`. |
| `payment_evidence` | what a field person photographed/uploaded as proof of payment: amount, date, mode, reference, status (pending/confirmed/rejected), who captured it and who reviewed it. Confirming creates a real `payments` row. |
| `sha_claims` | claims we lodge with the Social Health Authority on a facility's behalf: period, claim number, claim value, fee rate (default 1%), computed fee, status and dates. |
| `national_facilities` | the Kenya national registry (KMHFL / Master Facility List): every facility, its MFL code, KEPH level, owner and administrative location. Read-mostly reference data, independent of your operational tables. |
| `commission_rules` | editable rate table (role, kind, level tier, amount). |
| `commissions` | the ledger described above. |
| `activity_log` | audit trail of every state change, shown as the facility history timeline. |
| `meta` | import provenance. |

### Invariants worth knowing

- `commissions` is unique on `(facility_id, user_id, kind)`.
- `deployments` is unique on `(facility_id, person_id, assigned_on)`.
- `invoices` is unique on `(invoice_no, source)` when an invoice number exists — this makes the
  Business Central import safely re-runnable.
- Deleting a facility cascades to its contracts, deployments, payments and commissions, but
  **invoices survive with `facility_id = NULL`** so finance never loses a paper trail.

---

## API reference

All routes are under `/api`. Authentication is a session cookie; every handler checks the role.

### Auth

| Method | Path | Who | Notes |
|---|---|---|---|
| POST | `/api/auth/login` | anyone | `{phone, pin}` — a **telephone number or email** plus a **4-digit PIN**; sets the `sid` cookie. Returns `must_change_pin` when the account is on a temporary PIN. Locked accounts get `429`. |
| POST | `/api/auth/signup` | anyone (if enabled) | `{first_name, middle_name?, last_name, phone, email?, title}` → creates a **pending** request. No PIN, no session. `title` is matched against `SIGNUP_POSITIONS` and decides only the suggested role. `name` alone is still accepted from older clients. |
| POST | `/api/auth/forgot-pin` | anyone | `{identifier}` → logs a `pin_reset_requests` row. Always the same reply, so it cannot be used to discover accounts. |
| POST | `/api/auth/demo` | anyone (demo installations only) | signs in as the seeded read-only demo account with no credential. Refused when `AFYA_HIDE_DEMO=1` or `AFYA_DEMO_MODE=0`. |
| GET | `/api/auth/positions` | anyone | the ten positions offered on the sign-up form |
| GET | `/api/auth/signup-available` | anyone | `{enabled}` — mirrors `AFYA_ALLOW_SIGNUP` so the UI can hide the link |
| POST | `/api/auth/logout` | signed in | clears session |
| GET | `/api/auth/me` | signed in | current user |

The credential is **exactly 4 digits** (`PIN_RE`), stored as a scrypt hash in `users.pin_hash`
(formerly `password_hash` — renamed on first boot by a migration in `server/index.js` and
`etl/import.mjs`). There is **no shared default PIN**: `randomPin()` generates a unique temporary one
per account, skipping repeated digits, `1212`-style patterns and `1234`/`4321`/`0000`. Because 4
digits is only 10,000 combinations, the login handler also enforces `MAX_PIN_ATTEMPTS` (5) before
locking the account for `LOCK_MINUTES` (15); a successful sign-in or an admin reset clears the lock.

Account state drives the whole flow: `status` is `pending` → `active` (or `disabled`), and
`pin_is_temporary` forces the person to choose their own PIN at first sign-in. `requested_role` holds
what a self sign-up asked for until an admin approves and sets the real role — self sign-up can never
grant itself `admin` or `finance`.

Phone identifiers are compared on their **last 9 significant digits** (`PHONE_LAST9`), so country
code, spaces and the leading zero do not matter. `phoneTaken()` enforces **one phone number per
login** — it is checked on sign-up and when an admin creates a user, and
`GET /api/users/phone-audit` lists accounts that are missing a number or sharing one.

### People

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/users` | signed in | payout fields only for self, finance, admin |
| POST | `/api/users` | admin | requires a phone number or email; returns the generated `temporary_pin` |
| GET | `/api/users/auth-queue` | admin | pending sign-ups, open forgot-PIN requests, accounts still on a temporary PIN, and the phone-number audit |
| POST | `/api/users/:id/approve` | admin | `{role, pin?}` — activates a pending sign-up and issues a temporary PIN |
| POST | `/api/users/:id/reject-signup` | admin | marks the request disabled |
| POST | `/api/users/:id/reset-pin` | admin | issues a new temporary PIN, clears the lock, and closes any open reset request |
| POST | `/api/pin-reset-requests/:id/dismiss` | admin | closes a request without issuing a PIN |
| PATCH | `/api/users/:id` | self (payout + own PIN) / admin | `{pin}` sets a 4-digit PIN, clears `pin_is_temporary`, the attempt counter and any lock |
| DELETE | `/api/users/:id` | admin | soft-disable |

### Facilities

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/facilities` | signed in | filters: `stage, county, owner, q, mine` |
| GET | `/api/facilities/by-contact` | signed in | `q` = contact name or phone. Returns facilities grouped by contact identity (phone's last 9 digits when one exists, otherwise the contact name), each group carrying a `contracted` count. Registered **before** `/api/facilities/:id`. |
| POST | `/api/facilities` | sales, admin | creates a lead owned by the caller |
| GET | `/api/facilities/:id` | signed in | full record + timeline |
| PATCH | `/api/facilities/:id` | owner sales, admin | derived stages cannot be forced |
| DELETE | `/api/facilities/:id` | admin | cascade delete |
| GET | `/api/deployments` | signed in | filters: `status, open, mine, supervised` |

### Money in

| Method | Path | Who | Notes |
|---|---|---|---|
| POST | `/api/facilities/:id/payments` | finance, admin | optional receipt file |
| POST | `/api/facilities/:id/invoices` | finance, admin | manual invoice |
| POST | `/api/invoices/import` | finance, admin | `{rows:[...]}` from a Business Central CSV |
| GET | `/api/invoices` | signed in | `?unmatched=1` for the reconciliation queue |
| DELETE | `/api/invoices/:id` | finance, admin | |

### National facility registry

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/national-facilities` | signed in | `q`, `county`, `level`, `owner`, `limit` (max 200). Prefix matches rank first. |
| GET | `/api/national-facilities/meta` | signed in | total, 47 counties with counts, level breakdown, top owners |
| GET | `/api/national-facilities/export` | signed in | CSV download of the whole list or a filtered slice |
| POST | `/api/national-facilities/import` | admin | `{rows:[…], replace:true}` to refresh from a newer KMHFL export |

### Field capture & payment evidence

| Method | Path | Who | Notes |
|---|---|---|---|
| POST | `/api/facilities/:id/capture` | owning salesperson, supervisor, admin | one call for the whole field capture: `{contract:{contract_no,signed_date,amount,files:[{filename,file_base64}]}, payment:{amount,paid_on,mode,reference,payer,notes,files:[…]}}`. Contract photos create one `contracts` row + N documents; payment files create a `payment_evidence` row + N documents. |
| GET | `/api/payment-evidence` | signed in | field staff see only their own; finance/supervisor see all. `?status=pending` for the queue |
| GET | `/api/payment-evidence/:id/files` | signed in | the photos/scans attached to one capture |
| POST | `/api/payment-evidence/:id/confirm` | finance, admin | creates the real payment (which unlocks commission) and stamps who reviewed it |
| POST | `/api/payment-evidence/:id/reject` | finance, admin | keeps the reason; creates no payment |

Photos arrive already downscaled by the browser (`shrinkImage()` in `public/app.js`, 1600px JPEG).
The camera itself is `getUserMedia` with a live preview, falling back to an
`<input type="file" accept="image/*" capture="environment">` when a camera is unavailable.

### Invoice reconciliation & name standardisation

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/invoices/:id/suggestions` | finance, admin | ranked candidates for an unmatched invoice, from existing facilities/leads and the national registry |
| POST | `/api/invoices/:id/link` | finance, admin | attach the invoice to an existing facility |
| POST | `/api/invoices/:id/create-facility` | finance, admin | create the facility (often from a directory entry) and link the invoice in one step |
| POST | `/api/invoices/auto-match` | finance, admin | `{min}` (default 92) — links only unambiguous matches above the threshold |
| GET | `/api/facilities/name-suggestions` | admin, finance, supervisor | `?min=` — official KMHFL name for each facility, with a score |
| POST | `/api/facilities/apply-names` | admin | `{items:[…]}` or `{min}` — renames and records the MFL code; skips any rename that would duplicate another facility |

`matchScore()` compares two views of each name — a lightly cleaned `fullName()` and a
`normalizeName()` core with generic words removed — using Sørensen–Dice similarity over character
bigrams, so typos like *Northmark* / *Northmaric* still match. A core match on a **single** word is
capped at 66, which is what stops *Baraka Hospital* being declared identical to *Baraka Health
Centre*. Name suggestions shortlist candidates through a 3-character prefix index
(`nationalIndex()`), which keeps 392 × 8,932 comparisons at roughly 2 seconds.

### SHA claims

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/sha-claims` | signed in | filters `status`, `facility`, `from`, `to`; returns claims + `shaSummary()` |
| POST | `/api/sha-claims` | finance, admin | `fee_amount` is computed from `claim_amount × fee_rate` |
| PATCH | `/api/sha-claims/:id` | finance, admin | changing amount or rate recomputes the fee |
| DELETE | `/api/sha-claims/:id` | finance, admin | audit-logged |

### Reports (they back the clickable dashboard and the CSV downloads)

Every report returns the same envelope — `{ title, subtitle, columns:[{key,label,type,drill?}], rows, totals }` —
so one client function renders the drawer and one builds the CSV. A column may declare a `drill`
template such as `/api/reports/payments?month={month}&county={county}`; `reportCell()` substitutes the
row's own values, which is how any figure can be clicked to reveal the records it was built from.

| Path | Filters | Contents |
|---|---|---|
| `/api/reports/facilities` | `stage, owner, county, q, withOutstanding` | facility list with contracted date, contract value, invoiced, paid, outstanding, salesperson, contact |
| `/api/reports/payments` | `from, to, month, undated, county, facility` | payments received |
| `/api/reports/invoices` | `from, to, month, match_state, county, facility` | invoices |
| `/api/reports/commissions` | `status, role` | commission ledger (non-privileged users see only their own) |
| `/api/reports/months` | — | monthly contracted / billed / collected / outstanding / collection rate |
| `/api/reports/county-months` | `months` (default 6) | county × month grid: billed, collected, outstanding, then one column of collected per month, each with a drill-down |
| `/api/reports/subscriptions` | `months` (default 6, max 36) | facility × month grid with a `Paid <amount>` or `—` per month |
| `/api/reports/sha` | `status, from, to, facility` | SHA claims with claim value, fee % and fee |

`monthlySeries()` in `server/index.js` builds the month view from `facilities.contracted_date`,
`invoices.posting_date` and `payments.paid_on`; `shaSummary()` aggregates `sha_claims` by status and
month. Both are reused by `/api/dashboard`.

`facilities.national_code` stores the MFL code when a lead was picked from the registry; the
facility `source` then becomes `national-registry` instead of `app`.

### Contract and documents

| Method | Path | Who | Notes |
|---|---|---|---|
| POST | `/api/facilities/:id/contracts` | **owning salesperson or admin** | requires a file; unlocks sales commission |
| GET | `/api/facilities/:id/contracts` | signed in | |
| POST | `/api/facilities/:id/documents` | owner sales / supervisor / admin | ID, company registration, receipt |
| GET | `/api/documents/:id/download` | signed in | streams the stored file |

### Deployment

| Method | Path | Who | Notes |
|---|---|---|---|
| POST | `/api/facilities/:id/deployments` | supervisor, admin | assign a person |
| PATCH | `/api/deployments/:id` | supervisor, admin, own deployer | mark deployed |
| DELETE | `/api/deployments/:id` | supervisor, admin | cancel (soft) |

### Commissions

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/commissions` | signed in | non-privileged callers see only their own |
| POST | `/api/commissions/recompute` | finance, admin | rebuild the whole ledger |
| POST | `/api/commissions/:id/pay` | finance, admin | only `payable` rows; cannot double-pay |
| GET/PATCH | `/api/commission-rules[/:id]` | read: signed in; write: admin | |

### Reporting

| Method | Path | Who | Notes |
|---|---|---|---|
| GET | `/api/dashboard` | signed in | stage counts, money totals, commission totals, county table |
| GET | `/api/health` | anyone | liveness |

---

## ETL

`etl/extract.py` reads every xlsx with pandas and writes a single normalised JSON payload
(`etl/extracted.json`). `etl/import.mjs` then:

1. wipes transactional tables and reseeds commission rules,
2. creates users (parsing names like `"Ernest - 0712565636"` into name + phone),
3. inserts facilities, merging on normalised name across all sources,
4. attaches contracts (from historical "signed = yes"), payments, Business Central invoices and
   deployments,
5. resolves facility names to IDs with an exact-then-prefix match,
6. recomputes every stage and the whole commission ledger.

Dates are normalised by `iso_date()` (ISO strings first, then `dayfirst`), so the rep sheets'
`27/08/2026`, `2026-08-26 00:00:00`, `SEP/20/2026` and even the `14/009/2026` typo all land as
`YYYY-MM-DD`. Contract dates come from Annet's *Stage* column, John Michael's *Date*, Adreans'
*Date Signed*, Abraham's *CONTRACTED DATE* and Lawerence's *Date Contract Signed*; the "billed
amount" columns become `facilities.monthly_price`, and months paid is derived from amount ÷ price.

Re-running is safe and repeatable: `node etl/import.mjs`.

The national registry is a **separate pipeline** so the two never interfere:

```
etl/national_extract.py      data/src/kenya_health_facilities_ocha.xlsx -> etl/national_facilities.json
etl/import_national.mjs      json -> national_facilities table (replaces only that table)
```

`node etl/import.mjs` rebuilds your operational data and deliberately leaves
`national_facilities` alone; `node etl/import_national.mjs` refreshes the registry and leaves your
operational data alone.

---

## Extending it

- **New commission rule** — insert into `commission_rules`, then call recompute. No code change.
- **New source file** — add a section to `etl/extract.py` that appends to the same JSON shape.
- **Business Central sync** — replace the CSV paste with a scheduled job that POSTs to
  `/api/invoices/import`; the endpoint is already idempotent per invoice number.
- **SMS/email notifications** — hook the `activity_log` writes or the commission status change.
- **Move to PostgreSQL** — the schema is plain SQL; only `node:sqlite` calls in `server/index.js`
  and `etl/import.mjs` would need swapping for a `pg` client.
