Files
parking_solution/apps/server/src/shift-service.test.ts
T
julian a9ccf9e20c feat(carwash): Car Wash v1 + per-till shifts + site-level pay-at + till access by module permission
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
2026-09-05 13:23:09 +02:00

335 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"]);
});
});