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>
Every route had to remember to add the business filter to every query;
nothing stopped one that forgot. The audit for this change found several
that had: the M-Pesa status re-check, the stock ledger's duplicate checks,
the daily-inventory and branch-price upserts, a cash-sale replay lookup,
several load-by-id lookups, and every populate().
- tenantGuard: a Mongoose plugin on all ten business-scoped models. Any
find/update/delete/count/distinct, or an aggregate whose first stage is
not a $match, that does not pin exactly one businessId throws
TenantScopeError before reaching the database ($in, $exists and other
operators do not count).
- crossTenant("reason", fn): the explicit, documented exemption for the
queries that must span businesses - login's email lookup, refresh and
invite-token lookups, the M-Pesa callback, the platform console and the
maintenance scripts. git grep crossTenant lists them all.
- scopedPopulate: populated branch, product and staff names are also
restricted to the business.
- Every flagged query site now names the business (or is exempted with a
reason).
- Tests: a check that every model with a businessId field carries the
guard; the guard refusing unscoped queries of every kind; and a route
sweep where business A sends business B's ids to every read route
(nothing of B's comes back) and every write route (all refused, B's
data unchanged). Test-side database inspections go through db(), an
explicit test exemption. Removing one route's business filter makes the
suite fail.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One person could not work for two businesses, and signup and staff
creation told anyone whether an email or phone was registered anywhere
on the platform.
- User email and phone are now unique per business: indexes on
(businessId, email) and (businessId, phoneNumber). Duplicate checks in
signup and branch-manager creation are scoped to the business, so they
no longer reveal other businesses' users.
- Login finds the email in every business and checks the password
against each account. One match signs straight in; several return 300
with the businesses to choose from, and the login page shows a picker.
A wrong password returns the same 401 as before and reveals nothing;
failures count against each candidate account, and a correct password
only resets the accounts it opens.
- Platform-operator access now also needs a platformAdmin flag on the
account. With per-business emails, an email match alone would have let
any owner create a staff account with the operator's email and open the
cross-tenant console. The flag is only set by the seed and the
migration; removing the email from PLATFORM_ADMIN_EMAILS still revokes.
- npm run migrate:identity flags today's operators (while each listed
email still matches one account) and syncs the User indexes, dropping
the old unique email_1 and phoneNumber_1. Checked against a database in
the old shape; safe to re-run.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
Branch price overrides were already applied at the till, in cash sales
and in M-Pesa, but nothing could set them.
- Products -> Branch prices (/admin/prices): pick a branch and set its
price for any product; setting it back to the default removes the
override, and "Use default" resets it. Branch managers see their own
branch's prices read-only.
- /api/branch-prices: GET (owner: any branch; manager: own), PUT and
DELETE (owner only). Branch and product must belong to the business;
prices must be above zero; who changed a price is recorded.
- Branch pricing is a plan feature, as the Growth plan already
advertised: Growth and Enterprise, plus any business on its free
trial. A paid Starter business keeps any prices set during its trial
and can reset them, but cannot set new ones.
- The catalogue view on the Products page always shows default prices;
the till's view shows what the branch charges.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The initial test report (run before the pilot-readiness changes) raised
seven findings and a retest plan. This closes the ones still open.
- F-01: a business on its free trial may run 2 active branches whatever
its tier, so an owner can try transfers and per-branch stock. Only
active branches count towards any limit.
- F-06: owners can rename branches and deactivate/reactivate them from
the Branches page. A branch can only be deactivated once it holds no
stock and no active staff; its history stays in every report, and it
leaves every picker.
- F-04: the Products page lists deactivated products under a toggle with
Reactivate. Deactivating warns about stock still on hand, which stays
on the Stock page so it can be transferred or counted out.
- F-05: products carry a unit (L, ml, kg, g, pcs, tray, pack) instead of
everything being litres.
- CAT-12: product prices must be above zero and names are unique per
business, ignoring case.
- F-03: a reversed report period is refused by the page and the API, and
the page labels which period the cards show. Report dates are Nairobi
business days.
- F-07: cost of goods sold is units sold x average buying price. Stock
bought in the period is reported separately, with a cash surplus line,
so unsold stock no longer shows as a loss.
- STA-04: staff and new branch managers can be invited with a one-time,
7-day link; the person sets their own password. Managers can issue a
new link for their own cashiers.
- OFF-02 / POS-02: the till shows each product's stock and deducts every
sale immediately, online or offline; it warns before selling more than
the recorded stock, and has a Clear sale button.
- npm run backfill:stock turns purchases recorded before the stock ledger
into stock, so existing tenants (and the report's ZERRO fixtures) start
from what they bought.
tests/retest-plan.test.ts runs the report's completion and retest plan
and checks every balance and total it predicts.
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>
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>