feat(shift): cash drawer balance carried across shifts + admin cash movements
New signed cash_movement event (admin-only): load/remove drawer float, signed + attributed. ShiftService folds cash payments + movements by time into a drawer balance; shift open auto-inherits the prior shift's expected closing drawer as its opening float; the Z-report reports opening/taken/added/removed/expected (= next shift's opening float). Card payments excluded (settle to bank). Routes: POST /api/cash-movement, drawer in GET /api/shift/current. ShiftControl shows the live drawer + admin load/remove form + Z-report drawer block. Wiki: shift.md.
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<ShiftReport, "printed"> = {
|
||||
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 });
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [drawerMinor, setDrawerMinor] = useState<number | null>(null);
|
||||
const [currency, setCurrency] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Cash-movement form (admin only).
|
||||
const [moveAmount, setMoveAmount] = useState("");
|
||||
const [moveReason, setMoveReason] = useState("");
|
||||
const [moveMsg, setMoveMsg] = useState<string | null>(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 (
|
||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||
<strong>Shift:</strong>{" "}
|
||||
@@ -66,14 +100,58 @@ export function ShiftControl() {
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{/* Live drawer balance (what's in the till right now / inherited). */}
|
||||
{drawerMinor != null && (
|
||||
<div style={{ marginTop: "0.5rem", color: "#555" }}>
|
||||
Drawer: <strong>{money(drawerMinor, currency)}</strong>
|
||||
{startedAt && <span style={{ color: "#888" }}> (opening float inherited from the prior shift)</span>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{err && <p style={{ color: "crimson", margin: "0.5rem 0 0" }}>{err}</p>}
|
||||
|
||||
{/* Admin: load / remove physical drawer cash (signed cash_movement). */}
|
||||
{isAdmin && (
|
||||
<div style={{ marginTop: "0.6rem", paddingTop: "0.5rem", borderTop: "1px solid #eee" }}>
|
||||
<div style={{ color: "#666", fontSize: "0.85rem", marginBottom: "0.35rem" }}>
|
||||
Drawer cash (admin) — load or remove the float
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: "0.4rem", alignItems: "center", flexWrap: "wrap" }}>
|
||||
<input
|
||||
value={moveAmount}
|
||||
onChange={(e) => setMoveAmount(e.target.value)}
|
||||
placeholder="amount"
|
||||
inputMode="decimal"
|
||||
style={{ width: 90 }}
|
||||
/>
|
||||
<input
|
||||
value={moveReason}
|
||||
onChange={(e) => setMoveReason(e.target.value)}
|
||||
placeholder="reason (e.g. opening float)"
|
||||
style={{ flex: 1, minWidth: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => move(1)}>Load +</button>
|
||||
<button type="button" onClick={() => move(-1)}>Remove −</button>
|
||||
</div>
|
||||
{moveMsg && <div style={{ marginTop: "0.35rem", color: "#555", fontSize: "0.85rem" }}>{moveMsg}</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{report && (
|
||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||
<div>Payments: {report.paymentCount}</div>
|
||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Card: {money(report.cardTotalMinor, report.currency)}</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309" }}>
|
||||
<div style={{ marginTop: "0.4rem", color: "#666" }}>— Drawer —</div>
|
||||
<div>Opening float: {money(report.openingFloatMinor, report.currency)}</div>
|
||||
<div>Cash taken: {money(report.cashTotalMinor, report.currency)}</div>
|
||||
<div>Cash added: {money(report.cashAddedMinor, report.currency)}</div>
|
||||
<div>Cash removed: {money(report.cashRemovedMinor, report.currency)}</div>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
Expected drawer: {money(report.expectedDrawerMinor, report.currency)}
|
||||
</div>
|
||||
<div style={{ color: report.printed ? "#16a34a" : "#b45309", marginTop: "0.3rem" }}>
|
||||
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user