ee5bb5beb8
A transfer read the sending branch's stock and then wrote its movements as two separate steps. Requests arriving together all saw the same balance and all passed the check: five simultaneous transfers of 4 from a stock of 10 all succeeded, sending 20. The check and the write now run under withStockLock: a short lease on one product's stock at one branch, taken with a single atomic upsert (it only succeeds when no unexpired lease exists) and released when the request finishes. A second request waits briefly, then sees the updated balance; after 5 seconds it gets 409 "try again". A lease expires after 10 seconds, so a crashed request cannot block the stock for good. Physical counts take the same lock, so a count is never computed from a balance a transfer is changing. A double-tapped transfer that races itself still moves stock once. Sales do not take the lock: they are recorded even when stock runs out, by design. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
9.2 KiB
Markdown
148 lines
9.2 KiB
Markdown
# 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 check and the move run under a short per-branch, per-product lock (`withStockLock` in `src/lib/server/stock.ts`), so simultaneous transfers cannot together overdraw a branch; physical counts take the same lock. |
|
|
| 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 62 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.
|
|
|
|
## Follow-ups from the 11 October 2026 test report
|
|
|
|
The initial test report (run on the build before these changes) raised
|
|
seven findings and a retest plan. All of them are addressed on
|
|
`fix/test-report-followups`:
|
|
|
|
| Finding | What changed |
|
|
|---|---|
|
|
| F-01 Trial allows one branch | A business on its free trial may run **2 active branches** whatever its tier (`TRIAL_MAX_BRANCHES` in `src/lib/tiers.ts`). After the trial the tier's limit applies to adding branches. |
|
|
| F-02 No current stock | Done in the previous change (stock ledger). Purchases recorded before the ledger existed are added by `npm run backfill:stock`. |
|
|
| F-03 Reversed report dates | Refused in the page and by the API (400). The page labels which period the cards show. Report dates are Nairobi business days. |
|
|
| F-04 No way back for deactivated products | Products page lists deactivated products under a toggle, with Reactivate. Deactivating warns about stock still on hand; that stock stays on the Stock page and can be transferred or counted out. |
|
|
| F-05 Eggs shown as litres | Products carry a unit (L, ml, kg, g, pcs, tray, pack), chosen when the product is created and shown everywhere. |
|
|
| F-06 No branch or staff removal | Staff: deactivate/reactivate (previous change). Branches: rename and deactivate/reactivate on the Branches page. A branch can only be deactivated once it has no stock and no active staff; its history stays in reports. |
|
|
| F-07 Purchases shown as cost of goods | Cost of goods sold is now units sold x the product's average buying price. Purchases in the period are shown separately as "Stock purchased", with a "Cash surplus" line. |
|
|
| STA-04 No invites | Staff and new branch managers can be invited: the owner or manager gets a one-time link (7 days) to send, and the person sets their own password. A new link can be made from Staff. |
|
|
| CAT-12 Zero price, duplicates | Prices must be above zero; product names are unique per business, ignoring case. |
|
|
| OFF-02 Local stock deduction | The till shows each product's stock and deducts every sale immediately, online or offline. |
|
|
| POS-02 / INV-05 Oversell, cancel | The till warns before selling more than the recorded stock and asks to confirm (it does not block, because the sale may already have happened). A "Clear sale" button empties the cart. |
|
|
|
|
`tests/retest-plan.test.ts` runs the report's completion and retest plan
|
|
(online sale, transfer, two offline sales replayed twice, a Branch B sale)
|
|
and checks every balance and total it predicts.
|
|
|
|
### Before re-running the test report's plan on ZERRO
|
|
1. Deploy, then run `npm run backfill:stock` once so the existing test
|
|
purchases count as stock. Run it **before** anyone records an opening
|
|
count, or those units would be counted twice.
|
|
2. ZERRO is trialling, so it can now add Branch B without a plan change.
|
|
3. Create the manager and cashier logins with invite links from Staff.
|
|
|
|
## Branch prices
|
|
|
|
Owners set a different price per branch on **Products -> Branch prices**
|
|
(`/admin/prices`, API `/api/branch-prices`). Setting a price back to the
|
|
default removes the override. Branch managers see their own branch's
|
|
prices read-only.
|
|
|
|
- It is a Growth/Enterprise feature (as the plan list already advertised),
|
|
also available during the free trial. After a trial on Starter, existing
|
|
branch prices keep applying and can be reset, but not changed.
|
|
- The till, cash sales and M-Pesa all read the branch price. A phone picks
|
|
up a change the next time it loads products; a sale it records offline in
|
|
the meantime is kept at the server's price and flagged as a price
|
|
mismatch.
|
|
- The Products page always shows default prices.
|
|
|
|
## Still open
|
|
- **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.
|
|
- **Cost of goods is an average.** It uses each product's average buying
|
|
price across the business, not first-in-first-out per branch. Units sold
|
|
with no purchase on record are counted and flagged, but have no cost.
|
|
- **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:** email verification at signup, sending invites by
|
|
email (links are shared by hand, e.g. WhatsApp), empty-bottle and deposit
|
|
tracking, home-screen icons for the PWA.
|