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
+85 -4
View File
@@ -1,5 +1,5 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
import { priceSession, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import { BOOTH_TILL, priceSession, type ChargeLine, type TariffStructure, type Tender, type ValidationLine } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
@@ -29,6 +29,18 @@ export class NoTariffError extends Error {
}
}
/**
* A module that folds its own charges into a booth settlement (venue-modules.md):
* `lines(identity)` returns the open charges for the session (e.g. wash orders with
* payAt = "booth"); after the `payment` is signed, `onPaid` lets the module mark them
* settled. Registered by the module at boot (registerChargeProvider) — PayStation
* never imports a module.
*/
export interface ChargeProvider {
lines(identity: string): ChargeLine[];
onPaid(identity: string, lines: ChargeLine[], payment: { eventId: string; tender: Tender; operator?: string }): Promise<void>;
}
export interface Quote {
readonly identity: string;
/** Vehicle entry time (the session's original entry; for display/audit). */
@@ -39,8 +51,14 @@ export interface Quote {
* is priced as a fresh stay from there → now, with its own daily-cap ladder, NOT
* "full stay minus paid" (which a daily cap collapses toward zero). */
readonly periodStart: string;
/** Amount owed now: the fee for [periodStart → now], NET of merchant validations. */
/** Amount owed now: the parking fee for [periodStart → now] NET of merchant
* validations, PLUS any module charge lines (a wash paid at the booth). */
readonly amountMinor: number;
/** The parking-only net (amountMinor − chargesMinor). */
readonly parkingMinor: number;
/** Non-parking charges folded in by modules (see ChargeProvider). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number;
/** The pre-validation fee (= amountMinor when no validations apply). */
readonly grossMinor: number;
/** Total the merchant validations took off (gross − net). */
@@ -133,12 +151,16 @@ export interface SessionLookup {
readonly grossMinor: number | null;
readonly discountMinor: number | null;
readonly validationLines: ValidationLine[];
/** Module charge lines folded into `amountMinor` (e.g. a wash paid at the booth). */
readonly chargeLines: ChargeLine[];
readonly chargesMinor: number | null;
}
export class PayStation {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
readonly #chargeProviders: ChargeProvider[] = [];
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
@@ -146,6 +168,30 @@ export class PayStation {
this.#logger = logger;
}
/** Let a module fold its charges into booth settlements (see ChargeProvider). */
registerChargeProvider(p: ChargeProvider): void {
this.#chargeProviders.push(p);
}
/** The currency of the tariff in force right now (null = none published). Modules
* price their own goods in the same money the booth takes. */
activeCurrency(): string | null {
return this.#tariffVersionFor(new Date().toISOString())?.currency ?? null;
}
#chargeLines(identity: string): ChargeLine[] {
const out: ChargeLine[] = [];
for (const p of this.#chargeProviders) {
try {
out.push(...p.lines(identity));
} catch (err) {
// A module's fault must never block a parking settlement — log and price without it.
this.#logger.error(`charge provider failed for ${identity}: ${(err as Error).message}`);
}
}
return out;
}
/** Price an open session. Normally the period is entry→now. But for an OVERSTAY — a
* paid session whose walk-back grace has lapsed (the car re-parked, or a new period
* began) — the customer is billed for a FRESH period from grace-expiry→now, with its
@@ -184,11 +230,16 @@ export class PayStation {
category,
validations,
);
const chargeLines = this.#chargeLines(identity);
const chargesMinor = chargeLines.reduce((sum, l) => sum + l.amountMinor, 0);
return {
identity,
enteredAt: entry.occurredAt,
periodStart: p.periodStart,
amountMinor: p.amountMinor,
amountMinor: p.amountMinor + chargesMinor,
parkingMinor: p.amountMinor,
chargeLines,
chargesMinor,
grossMinor: p.grossMinor,
discountMinor: p.discountMinor,
validationLines: p.validationLines,
@@ -246,6 +297,7 @@ export class PayStation {
amountMinor,
currency: subWindow.currency ?? undefined,
tender,
till: BOOTH_TILL,
...(subWindow.tariffVersionId ? { tariffVersionId: subWindow.tariffVersionId } : {}),
subscriptionWindowCharge: true,
...(overrideMinor != null ? { reason: "operator-set amount", quotedMinor: subWindow.dueMinor } : {}),
@@ -258,7 +310,7 @@ export class PayStation {
const q = this.quote(identity);
const amountMinor = overrideMinor ?? q.amountMinor;
await this.#log.append({
const paymentEvent = await this.#log.append({
type: "payment",
source: "manual",
identity,
@@ -267,7 +319,19 @@ export class PayStation {
amountMinor,
currency: q.currency,
tender,
// Parking money is BOOTH money (a wash paid at the booth rides along as
// chargeLines, so it is booth money too). See wiki/concepts/shift.md "Tills".
till: BOOTH_TILL,
tariffVersionId: q.tariffVersionId,
// Module charges (e.g. a wash paid at the booth): frozen as lines so the
// receipt reproduces and reporting can split parking from the rest.
...(q.chargeLines.length
? {
chargeLines: q.chargeLines.map((l) => ({ ...l })),
chargesMinor: q.chargesMinor,
parkingMinor: q.parkingMinor,
}
: {}),
// The exit flow reads graceExitMin off the payment to validate the
// walk-back window without re-resolving the tariff.
graceExitMin: q.graceExitMin,
@@ -294,6 +358,17 @@ export class PayStation {
this.#logger.error(`session-cache mark-paid failed for ${identity}: ${(err as Error).message}`);
}
// Let each module mark the charge lines it contributed as settled by this payment.
if (q.chargeLines.length) {
for (const p of this.#chargeProviders) {
try {
await p.onPaid(identity, q.chargeLines, { eventId: paymentEvent.id, tender });
} catch (err) {
this.#logger.error(`charge provider onPaid failed for ${identity}: ${(err as Error).message}`);
}
}
}
this.#logger.info(`payment ${amountMinor} ${q.currency} (${tender}) for ${identity}`);
return { amountMinor, currency: q.currency };
}
@@ -320,6 +395,7 @@ export class PayStation {
withinGrace: false, graceExpiresAt: null,
overstay: false, subscription: false, subscriptionId: null, subscriptionHolder: null, plate: null,
grossMinor: null, discountMinor: null, validationLines: [],
chargeLines: [], chargesMinor: null,
};
}
// Subscription occurrence? The entry payload carries permit:true + permitId.
@@ -360,6 +436,8 @@ export class PayStation {
let grossMinor: number | null = null;
let discountMinor: number | null = null;
let validationLines: ValidationLine[] = [];
let chargeLines: ChargeLine[] = [];
let chargesMinor: number | null = null;
if (open && !isSubscription) {
try {
const q = this.quote(id);
@@ -368,6 +446,8 @@ export class PayStation {
grossMinor = q.grossMinor;
discountMinor = q.discountMinor;
validationLines = q.validationLines;
chargeLines = q.chargeLines;
chargesMinor = q.chargesMinor;
} catch {
/* no active tariff — leave null; modal shows session without a price */
}
@@ -389,6 +469,7 @@ export class PayStation {
subscriptionHolder: this.#holderOf(subscriptionId),
plate: plateForIdentity(this.#db, id)?.plate ?? null,
grossMinor, discountMinor, validationLines,
chargeLines, chargesMinor,
};
}