import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { createTestDb } from "@parking/db/testing"; import { type Db } from "@parking/db"; import { ShiftService, ShiftAlreadyOpenError, NoOpenShiftError, NoShiftOpenError, InvalidCashMovementError, } from "./shift-service.js"; import type { EventLog } from "./event-log.js"; import { makeLog, silentLogger } from "./test-helpers.js"; // The shift is an operator's accountability period — signed shift_open … shift_z_report, // no mutable table. These tests pin: the site-wide single-open invariant, the takings // SPLIT by source (subscription sales vs out-of-window charges vs transient tickets — the // 2026-06-21 work), the drawer carry-forward, and that close signs a Z-report with the // right figures. let db: Db; let close: () => void; let log: EventLog; let shift: ShiftService; beforeEach(() => { const t = createTestDb(); db = t.db; close = t.close; log = makeLog(db); shift = new ShiftService(db, log, silentLogger()); }); afterEach(() => close()); /** Append a signed payment with source-split flags, as the booth/pay paths do. */ async function payment( amountMinor: number, opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {}, ) { await log.append({ type: "payment", source: "manual", identity: "T", payload: { sessionRef: "T", amountMinor, currency: "ALL", tender: opts.tender ?? "cash", ...(opts.subscriptionSale ? { subscriptionSale: true } : {}), ...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}), }, }); } describe("single-open invariant", () => { it("opens a shift and reports it as the current open one", async () => { await shift.open("alice"); const cur = shift.currentOpenShift(); expect(cur?.identity).toBe("alice"); }); it("refuses a second open while one is already open (even another operator)", async () => { await shift.open("alice"); await expect(shift.open("alice")).rejects.toBeInstanceOf(ShiftAlreadyOpenError); await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError); }); it("allows a new shift after the prior one closes", async () => { await shift.open("alice"); await shift.close("alice"); await expect(shift.open("bob")).resolves.toBeTruthy(); }); it("close without an open shift throws", async () => { await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError); }); it("requireOpenShift throws when none is open", () => { expect(() => shift.requireOpenShift()).toThrow(NoShiftOpenError); }); }); describe("takings split by source", () => { it("separates subscription sales, out-of-window charges, and transient tickets", async () => { await shift.open("alice"); await payment(50000, { subscriptionSale: true }); // monthly fee await payment(20000, { subscriptionWindowCharge: true }); // out-of-window await payment(10000); // transient ticket await payment(30000, { tender: "card" }); // transient ticket, card const r = shift.currentReport()!; expect(r.subscriptionSalesMinor).toBe(50000); expect(r.subscriptionWindowMinor).toBe(20000); expect(r.subscriptionTotalMinor).toBe(70000); expect(r.ticketTotalMinor).toBe(40000); // 10000 cash + 30000 card // The split must reconcile to the cash+card grand total. expect(r.cashTotalMinor + r.cardTotalMinor).toBe( r.ticketTotalMinor + r.subscriptionTotalMinor, ); expect(r.cashTotalMinor).toBe(80000); // 50000 + 20000 + 10000 expect(r.cardTotalMinor).toBe(30000); }); }); describe("drawer carry-forward", () => { it("cash payments enter the drawer; card does not", async () => { await shift.open("alice"); await payment(10000, { tender: "cash" }); await payment(50000, { tender: "card" }); const r = shift.currentReport()!; expect(r.cashTotalMinor).toBe(10000); // Expected drawer = opening(0) + cash(10000) + added(0) − removed(0). expect(r.expectedDrawerMinor).toBe(10000); }); it("a closed shift's expected drawer becomes the next shift's opening float", async () => { await shift.open("alice"); await payment(25000, { tender: "cash" }); const closed = await shift.close("alice"); expect(closed.expectedDrawerMinor).toBe(25000); const next = await shift.open("bob"); expect(next.openingFloatMinor).toBe(25000); // inherited }); it("cash_in / cash_out movements adjust the drawer", async () => { await shift.open("alice"); await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float load" }); await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 30000, reason: "bank drop" }); const r = shift.currentReport()!; expect(r.cashAddedMinor).toBe(100000); expect(r.cashRemovedMinor).toBe(30000); expect(r.expectedDrawerMinor).toBe(70000); }); it("rejects a non-positive movement amount", async () => { await shift.open("alice"); await expect( shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 0, reason: "x" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); await expect( shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: -5, reason: "x" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); }); }); describe("drawer review (operator records, admin reviews after)", () => { it("a new movement starts pending; review sets authorized/denied", async () => { await shift.open("alice"); const m = await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 5000, reason: "supplies" }); // Find the movement's ledger id via the status list. let list = shift.movementsWithStatus({ operator: "alice" }); expect(list).toHaveLength(1); expect(list[0].status).toBe("pending"); expect(list[0].voucherNo).toBe(m.voucherNo); await shift.reviewMovement({ refId: list[0].id, decision: "deny", reviewedBy: "admin", note: "not genuine" }); list = shift.movementsWithStatus({ operator: "alice" }); expect(list[0].status).toBe("denied"); expect(list[0].reviewedBy).toBe("admin"); expect(list[0].reviewNote).toBe("not genuine"); }); it("DENY is a flag only — it does NOT reverse the movement or touch the drawer", async () => { await shift.open("alice"); await shift.recordVoucher({ type: "cash_out", operator: "alice", amountMinor: 10000, reason: "x" }); const before = shift.drawerBalance().balanceMinor; expect(before).toBe(-10000); // the disbursement counted immediately const id = shift.movementsWithStatus({ operator: "alice" })[0].id; await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }); // Balance UNCHANGED by the denial — the correction is settled outside the app. expect(shift.drawerBalance().balanceMinor).toBe(-10000); }); it("a denied movement in a CLOSED shift never leaks into the next operator's drawer", async () => { // The regression that motivated the redesign: op1 disburses, shift closes, op2 // inherits; op1's disbursement is later DENIED. op2's drawer must be untouched. await shift.open("op1"); await shift.recordVoucher({ type: "cash_out", operator: "op1", amountMinor: 10000, reason: "questionable" }); const closed = await shift.close("op1"); expect(closed.expectedDrawerMinor).toBe(-10000); const next = await shift.open("op2"); expect(next.openingFloatMinor).toBe(-10000); // op2 inherits the real till balance const id = shift.movementsWithStatus({ operator: "op1" })[0].id; await shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }); // op2's drawer is STILL -10000 — the denial added no reversing cash. expect(shift.drawerBalance().balanceMinor).toBe(-10000); expect(shift.currentReport()!.openingFloatMinor).toBe(-10000); }); it("rejects reviewing a non-movement or an already-reviewed movement", async () => { await shift.open("alice"); await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 5000, reason: "x" }); const id = shift.movementsWithStatus({ operator: "alice" })[0].id; await expect( shift.reviewMovement({ refId: "not-a-real-id", decision: "authorize", reviewedBy: "admin" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); await shift.reviewMovement({ refId: id, decision: "authorize", reviewedBy: "admin" }); await expect( shift.reviewMovement({ refId: id, decision: "deny", reviewedBy: "admin" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); // already reviewed }); it("scopes movements by operator", async () => { await shift.open("alice"); await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 1000, reason: "a" }); await shift.close("alice"); await shift.open("bob"); await shift.recordVoucher({ type: "cash_out", operator: "bob", amountMinor: 2000, reason: "b" }); expect(shift.movementsWithStatus({ operator: "alice" })).toHaveLength(1); expect(shift.movementsWithStatus({ operator: "bob" })).toHaveLength(1); expect(shift.movementsWithStatus()).toHaveLength(2); // reviewer sees all expect(shift.movementsWithStatus({ status: "pending" })).toHaveLength(2); }); }); describe("close signs a Z-report; listShifts reads it back", () => { it("a closed shift appears in history with its split figures", async () => { await shift.open("alice"); await payment(50000, { subscriptionSale: true }); await payment(10000); // ticket await shift.close("alice"); const history = shift.listShifts(); expect(history).toHaveLength(1); const s = history[0]; expect(s.operator).toBe("alice"); expect(s.subscriptionSalesMinor).toBe(50000); expect(s.ticketTotalMinor).toBe(10000); expect(s.cashTotalMinor).toBe(60000); // The Z-report is a signed chain event. expect(log.verifyChain()).toEqual({ ok: true }); }); it("filters history by operator", async () => { await shift.open("alice"); await shift.close("alice"); await shift.open("bob"); await shift.close("bob"); expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]); }); it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => { await shift.open("bob"); await shift.close("bob"); await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct await shift.open("alice"); // open, no z-report yet expect(shift.listOperators()).toEqual(["alice", "bob"]); }); }); describe("tills: one shift per till, one drawer per till", () => { /** A bay payment as the Car Wash module signs it (till = carwash). */ async function bayPayment(amountMinor: number, tender: "cash" | "card" = "cash") { await log.append({ type: "carwash_payment", source: "manual", identity: "T", payload: { sessionRef: "T", orderId: "o1", amountMinor, currency: "ALL", tender, till: "carwash" }, }); } it("the booth and the carwash till can both be open at once, by different operators", async () => { await shift.open("alice"); await expect(shift.open("wanda", "carwash")).resolves.toMatchObject({ till: "carwash" }); expect(shift.currentOpenShift()?.identity).toBe("alice"); expect(shift.currentOpenShift("carwash")?.identity).toBe("wanda"); // Each till keeps its own single-open rule. await expect(shift.open("bob", "carwash")).rejects.toBeInstanceOf(ShiftAlreadyOpenError); await expect(shift.open("bob")).rejects.toBeInstanceOf(ShiftAlreadyOpenError); }); it("requireOpenShift is per till: a booth shift does not cover the bay", async () => { await shift.open("alice"); expect(() => shift.requireOpenShift("carwash")).toThrow(NoShiftOpenError); await shift.open("wanda", "carwash"); expect(shift.requireOpenShift("carwash").identity).toBe("wanda"); }); it("money folds into ITS till only: bay cash is the wash operator's, not the booth's", async () => { await shift.open("alice"); await shift.open("wanda", "carwash"); await payment(10000); // booth (payment events carry till=booth or nothing) await bayPayment(70000); await bayPayment(20000, "card"); const booth = shift.currentReport()!; expect(booth.till).toBe("booth"); expect(booth.cashTotalMinor).toBe(10000); expect(booth.paymentCount).toBe(1); expect(booth.expectedDrawerMinor).toBe(10000); const wash = shift.currentReport("carwash")!; expect(wash.till).toBe("carwash"); expect(wash.cashTotalMinor).toBe(70000); expect(wash.cardTotalMinor).toBe(20000); expect(wash.paymentCount).toBe(2); expect(wash.expectedDrawerMinor).toBe(70000); expect(shift.drawerBalance().balanceMinor).toBe(10000); expect(shift.drawerBalance("carwash").balanceMinor).toBe(70000); }); it("vouchers name their till; each till's expected drawer carries forward on its own", async () => { await shift.open("alice"); await shift.open("wanda", "carwash"); await shift.recordVoucher({ type: "cash_in", operator: "wanda", amountMinor: 5000, reason: "float", till: "carwash" }); await shift.recordVoucher({ type: "cash_in", operator: "alice", amountMinor: 100000, reason: "float" }); await bayPayment(70000); expect(shift.movementsWithStatus({ till: "carwash" }).map((m) => m.amountMinor)).toEqual([5000]); const washZ = await shift.close("wanda", "carwash"); expect(washZ).toMatchObject({ till: "carwash", cashAddedMinor: 5000, cashTotalMinor: 70000, expectedDrawerMinor: 75000 }); const boothZ = await shift.close("alice"); expect(boothZ).toMatchObject({ till: "booth", cashAddedMinor: 100000, cashTotalMinor: 0, expectedDrawerMinor: 100000 }); // Next shift on each till inherits that till's drawer only. expect((await shift.open("wanda", "carwash")).openingFloatMinor).toBe(75000); expect((await shift.open("bob")).openingFloatMinor).toBe(100000); }); it("close is per till: closing the booth never closes the wash desk", async () => { await shift.open("alice"); await shift.open("alice", "carwash"); await shift.close("alice"); expect(shift.currentOpenShift()).toBeNull(); expect(shift.currentOpenShift("carwash")?.identity).toBe("alice"); await expect(shift.close("alice")).rejects.toBeInstanceOf(NoOpenShiftError); }); it("history lists both tills, filterable; pre-till reports read as booth", async () => { await shift.open("alice"); await shift.open("wanda", "carwash"); await shift.close("wanda", "carwash"); await shift.close("alice"); const all = shift.listShifts(); expect(all.map((s) => s.till).sort()).toEqual(["booth", "carwash"]); expect(shift.listShifts({ till: "carwash" }).map((s) => s.operator)).toEqual(["wanda"]); expect(shift.listShifts({ till: "booth" }).map((s) => s.operator)).toEqual(["alice"]); expect(shift.listOperators("carwash")).toEqual(["wanda"]); }); });