Pilot readiness: server-side roles, stock ledger and transfers, offline-safe sync #1

Merged
denis_ireri merged 4 commits from fix/pilot-readiness into main 2026-10-12 06:14:51 +00:00
Contributor

Closes the gaps found when the MVP was reviewed against PRD.md and REVIEW.md. Full write-up, decisions and open items: docs/PILOT_READINESS.md.

What changes

Tenant isolation and roles (PRD 5.1)

  • Every role-limited route now checks the role on the server (401 signed out, 403 wrong role). Cashiers can no longer read or write profit, expenses or purchases; only the owner edits products and prices.
  • Owners can deactivate or reactivate staff (managers: their own cashiers) and rename branches. Deactivation revokes device sessions.
  • Login and token refresh refuse inactive or SUSPENDED businesses. Public signup refuses PLATFORM_ADMIN_EMAILS addresses.
  • Malformed ids return 400 instead of 500; inventory routes and reconciliation lookups are tenant-scoped.

Stock ledger and transfers (PRD 5.2)

  • New StockMovement ledger: stock = sum of movements, each keyed by its cause so replays never double-count. Sales and confirmed M-Pesa payments deduct stock; purchases add it.
  • New /api/stock, /api/stock/transfers, /api/stock/adjustments, /api/stock/movements, and a Stock admin page (per-branch levels with low/out flags, counts, transfers, morning dispatch / evening count). Dashboard shows stock per branch.

Offline POS and sync (PRD 5.3, 5.5)

  • POST /api/transactions/cash returns saved / duplicate / rejected per sale. The old client never learned a replayed sale had synced, so once 100+ rows piled up every sync failed with 400.
  • The device queue is keyed by cashier and branch, sends only the signed-in cashier's sales in chunks of 50, and shows rejected sales instead of retrying them forever.
  • Service worker + cached session/products: the terminal opens and sells with no network. Multi-product sales, optional customer phone, printable receipt.

Other

  • "Today" and "this month" follow Africa/Nairobi instead of the server's UTC clock.
  • M-Pesa is behind FEATURE_MPESA (off by default; out of scope for the pilot). Turning it on in production requires the callback IP allowlist.

Testing

  • npm test: 37 API tests against an in-memory MongoDB (role enforcement, cross-tenant attempts on sales/stock/transfers, suspension, sync protocol including a 120-sale backlog replayed twice, business day).
  • Production build passes.
  • Offline flow checked end to end in headless Chrome: sign in, stop the server, reload the terminal, record two sales, restart the server; both synced exactly once and deducted stock.

Before deploying

  • Existing branches have no stock on record. Each branch should record an opening count on the Stock page.
  • Set FEATURE_MPESA=false (or leave unset) and PLATFORM_ADMIN_EMAILS in the environment; see .env.example.

Still open (decisions needed)

Removing branches, a screen to set branch prices, concurrent transfers racing past available stock, global email/phone uniqueness, invites and email verification, and empty-bottle/deposit tracking. Details in docs/PILOT_READINESS.md.

🤖 Generated with Claude Code

Closes the gaps found when the MVP was reviewed against `PRD.md` and `REVIEW.md`. Full write-up, decisions and open items: `docs/PILOT_READINESS.md`. ## What changes **Tenant isolation and roles (PRD 5.1)** - Every role-limited route now checks the role on the server (401 signed out, 403 wrong role). Cashiers can no longer read or write profit, expenses or purchases; only the owner edits products and prices. - Owners can deactivate or reactivate staff (managers: their own cashiers) and rename branches. Deactivation revokes device sessions. - Login and token refresh refuse inactive or SUSPENDED businesses. Public signup refuses `PLATFORM_ADMIN_EMAILS` addresses. - Malformed ids return 400 instead of 500; inventory routes and reconciliation lookups are tenant-scoped. **Stock ledger and transfers (PRD 5.2)** - New `StockMovement` ledger: stock = sum of movements, each keyed by its cause so replays never double-count. Sales and confirmed M-Pesa payments deduct stock; purchases add it. - New `/api/stock`, `/api/stock/transfers`, `/api/stock/adjustments`, `/api/stock/movements`, and a Stock admin page (per-branch levels with low/out flags, counts, transfers, morning dispatch / evening count). Dashboard shows stock per branch. **Offline POS and sync (PRD 5.3, 5.5)** - `POST /api/transactions/cash` returns saved / duplicate / rejected per sale. The old client never learned a replayed sale had synced, so once 100+ rows piled up every sync failed with 400. - The device queue is keyed by cashier and branch, sends only the signed-in cashier's sales in chunks of 50, and shows rejected sales instead of retrying them forever. - Service worker + cached session/products: the terminal opens and sells with no network. Multi-product sales, optional customer phone, printable receipt. **Other** - "Today" and "this month" follow Africa/Nairobi instead of the server's UTC clock. - M-Pesa is behind `FEATURE_MPESA` (off by default; out of scope for the pilot). Turning it on in production requires the callback IP allowlist. ## Testing - `npm test`: 37 API tests against an in-memory MongoDB (role enforcement, cross-tenant attempts on sales/stock/transfers, suspension, sync protocol including a 120-sale backlog replayed twice, business day). - Production build passes. - Offline flow checked end to end in headless Chrome: sign in, stop the server, reload the terminal, record two sales, restart the server; both synced exactly once and deducted stock. ## Before deploying - Existing branches have no stock on record. Each branch should record an opening count on the Stock page. - Set `FEATURE_MPESA=false` (or leave unset) and `PLATFORM_ADMIN_EMAILS` in the environment; see `.env.example`. ## Still open (decisions needed) Removing branches, a screen to set branch prices, concurrent transfers racing past available stock, global email/phone uniqueness, invites and email verification, and empty-bottle/deposit tracking. Details in `docs/PILOT_READINESS.md`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
denis_ireri added 4 commits 2026-10-11 15:56:51 +00:00
The admin pages redirected cashiers away, but the API itself let them read
profit, expenses and purchases and record expenses and purchases. Branch
managers could also change product prices for every branch. Every gated
route now goes through authorize(), which answers 401 when signed out and
403 for the wrong role.

- Owners and managers only: profit report, expenses, purchases, users,
  morning dispatch and evening count. Owner only: create and edit products,
  rename branches.
- PATCH /api/users/:id deactivates or reactivates staff (managers: their own
  cashiers only) and revokes the user's device sessions. PATCH
  /api/branches/:id renames a branch.
- Login and token refresh refuse inactive or SUSPENDED businesses.
- Public signup refuses PLATFORM_ADMIN_EMAILS addresses, so an unregistered
  operator email can no longer be claimed.
- M-Pesa sits behind FEATURE_MPESA (off by default, out of scope for the
  pilot): its routes return 404 and the Daraja variables are optional.
  Turning it on in production requires the callback IP allowlist.
- "Today" and the default month now follow Africa/Nairobi instead of the
  server's UTC clock, so the business day no longer starts at 03:00 EAT.
- Malformed ids in query strings and bodies return 400 instead of a 500,
  the inventory routes check the product belongs to the tenant, and the
  reconciliation lookups are tenant-scoped.
- Branch-scoped staff see their branch's override price from GET /api/products.
- Adds a vitest suite against an in-memory MongoDB (npm test).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Stock (PRD 5.2): a branch's stock of a product is now the sum of its
StockMovement rows. Every movement names the document that caused it and
(type, refId) is unique, so replaying a sale, retrying a transfer or a
second M-Pesa callback never moves the same stock twice.

- Cash sales and confirmed M-Pesa payments deduct stock; purchases of a
  catalogue product add it.
- POST /api/stock/transfers moves stock between two branches (owner: any
  two; manager: out of their own) and refuses to send more than the
  sending branch holds. Double submits are applied once.
- POST /api/stock/adjustments records opening stock, breakage or a
  physical count (the ledger is corrected to the counted number).
- GET /api/stock returns every branch x product with LOW/OUT flags
  (per-product lowStockThreshold, default 10); GET /api/stock/movements
  is the history.

Sync (PRD 5.3/5.5): POST /api/transactions/cash now returns a result per
sale - saved, duplicate or rejected - instead of silently skipping
replays. The old client never learned a replayed sale had synced, so
rows piled up until the batch passed the 100-sale limit and every flush
failed with 400. Also:
- A sale can hold several products (one Transaction per line, grouped by
  saleId); the old single-product payload is still accepted.
- One bad sale is rejected on its own instead of failing the batch.
- A queued sale is only accepted from the cashier who recorded it, and
  stays with the branch where it happened.
- A product deactivated while offline is still recorded at its price; an
  invalid phone is dropped rather than losing the sale; the phone is now
  optional for cash sales.
- The price the device showed is kept and a difference is flagged
  (priceMismatch) instead of being silently overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Terminal (PRD 5.3/5.5):
- Opens with no network: a service worker caches the app shell and build
  assets, and the last confirmed session and product list are kept on the
  phone. Only a definitive 401/403 sends the cashier to the login page.
- The offline queue is keyed by cashier and branch, sends only the
  signed-in cashier's sales, chunks to the server's batch limit, and acts
  on the per-sale result: synced, or rejected with the reason shown and
  never retried. Rows from the old queue are adopted on first sync.
- Several products per sale (tap to add), optional customer details, and
  a printable receipt. The banner shows sales waiting to sync and any
  that need attention; sign-out warns about unsynced sales.
- M-Pesa only appears when FEATURE_MPESA is on. A timed-out prompt now
  tells the cashier not to take cash until the payment is checked,
  instead of reporting it as failed.

Admin:
- New Stock page: stock per branch with low/out flags, physical counts
  and adjustments, inter-branch transfers, morning dispatch and evening
  count entry, and recent movements.
- Dashboard shows stock on hand per branch and links to Stock.
- Staff page can deactivate and reactivate accounts.
- Products page is read-only for branch managers and edits the low-stock
  alert level.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The terminal's badge only looked at navigator.onLine, so it said Online
while the server was down. It now also reflects whether the last sync or
health check reached the server. The cart badge no longer covers the
product name.

docs/PILOT_READINESS.md records what changed against the PRD review, the
decisions taken (offline sales may take stock negative, transfers may
not, server price wins but differences are flagged), rollout notes, and
what is still open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
denis_ireri requested review from Ezekiel_Nyangweso 2026-10-11 16:01:39 +00:00
denis_ireri marked the pull request as work in progress 2026-10-11 16:01:45 +00:00
denis_ireri marked the pull request as ready for review 2026-10-12 06:13:43 +00:00
denis_ireri merged commit cb962466b3 into main 2026-10-12 06:14:51 +00:00
denis_ireri deleted branch fix/pilot-readiness 2026-10-12 06:15:02 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Ezekiel_Nyangweso/ERP#1