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 vouchers adjust the drawer", async () => { await shift.open("alice"); await shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 100000, reason: "float load" }); await shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", 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 voucher amount", async () => { await shift.open("alice"); await expect( shift.recordVoucher({ type: "cash_in", operator: "alice", authorizedBy: "admin", amountMinor: 0, reason: "x" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); await expect( shift.recordVoucher({ type: "cash_out", operator: "alice", authorizedBy: "admin", amountMinor: -5, reason: "x" }), ).rejects.toBeInstanceOf(InvalidCashMovementError); }); }); 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"]); }); });