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>
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>