ff75cf2fa7
Cost of goods sold used each product's average buying price across the whole business. Ten jerrycans bought at 100 and ten at 140, with 15 sold, cost 1,800 instead of the 1,700 actually paid for those units; a branch that bought dearer was costed like one that bought cheaper; and units moved between branches lost their cost. src/lib/server/costing.ts replays the stock ledger in order with cost layers per branch and product: - purchases add stock at their unit cost; transfers carry the sending branch's layers to the receiving branch; opening stock and upward counts are valued at the product's purchase price at the time; - sales take the oldest layers first, and that is cost of goods sold; - stock written off by counts or adjustments is a separate "stock losses" figure, now deducted in net profit; - a sale recorded before its stock (stock gone negative) is costed by the next stock to arrive at that branch, even after the period ended; until then it is reported as units with no purchase cost on record. The report shows the method and a Stock losses card. Sales recorded before the stock ledger existed are counted as uncosted. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
210 lines
12 KiB
Markdown
210 lines
12 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 covers only units sold (now first-in-first-out per branch; see "Cost of goods sold" below). 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.
|
|
|
|
## People in more than one business
|
|
|
|
Email and phone are unique **within a business**, not across the platform,
|
|
so one person can work for two businesses. Each account has its own
|
|
password and role.
|
|
|
|
- Login looks the email up in every business. If the password matches one
|
|
account, it signs straight in; if it matches several, the login page asks
|
|
which business. Someone with a wrong password learns nothing about which
|
|
businesses an email belongs to, and a wrong guess never resets the
|
|
failed-login count of another business's account.
|
|
- Signup and staff creation only check for duplicates inside the business,
|
|
so they no longer reveal whether an email or phone is registered
|
|
elsewhere.
|
|
- Platform-operator access now needs the `platformAdmin` flag on the
|
|
account as well as the email in `PLATFORM_ADMIN_EMAILS`. Without the
|
|
flag, any owner could create a staff account with the operator's email
|
|
in their own business. The flag is only set by `npm run seed` and
|
|
`npm run migrate:identity`, never through the app.
|
|
|
|
**Deploying this change:** run `npm run migrate:identity` straight after
|
|
the deploy. It flags the existing operator accounts and swaps the old
|
|
platform-wide unique indexes for per-business ones. Until it runs,
|
|
operators cannot open the Platform console and a second business cannot
|
|
reuse an email. It is safe to re-run.
|
|
|
|
## Tenant isolation is enforced
|
|
|
|
Every model that holds one business's data carries a guard
|
|
(`src/lib/server/tenantGuard.ts`). A query that does not name exactly one
|
|
business (`businessId: <id>`) throws `TenantScopeError` before it reaches
|
|
the database, so a route that forgets the business filter fails loudly in
|
|
development and in the tests instead of reading or changing another
|
|
business's data.
|
|
|
|
- Guarded: User, Branch, Product, BranchPricing, Transaction,
|
|
DailyInventory, Expense, Purchase, StockMovement, StockTransfer. A test
|
|
fails if a new model with a `businessId` field is added without the guard.
|
|
- Queries that genuinely span businesses must be wrapped in
|
|
`crossTenant("reason", ...)`: login's email lookup, refresh-session and
|
|
invite-token lookups, the M-Pesa callback, the platform console, and the
|
|
maintenance scripts. `git grep crossTenant` lists every one.
|
|
- Related records are loaded with `scopedPopulate`, so a populated branch,
|
|
product or staff name is also restricted to the business.
|
|
- `tests/isolation.test.ts` sweeps every read route with another business's
|
|
ids (nothing of theirs comes back) and every write route (all refused,
|
|
their data unchanged).
|
|
- Not guarded, by design: Business (the tenant itself, always looked up by
|
|
the session's id), UserSession, MpesaCallbackLog and StockLock.
|
|
|
|
## Cost of goods sold
|
|
|
|
The profit report costs sales first-in-first-out, per branch
|
|
(`src/lib/server/costing.ts`). It replays the stock ledger in order. Each
|
|
branch keeps queues of stock at the price it was bought at, and a sale
|
|
takes the oldest first.
|
|
|
|
- **Transfers carry their cost.** Stock moved between branches arrives at
|
|
what the sending branch paid.
|
|
- **Opening stock and upward counts** are valued at the product's purchase
|
|
price at the time (or its first purchase price, if they came first).
|
|
- **A sale recorded before its stock** (e.g. an offline sale before the
|
|
purchase was entered) is costed by the next stock to arrive at that
|
|
branch. Until then it counts as "units with no purchase cost on record".
|
|
- **Stock written off** by counts or adjustments is shown as "Stock losses"
|
|
and deducted in net profit. It is not part of cost of goods sold.
|
|
- Each report replays the business's whole stock history. That is fast at
|
|
pilot volumes; a business with years of history would want stored cost
|
|
layers instead.
|
|
|
|
## Still open
|
|
- **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.
|