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
+39 -1
View File
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, shifts, anti-fraud]
sources: []
updated: 2026-07-05
updated: 2026-09-05
status: open
---
@@ -23,6 +23,12 @@ don't force one model across both.
## Site-wide single-open + the booth gate (decided + built 2026-06-18)
> **Superseded 2026-09-05 — now PER TILL (built).** With money-taking venue modules (Car Wash
> at the bay), "site-wide" became **per till**: one open shift and one drawer per till
> (`booth`, `carwash`, …), each with its own operator, float, vouchers and Z-report; every
> money event names its till. See §"Tills" below and [[venue-modules]] §"Tills". Everything
> in this section stays true *within* a till.
A shift is a **site-wide accountability period**: at most **one shift may be open at a time** across
the whole appliance. This is what makes a taking unambiguously attributable — every payment/exit
falls inside exactly one operator's window. Consequences:
@@ -266,6 +272,38 @@ read a slow open as a dead click. Every shift open/close button (header, /shifts
"open shift now", the end-shift confirm) now pairs the busy label with an animated spinner
(`ui/Spinner.tsx`, reusable) and dims while disabled.
## Tills — one shift and one drawer per money-taking desk (built 2026-09-05)
A **till** is a physical cash drawer with its own accountability. The booth is the till that
always existed; a venue module that takes money at its own desk declares its own till in its
manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
- A shift is **opened on a till**. At most one shift open per till; tills are independent (the
booth and the wash desk run side by side, by different — or the same — operators).
- **Every money event names its till** (`payload.till`): parking `payment` and the
subscription sale = `booth` (a wash paid at the booth rides the parking payment as
`chargeLines`, so it is booth money too); `carwash_payment` at the bay = `carwash`;
`cash_in`/`cash_out` carry the drawer they moved. `shift_open`/`shift_z_report` carry theirs.
- **Absent `till` = booth.** Every event before tills existed is booth money, so the chain
re-folds identically and old Z-reports read as booth shifts. `tillOf()` in `@parking/shared`
is the one place this rule lives.
- The drawer fold, the X/Z-report window (payments **and** vouchers) and carry-forward all
filter by till: the wash operator's expected drawer is *their* float + *their* bay cash +
*their* vouchers, and the booth's never includes bay money. The counted-vs-expected moment
therefore sits with whoever holds the cash — which is the whole point (see §below).
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
carries its own shift control. The header button stays the booth's. The shift hub lists
every open shift with a till badge; the drawer hub switches tills.
- **Working a till needs that till's module permission** (added 2026-09-05 after the user
found a wash user could open the *booth's* shift): the manifest names it
(`tillPermission` — booth: `session:read`, carwash: `carwash:read`), `tillsFor()` in
`@parking/shared` resolves a role's tills, the shift/drawer routes refuse the rest with
`403 till_forbidden`, and `/api/shift/tills` + `current.tills` return only the role's
tills — so the header button, the hub's start buttons and the drawer switch never offer a
till the server would refuse. `shift:create` alone opens nothing.
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
figures are per till, the event list is not); bay slips print on the booth printer.
## Where the fraud control actually lives
Deliberately **not** in a shift-close ceremony. Because every payment is a **signed event in the