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
+185 -3
View File
@@ -31,6 +31,7 @@ export const RESOURCES = [
"log", // application/diagnostic logs (app_logs) — view + retention
"recyclebin", // soft-deleted master data: view / restore / purge
"backup", // encrypted DB backups: configure target + trigger a manual run
"carwash", // Car Wash module: orders/queue (read), intake (create), done/pay/void (update)
] as const;
export type Resource = (typeof RESOURCES)[number];
@@ -86,6 +87,10 @@ export const PERMISSIONS: readonly Permission[] = [
// action on a fresh appliance, never reachable from the running console. See
// wiki/concepts/backup-recovery.md.
"backup:read", "backup:update", "backup:create",
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup;
// create = intake an order; update = mark done / take a bay payment / void. Settings
// (categories, services, price matrix, sponsorship program) ride site:update.
"carwash:read", "carwash:create", "carwash:update",
] as const;
/** The protected built-in role: non-deletable, non-editable, always = ALL
@@ -288,6 +293,13 @@ export type LedgerEventType =
// the referenced validation event (append-only correction, mirrors cash_review).
// See wiki/concepts/validation-discounts.md.
| "validation"
// Car Wash module (venue-modules.md). `carwash_order` is the order's life on the
// chain — payload.action = "created" | "done" | "void", with the category/service/
// price FROZEN at intake so renames never rewrite history. `carwash_payment` is
// money taken AT THE BAY (payAt = "bay"); a wash paid AT THE BOOTH rides the
// parking `payment` as chargeLines instead (see PayStation charge providers).
| "carwash_order"
| "carwash_payment"
| "anomaly";
/** How money was tendered (for payment events + the shift Z-report). */
@@ -317,6 +329,19 @@ export interface LedgerPayload {
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
* stamped so the printed receipt reproduces without re-deriving the fold. */
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
/** payment: non-parking charges a module folded into this settlement (e.g. a wash
* paid at the booth). `amountMinor` (NET) INCLUDES them; `parkingMinor` is the
* parking-only net; `chargesMinor` their sum. See PayStation charge providers. */
readonly chargeLines?: ChargeLine[];
readonly chargesMinor?: number;
readonly parkingMinor?: number;
/** carwash_order / carwash_payment: the order + what was frozen at intake. */
readonly orderId?: string;
readonly action?: string;
readonly categoryName?: string;
readonly serviceName?: string;
readonly priceMinor?: number;
readonly payAt?: string;
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
readonly programId?: string;
readonly programLabel?: string;
@@ -326,6 +351,12 @@ export interface LedgerPayload {
readonly percent?: number;
/** validation / cash vouchers: the username of the user who recorded it. */
readonly operator?: string;
/** MONEY events (payment, carwash_payment, cash_in/out, shift_open, shift_z_report):
* the TILL the money belongs to. A shift is opened on a till; every taking and
* voucher names one; the drawer fold and the Z-report filter by it. ABSENT = the
* booth (every event before tills existed, 2026-09-05, is booth money — so the
* chain re-folds identically). See wiki/concepts/shift.md "Tills". */
readonly till?: TillId;
/** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */
readonly fxRate?: number | null;
/** void / anomaly / override: a human-readable English sentence, signed as the
@@ -744,8 +775,20 @@ export interface SessionPayment {
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
* by the merchant at scan time, capped) / a percentage off. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** How a validation program discounts the parking fee. The first four are the merchant
* modes (applied at scan). The last two are RESOLVED at apply time by the Car Wash module
* and can only be applied through a wash order (a merchant scan refuses them):
* - doneTolerance: the WASH WINDOW is free — from the order's intake until it is marked
* DONE, plus `minutes` tolerance — resolved into a timeCredit of (window + minutes).
* Parking before the order and after the tolerance stays at the tariff;
* - washPrice: the wash price comes off the parking fee, floored at 0 — resolved into a
* fixed discount of the order's price. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent" | "doneTolerance" | "washPrice";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"];
/** Modes a MERCHANT may apply at scan (the wash-only modes need a wash order's context). */
export const MERCHANT_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** Modes the Car Wash discount editor offers (no typed amounts, no percent — see venue-modules.md). */
export const CARWASH_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "doneTolerance", "washPrice", "timeCredit"];
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
* are the well-known ids the /setup/site checkboxes toggle). */
@@ -1721,9 +1764,29 @@ export interface Signer {
// tables stay migrated, history stays, role grants stay; routes reject and UI hides.
// Design + rationale: wiki/decisions/venue-modules.md.
export const MODULE_IDS = ["parking", "validation"] as const;
export const MODULE_IDS = ["parking", "validation", "carwash"] as const;
export type ModuleId = (typeof MODULE_IDS)[number];
// --- Tills --------------------------------------------------------------------
// A TILL is a physical cash drawer with its own accountability: shifts are opened on
// a till, money events name their till, and the Z-report reconciles one till. The
// booth is the till that has always existed; a money-taking module declares its own
// (Car Wash → "carwash") so its operator counts THEIR drawer against THEIR expected
// figure — the wash operator and the booth operator do not share a shift. A till is
// available when the module that declares it is effective. See wiki/concepts/shift.md.
export const TILL_IDS = ["booth", "carwash"] as const;
export type TillId = (typeof TILL_IDS)[number];
/** The till every pre-till event and every un-tagged money event belongs to. */
export const BOOTH_TILL: TillId = "booth";
export function isTillId(v: unknown): v is TillId {
return typeof v === "string" && (TILL_IDS as readonly string[]).includes(v);
}
/** The till a money event belongs to: its payload's `till`, else the booth. ONE rule,
* shared by the drawer fold, the Z-report, and the UI — never re-derive it elsewhere. */
export function tillOf(payload: { till?: TillId } | null | undefined): TillId {
return payload?.till ?? BOOTH_TILL;
}
export interface ModuleManifest {
readonly id: ModuleId;
/** Cannot be deactivated (and is always entitled). Parking is the product today. */
@@ -1738,6 +1801,15 @@ export interface ModuleManifest {
/** Ledger event types this module appends (informational; the union stays ONE
* append-only type — see LedgerEventType). */
readonly ledgerEventTypes: readonly LedgerEventType[];
/** The TILL this module takes money on, if it takes money at its own desk. Its
* operators open shifts on that till and reconcile that drawer. Absent = the
* module has no money of its own (validation) — or, for parking, the booth. */
readonly till?: TillId;
/** The permission that lets a role WORK this module's till: open/close its shift and
* move its cash. The booth's is the booth screen's own (`session:read`); a wash
* operator's role holds `carwash:read` and not that, so they can never open the
* booth's shift — and vice versa. Enforced server-side (shift/drawer routes). */
readonly tillPermission?: Permission;
}
/** The registry. Adding a module = one entry here + its server/web folders
@@ -1749,6 +1821,8 @@ export const MODULES: readonly ModuleManifest[] = [
dependsOn: [],
resources: ["tariff", "subscription", "payment", "session"],
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
till: "booth",
tillPermission: "session:read",
},
{
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
@@ -1759,8 +1833,116 @@ export const MODULES: readonly ModuleManifest[] = [
resources: ["validation"],
ledgerEventTypes: ["validation"],
},
{
// The pilot module. Depends on parking (the wash sits inside the park; the ticket
// IS the customer identity) and on validation (the sponsorship engine: a completed
// wash applies the site's "carwash" validation program to the session).
id: "carwash",
required: false,
dependsOn: ["parking", "validation"],
resources: ["carwash"],
ledgerEventTypes: ["carwash_order", "carwash_payment"],
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
till: "carwash",
tillPermission: "carwash:read",
},
];
/** The tills available given the EFFECTIVE modules — the booth always (parking is
* required), plus each effective module's own till. Registry order. */
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
const out = new Set<TillId>([BOOTH_TILL]);
for (const m of MODULES) if (m.till && effective.includes(m.id)) out.add(m.till);
return TILL_IDS.filter((t) => out.has(t));
}
/** The tills a ROLE may work at this site: the effective tills whose module's
* `tillPermission` the role holds. What the shift/drawer routes enforce and what the
* UI offers (header button, start buttons, drawer switch). */
export function tillsFor(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
const site = tillsOf(effective);
return site.filter((t) => {
const m = MODULES.find((x) => x.till === t);
return !!m && (m.tillPermission ? has(m.tillPermission) : true);
});
}
// --- Car Wash module ----------------------------------------------------------
// Types shared by apps/server/src/modules/carwash and apps/web/src/modules/carwash.
// Data model + rules: wiki/decisions/venue-modules.md ("Car Wash — the pilot module").
/** Where the wash is paid — a per-order choice at intake. `booth`: the wash is a charge
* line on the parking settlement at the booth (the exit barrier opens after that
* payment as usual). `bay`: the wash operator collects at the bay; the parking session
* is then settled to zero-due (via the sponsorship program) so the exit READER opens. */
export type CarWashPayAt = "booth" | "bay";
export const CARWASH_PAY_AT: readonly CarWashPayAt[] = ["booth", "bay"];
/** Where wash money is taken is a SITE setting (Setup → Car wash), not a per-order
* choice: the site either settles washes at the booth (on the parking ticket) or at
* the bay (the wash operator's own till). Every order freezes the policy in force. */
export const CARWASH_PAY_AT_DEFAULT: CarWashPayAt = "booth";
/** An order's working state. `paid` is tracked separately (paidAt / payment ref) since a
* bay order may be paid before or after the wash is done. */
export type CarWashOrderStatus = "open" | "done" | "void";
/** The validation-program row id the Car Wash module uses for its parking sponsorship —
* the same shape a merchant validation has (comp / timeCredit / fixed / percent,
* maxPerDay), composed on Setup → Car wash, applied automatically when a wash is done. */
export const CARWASH_PROGRAM_ID = "carwash";
/** A non-parking charge folded into a booth settlement by a module (today: a wash
* ordered with payAt = "booth"). Frozen onto the `payment` payload as `chargeLines`. */
export interface ChargeLine {
/** Who owns the line — the module id. */
readonly module: ModuleId;
/** The module's own record this settles (e.g. the wash order id). */
readonly ref: string;
/** Receipt/display label, e.g. "Car wash — SUV · Standard". */
readonly label: string;
readonly amountMinor: number;
}
/** Setup → Car wash: the admin-maintained master data, as read/written by
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
export interface CarwashSettingsView {
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[];
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
/** One entry per priced (category, service) pair. */
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
readonly currency: string | null;
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the
* wash operator's till). Site-level; the desk no longer asks per order. */
readonly payAt: CarWashPayAt;
}
/** A wash order as the desk sees it (GET /api/carwash/orders). */
export interface CarwashOrderView {
readonly id: string;
readonly identity: string;
readonly plate: string | null;
readonly categoryId: string;
readonly categoryName: string;
readonly serviceId: string;
readonly serviceName: string;
readonly priceMinor: number;
readonly currency: string;
readonly payAt: CarWashPayAt;
readonly status: CarWashOrderStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly doneAt: string | null;
readonly doneBy: string | null;
readonly paidAt: string | null;
readonly paidBy: string | null;
readonly tender: Tender | null;
/** True once the order needs nothing more (done + paid, or void). */
readonly closed: boolean;
readonly validationEventId: string | null;
readonly voidBy: string | null;
readonly voidReason: string | null;
}
export function isModuleId(v: unknown): v is ModuleId {
return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v);
}