diff --git a/docs/PILOT_READINESS.md b/docs/PILOT_READINESS.md new file mode 100644 index 0000000..d733b69 --- /dev/null +++ b/docs/PILOT_READINESS.md @@ -0,0 +1,104 @@ +# Pilot readiness changes + +This branch (`fix/pilot-readiness`) closes the gaps found when the MVP was +reviewed against `PRD.md` and `REVIEW.md`. This page records what changed, +the decisions taken along the way, and what is still open. + +## What changed + +### Tenant isolation and roles (PRD 5.1) +- Every role-limited API route goes through `authorize()` in + `src/lib/server/auth.ts`: **401** when signed out, **403** for the wrong + role. Cashiers can no longer read profit, expenses or purchases, or + record expenses and purchases. Only the owner creates or edits products + and prices. +- `PATCH /api/users/:id` deactivates and reactivates staff (managers: only + their own cashiers). Deactivating or moving a user revokes their device + sessions. +- `PATCH /api/branches/:id` renames a branch (owner only). +- Login and token refresh refuse an inactive or `SUSPENDED` business. +- Public signup refuses any address in `PLATFORM_ADMIN_EMAILS`. +- Ids in query strings and bodies are validated (400, not a 500), inventory + routes check the product belongs to the tenant, and reconciliation + lookups are tenant-scoped. + +### Stock ledger and transfers (PRD 5.2) +- `StockMovement` is the ledger: a branch's stock of a product is the sum + of its movements. Each movement names its cause, and `(type, refId)` is + unique, so a replayed sale or retried transfer never moves stock twice. +- Sales and confirmed M-Pesa payments deduct stock; purchases of a + catalogue product add it. +- `POST /api/stock/transfers` moves stock between branches. + `POST /api/stock/adjustments` records opening stock, breakage and + physical counts. `GET /api/stock` returns stock per branch with LOW/OUT + flags (per-product `lowStockThreshold`, default 10). +- The admin **Stock** page (`/admin/stock`) covers all of this, plus the + existing morning dispatch and evening count. The dashboard shows stock + on hand per branch. + +### Offline POS and sync (PRD 5.3, 5.5) +- `POST /api/transactions/cash` answers each sale with `saved`, + `duplicate` or `rejected`. Previously a replayed sale was skipped + silently, the device never marked it synced, and once more than 100 rows + piled up every sync failed. +- The device queue (`src/lib/offlineQueue.ts`) is keyed by cashier and + branch, sends only the signed-in cashier's sales, chunks to 50 per + request, and stops retrying rejected sales (they are shown to the + cashier instead). +- A service worker (`public/sw.js`) caches the app shell, and the last + session and product list are kept on the phone, so the terminal opens + and sells with no network. +- Sales can contain several products, the customer phone is optional for + cash, and the terminal shows a printable receipt. + +### Other +- "Today" and "this month" follow Africa/Nairobi, not the server's UTC + clock (`src/lib/server/businessDay.ts`). +- M-Pesa is behind `FEATURE_MPESA` (off by default; out of scope for the + pilot per PRD section 4). + +## Decisions taken + +| Decision | Why | +|---|---| +| Offline sales are always accepted, even if they take stock below zero. | The sale already happened. Refusing it would lose real cash. Negative stock is shown on the Stock page and fixed with a physical count. | +| Transfers are refused when the sending branch lacks the stock. | Transfers are made online by a manager, so they can be checked. | +| The server price is authoritative; a different price shown on the device is kept (`clientUnitPrice`) and flagged (`priceMismatch`). | Trusting the device's price would let a cashier under-charge. Overwriting it silently would hide a cash discrepancy. | +| A queued sale stays with the branch where it was recorded, even if the cashier has since moved. | That is where the stock left and the cash was taken. | +| A product deactivated while a phone was offline is still recorded at its price. | Same reason as the first row: the sale happened. | + +## Rolling out to an existing database +- No migration is needed: new fields have defaults, and Mongoose creates + the new indexes on start-up. +- Existing branches start with **no stock on record**. Before the pilot, + each branch should record an opening count on the Stock page. +- Cashiers' phones upgrade their offline queue automatically; any unsynced + sales from the old version are sent on the next sync. + +## Testing +- `npm test` runs 37 API tests against an in-memory MongoDB, covering role + enforcement, tenant isolation for sales, stock and transfers, suspension, + the sync protocol (including a 120-sale backlog replayed twice), and the + business day. +- The offline flow was also checked end to end in headless Chrome: sign + in, stop the server, reload the terminal, record two sales, restart the + server, confirm they sync once and deduct stock. + +## Still open +- **Removing a branch.** Branches can be renamed but not removed. Someone + needs to decide what happens to a removed branch's stock and history. +- **Setting branch prices.** Branch price overrides are applied everywhere, + but there is no screen or API to set them yet. +- **Global email and phone uniqueness.** One person cannot work for two + businesses, and signup reveals whether an address is already registered. +- **Isolation by convention.** Tenant filters are still written per route. + The tests cover the sales, stock, transfer and access paths, not every + route. +- **Concurrent transfers.** Two transfers submitted at the same moment + could together send more than the branch holds. +- **Cash-basis profit.** The profit report counts all purchases in the + period as cost of goods. +- **M-Pesa, if turned on.** Only one product per payment. A prompt that + times out still needs a manual check before taking cash. +- **Not modelled yet:** invites and email verification, empty-bottle and + deposit tracking, home-screen icons for the PWA. diff --git a/src/app/terminal/page.tsx b/src/app/terminal/page.tsx index 8e74308..0eb8987 100644 --- a/src/app/terminal/page.tsx +++ b/src/app/terminal/page.tsx @@ -41,6 +41,9 @@ export default function TerminalPage() { const [mpesaEnabled, setMpesaEnabled] = useState(false); const [bootError, setBootError] = useState(null); const [online, setOnline] = useState(true); + // navigator.onLine only knows about the phone's own connection; this + // also goes false when the server could not be reached. + const [serverReachable, setServerReachable] = useState(true); const [counts, setCounts] = useState({ pending: 0, rejected: 0 }); const [showQueue, setShowQueue] = useState(false); const [queueRows, setQueueRows] = useState([]); @@ -98,6 +101,7 @@ export default function TerminalPage() { return; } user = cached.user; + setServerReachable(false); setMpesaEnabled(false); // M-Pesa needs a connection anyway show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info"); } @@ -137,6 +141,7 @@ export default function TerminalPage() { window.addEventListener("offline", goOffline); const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => { setCounts((c) => ({ ...c, pending: r.pending })); + setServerReachable(!r.offline); if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success"); if (r.rejected > 0) { show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error"); @@ -235,6 +240,7 @@ export default function TerminalPage() { if (navigator.onLine) { flushQueue(owner).then(async (r) => { setCounts((c) => ({ ...c, pending: r.pending })); + setServerReachable(!r.offline); const rows = await salesFor(me.id); const updated = rows.find((row) => row.clientUuid === sale.clientUuid); if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s)); @@ -374,6 +380,7 @@ export default function TerminalPage() { } const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity])); + const connected = online && serverReachable; return (
@@ -387,10 +394,10 @@ export default function TerminalPage() { - {online ? "● Online" : "● Offline"} + {connected ? "● Online" : "● Offline"}