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
+101 -2
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { blob, integer, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
import { blob, integer, primaryKey, sqliteTable, text, unique } from "drizzle-orm/sqlite-core";
// Schema notes:
// - TWO event streams, deliberately separate (see wiki/decisions/event-streams-split.md):
@@ -491,7 +491,7 @@ export const validationPrograms = sqliteTable("validation_programs", {
// Receipt label printed on the booth settlement line (e.g. "Lavazh — 1 orë falas").
name: text("name").notNull(),
// How the program discounts — see @parking/shared ValidationMode.
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent"] })
mode: text("mode", { enum: ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"] })
.notNull()
.default("comp"),
// timeCredit: the free minutes.
@@ -621,3 +621,102 @@ export type ValidationProgramRow = typeof validationPrograms.$inferSelect;
export type ValidationProgramUserRow = typeof validationProgramUsers.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AppLogRow = typeof appLogs.$inferSelect;
// --- Car Wash module (wiki/decisions/venue-modules.md) ----------------------
// Admin-maintained master data (categories, services, the price matrix) + the order
// rows that ARE the wash desk's queue. Master data is plainly mutable; every order
// freezes the category/service NAMES and the price at intake, and the order's life
// (created / done / void, and a bay payment) is signed onto the ledger — so history
// never depends on these rows. Soft-delete on the master data (recycle-bin pattern).
export const carwashCategories = sqliteTable("carwash_categories", {
id: text("id").primaryKey(),
/** Display name, e.g. "Car", "SUV", "Van", "Truck". */
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
export const carwashServices = sqliteTable("carwash_services", {
id: text("id").primaryKey(),
/** Display name, e.g. "Standard", "Outside", "Inside", "Details". */
name: text("name").notNull(),
sortOrder: integer("sort_order").notNull().default(0),
active: integer("active", { mode: "boolean" }).notNull().default(true),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
deletedAt: text("deleted_at"),
deletedBy: text("deleted_by"),
});
/** The price matrix: one row per (category, service) the admin priced. A missing pair
* is simply not sellable. Minor units. */
export const carwashPrices = sqliteTable(
"carwash_prices",
{
categoryId: text("category_id")
.notNull()
.references(() => carwashCategories.id),
serviceId: text("service_id")
.notNull()
.references(() => carwashServices.id),
priceMinor: integer("price_minor").notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.categoryId, t.serviceId] }),
}),
);
export const carwashOrders = sqliteTable("carwash_orders", {
id: text("id").primaryKey(),
/** The parking ticket id = the customer identity (the wash sits inside the park). */
identity: text("identity").notNull(),
plate: text("plate"),
categoryId: text("category_id").notNull(),
/** Frozen at intake (renames never rewrite an order). */
categoryName: text("category_name").notNull(),
serviceId: text("service_id").notNull(),
serviceName: text("service_name").notNull(),
priceMinor: integer("price_minor").notNull(),
currency: text("currency").notNull(),
/** "booth" | "bay" — see @parking/shared CarWashPayAt. */
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull(),
/** "open" | "done" | "void". Paid-ness is the separate paidAt below. */
status: text("status", { enum: ["open", "done", "void"] }).notNull().default("open"),
createdAt: text("created_at").notNull(),
createdBy: text("created_by").notNull(),
doneAt: text("done_at"),
doneBy: text("done_by"),
/** Set when settled — at the bay (carwash_payment) or at the booth (the parking
* payment that carried this order as a charge line). */
paidAt: text("paid_at"),
paidBy: text("paid_by"),
tender: text("tender"),
/** Ledger event id of the payment that settled it (carwash_payment or payment). */
paymentEventId: text("payment_event_id"),
/** Ledger event id of the sponsorship validation this order applied, if any. */
validationEventId: text("validation_event_id"),
voidAt: text("void_at"),
voidBy: text("void_by"),
voidReason: text("void_reason"),
});
/** Module-level settings singleton (id = 1). `payAt`: where wash money is taken at this
* site — "booth" (on the parking ticket) or "bay" (the wash operator's own till). */
export const carwashConfig = sqliteTable("carwash_config", {
id: integer("id").primaryKey(),
payAt: text("pay_at", { enum: ["booth", "bay"] }).notNull().default("booth"),
updatedAt: text("updated_at"),
updatedBy: text("updated_by"),
});
export type CarwashCategoryRow = typeof carwashCategories.$inferSelect;
export type CarwashServiceRow = typeof carwashServices.$inferSelect;
export type CarwashPriceRow = typeof carwashPrices.$inferSelect;
export type CarwashOrderRow = typeof carwashOrders.$inferSelect;