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:
@@ -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]].
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user