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) => {