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
+27 -5
View File
@@ -1,5 +1,7 @@
import type { FastifyInstance } from "fastify";
import type { Db } from "@parking/db";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { accessibleTillsFor, parseTill } from "../modules.js";
import { InvalidCashMovementError, type MovementStatus, type ShiftService } from "../shift-service.js";
// Drawer cash movements (manned mode). Redesigned 2026-07-01: an operator RECORDS a
@@ -14,6 +16,8 @@ import { InvalidCashMovementError, type MovementStatus, type ShiftService } from
// amount that carries across shifts).
// The drawer BALANCE math is unchanged — a movement counts immediately; a denial is a
// judgment about the operator settled outside the app, never a cash reversal.
// TILLS: a movement names the drawer it moved in/out of (`till`, default booth); the
// balance and the list take a `till` filter. See wiki/concepts/shift.md "Tills".
interface MovementBody {
/** Direction is the document TYPE, not a sign: cash_in = Mandat Arkëtimi (pay-IN),
@@ -23,6 +27,8 @@ interface MovementBody {
amountMinor: number;
reason?: string;
currency?: string;
/** Which drawer (default: the booth). */
till?: string;
}
interface ReviewBody {
@@ -36,9 +42,11 @@ interface ReviewBody {
interface MovementsQuery {
/** Reviewers only: filter to pending/authorized/denied. Ignored for non-reviewers. */
status?: MovementStatus;
/** Filter to one till; absent = every till. */
till?: string;
}
export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
export async function drawerRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
const createGuard = requirePermission("drawer:create");
const reviewGuard = requirePermission("drawer:review");
const readGuard = requirePermission("shift:read");
@@ -49,6 +57,12 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
if (b.type !== "cash_in" && b.type !== "cash_out") {
return reply.code(400).send({ error: "type must be cash_in or cash_out" });
}
const till = parseTill(db, b.till);
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
// Moving a till's cash needs that till's module permission (see routes/shift.ts).
if (!accessibleTillsFor(db, req.user.roleId).includes(till)) {
return reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
}
try {
return await shift.recordVoucher({
type: b.type,
@@ -56,6 +70,7 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
amountMinor: b.amountMinor,
reason: b.reason ?? "",
currency: b.currency,
till,
});
} catch (err) {
if (err instanceof InvalidCashMovementError) return reply.code(400).send({ error: err.message });
@@ -65,20 +80,27 @@ export async function drawerRoutes(app: FastifyInstance, shift: ShiftService): P
// List movements + review status. Operators are hard-scoped to their OWN movements; a
// reviewer sees ALL and may filter by status (the pending review queue).
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req) => {
app.get<{ Querystring: MovementsQuery }>("/api/drawer/movements", { preHandler: readGuard }, async (req, reply) => {
const canReview = roleHasPermissions(req.user.roleId, ["drawer:review"]);
const q = req.query ?? {};
const status = canReview && ["pending", "authorized", "denied"].includes(q.status ?? "") ? q.status : undefined;
const till = q.till?.trim() ? parseTill(db, q.till.trim()) : undefined;
if (till === null) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
const movements = shift.movementsWithStatus({
operator: canReview ? undefined : req.user.username,
status,
till,
});
return { movements, scope: canReview ? "all" : "self" };
});
// The physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — the drawer is a single site-wide till, not per-operator data.
app.get("/api/drawer/balance", { preHandler: readGuard }, async () => shift.drawerBalance());
// A till's physical drawer balance now. Same visibility as the open shift's X-report
// (shift:read) — a drawer is a shared till, not per-operator data.
app.get<{ Querystring: { till?: string } }>("/api/drawer/balance", { preHandler: readGuard }, async (req, reply) => {
const till = parseTill(db, req.query?.till);
if (!till) return reply.code(400).send({ error: "unknown till", code: "bad_till" });
return { till, ...shift.drawerBalance(till) };
});
// Admin AUTHORIZES or DENIES a recorded movement. A flag only — no cash reversal.
app.post<{ Body: ReviewBody }>("/api/drawer/review", { preHandler: reviewGuard }, async (req, reply) => {
+1 -1
View File
@@ -142,7 +142,7 @@ describe("drawer balance (the till NOW)", () => {
const { cookie } = await login(app, viewer.username, viewer.password);
const ok = await app.inject({ method: "GET", url: "/api/drawer/balance", headers: { cookie } });
expect(ok.statusCode).toBe(200);
expect(ok.json()).toEqual({ balanceMinor: 0, currency: null });
expect(ok.json()).toEqual({ till: "booth", balanceMinor: 0, currency: null });
const outsider = await seedUser(db, { username: "noshift", roleId: "noshift", permissions: ["site:read"] });
const other = await login(app, outsider.username, outsider.password);
+82 -24
View File
@@ -1,5 +1,8 @@
import type { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyReply } from "fastify";
import type { Db } from "@parking/db";
import type { TillId } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { accessibleTillsFor, effectiveTillsFor, parseTill } from "../modules.js";
import { NoOpenShiftError, ShiftAlreadyOpenError, type ShiftService } from "../shift-service.js";
interface ShiftsQuery {
@@ -8,43 +11,85 @@ interface ShiftsQuery {
/** ISO window over shift START time. */
from?: string;
to?: string;
/** Filter to one till; absent = every till. */
till?: string;
}
interface TillQuery {
/** Which till (default: the booth). */
till?: string;
}
interface TillBody {
till?: string;
}
// Shift endpoints (manned mode). The operator is the logged-in user; a shift is
// opened/closed explicitly (not time-based — see wiki/concepts/shift.md and
// local-jwt-auth.md "until logout"). End Shift signs a shift_z_report + prints it.
//
// TILLS: every endpoint takes a `till` (query on GET, body on POST; default booth).
// A till is addressable only when the module that declares it is effective here
// (400 otherwise) — the wash desk's shift control passes till=carwash. WORKING a till
// (open/close, its state) additionally needs the role to hold that till's module
// permission (booth: session:read; carwash: carwash:read) — 403 `till_forbidden` — so a
// wash operator's role can never open the booth's shift, nor a booth operator the
// wash's. History (`/api/shifts`) stays scoped by shift:read/cash, not by till.
export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Promise<void> {
export async function shiftRoutes(app: FastifyInstance, db: Db, shift: ShiftService): Promise<void> {
// Reading the shift state vs. opening/closing one's own shift.
const readGuard = requirePermission("shift:read");
const guard = requirePermission("shift:create");
// The SITE-WIDE shift state (at most one shift open at a time). The UI uses this
// to render the header control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - open: the open shift { startedAt, operator } or null (site-wide)
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
app.get("/api/shift/current", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
const open = shift.currentOpenShift();
const badTill = (reply: FastifyReply) => reply.code(400).send({ error: "unknown till", code: "bad_till" });
const forbidden = (reply: FastifyReply, till: TillId) =>
reply.code(403).send({ error: `your role cannot work the ${till} till`, code: "till_forbidden", till });
const mayWork = (roleId: string, till: TillId) => accessibleTillsFor(db, roleId).includes(till);
const statusOf = (till: TillId, me: string) => {
const open = shift.currentOpenShift(till);
const heldBy = open?.identity ?? null;
const drawer = shift.drawerBalance();
const drawer = shift.drawerBalance(till);
return {
operator: me,
till,
open: open ? { startedAt: open.occurredAt, operator: heldBy } : null,
isMine: open != null && heldBy === me,
drawerMinor: drawer.balanceMinor,
currency: drawer.currency,
};
};
// The shift state of ONE till (at most one shift open per till). The UI uses this
// to render a till's control: no shift → "Open"; my shift → "Close" (enabled);
// someone else's shift → disabled. Also returns the live drawer balance.
// - till: which till this describes
// - open: the open shift { startedAt, operator } or null
// - isMine: true iff the open shift belongs to the requesting operator
// - operator: the requesting user (for the UI's own identity)
// - tills: every till THIS ROLE may work (the booth + effective modules' tills it
// holds the permission for) — what the UI offers controls for
app.get<{ Querystring: TillQuery }>("/api/shift/current", { preHandler: readGuard }, async (req, reply) => {
const till = parseTill(db, req.query?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
return { operator: req.user.username, tills: accessibleTillsFor(db, req.user.roleId), ...statusOf(till, req.user.username) };
});
// The state of every till this role may work, in one read — the shift hub lists
// each open shift and offers "start" for the idle ones.
app.get("/api/shift/tills", { preHandler: readGuard }, async (req) => {
const me = req.user.username;
return { operator: me, tills: accessibleTillsFor(db, req.user.roleId).map((t) => statusOf(t, me)) };
});
// Mid-shift X-report: a READ-ONLY "so far" snapshot of the OPEN shift's takings +
// drawer (opening float, cash/card taken, pay-ins/outs, expected drawer), computed
// as of now. Appends nothing — it's not an accountability mark, just a projection
// (the Z-report at close is the signed record). 204 when no shift is open.
app.get("/api/shift/report", { preHandler: readGuard }, async (_req, reply) => {
const report = shift.currentReport();
app.get<{ Querystring: TillQuery }>("/api/shift/report", { preHandler: readGuard }, async (req, reply) => {
const till = parseTill(db, req.query?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
const report = shift.currentReport(till);
if (!report) return reply.code(204).send();
return report;
});
@@ -54,36 +99,49 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
// - `shift:cash` (admin-grade) → all operators, optionally filtered by
// `operator` and a `from`/`to` time window over each shift's START.
// This keeps one operator from reading another's takings while letting admins
// reconcile across the site. The data is the signed shift_z_report chain.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req) => {
// reconcile across the site. The data is the signed shift_z_report chain. Both
// scopes may filter by `till`.
app.get<{ Querystring: ShiftsQuery }>("/api/shifts", { preHandler: readGuard }, async (req, reply) => {
const canSeeAll = roleHasPermissions(req.user.roleId, ["shift:cash"]);
const q = req.query ?? {};
// Non-admins are hard-scoped to themselves regardless of any operator param.
const operator = canSeeAll ? (q.operator?.trim() || undefined) : req.user.username;
const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to });
let till: TillId | undefined;
if (q.till?.trim()) {
const parsed = parseTill(db, q.till.trim());
if (!parsed) return badTill(reply);
till = parsed;
}
const shifts = shift.listShifts({ operator, from, to, till });
// Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
return { shifts, scope: "self" };
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators(), tills: effectiveTillsFor(db) };
return { shifts, scope: "self", tills: effectiveTillsFor(db) };
});
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
// feature is no longer part of the shift route. See wiki/concepts/shift.md.
app.post("/api/shift/open", { preHandler: guard }, async (req, reply) => {
app.post<{ Body: TillBody }>("/api/shift/open", { preHandler: guard }, async (req, reply) => {
const till = parseTill(db, req.body?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
try {
return await shift.open(req.user.username);
return await shift.open(req.user.username, till);
} catch (err) {
if (err instanceof ShiftAlreadyOpenError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
}
});
app.post("/api/shift/close", { preHandler: guard }, async (req, reply) => {
app.post<{ Body: TillBody }>("/api/shift/close", { preHandler: guard }, async (req, reply) => {
const till = parseTill(db, req.body?.till);
if (!till) return badTill(reply);
if (!mayWork(req.user.roleId, till)) return forbidden(reply, till);
try {
return await shift.close(req.user.username);
return await shift.close(req.user.username, till);
} catch (err) {
if (err instanceof NoOpenShiftError) return reply.code(409).send({ error: err.message });
return reply.code(500).send({ error: (err as Error).message });
+2 -1
View File
@@ -2,7 +2,7 @@ import { randomBytes, randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, eq, isNull, devices, subscriptionCredentials, subscriptionPlans, subscriptionPlates, subscriptions, type Db } from "@parking/db";
import { NoPrinterAvailableError } from "@parking/devices";
import type { SubscriptionPlan, SubscriptionQuote, Tender } from "@parking/shared";
import { BOOTH_TILL, type SubscriptionPlan, type SubscriptionQuote, type Tender } from "@parking/shared";
import { requirePermission, roleHasPermissions } from "../auth.js";
import { softDelete } from "../recycle-bin.js";
import { invalidateHolder } from "../event-enrich.js";
@@ -381,6 +381,7 @@ export async function subscriptionRoutes(
amountMinor,
currency,
tender,
till: BOOTH_TILL,
operator,
// Flags this `payment` as a subscription SALE (not a parking payment) so the
// live feed / activity log can label it distinctly. plan + periods for audit
+20 -81
View File
@@ -10,11 +10,11 @@ import {
validationPrograms,
type Db,
} from "@parking/db";
import { VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { MERCHANT_VALIDATION_MODES, VALIDATION_MODES, type ValidationMode } from "@parking/shared";
import { requirePermission } from "../auth.js";
import { requireModule } from "../modules.js";
import type { EventLog } from "../event-log.js";
import { liveValidations, sessionValidations } from "../validations.js";
import { applyValidation, liveValidations, sessionValidations } from "../validations.js";
// Merchant validations (bar / lavazh). The merchant is VALIDATION-ONLY: they scan the
// customer's ticket on their own device and apply their program — all money and paper
@@ -64,12 +64,18 @@ function validateProgram(b: ProgramBody): string | null {
if (!b.name || !String(b.name).trim()) return "name is required";
if (!VALIDATION_MODES.includes(b.mode as ValidationMode)) return "mode must be comp|timeCredit|fixed|percent";
const intOrNull = (v: unknown) => v == null || (Number.isInteger(v) && (v as number) > 0);
if (!intOrNull(b.minutes)) return "minutes must be a positive integer";
// doneTolerance's minutes is a TOLERANCE — zero is a legitimate "free until done, not a
// minute more"; every other minutes use is a positive credit.
const minutesOk = b.mode === "doneTolerance"
? b.minutes == null || (Number.isInteger(b.minutes) && (b.minutes as number) >= 0)
: intOrNull(b.minutes);
if (!minutesOk) return b.mode === "doneTolerance" ? "minutes must be a non-negative integer" : "minutes must be a positive integer";
if (!intOrNull(b.maxAmountMinor)) return "maxAmountMinor must be a positive integer";
if (!intOrNull(b.maxPerDay)) return "maxPerDay must be a positive integer";
if (b.percent != null && (!Number.isInteger(b.percent) || b.percent < 1 || b.percent > 100))
return "percent must be 1..100";
if (b.mode === "timeCredit" && b.minutes == null) return "timeCredit needs minutes";
if (b.mode === "doneTolerance" && b.minutes == null) return "doneTolerance needs minutes (the tolerance; 0 allowed)";
if (b.mode === "percent" && b.percent == null) return "percent mode needs percent";
if (b.mode === "fixed" && b.maxAmountMinor == null) return "fixed mode needs maxAmountMinor";
return null;
@@ -247,88 +253,21 @@ export async function validationRoutes(app: FastifyInstance, db: Db, eventLog: E
if (!boundUserIds(programId).includes(req.user.sub)) {
return reply.code(403).send({ error: "you are not bound to this program" });
}
// Session state — an open transient (subscriptions are prepaid; nothing to discount).
const rows = db
.select({ type: ledgerEvents.type, payload: ledgerEvents.payload })
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return reply.code(404).send({ error: "no session for ticket" });
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return reply.code(409).send({ error: "subscription sessions cannot be validated" });
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return reply.code(409).send({ error: "session is closed" });
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return reply.code(409).send({ error: "this program is already applied to the ticket" });
if (!MERCHANT_VALIDATION_MODES.includes(program.mode)) {
return reply.code(400).send({ error: "this program's discount is resolved by a car wash order, not at scan" });
}
// Per-day cap: unvoided applications of this program since LOCAL midnight (the
// appliance runs in site time).
if (program.maxPerDay != null) {
const midnight = new Date();
midnight.setHours(0, 0, 0, 0);
const todays = db
.select({ id: ledgerEvents.id, occurredAt: ledgerEvents.occurredAt, payload: ledgerEvents.payload, type: ledgerEvents.type })
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "validation"))
.all()
.filter((r) => Date.parse(r.occurredAt) >= midnight.getTime());
const voidedIds = new Set(
todays.map((r) => (r.payload as { refId?: string } | null)?.refId).filter(Boolean) as string[],
);
const count = todays.filter((r) => {
const p = (r.payload ?? {}) as { programId?: string; refId?: string };
return p.programId === programId && !p.refId && !voidedIds.has(r.id);
}).length;
if (count >= program.maxPerDay) {
return reply.code(409).send({ error: "daily cap reached for this program" });
}
}
// Resolve the values off the program row (frozen into the signed event).
let amountMinor: number | undefined;
if (program.mode === "fixed") {
const a = req.body?.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return reply.code(400).send({ error: "amountMinor (positive integer) required for this program" });
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return reply.code(400).send({ error: `amount exceeds the program cap (${program.maxAmountMinor})` });
}
amountMinor = a;
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode: program.mode,
...(program.mode === "timeCredit" && program.minutes != null ? { minutes: program.minutes } : {}),
...(program.mode === "percent" && program.percent != null ? { percent: program.percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: req.user.username,
},
});
return reply.code(201).send({
ok: true,
eventId: ev.id,
// The decision chain + the signed append live in ../validations.ts (applyValidation)
// — shared with the Car Wash module, which applies its own sponsorship program with
// no user binding. Only the binding check above is merchant-specific.
const result = await applyValidation(db, eventLog, {
programId,
label: program.name,
mode: program.mode,
minutes: program.mode === "timeCredit" ? program.minutes : undefined,
percent: program.mode === "percent" ? program.percent : undefined,
amountMinor,
identity,
actor: req.user.username,
amountMinor: req.body?.amountMinor,
});
if (!result.ok) return reply.code(result.status).send({ error: result.error });
return reply.code(201).send(result);
});
// VOID my own UNUSED validation (fat-fingered amount / wrong ticket). Append-only: