Files
parking_solution/apps/server/src/shift-service.test.ts
T
julian 114a32e6f2 feat(drawer): operator records cash movements, admin reviews after (own /drawer route)
Rework drawer cash movements from synchronous admin-authorization-at-creation
(operator typed an admin's password inline for every receipt/disbursement) to
operator-records-freely -> admin-reviews-after.

- New `drawer` resource: drawer:create (operator records; admin-revocable per
  role) + drawer:review (admin authorizes/denies). Migration 0018 grants the
  default operator role drawer:create; admin gets all in code.
- New signed `cash_review` ledger event { refId, decision, reviewedBy, note? }.
  A DENIAL is a FLAG, not a reversal: it never appends reversing cash and never
  touches the drawer balance (the correction is settled outside the app). This
  is what keeps a late review from leaking into the next operator's inherited
  drawer — a denial that lands after the reviewed shift closed moves no cash.
  Regression test: op1 disburses -> closes -> op2 inherits -> admin denies ->
  op2 drawer unchanged.
- Move the feature OFF the polluted /shifts route to a top-level /drawer
  (operator: record + own; admin: review queue + all). routes/drawer.ts lifted
  from routes/shift.ts (retired the authorizer-password gate; kept shift:cash
  for its other job = admin-sees-all-shifts). New DrawerManager.tsx.

Display fixes bundled:
- Render cash_review in the event-detail modal (decision / reviewed-by / note /
  movement ref) — previously showed nothing.
- Relabel the shift drawer figures for clarity: Daily takings / Receipts /
  Disbursements (was Cash payments / Cash added / Cash removed).
- Hide the Card figure everywhere when CARD_PAYMENTS_ENABLED is false (no POS
  on-site), matching the card-tender gate.

shared/db/server/web all typecheck; 225 server tests pass (incl. the drawer
review + cross-shift-leak regression); web build + i18n parity green. Verified
end-to-end via Playwright. Recorded in wiki/concepts/shift.md.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-07-01 11:17:20 +02:00

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