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:
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user