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