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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user