Show Offline when the server is unreachable, and document the pilot changes

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>
This commit is contained in:
2026-10-11 18:54:24 +03:00
parent 472573a1a8
commit 8fe50af341
4 changed files with 122 additions and 6 deletions
+104
View File
@@ -0,0 +1,104 @@
# 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 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 37 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.
## Still open
- **Removing a branch.** Branches can be renamed but not removed. Someone
needs to decide what happens to a removed branch's stock and history.
- **Setting branch prices.** Branch price overrides are applied everywhere,
but there is no screen or API to set them yet.
- **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.
- **Concurrent transfers.** Two transfers submitted at the same moment
could together send more than the branch holds.
- **Cash-basis profit.** The profit report counts all purchases in the
period as cost of goods.
- **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:** invites and email verification, empty-bottle and
deposit tracking, home-screen icons for the PWA.
+9 -2
View File
@@ -41,6 +41,9 @@ export default function TerminalPage() {
const [mpesaEnabled, setMpesaEnabled] = useState(false);
const [bootError, setBootError] = useState<string | null>(null);
const [online, setOnline] = useState(true);
// navigator.onLine only knows about the phone's own connection; this
// also goes false when the server could not be reached.
const [serverReachable, setServerReachable] = useState(true);
const [counts, setCounts] = useState({ pending: 0, rejected: 0 });
const [showQueue, setShowQueue] = useState(false);
const [queueRows, setQueueRows] = useState<QueuedSale[]>([]);
@@ -98,6 +101,7 @@ export default function TerminalPage() {
return;
}
user = cached.user;
setServerReachable(false);
setMpesaEnabled(false); // M-Pesa needs a connection anyway
show("Offline - using this phone's saved session. Sales will sync when you're back online.", "info");
}
@@ -137,6 +141,7 @@ export default function TerminalPage() {
window.addEventListener("offline", goOffline);
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
setCounts((c) => ({ ...c, pending: r.pending }));
setServerReachable(!r.offline);
if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success");
if (r.rejected > 0) {
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
@@ -235,6 +240,7 @@ export default function TerminalPage() {
if (navigator.onLine) {
flushQueue(owner).then(async (r) => {
setCounts((c) => ({ ...c, pending: r.pending }));
setServerReachable(!r.offline);
const rows = await salesFor(me.id);
const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
if (updated) setScreen((s) => (s.kind === "receipt" && s.sale.clientUuid === sale.clientUuid ? { ...s, sale: updated } : s));
@@ -374,6 +380,7 @@ export default function TerminalPage() {
}
const quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
const connected = online && serverReachable;
return (
<main className="min-h-screen bg-sand-50 pb-10">
@@ -387,10 +394,10 @@ export default function TerminalPage() {
<span
className={
"rounded-full px-2.5 py-1 text-xs font-medium " +
(online ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
(connected ? "bg-confirm-500/20 text-confirm-500" : "bg-alert-500/20 text-alert-500")
}
>
{online ? "● Online" : "● Offline"}
{connected ? "● Online" : "● Offline"}
</span>
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
Sign out
+4 -4
View File
@@ -19,8 +19,8 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
}
return (
<div>
<span className="mb-1.5 block text-sm font-medium text-depth-600">Tap a product to add it</span>
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
<span className="mb-2 block text-sm font-medium text-depth-600">Tap a product to add it</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{products.map((p) => {
const inCart = quantities[p._id] ?? 0;
@@ -31,8 +31,8 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
onClick={() => onAdd(p._id)}
aria-label={`Add ${p.label}`}
className={
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 px-3 py-2 text-left transition active:scale-[0.97] " +
(inCart > 0 ? "border-flow-500 bg-flow-500/10" : "border-depth-200 bg-white")
"tap-target relative flex flex-col items-start justify-center rounded-xl2 border-2 py-2 pl-3 text-left transition active:scale-[0.97] " +
(inCart > 0 ? "border-flow-500 bg-flow-500/10 pr-9" : "border-depth-200 bg-white pr-3")
}
>
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
+5
View File
@@ -217,6 +217,11 @@ async function doFlush(owner: QueueOwner): Promise<FlushResult> {
let synced = 0;
let rejected = 0;
let offline = false;
if (pending.length === 0) {
// Nothing to send: still find out whether the server is reachable, so
// the terminal's Online/Offline badge is truthful.
offline = !(await fetch("/api/health", { cache: "no-store" }).then((r) => r.ok).catch(() => false));
}
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
try {