diff --git a/apps/server/src/routes/shift.ts b/apps/server/src/routes/shift.ts index d69893d..af7a2d7 100644 --- a/apps/server/src/routes/shift.ts +++ b/apps/server/src/routes/shift.ts @@ -1,11 +1,19 @@ import type { FastifyInstance } from "fastify"; import { requireRole } from "../auth.js"; import { + InvalidCashMovementError, NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService, } from "../shift-service.js"; +interface CashMovementBody { + /** Signed minor units: positive = load INTO drawer, negative = remove FROM drawer. */ + amountMinor: number; + reason?: string; + currency?: string; +} + // Shift endpoints (manned mode). The operator is the logged-in user; a shift is // opened/closed explicitly (not time-based — see wiki/concepts/shift.md and // local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it. @@ -15,12 +23,36 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr const guard = requireRole("admin", "operator", "cashier"); // Is the current operator's shift open? (For the UI to show Start vs. End.) + // Also returns the live drawer balance so the UI can show what's in the till. app.get("/api/shift/current", { preHandler: guard }, async (req) => { const operator = req.user.username; const open = shift.openShiftFor(operator); - return { operator, open: open ? { startedAt: open.occurredAt } : null }; + const drawer = shift.drawerBalance(); + return { + operator, + open: open ? { startedAt: open.occurredAt } : null, + drawerMinor: drawer.balanceMinor, + currency: drawer.currency, + }; }); + // Admin loads/removes physical drawer cash (the float). Signed cash_movement + // event. ADMIN ONLY — an operator takes payments but cannot move the float. + // amountMinor is signed: + load IN, − remove OUT. See wiki/concepts/shift.md. + app.post<{ Body: CashMovementBody }>( + "/api/cash-movement", + { preHandler: requireRole("admin") }, + async (req, reply) => { + const { amountMinor, reason, currency } = req.body ?? ({} as CashMovementBody); + try { + return await shift.recordCashMovement(req.user.username, amountMinor, reason ?? "", currency); + } catch (err) { + if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message }); + return reply.code(500).send({ error: (err as Error).message }); + } + }, + ); + app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => { try { return await shift.open(req.user.username); diff --git a/apps/server/src/shift-service.ts b/apps/server/src/shift-service.ts index 467fd7a..2f41395 100644 --- a/apps/server/src/shift-service.ts +++ b/apps/server/src/shift-service.ts @@ -31,9 +31,25 @@ export interface ShiftReport { readonly cardTotalMinor: number; readonly currency: string | null; readonly paymentCount: number; + // --- Drawer (physical cash till; carries across shifts) --- + /** Cash in the drawer at shift start = prior shift's expected closing drawer. */ + readonly openingFloatMinor: number; + /** Admin cash LOADED into the drawer during the shift (sum of + movements). */ + readonly cashAddedMinor: number; + /** Admin cash REMOVED from the drawer during the shift (sum of − movements, as +). */ + readonly cashRemovedMinor: number; + /** Expected drawer at close = opening + cashTaken + added − removed. Carries forward. */ + readonly expectedDrawerMinor: number; readonly printed: boolean; } +export class InvalidCashMovementError extends Error { + constructor(msg: string) { + super(msg); + this.name = "InvalidCashMovementError"; + } +} + export class ShiftService { readonly #db: Db; readonly #log: EventLog; @@ -45,6 +61,12 @@ export class ShiftService { this.#logger = logger; } + /** Current physical drawer balance (cash payments + cash_movements, by time). For + * the UI to show "inherited / in the drawer now". */ + drawerBalance(): { balanceMinor: number; currency: string | null } { + return this.#drawerBalanceAt(new Date().toISOString()); + } + /** Is there an open shift for this operator? Returns the open `shift_open` row or null. */ openShiftFor(operator: string) { // Scan shift events for this operator; the shift is open if the most recent @@ -60,19 +82,87 @@ export class ShiftService { return last && last.type === "shift_open" ? last : null; } - /** Open a shift for the operator (explicit start). */ - async open(operator: string): Promise<{ startedAt: string }> { + /** + * The physical drawer balance at `at`: a fold over the SIGNED chain BY TIME (not + * by operator — a cash_movement is the admin's, not the shift operator's). Cash + * payments add to the drawer; card payments never touch it; cash_movement amounts + * (signed: + load, − removal) adjust it. This is what carries across shifts. + */ + #drawerBalanceAt(at: string): { balanceMinor: number; currency: string | null } { + const rows = this.#db + .select() + .from(ledgerEvents) + .orderBy(ledgerEvents.index) + .all() + .filter((r) => r.occurredAt <= at && (r.type === "payment" || r.type === "cash_movement")); + let balanceMinor = 0; + let currency: string | null = null; + for (const r of rows) { + const pl = (r.payload ?? {}) as LedgerPayload; + const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; + if (r.type === "payment") { + // Only CASH enters the till; card settles to the bank. + if (pl.tender !== "card") balanceMinor += amt; + } else { + // cash_movement amount is signed (+ load, − removal). + balanceMinor += amt; + } + if (pl.currency) currency = pl.currency; + } + return { balanceMinor, currency }; + } + + /** + * Record an admin cash movement (load/remove drawer float). `amountMinor` is + * signed: positive = cash loaded IN, negative = cash taken OUT. Signed + + * attributed. Admin-only is enforced at the route. Returns the new drawer balance. + */ + async recordCashMovement( + operator: string, + amountMinor: number, + reason: string, + currency?: string, + ): Promise<{ amountMinor: number; balanceMinor: number }> { + if (!Number.isInteger(amountMinor) || amountMinor === 0) { + throw new InvalidCashMovementError("amountMinor must be a non-zero integer (minor units)"); + } + const now = new Date().toISOString(); + await this.#log.append({ + type: "cash_movement", + source: "manual", + identity: operator, // who moved the cash (admin) + payload: { + amountMinor, + ...(reason ? { reason } : {}), + ...(currency ? { currency } : {}), + operator, + }, + occurredAt: now, + }); + const { balanceMinor } = this.#drawerBalanceAt(now); + this.#logger.info( + `cash_movement ${amountMinor >= 0 ? "+" : ""}${amountMinor} by ${operator} (${reason || "no reason"}) → drawer ${balanceMinor}`, + ); + return { amountMinor, balanceMinor }; + } + + /** Open a shift for the operator (explicit start). The opening float is auto- + * inherited from the chain = the drawer balance at the start instant. */ + async open(operator: string): Promise<{ startedAt: string; openingFloatMinor: number }> { if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator); const startedAt = new Date().toISOString(); + const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt); await this.#log.append({ type: "shift_open", source: "manual", identity: operator, // the shift's operator; `identity` keys the shift to them - payload: { operator }, + // Record the inherited opening float on the shift_open so it's reproducible + // and the next operator's handover figure is fixed in the chain. + payload: { operator, openingFloatMinor }, occurredAt: startedAt, }); - this.#logger.info(`shift opened for ${operator}`); - return { startedAt }; + this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`); + return { startedAt, openingFloatMinor }; } /** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */ @@ -102,6 +192,50 @@ export class ShiftService { if (pl.currency) currency = pl.currency; } + // --- Drawer figures --- + // Opening float was fixed on shift_open (inherited from the chain at start); + // fall back to a fresh fold if an older shift_open lacks it. + const openPl = (open.payload ?? {}) as LedgerPayload & { openingFloatMinor?: number }; + const openingFloatMinor = + typeof openPl.openingFloatMinor === "number" + ? openPl.openingFloatMinor + : this.#drawerBalanceAt(startedAt).balanceMinor; + + // Cash movements within the shift window, split into added (+) and removed (−). + const movements = this.#db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.type, "cash_movement")) + .all() + .filter((r) => r.occurredAt >= startedAt && r.occurredAt <= endedAt); + let cashAddedMinor = 0; + let cashRemovedMinor = 0; + for (const m of movements) { + const pl = (m.payload ?? {}) as LedgerPayload; + const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; + if (amt >= 0) cashAddedMinor += amt; + else cashRemovedMinor += -amt; // store as a positive magnitude + if (pl.currency) currency = pl.currency; + } + + // Expected drawer at close = opening + cash taken + added − removed. This is the + // figure the NEXT shift inherits as its opening float. + const expectedDrawerMinor = openingFloatMinor + cashTotalMinor + cashAddedMinor - cashRemovedMinor; + + const report: Omit = { + operator, + startedAt, + endedAt, + cashTotalMinor, + cardTotalMinor, + currency, + paymentCount: payments.length, + openingFloatMinor, + cashAddedMinor, + cashRemovedMinor, + expectedDrawerMinor, + }; + await this.#log.append({ type: "shift_z_report", source: "manual", @@ -114,23 +248,20 @@ export class ShiftService { cardTotalMinor, currency: currency ?? undefined, paymentCount: payments.length, + openingFloatMinor, + cashAddedMinor, + cashRemovedMinor, + expectedDrawerMinor, }, }); - const printed = await this.#printZReport({ - operator, - startedAt, - endedAt, - cashTotalMinor, - cardTotalMinor, - currency, - paymentCount: payments.length, - }); + const printed = await this.#printZReport(report); this.#logger.info( - `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments)`, + `shift closed for ${operator}: cash ${cashTotalMinor} card ${cardTotalMinor} (${payments.length} payments); ` + + `drawer open ${openingFloatMinor} +${cashAddedMinor} −${cashRemovedMinor} → expected ${expectedDrawerMinor}`, ); - return { operator, startedAt, endedAt, cashTotalMinor, cardTotalMinor, currency, paymentCount: payments.length, printed }; + return { ...report, printed }; } /** Print the Z-report on a booth-receipt printer (best-effort; the signed event @@ -151,6 +282,13 @@ export class ShiftService { `Payments: ${r.paymentCount}`, `Cash: ${money(r.cashTotalMinor)} ${cur}`, `Card: ${money(r.cardTotalMinor)} ${cur}`, + "", + "-- Drawer --", + `Opening float: ${money(r.openingFloatMinor)} ${cur}`, + `Cash taken: ${money(r.cashTotalMinor)} ${cur}`, + `Cash added: ${money(r.cashAddedMinor)} ${cur}`, + `Cash removed: ${money(r.cashRemovedMinor)} ${cur}`, + `Expected drawer: ${money(r.expectedDrawerMinor)} ${cur}`, ]; try { await printer.printReport({ title: "SHIFT Z-REPORT", lines }); diff --git a/apps/web/src/ShiftControl.tsx b/apps/web/src/ShiftControl.tsx index c158526..804bea3 100644 --- a/apps/web/src/ShiftControl.tsx +++ b/apps/web/src/ShiftControl.tsx @@ -1,25 +1,39 @@ import { useEffect, useState } from "react"; -import { closeShift, fetchShift, openShift, type ShiftReport } from "./api.js"; +import { closeShift, fetchShift, openShift, recordCashMovement, type ShiftReport } from "./api.js"; // Manned-mode shift control. Start/End are explicit (not time-based — see // wiki/concepts/shift.md). End Shift signs + prints a Z-report and shows the -// totals. Available to cashier/operator/admin (readonly has no shift). +// totals + the DRAWER picture (opening float carried from the prior shift, cash +// taken/added/removed, expected drawer). Admins can load/remove drawer cash. +// Available to cashier/operator/admin (readonly has no shift). const money = (m: number, cur: string | null) => `${(m / 100).toFixed(2)} ${cur ?? ""}`.trim(); -export function ShiftControl() { +export function ShiftControl({ isAdmin = false }: { isAdmin?: boolean }) { const [startedAt, setStartedAt] = useState(null); + const [drawerMinor, setDrawerMinor] = useState(null); + const [currency, setCurrency] = useState(null); const [busy, setBusy] = useState(false); const [report, setReport] = useState(null); const [err, setErr] = useState(null); - useEffect(() => { + // Cash-movement form (admin only). + const [moveAmount, setMoveAmount] = useState(""); + const [moveReason, setMoveReason] = useState(""); + const [moveMsg, setMoveMsg] = useState(null); + + function refresh() { fetchShift() - .then((s) => setStartedAt(s.open?.startedAt ?? null)) + .then((s) => { + setStartedAt(s.open?.startedAt ?? null); + setDrawerMinor(s.drawerMinor); + setCurrency(s.currency); + }) .catch(() => { /* readonly / not permitted — hide control */ }); - }, []); + } + useEffect(refresh, []); async function start() { setBusy(true); @@ -28,6 +42,7 @@ export function ShiftControl() { try { const { startedAt } = await openShift(); setStartedAt(startedAt); + refresh(); } catch (e) { setErr((e as Error).message); } finally { @@ -41,6 +56,7 @@ export function ShiftControl() { const z = await closeShift(); setReport(z); setStartedAt(null); + refresh(); } catch (e) { setErr((e as Error).message); } finally { @@ -48,6 +64,24 @@ export function ShiftControl() { } } + async function move(sign: 1 | -1) { + setMoveMsg(null); + const major = Number(moveAmount); + if (!Number.isFinite(major) || major <= 0) { + setMoveMsg("Enter a positive amount."); + return; + } + try { + const r = await recordCashMovement(sign * Math.round(major * 100), moveReason.trim()); + setMoveAmount(""); + setMoveReason(""); + setMoveMsg(`Drawer now ${money(r.balanceMinor, currency)}.`); + refresh(); + } catch (e) { + setMoveMsg((e as Error).message); + } + } + return (
Shift:{" "} @@ -66,14 +100,58 @@ export function ShiftControl() { )} + {/* Live drawer balance (what's in the till right now / inherited). */} + {drawerMinor != null && ( +
+ Drawer: {money(drawerMinor, currency)} + {startedAt && (opening float inherited from the prior shift)} +
+ )} + {err &&

{err}

} + + {/* Admin: load / remove physical drawer cash (signed cash_movement). */} + {isAdmin && ( +
+
+ Drawer cash (admin) — load or remove the float +
+
+ setMoveAmount(e.target.value)} + placeholder="amount" + inputMode="decimal" + style={{ width: 90 }} + /> + setMoveReason(e.target.value)} + placeholder="reason (e.g. opening float)" + style={{ flex: 1, minWidth: 140 }} + /> + + +
+ {moveMsg &&
{moveMsg}
} +
+ )} + {report && (
Z-REPORT — {report.operator}
Payments: {report.paymentCount}
Cash: {money(report.cashTotalMinor, report.currency)}
Card: {money(report.cardTotalMinor, report.currency)}
-
+
— Drawer —
+
Opening float: {money(report.openingFloatMinor, report.currency)}
+
Cash taken: {money(report.cashTotalMinor, report.currency)}
+
Cash added: {money(report.cashAddedMinor, report.currency)}
+
Cash removed: {money(report.cashRemovedMinor, report.currency)}
+
+ Expected drawer: {money(report.expectedDrawerMinor, report.currency)} +
+
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 2711684..a1f3baa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -52,6 +52,10 @@ export type LedgerEventType = // with a takings summary (shift_z_report). See wiki/concepts/shift.md. | "shift_open" | "shift_z_report" + // Admin loads/removes physical drawer cash (the float). Signed payload: + // { amountMinor (signed: + load, − removal), reason, currency, operator }. + // Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md. + | "cash_movement" | "anomaly"; /** How money was tendered (for payment events + the shift Z-report). */ diff --git a/wiki/concepts/shift.md b/wiki/concepts/shift.md index ee30b0b..a57f333 100644 --- a/wiki/concepts/shift.md +++ b/wiki/concepts/shift.md @@ -70,6 +70,54 @@ no variance gate, no manager override. close totals correct + signed + printed → close-again 409 → re-open works; readonly 403; verifyChain ok. +## Drawer balance — opening float, cash movements, carry-over (decided 2026-06-18) + +The Z-report's payment totals answer "how much did this shift *take*?" — but a manned booth also has a +**physical cash drawer** that carries across shifts. The drawer is tracked as a running balance over +the signed chain, so each shift knows what it **inherited** and what it should **hand over**. + +**The events:** +- A new signed **`cash_movement`** event: the admin loads or removes drawer cash, `{ amountMinor + (signed: + load, − removal), reason, operator }`. **Admin-only** (an operator takes payments but + cannot move the float in/out). The opening-day load (+5000 ALL) and a mid-shift withdrawal (−5000) + are both `cash_movement` events. +- The existing `payment` events already add cash to the drawer (cash tender only; card never touches + the drawer). + +**The math — drawer is a fold over the chain BY TIME, not by operator** (a `cash_movement` is the +admin's, not the shift operator's, so it can't key off `identity`): + +``` +expectedDrawer(at) = Σ cash payments (tender=cash) up to `at` + + Σ cash_movement amounts up to `at` +``` + +A shift's **opening float = expectedDrawer(shiftStart)** — i.e. everything that happened to the drawer +before this shift's start mark. It is **auto-inherited from the chain** (no operator entry). The +first shift ever opens at **0**; the admin's load makes it 5000. + +**The Z-report at close** reports the full drawer picture for the shift window `[start, end]`: +`openingFloat`, `cashTakenMinor` (cash payments in-window), `cashAddedMinor` / `cashRemovedMinor` +(movements in-window), and `expectedDrawerMinor = openingFloat + cashTaken + cashAdded − cashRemoved`. +That `expectedDrawer` is exactly the **next** shift's opening float — the carry-over. + +**Worked example (the canonical scenario):** + +| Step | Event | Drawer | +| --- | --- | --- | +| Opening day | admin `cash_movement` +5000 | 5000 | +| Shift 1 takes 6500 cash | payments | 11500 | +| Shift 1 closes | Z: open 5000, took 6500, expected **11500** | 11500 | +| Shift 2 opens | opening float = **11500** (inherited) | 11500 | +| admin `cash_movement` −5000 | withdrawal | 6500 | +| Shift 2 takes 4500 cash | payments | 11000 | +| Shift 2 closes | Z: open 11500, took 4500, removed 5000, expected **11000** | 11000 | +| Shift 3 opens | opening float = **11000** | … | + +Card payments are excluded from the drawer (they settle to the bank, not the till). The drawer figure +is **expected**, not counted — the optional blind-count enhancement below would record the *variance* +against it. + ## Where the fraud control actually lives Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the @@ -86,6 +134,9 @@ reconciles the signed Z-report against the actual drawer and the bank/POS batch ## Open +- **Drawer carry-over (decided 2026-06-18, building):** opening float auto-inherits the prior shift's + expected drawer; admin-only `cash_movement` events; Z-report reports the full drawer picture. See + the Drawer balance section above. - **Shift ↔ session boundary:** a vehicle may enter under one shift and pay under another — the Z-report sums by **payment time** (when cash/card was taken), which is the operator who handled the money. Confirm that's the intended accountability (vs. by entry).