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
+66
View File
@@ -2937,3 +2937,69 @@ activation with dependency rules and one signed config_change per module that ac
and router.tsx spreads WEB_MODULES into nav + route tree, SiteSettings gets a Modules panel and
hides the validation section when the module is off; MODULES_ENTITLED set explicitly in both
booth stacks. 7 new server tests, suite 329/329, web build clean. Car Wash next.
## [2026-09-05] feat | Car Wash v1 built as the pilot venue module — one manifest, two folders, two core seams
Delivered the pilot on [[venue-modules]]: carwash_{categories,services,prices,orders} (migration
0027), ledger types carwash_order (created/done/void, names + price frozen) and carwash_payment
(bay money), a server module (settings, oldest-first queue, intake against an open ticket, done →
applies the "carwash" validation program via the extracted applyValidation(), bay payment →
signs carwash_payment and, if the sponsorship made the session zero-due, the $0 parking payment
the exit reader needs; void takes back a live sponsorship) and a web module (/wash desk,
/setup/carwash master data + sponsorship editor). Two deliberate core seams: PayStation charge
providers (a wash paid at the booth rides the parking payment as chargeLines; BoothPayModal shows
them) and applyValidation() shared with the merchant route; shift money folds include bay
payments. Registry proof: the module needed exactly one manifest entry + one line in each
registry + its folders. 7 new tests, suite 337/337; full bay flow verified live in the browser.
Booth stacks not yet entitled to carwash. Next increment: vision category flag + bay camera.
## [2026-09-05] query | Car Wash review pass: discount modes, price matrix, 74-day comp bug, finished list — and the tills requirement
Hands-on review of Car Wash v1 with the user, recorded on [[venue-modules]] §"Review log":
renamed the discount section; added two wash-only discount modes (free during the wash +
tolerance; wash price off the fee, floored at 0) resolved at done via applyValidation() and
refused at merchant scan; hid typed-amount and percent from the wash editor (typed amount =
the operator picks the money → highest fraud exposure; kept for the Bar); fixed the
price-matrix cells for unsaved rows (two-request save); found and fixed a real money bug —
"free until done" was anchored at entry and would have comped a 74-day stay for one wash
(ticket 92498375903) — now anchored at the order's intake; long durations render y/d/h/m;
added a Finished list to the desk. Raised a structural requirement: shifts must become
per-till (booth / carwash) — a bay payment currently needs the booth shift and folds into
the booth drawer, which breaks both operators' Z-reports; design recorded, awaiting a go
([[shift]] carries a forward pointer).
## [2026-09-05] ingest | Tills built: one shift + one drawer per money-taking desk
The user confirmed the tills design ("go ahead and start building it"). Built in the core:
a shift is opened on a till (`booth` | `carwash`), every money event names its till
(`payload.till`, absent = booth so the chain re-folds identically), the ShiftService folds,
X/Z-reports, vouchers and carry-forward are per till, single-open is per till, and a bay
payment now requires the **carwash** shift. Web: the shift button became a per-till
component (header = booth, wash desk = carwash, with "Wash drawer now" and pay buttons
gated on my wash shift); the shift hub lists every open shift with till badges + a till
filter; the drawer hub switches tills. 6 new shift tests + the bay test now proves the booth
shift does not cover the bay; verified live with the booth held by another operator. Recorded
on [[shift]] §"Tills" and [[venue-modules]] §"Tills → As-built". Follow-ups: per-till
activity log, wash bucket on the booth Z, wash-desk printer role.
## [2026-09-05] ingest | Where wash money is taken became a site setting (Setup → Car wash)
User: the booth|bay choice belongs in `/setup/carwash` ("Pagesa: në kabinë / në lavazh"), and
the per-order radio goes away from `/wash`. Built: `carwash_config` singleton (migration 0028,
default booth), `payAt` on the settings view/body, signed `config_change carwash.payAt` on a
flip, orders freeze the policy in force, `409 pay_at_policy` for a stale client; Setup radio;
desk shows the policy read-only. Tests: 1 new (default, persist, sign, freeze, refuse).
Recorded on [[venue-modules]] §"v1 answers" item 2.
## [2026-09-05] ingest | Till access by module permission; landing per module
The user found their wash user could open the booth's shift (any `shift:create` could open any
till) and asked for the wash interface to be filtered off booth screens. Built: manifest
`tillPermission` + `tillsFor()`; server `accessibleTillsFor()` guards shift open/close/state
and cash movements (`403 till_forbidden`), `/api/shift/tills` returns only the role's tills;
header shift button needs `session:read`; module `landing` replaces the hard-coded merchant
landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the dev
`Lavazhier` role: it holds booth permissions (`session:read`, `payment:create`,
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
and [[venue-modules]] §Tills → As-built.