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
+81
View File
@@ -64,13 +64,74 @@ export const en: Catalog = {
name: {
parking: "Parking",
validation: "Merchant validations (Bar)",
carwash: "Car wash",
},
},
wash: {
tillTitle: "Wash till",
tillHint: "Money taken at the bay is recorded on the wash till — open your wash shift first. The booth's shift does not cover it.",
tillOtherHint: "{{operator}} holds the wash shift; only they can take money at the bay.",
drawerNow: "Wash drawer now",
intake: "New wash",
ticketPh: "Parking ticket (scan or type)",
lookup: "Look up",
notFound: "No session for this ticket.",
closed: "This session is already closed.",
ticket: "Ticket",
plate: "Plate",
enteredAt: "Entered",
alreadyOpen: "This ticket already has an open wash order.",
category: "Vehicle category",
service: "Service",
price: "Price",
noPrice: "no price set for this pair",
payAt: "Payment",
payAtBooth: "At the booth",
payAtBay: "At the bay",
payAtBoothHint: "Added to the parking settlement; the exit barrier opens after the booth payment.",
payAtBayHint: "You take the money here; the customer leaves by scanning the ticket at the exit reader (the parking sponsorship must cover the fee).",
create: "Create order",
created: "Order created.",
queue: "Open orders",
empty: "Nothing to wash.",
time: "Time",
what: "Wash",
status: "Status",
statusOpen: "in progress",
statusDone: "done",
paid: "paid",
unpaid: "unpaid",
done: "Done",
payCash: "Paid cash",
payCard: "Paid card",
void: "Void",
voidReason: "Reason",
categories: "Vehicle categories",
services: "Services",
prices: "Prices",
pricesHint: "One price per category × service. Leave a cell blank to make that pair unsellable.",
addCategory: "category",
addService: "service",
active: "active",
save: "Save",
saved: "Saved.",
finished: "Finished",
finishedEmpty: "No finished washes yet.",
voided: "voided",
by: "By",
cash: "cash",
card: "card",
sponsorship: "Parking discount",
sponsorshipHint: "What a finished wash takes off the customer's parking fee. Applied automatically when a wash is marked done.",
sponsorshipLabel: "Car wash",
},
update: {
available: "Update available",
prompt: "Version {{version}} is available. Install now and restart? (Installing requires the administrator password.)",
},
nav: {
wash: "Car wash",
carwash: "Car wash",
booth: "Booth",
shift: "Shift",
setup: "Setup",
@@ -256,6 +317,9 @@ export const en: Catalog = {
evtCashReview: "REVIEW",
evtConfigChange: "CONFIG",
evtValidation: "VALIDATION",
evtCarwashOrder: "CAR WASH",
evtCarwashPayment: "WASH PAYMENT",
charges: "Extra charges",
decision: { authorize: "authorized", deny: "denied" },
evtAnomaly: "ANOMALY",
evtRefused: "REFUSED",
@@ -775,6 +839,11 @@ export const en: Catalog = {
modeTimeCredit: "First minutes free",
modeFixed: "Amount off (typed at scan)",
modePercent: "Percent off",
modeDoneTolerance: "Free while the wash runs (+ tolerance)",
modeDoneToleranceHint: "The time from the wash order to \"done\", plus the tolerance minutes, comes off the parking. Time parked before the order and after the tolerance is charged at the tariff.",
modeWashPrice: "Wash price off the parking fee",
modeWashPriceHint: "The parking fee minus the wash price; never below zero.",
toleranceMinutes: "Tolerance after done (minutes)",
minutes: "Free minutes",
percent: "Percent (%)",
maxAmount: "Cap per validation",
@@ -903,6 +972,18 @@ export const en: Catalog = {
"{{operator}} has an open shift. Only one shift may be open at a time — they must close theirs before you can open yours.",
openNow: "Open shift now",
opening: "Opening…",
// Tills — a shift belongs to a drawer (booth / wash desk), not the site.
tillOpen: "Open {{till}} shift",
tillClose: "Close {{till}} shift",
tillHeldByShort: "{{till}}: {{operator}}",
tillNoShift: "No {{till}} shift",
},
till: {
booth: "Booth",
carwash: "Wash",
boothLong: "Booth till",
carwashLong: "Wash till",
all: "All tills",
},
shifts: {
title: "Shift history",