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:
+101
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user