a9ccf9e20c
Car Wash — the pilot venue module (wiki/decisions/venue-modules.md): - Master data (categories × services price matrix) at /setup/carwash; the desk at /wash (ticket lookup → order; open queue oldest-first: Done / Paid cash / Paid card / Void; Finished list). Orders freeze names + price; their life is signed (carwash_order, carwash_payment). Migration 0027. - Where money is taken is a SITE setting (carwash_config.pay_at, migration 0028, signed config_change on a flip) — no per-order radio; a stale client is refused (409). - Core seams: PayStation charge providers (a booth-paid wash rides the parking payment as chargeLines) + applyValidation() shared with the merchant route. A bay-paid, done wash signs the $0 parking payment so the exit reader releases the car. - "Parking discount" modes for the wash: free while the wash runs (+ tolerance) and wash price off the fee (floored at 0), resolved at done and anchored at the order's intake (the entry-anchored version comped a 74-day stay); typed-amount and percent hidden for the wash. Long durations render y/d/h/m. Tills — a shift belongs to a till, not the site (wiki/concepts/shift.md §Tills): - TillId booth|carwash; every money event names its till (absent = booth, so the chain re-folds identically). ShiftService is per till: single-open, folds, X/Z-reports, vouchers, carry-forward. A bay payment needs the carwash shift. - Working a till needs that till's module permission (manifest tillPermission; 403 till_forbidden); /api/shift/tills lists only the role's tills. - Web: ShiftButton per till (header = booth, wash desk = carwash); shift hub lists every open shift with till badges + filter; drawer hub switches tills. Modules: landing per module (index route resolves booth → module landing → shifts → profile); guards bounce to "/", /booth needs session:read. Tests: carwash e2e suite (settings, intake, booth/bay paths, modes, void, gate, pay-at policy, till permissions), 6 per-till shift tests; suite green (1 pre-existing flaky backup test under the parallel run). Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
335 lines
15 KiB
TypeScript
335 lines
15 KiB
TypeScript
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"]);
|
||
});
|
||
});
|