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
+17 -5
View File
@@ -44,12 +44,12 @@ describe("defaults (no env, nothing activated)", () => {
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.statusCode).toBe(200);
const body = cfg.json();
expect(body.modulesEntitled).toEqual(["parking", "validation"]);
expect(body.modulesActivated).toEqual(["parking", "validation"]);
expect(body.modules).toEqual(["parking", "validation"]);
expect(body.modulesEntitled).toEqual(["parking", "validation", "carwash"]);
expect(body.modulesActivated).toEqual(["parking", "validation", "carwash"]);
expect(body.modules).toEqual(["parking", "validation", "carwash"]);
const me = await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie } });
expect(me.json().modules).toEqual(["parking", "validation"]);
expect(me.json().modules).toEqual(["parking", "validation", "carwash"]);
// A module route answers normally while the module is on.
const programs = await app.inject({ method: "GET", url: "/api/validation/programs", headers: { cookie } });
@@ -107,6 +107,16 @@ describe("activation (site admin)", () => {
});
it("rejects unknown ids with 400", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "bar"] },
});
expect(put.statusCode).toBe(400);
});
it("dependency rule: carwash cannot be on while validation is off", async () => {
const { cookie, csrf } = await admin();
const put = await app.inject({
method: "PUT", url: "/api/site-config",
@@ -114,6 +124,7 @@ describe("activation (site admin)", () => {
payload: { modules: ["parking", "carwash"] },
});
expect(put.statusCode).toBe(400);
expect(put.json().error).toMatch(/requires "validation"/);
});
it("a no-op resave signs nothing", async () => {
@@ -123,7 +134,7 @@ describe("activation (site admin)", () => {
await app.inject({
method: "PUT", url: "/api/site-config",
headers: { cookie, "x-csrf-token": csrf },
payload: { modules: ["parking", "validation"] },
payload: { modules: ["parking", "validation", "carwash"] },
});
const after = await app.inject({ method: "GET", url: "/api/events?limit=50", headers: { cookie } });
expect(((after.json().events ?? after.json()) as unknown[]).length).toBe(countBefore);
@@ -162,5 +173,6 @@ describe("entitlement (vendor env)", () => {
const { cookie } = await admin();
const cfg = await app.inject({ method: "GET", url: "/api/site-config", headers: { cookie } });
expect(cfg.json().modulesEntitled).toEqual(["parking", "validation"]);
expect(cfg.json().modules).toEqual(["parking", "validation"]);
});
});