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 type { FastifyInstance } from "fastify";
|
||||||
import { requireRole } from "../auth.js";
|
import { requireRole } from "../auth.js";
|
||||||
import {
|
import {
|
||||||
|
InvalidCashMovementError,
|
||||||
NoOpenShiftError,
|
NoOpenShiftError,
|
||||||
ShiftAlreadyOpenError,
|
ShiftAlreadyOpenError,
|
||||||
type ShiftService,
|
type ShiftService,
|
||||||
} from "../shift-service.js";
|
} 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
|
// 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
|
// 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.
|
// 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");
|
const guard = requireRole("admin", "operator", "cashier");
|
||||||
|
|
||||||
// Is the current operator's shift open? (For the UI to show Start vs. End.)
|
// 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) => {
|
app.get("/api/shift/current", { preHandler: guard }, async (req) => {
|
||||||
const operator = req.user.username;
|
const operator = req.user.username;
|
||||||
const open = shift.openShiftFor(operator);
|
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) => {
|
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
|
||||||
try {
|
try {
|
||||||
return await shift.open(req.user.username);
|
return await shift.open(req.user.username);
|
||||||
|
|||||||
@@ -31,9 +31,25 @@ export interface ShiftReport {
|
|||||||
readonly cardTotalMinor: number;
|
readonly cardTotalMinor: number;
|
||||||
readonly currency: string | null;
|
readonly currency: string | null;
|
||||||
readonly paymentCount: number;
|
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;
|
readonly printed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvalidCashMovementError extends Error {
|
||||||
|
constructor(msg: string) {
|
||||||
|
super(msg);
|
||||||
|
this.name = "InvalidCashMovementError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class ShiftService {
|
export class ShiftService {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -45,6 +61,12 @@ export class ShiftService {
|
|||||||
this.#logger = logger;
|
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. */
|
/** Is there an open shift for this operator? Returns the open `shift_open` row or null. */
|
||||||
openShiftFor(operator: string) {
|
openShiftFor(operator: string) {
|
||||||
// Scan shift events for this operator; the shift is open if the most recent
|
// 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;
|
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);
|
if (this.openShiftFor(operator)) throw new ShiftAlreadyOpenError(operator);
|
||||||
const startedAt = new Date().toISOString();
|
const startedAt = new Date().toISOString();
|
||||||
|
const { balanceMinor: openingFloatMinor } = this.#drawerBalanceAt(startedAt);
|
||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "shift_open",
|
type: "shift_open",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
identity: operator, // the shift's operator; `identity` keys the shift to them
|
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,
|
occurredAt: startedAt,
|
||||||
});
|
});
|
||||||
this.#logger.info(`shift opened for ${operator}`);
|
this.#logger.info(`shift opened for ${operator} (opening float ${openingFloatMinor})`);
|
||||||
return { startedAt };
|
return { startedAt, openingFloatMinor };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Close the operator's open shift: sum payments in the window, sign + print the Z-report. */
|
/** 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;
|
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({
|
await this.#log.append({
|
||||||
type: "shift_z_report",
|
type: "shift_z_report",
|
||||||
source: "manual",
|
source: "manual",
|
||||||
@@ -114,23 +248,20 @@ export class ShiftService {
|
|||||||
cardTotalMinor,
|
cardTotalMinor,
|
||||||
currency: currency ?? undefined,
|
currency: currency ?? undefined,
|
||||||
paymentCount: payments.length,
|
paymentCount: payments.length,
|
||||||
|
openingFloatMinor,
|
||||||
|
cashAddedMinor,
|
||||||
|
cashRemovedMinor,
|
||||||
|
expectedDrawerMinor,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const printed = await this.#printZReport({
|
const printed = await this.#printZReport(report);
|
||||||
operator,
|
|
||||||
startedAt,
|
|
||||||
endedAt,
|
|
||||||
cashTotalMinor,
|
|
||||||
cardTotalMinor,
|
|
||||||
currency,
|
|
||||||
paymentCount: payments.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.#logger.info(
|
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
|
/** Print the Z-report on a booth-receipt printer (best-effort; the signed event
|
||||||
@@ -151,6 +282,13 @@ export class ShiftService {
|
|||||||
`Payments: ${r.paymentCount}`,
|
`Payments: ${r.paymentCount}`,
|
||||||
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
`Cash: ${money(r.cashTotalMinor)} ${cur}`,
|
||||||
`Card: ${money(r.cardTotalMinor)} ${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 {
|
try {
|
||||||
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
await printer.printReport({ title: "SHIFT Z-REPORT", lines });
|
||||||
|
|||||||
@@ -1,25 +1,39 @@
|
|||||||
import { useEffect, useState } from "react";
|
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
|
// 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
|
// 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();
|
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 [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 [busy, setBusy] = useState(false);
|
||||||
const [report, setReport] = useState<ShiftReport | null>(null);
|
const [report, setReport] = useState<ShiftReport | null>(null);
|
||||||
const [err, setErr] = useState<string | 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()
|
fetchShift()
|
||||||
.then((s) => setStartedAt(s.open?.startedAt ?? null))
|
.then((s) => {
|
||||||
|
setStartedAt(s.open?.startedAt ?? null);
|
||||||
|
setDrawerMinor(s.drawerMinor);
|
||||||
|
setCurrency(s.currency);
|
||||||
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
/* readonly / not permitted — hide control */
|
/* readonly / not permitted — hide control */
|
||||||
});
|
});
|
||||||
}, []);
|
}
|
||||||
|
useEffect(refresh, []);
|
||||||
|
|
||||||
async function start() {
|
async function start() {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
@@ -28,6 +42,7 @@ export function ShiftControl() {
|
|||||||
try {
|
try {
|
||||||
const { startedAt } = await openShift();
|
const { startedAt } = await openShift();
|
||||||
setStartedAt(startedAt);
|
setStartedAt(startedAt);
|
||||||
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -41,6 +56,7 @@ export function ShiftControl() {
|
|||||||
const z = await closeShift();
|
const z = await closeShift();
|
||||||
setReport(z);
|
setReport(z);
|
||||||
setStartedAt(null);
|
setStartedAt(null);
|
||||||
|
refresh();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setErr((e as Error).message);
|
setErr((e as Error).message);
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
<section style={{ marginTop: "1.5rem", padding: "0.75rem 1rem", border: "1px solid #ddd", borderRadius: 6, maxWidth: 460 }}>
|
||||||
<strong>Shift:</strong>{" "}
|
<strong>Shift:</strong>{" "}
|
||||||
@@ -66,14 +100,58 @@ export function ShiftControl() {
|
|||||||
</button>
|
</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>}
|
{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 && (
|
{report && (
|
||||||
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
<div style={{ marginTop: "0.75rem", fontFamily: "ui-monospace, monospace", fontSize: "0.9em" }}>
|
||||||
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
<div style={{ fontWeight: 600 }}>Z-REPORT — {report.operator}</div>
|
||||||
<div>Payments: {report.paymentCount}</div>
|
<div>Payments: {report.paymentCount}</div>
|
||||||
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
<div>Cash: {money(report.cashTotalMinor, report.currency)}</div>
|
||||||
<div>Card: {money(report.cardTotalMinor, 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)."}
|
{report.printed ? "Printed to booth receipt." : "Recorded (no printer to print to)."}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ export type LedgerEventType =
|
|||||||
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
// with a takings summary (shift_z_report). See wiki/concepts/shift.md.
|
||||||
| "shift_open"
|
| "shift_open"
|
||||||
| "shift_z_report"
|
| "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";
|
| "anomaly";
|
||||||
|
|
||||||
/** How money was tendered (for payment events + the shift Z-report). */
|
/** How money was tendered (for payment events + the shift Z-report). */
|
||||||
|
|||||||
@@ -70,6 +70,54 @@ no variance gate, no manager override.
|
|||||||
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
close totals correct + signed + printed → close-again 409 → re-open works; readonly 403;
|
||||||
verifyChain ok.
|
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
|
## Where the fraud control actually lives
|
||||||
|
|
||||||
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the
|
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
|
## 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
|
- **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
|
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).
|
the money. Confirm that's the intended accountability (vs. by entry).
|
||||||
|
|||||||
Reference in New Issue
Block a user