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"]); }); });