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
+41 -7
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MERCHANT_VALIDATION_MODES } from "@parking/shared";
import {
fetchUsers,
saveValidationProgram,
@@ -30,7 +31,7 @@ export function stationLabelKey(id: StationId): string {
}
/** A blank program draft for a station enabled for the first time. */
export function defaultProgram(id: StationId, label: string): Omit<ValidationProgramView, "id"> {
export function defaultProgram(id: string, label: string): Omit<ValidationProgramView, "id"> {
return {
name: label,
mode: "comp",
@@ -56,13 +57,36 @@ const toInt = (s: string): number | null => {
const n = Number(v);
return Number.isInteger(n) && n > 0 ? n : null;
};
/** Like toInt but 0 is valid (a tolerance of "not a minute more"). */
const toNonNeg = (s: string): number | null => {
const v = s.trim();
if (v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n >= 0 ? n : null;
};
const MODE_LABEL_KEY: Record<ValidationMode, string> = {
comp: "val.modeComp",
timeCredit: "val.modeTimeCredit",
fixed: "val.modeFixed",
percent: "val.modePercent",
doneTolerance: "val.modeDoneTolerance",
washPrice: "val.modeWashPrice",
};
function StationForm({
/** One validation program's editor. Also reused by the Car Wash module for its
* sponsorship program (`hideUsers`: that program is applied by the wash flow, not by
* bound merchant users). */
export function StationForm({
program,
onSaved,
hideUsers = false,
modes = MERCHANT_VALIDATION_MODES,
}: {
program: ValidationProgramView;
onSaved: (p: ValidationProgramView) => void;
hideUsers?: boolean;
/** Which discount modes to offer (merchant stations vs the car wash differ). */
modes?: readonly ValidationMode[];
}) {
const { t } = useTranslation();
const [name, setName] = useState(program.name);
@@ -96,6 +120,7 @@ function StationForm({
const valid = useMemo(() => {
if (!name.trim()) return false;
if (mode === "timeCredit") return toInt(minutes) != null;
if (mode === "doneTolerance") return toNonNeg(minutes) != null;
if (mode === "percent") {
const p = toInt(percent);
return p != null && p <= 100;
@@ -110,7 +135,7 @@ function StationForm({
const saved = await saveValidationProgram(program.id, {
name: name.trim(),
mode,
minutes: mode === "timeCredit" ? toInt(minutes) : null,
minutes: mode === "timeCredit" ? toInt(minutes) : mode === "doneTolerance" ? toNonNeg(minutes) : null,
percent: mode === "percent" ? toInt(percent) : null,
maxAmountMinor: mode === "fixed" ? toMinor(maxAmount) : null,
maxPerDay: toInt(maxPerDay),
@@ -140,11 +165,12 @@ function StationForm({
<div className="field">
<span className="label">{t("val.mode")}</span>
<select className="input w-fit" value={mode} onChange={(e) => setMode(e.target.value as ValidationMode)}>
<option value="comp">{t("val.modeComp")}</option>
<option value="timeCredit">{t("val.modeTimeCredit")}</option>
<option value="fixed">{t("val.modeFixed")}</option>
<option value="percent">{t("val.modePercent")}</option>
{modes.map((m) => (
<option key={m} value={m}>{t(MODE_LABEL_KEY[m])}</option>
))}
</select>
{mode === "doneTolerance" && <span className="hint">{t("val.modeDoneToleranceHint")}</span>}
{mode === "washPrice" && <span className="hint">{t("val.modeWashPriceHint")}</span>}
</div>
{mode === "timeCredit" && (
<div className="field">
@@ -152,6 +178,12 @@ function StationForm({
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="60" />
</div>
)}
{mode === "doneTolerance" && (
<div className="field">
<span className="label">{t("val.toleranceMinutes")}</span>
<input className="input w-32" value={minutes} onChange={(e) => setMinutes(e.target.value)} placeholder="15" />
</div>
)}
{mode === "percent" && (
<div className="field">
<span className="label">{t("val.percent")}</span>
@@ -168,6 +200,7 @@ function StationForm({
<span className="label">{t("val.maxPerDay")}</span>
<input className="input w-32" value={maxPerDay} onChange={(e) => setMaxPerDay(e.target.value)} />
</div>
{!hideUsers && (
<div>
<div className="label">{t("val.users")}</div>
<span className="hint block">{t("val.usersHint")}</span>
@@ -192,6 +225,7 @@ function StationForm({
)}
</div>
</div>
)}
<div className="flex items-center gap-3">
<button type="button" className="btn btn-primary btn-sm" disabled={!valid} onClick={save}>
{t("site.save")}