Pilot readiness: server-side roles, stock ledger and transfers, offline-safe sync #1
@@ -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.
|
||||||
@@ -41,6 +41,9 @@ export default function TerminalPage() {
|
|||||||
const [mpesaEnabled, setMpesaEnabled] = useState(false);
|
const [mpesaEnabled, setMpesaEnabled] = useState(false);
|
||||||
const [bootError, setBootError] = useState<string | null>(null);
|
const [bootError, setBootError] = useState<string | null>(null);
|
||||||
const [online, setOnline] = useState(true);
|
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 [counts, setCounts] = useState({ pending: 0, rejected: 0 });
|
||||||
const [showQueue, setShowQueue] = useState(false);
|
const [showQueue, setShowQueue] = useState(false);
|
||||||
const [queueRows, setQueueRows] = useState<QueuedSale[]>([]);
|
const [queueRows, setQueueRows] = useState<QueuedSale[]>([]);
|
||||||
@@ -98,6 +101,7 @@ export default function TerminalPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
user = cached.user;
|
user = cached.user;
|
||||||
|
setServerReachable(false);
|
||||||
setMpesaEnabled(false); // M-Pesa needs a connection anyway
|
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");
|
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);
|
window.addEventListener("offline", goOffline);
|
||||||
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
|
const unregister = registerOfflineSync({ id: me.id, businessId: me.businessId, branchId: me.branchId }, (r) => {
|
||||||
setCounts((c) => ({ ...c, pending: r.pending }));
|
setCounts((c) => ({ ...c, pending: r.pending }));
|
||||||
|
setServerReachable(!r.offline);
|
||||||
if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success");
|
if (r.synced > 0) show(r.synced + " saved sale(s) synced", "success");
|
||||||
if (r.rejected > 0) {
|
if (r.rejected > 0) {
|
||||||
show(r.rejected + " sale(s) could not be synced - tap the banner to see why", "error");
|
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) {
|
if (navigator.onLine) {
|
||||||
flushQueue(owner).then(async (r) => {
|
flushQueue(owner).then(async (r) => {
|
||||||
setCounts((c) => ({ ...c, pending: r.pending }));
|
setCounts((c) => ({ ...c, pending: r.pending }));
|
||||||
|
setServerReachable(!r.offline);
|
||||||
const rows = await salesFor(me.id);
|
const rows = await salesFor(me.id);
|
||||||
const updated = rows.find((row) => row.clientUuid === sale.clientUuid);
|
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));
|
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 quantities = Object.fromEntries(cart.map((c) => [c.productId, c.quantity]));
|
||||||
|
const connected = online && serverReachable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-sand-50 pb-10">
|
<main className="min-h-screen bg-sand-50 pb-10">
|
||||||
@@ -387,10 +394,10 @@ export default function TerminalPage() {
|
|||||||
<span
|
<span
|
||||||
className={
|
className={
|
||||||
"rounded-full px-2.5 py-1 text-xs font-medium " +
|
"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>
|
</span>
|
||||||
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
<button onClick={handleSignOut} className="text-xs text-sand-200/60 underline underline-offset-2">
|
||||||
Sign out
|
Sign out
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="rounded-xl2 border border-depth-200/60 bg-white p-4 shadow-soft">
|
||||||
<span className="mb-1.5 block text-sm font-medium text-depth-600">Tap a product to add it</span>
|
<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">
|
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
|
||||||
{products.map((p) => {
|
{products.map((p) => {
|
||||||
const inCart = quantities[p._id] ?? 0;
|
const inCart = quantities[p._id] ?? 0;
|
||||||
@@ -31,8 +31,8 @@ export default function ProductGrid({ products, quantities, onAdd }: Props) {
|
|||||||
onClick={() => onAdd(p._id)}
|
onClick={() => onAdd(p._id)}
|
||||||
aria-label={`Add ${p.label}`}
|
aria-label={`Add ${p.label}`}
|
||||||
className={
|
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] " +
|
"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" : "border-depth-200 bg-white")
|
(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>
|
<span className="font-display text-sm font-bold leading-tight text-depth-900">{p.label}</span>
|
||||||
|
|||||||
@@ -217,6 +217,11 @@ async function doFlush(owner: QueueOwner): Promise<FlushResult> {
|
|||||||
let synced = 0;
|
let synced = 0;
|
||||||
let rejected = 0;
|
let rejected = 0;
|
||||||
let offline = false;
|
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) {
|
for (let i = 0; i < pending.length; i += MAX_SALES_PER_REQUEST) {
|
||||||
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
|
const chunk = pending.slice(i, i + MAX_SALES_PER_REQUEST);
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user