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
This commit is contained in:
2026-09-05 13:23:09 +02:00
parent 23d6379be8
commit a9ccf9e20c
46 changed files with 3966 additions and 510 deletions
+90
View File
@@ -242,3 +242,93 @@ describe("close signs a Z-report; listShifts reads it back", () => {
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"]);
});
});