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:
2026-06-18 11:05:36 +02:00
parent eb3dc18e67
commit 50a3095ef3
5 changed files with 327 additions and 24 deletions
+33 -1
View File
@@ -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);