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
+5 -2
View File
@@ -133,5 +133,8 @@ procurement. (See [[parking-system-architecture]] §10.)
15. **Venue modules — Car Wash / Bar as peers of Parking.** _(Raised by the user, 2026-09-04.)_
Optional per-site modules on a shared venue core, with Parking itself becoming a module.
Name stays `parking-system` (settled 2026-09-05); validation stays for the Bar, only the
Lavazh station retires when Car Wash (the pilot module) ships. Full design and the remaining
questions on [[venue-modules]].
Lavazh station retires when Car Wash (the pilot module) ships. **Registry + Car Wash v1 are
built (2026-09-05), and so are tills** — shifts/drawers per money-taking module (a bay
payment lands on the wash operator's own till, never the booth's). Open: vision category
flag, bay camera, the Bar's scope. Full design and the remaining questions on
[[venue-modules]].
+210 -1
View File
@@ -8,7 +8,7 @@ status: open
# Venue modules — Car Wash, Bar/Restaurant, and Parking as peers
**Status: OPEN** (Car Wash not yet built; the module registry IS — see "As-built" below). Design
**Status: OPEN** (Car Wash v1 and the module registry are BUILT — see the two "As-built" sections; open items remain below). Design
captured from working sessions with the user on 2026-09-04/05. Decisions marked **(settled)** were stated by the
user in that session; everything else is the proposed shape awaiting a go.
@@ -195,6 +195,47 @@ vehicle. The Hikvision push's `detectionTarget` only says `vehicle`/`human` on t
stock, staff scheduling, appointment booking, customer accounts. Design the order so a payment
can later reference a package, and stop there.
### v1 answers from the user (2026-09-05) — these shape the tables
1. **Price = category × service.** The admin declares a price per (vehicle category, service)
pair — e.g. Car·Standard 500, Car·Inside 300, Car·Outside 300, SUV·Standard 700. Categories
(Car, SUV, Van, Truck, …) and services (Standard, Outside, Inside, Details, …) are both
admin-maintained lists; a missing pair simply isn't sellable.
2. **Where the money is taken — "in booth" or "in bay" — is a SITE setting** (Setup → Car
wash; **changed 2026-09-05 from a per-order radio** at the user's request: "remove it from
/wash"). The desk shows the policy in force read-only; every order freezes it
(`carwash_config.pay_at`, migration 0028; a flip signs `config_change carwash.payAt` with
prev/value — it decides which till the cash lands on and which device releases the car, so it
is attributed like other fraud-relevant config). A stale client sending the other value is
refused (`409 pay_at_policy`), never silently overridden.
- *In booth*: the wash is a line on the parking settlement at the booth; after that payment
the exit-lane barrier opens exactly as it does for a parking-only exit.
- *In bay*: the wash operator collects at the bay; the customer then leaves by scanning the
ticket barcode at the exit **reader**, which must open — i.e. the parking session must be
settled to zero-due by then (see 4).
3. **Queue = a plain list of open orders, oldest first.** No display board, no "next car".
4. **The wash grants the parking discount through the same ability validations have**
(review 2026-09-05, after the first build — labelled **"Zbritje parkimi"** / "Parking
discount", not "sponsorship"). The wash editor offers: *free* (comp), **free while the wash
runs + N minutes tolerance** (`doneTolerance`, resolved at done into a timeCredit of the
WASH WINDOW — order intake → done — plus N; NOT the time since entry: a first build
anchored it at entry and a ticket parked 74 days would have been comped by a wash —
caught by the user on ticket 92498375903 the same day; parking before the order and
after the tolerance stays at the tariff), **the wash price off the parking fee, floored at 0** (`washPrice`, resolved
into a fixed discount of the order's price), and first-N-minutes. It does NOT offer the
typed-amount mode (the only mode where the operator picks the money — the highest-risk one,
kept for the Bar behind its cap + per-day limit + attribution) nor percent (a real Bar use,
not a wash one). The two wash-only modes can't be applied by a merchant scan (400) — they
need a wash order's context, and the signed event records the resolved mode plus
`programMode` for audit. The
site admin configures, for the car wash, the same program shape a merchant validation has
(comp / first N minutes free / amount / percent, max per day); a completed wash applies it
to the customer's session automatically, attributed to the wash operator. So the module
`dependsOn` **validation** (the sponsorship engine) as well as parking, and the earlier
"own event, validation absorbed later" idea is superseded: validation IS the engine. With
program = comp, an in-bay-paid wash lets the car out at the reader; with a partial program
the remainder is still paid at the booth (the reader refuses, as for any unpaid session).
### Anti-fraud — the reason this fits here and not a generic wash product
Same adversary as the booth ([[threat-model]]): the person taking cash. The fraud is the
@@ -271,6 +312,172 @@ no parking code moved (the seam exists, the code crosses it as each subsystem is
- **Acceptance test for Car Wash** (unchanged): one manifest entry, one `SERVER_MODULES` line,
one `WEB_MODULES` line, its two folders, its migration — nothing else in the core touched.
## As-built: Car Wash v1 (2026-09-05) — the pilot, delivered
Built the same day the v1 answers landed. Everything the module is lives in
`apps/server/src/modules/carwash/` and `apps/web/src/modules/carwash/`; the core changed only
at the two seams the design names, and the registry earned its keep: **one manifest entry, one
`SERVER_MODULES` line, one `WEB_MODULES` line, one migration, two folders.**
- **Data** (`0027_carwash`): `carwash_categories`, `carwash_services` (admin lists, soft-delete),
`carwash_prices` (category × service → minor units; a missing pair is unsellable),
`carwash_orders` (the queue; names + price + currency FROZEN at intake; `pay_at` booth|bay;
`status` open|done|void; paid-ness is `paid_at` + the settling event id, separate from status
because a bay order may be paid before or after the wash).
- **Ledger:** `carwash_order` (payload `action` created|done|void, frozen names/price) and
`carwash_payment` (money at the bay). A wash paid at the booth is NOT its own event — it rides
the parking `payment` as `chargeLines` / `chargesMinor` / `parkingMinor`.
- **Two core seams, both deliberate:**
1. **`PayStation.registerChargeProvider()`** — a module folds charges into the booth
settlement: `lines(identity)` at quote time, `onPaid(identity, lines, payment)` after the
payment is signed. `Quote`/`SessionLookup` gained `chargeLines`, `chargesMinor`,
`parkingMinor`; `BoothPayModal` renders the "+" lines. A provider fault is logged and
priced around, never blocks a parking settlement.
2. **`applyValidation()`** extracted from the merchant route into `validations.ts` — the
decision chain + signed append, shared; the merchant route keeps only its program↔user
binding check. The wash applies the site's **`carwash`** validation program (composed on
Setup → Car wash with the same `StationForm`, users hidden) when an order is marked done,
attributed to the wash operator.
Plus the shift money folds (`#drawerBalanceAt`, Z-report tender totals) now include
`carwash_payment` so the expected drawer is right; a separate wash bucket on the Z-report is
a follow-up.
- **The exit-reader rule, honoured:** a validation alone opens nothing — the reader checks for
a signed `payment` + grace. So after a bay payment on a done order the module asks the core
for a quote and, if the sponsorship made it zero-due, signs the $0 parking payment via
`PayStation.pay()`. A partial sponsorship leaves the remainder for the booth (verified).
- **Modules reach the core only via `ServerModuleDeps`** (db, eventLog, payStation,
shiftService) — no module imports another; `dependsOn: ["parking", "validation"]` is enforced
by the activation rules (verified: carwash cannot be on with validation off).
- **Web:** `/wash` (the desk: ticket lookup → category/service/price → order, the site's
booth|bay policy shown read-only;
the queue oldest-first with Done / Paid cash / Paid card / Void) and `/setup/carwash`
(categories, services, the price matrix, the sponsorship program). `WebModule` gained
`setupNav` / `setupRoutes`; the Setup tab bar spreads them like the header does. i18n en+sq.
- **Verified:** 7 new server tests (`modules/carwash/carwash.test.ts`: settings + signed
config_change; intake rules + oldest-first queue; booth path = charge line on quote + payment
payload + order marked paid; bay path = validation applied, `carwash_payment`, $0 parking
payment, within grace, queue empty; partial sponsorship leaves a balance; void takes back a
live sponsorship; module off = 403 + no charge lines). Suite 337/337 (two backup-service
tests flake under the parallel run, pass in isolation — pre-existing, unrelated). Live in the
browser on the dev server: activated the module in Setup → Site (header + Setup tab appeared
without reload), saved the sponsorship, seeded master data, and ran a real bay-paid SUV wash
against an open ticket — ledger read `carwash_order:created → validation → carwash_order:done
→ carwash_payment 70000 → payment 0`, session `paidAt` set, `withinGrace: true`.
- **Review fixes (same day):** the price matrix let you type prices for new rows only after a
save (new rows had no id) — the Save button now does two requests behind one click (lists
first, then prices mapped to the returned ids). Discount modes extended as in "v1 answers" 4.
- **Review fix 2 (same day):** `doneTolerance` re-anchored at the order's intake (above);
long durations now display as `Xy Xd Xh Xm` everywhere (`formatDuration` /
`formatMinutes`), so a stale ticket reads "74d 21h 23m", not "1797h 23m".
- **Not yet:** the booths' `MODULES_ENTITLED` stays `parking,validation` — entitle `carwash`
per site when a site buys it. Vision category (advisory flag) and the bay-camera signals are
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
<service>` (Albanian, frozen on the payment).
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**
accountability period with one drawer, implicitly the booth's. A bay payment today (a) requires
the *booth's* shift to be open and (b) folds its cash into the *booth's* expected drawer. So the
booth operator's Z-report comes up short by exactly what the wash operator holds, and the wash
operator — who neither cares about nor belongs to the park shift — has no Z-report at all. That
is the opposite of what the [[threat-model]] wants: the counted-vs-expected moment is the one
control against the unrecorded-wash vector, and it must sit with the person holding the cash.
**Decision (user: "go ahead and start building it", 2026-09-05):** a shift belongs to a
**till**, not to the site.
- `booth` is the till that exists today. A money-taking module declares its own till in its
manifest (`carwash`; a future `bar`). Two shifts may be open at once — one per till — each
with its own operator, opening float, cash in/out, expected drawer and Z-report.
- Every money event names its till: parking `payment` (and the booth-paid wash riding it as
`chargeLines`) = `booth`; `carwash_payment` at the bay = `carwash`. Drawer fold and
Z-report filter by till. Ledger events without a `till` field are booth events, so history
verifies and folds unchanged.
- The header shift button stays the booth's. The wash desk gets its own shift control (open,
cash in/out, Z-report — the same ceremony); **"take money at the bay" requires the
`carwash` shift to be open**, not the booth's.
- Rejected: no shift for the wash, reconciling from the per-operator report — it throws away
the counted-vs-expected control, which is also what the bay-camera signal will reconcile
against later.
- Cost: ~a day in the core `ShiftService` (till on shift + payment events, folds, per-till
Z-reports), a shift control on the wash desk, tests. Modules then get a drawer for free.
### As-built (2026-09-05)
- **Shared:** `TILL_IDS = ["booth", "carwash"]`, `TillId`, `BOOTH_TILL`, `isTillId`, and the
one rule everything reads through — `tillOf(payload) = payload.till ?? "booth"`.
`ModuleManifest.till?` (parking → `booth`, carwash → `carwash`); `tillsOf(effective)` = the
tills addressable at a site (booth + each effective module's). `LedgerPayload.till?`.
- **ShiftService** (`apps/server/src/shift-service.ts`): every public method takes a `till`
defaulting to the booth — `open/close/currentOpenShift/openShiftFor/requireOpenShift/
currentReport/drawerBalance/listShifts({till})/listOperators(till)/recordVoucher({till})/
movementsWithStatus({till})`. `shift_open` and `shift_z_report` payloads carry `till`; the
drawer fold, the window summary (payments **and** vouchers) and the shift-boundary scan all
filter by `tillOf`. Single-open is **per till**; no cross-till rule (a small site's one
person may hold both). Z-report/voucher slips print an `Arka: Lavazhi` line off the booth
only, so booth slips stay byte-identical.
- **Producers stamp their till:** `PayStation.pay()` (both parking paths) and the
subscription sale write `till: "booth"`; `carwash_payment` writes `till: "carwash"` and
`payAtBay` requires the **carwash** shift (`409 no_shift` now also returns `till`).
- **Routes:** `GET /api/shift/current?till=`, new `GET /api/shift/tills` (every till's state
in one read), `GET /api/shift/report?till=`, `POST /api/shift/open|close { till }`,
`GET /api/shifts?till=` (+ `tills` in the answer), `POST /api/drawer/movement { till }`,
`GET /api/drawer/movements?till=`, `GET /api/drawer/balance?till=`. `parseTill()` (server
`modules.ts`) answers `400 bad_till` for an unknown till or one whose module is off.
- **Web:** `useShift(till)` (key `["shift","current",till]`, under the WS-invalidated
prefix); the header `ShiftButton` moved to `ShiftControl.tsx` and takes a `till` — the
header renders the booth's, the **wash desk renders `till="carwash"`** with "Wash drawer
now" and gates *Paid cash/card* on **my** wash shift; the shift hub lists every open shift
(one per till) with Booth/Wash badges, a till filter and a start button per idle till; the
drawer hub has a till switch (only when the site has >1) scoping every panel.
- **Tests:** 6 in `shift-service.test.ts` (per-till single-open, requireOpenShift per till,
money folds into its till only, vouchers per till + separate carry-forward, close per till,
history/filter + pre-till = booth); carwash bay test now proves the booth shift does *not*
cover the bay and that the wash Z carries the money while the booth Z does not. Verified
live: booth held by `admin` since July, `testadmin` opened + closed a wash shift around one
bay payment — wash Z: cash 500, booth untouched, drawer hub shows each till's own figure.
- **Till access = module permission (2026-09-05, same day).** `ModuleManifest.tillPermission`
(booth `session:read`, carwash `carwash:read`) + `tillsFor(effective, has)`; server
`accessibleTillsFor(db, roleId)` guards open/close/current/report and cash movements
(`403 till_forbidden`); `/api/shift/tills` lists only the role's tills. A wash role
therefore never sees or opens the booth's shift. A role that *should* work both simply
holds both permissions.
- **Landing per module (2026-09-05).** `WebModule.landing` (`/wash` for `carwash:read`,
`/validate` for `validation:create`); the index route lands on the booth iff
`session:read`, else the first module landing the role holds, else `/shifts`, else the
profile; every guard bounces to `/` (the resolver), never to the booth, and `/booth`
itself now requires `session:read`. The hard-coded merchant special case is gone.
- **Not done:** role presets from the manifest (a one-click "wash operator" role in Setup
→ Roles); a permission-scoped live feed for module desks (the WS is `report:read` only —
the wash desk polls, 5 s / 15 s).
**Known follow-ups.** A shift's *activity log* (right pane of the hub, Drawer "today") is
still a time window over the whole chain, so a booth shift's log shows wash events in that
window (money figures are per till; the log is not). A separate wash bucket on the booth's
Z-report (booth-paid washes ride `chargeLines`) is still open. Bay slips print on the booth
printer until a wash-desk printer role exists.
## Review log — issues and ideas from the first hands-on pass (2026-09-05)
Recorded so the reasoning survives; each item's fix is in the As-built sections above.
1. **"Parking sponsorship" → "Zbritje parkimi" / "Parking discount".** Wording.
2. **Discount modes for the wash.** Owner-level needs: free *during* the wash (+ tolerance),
and "parking fee − wash price, floored at 0". Both added as wash-only modes resolved at
done. The typed-amount mode is the only one where the operator picks the money (highest
fraud exposure; kept for the Bar behind cap + per-day + attribution, hidden for the wash);
percent is a Bar use, not a wash one (hidden for the wash).
3. **Price-matrix cells for new rows were disabled until save** (no id yet). Fixed with a
two-request save behind one button.
4. **"Free until done" comped a 74-day stay** (ticket 92498375903, 1797h) — the credit was
anchored at entry. Re-anchored at the order's intake: only the wash window (+ tolerance)
is credited. Also: long durations now render `Xy Xd Xh Xm` everywhere.
5. **The desk needs to see finished washes.** Added a "Finished" list (done + paid, or
voided; newest first; who closed it; reason on void) under the queue.
6. **Wash operators vs the park shift** → the tills requirement above.
## Open questions to settle before building
- ~~Platform name~~ — **settled 2026-09-05: it stays `parking-system` / `com.parking.desktop`.**
@@ -280,6 +487,8 @@ no parking code moved (the seam exists, the code crosses it as each subsystem is
- **Which body-type categories the Car Wash tariff actually needs** — decides COCO-five vs
training.
- **Entitlement as env vs signed file** — start with env; revisit only for commercial reasons.
- ~~**Tills**~~ — **built 2026-09-05** (above). Left: per-till activity log, wash bucket on
the booth Z, a printer role for the wash desk.
- The **Bar** data model — separate scoping session (Car Wash v1 scope is above).
## Related