Files
parking_solution/apps/server/src/validations.ts
T
julian a9ccf9e20c 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
2026-09-05 13:23:09 +02:00

257 lines
9.6 KiB
TypeScript

import { eq, ledgerEvents, type Db, and, isNull, validationPrograms } from "@parking/db";
import type { EventLog } from "./event-log.js";
import type { SessionValidation, ValidationMode } from "@parking/shared";
// Merchant-validation ledger folds. A validation is a SIGNED, appended event on the
// session (never a mutable flag): payload carries the RESOLVED values (programId,
// label, mode, minutes/amountMinor/percent) + the merchant username. A validation
// event with `refId` set VOIDS the referenced one; a payment's `validationIds` marks
// which validations it CONSUMED (so an overstay's fresh period never re-applies
// them). See wiki/concepts/validation-discounts.md.
/** A validation event folded with its lifecycle state. */
export interface AppliedValidation extends SessionValidation {
readonly eventId: string;
readonly occurredAt: string;
/** The merchant username who applied it. */
readonly operator: string | null;
/** Voided by a later validation event referencing it. */
readonly voided: boolean;
/** The payment event id that consumed it, if settled. */
readonly consumedBy: string | null;
}
/** All validations ever applied to a session (newest last), with voided/consumed
* state folded from the chain. One identity-scoped ledger scan. */
export function sessionValidations(db: Db, identity: string): AppliedValidation[] {
const rows = db
.select({
id: ledgerEvents.id,
type: ledgerEvents.type,
occurredAt: ledgerEvents.occurredAt,
payload: ledgerEvents.payload,
})
.from(ledgerEvents)
.where(eq(ledgerEvents.identity, identity))
.orderBy(ledgerEvents.index)
.all();
const voided = new Set<string>();
const consumedBy = new Map<string, string>();
const applies: AppliedValidation[] = [];
for (const r of rows) {
const p = (r.payload ?? {}) as {
refId?: string;
programId?: string;
programLabel?: string;
mode?: ValidationMode;
minutes?: number;
amountMinor?: number;
percent?: number;
operator?: string;
validationIds?: string[];
};
if (r.type === "validation") {
if (p.refId) {
voided.add(p.refId);
} else if (p.programId && p.mode) {
applies.push({
eventId: r.id,
occurredAt: r.occurredAt,
programId: p.programId,
label: p.programLabel ?? p.programId,
mode: p.mode,
...(typeof p.minutes === "number" ? { minutes: p.minutes } : {}),
...(typeof p.amountMinor === "number" ? { amountMinor: p.amountMinor } : {}),
...(typeof p.percent === "number" ? { percent: p.percent } : {}),
operator: p.operator ?? null,
voided: false,
consumedBy: null,
});
}
} else if (r.type === "payment" && Array.isArray(p.validationIds)) {
for (const vid of p.validationIds) consumedBy.set(vid, r.id);
}
}
return applies.map((a) => ({
...a,
voided: voided.has(a.eventId),
consumedBy: consumedBy.get(a.eventId) ?? null,
}));
}
/** The LIVE validations for pricing: applied, not voided, not consumed by a prior
* payment. This is exactly what `priceSession(..., validations)` expects. */
export function liveValidations(db: Db, identity: string): AppliedValidation[] {
return sessionValidations(db, identity).filter((v) => !v.voided && v.consumedBy == null);
}
// --- Apply (shared by the merchant route and the Car Wash module) ----------------
export interface ApplyValidationInput {
programId: string;
identity: string;
/** Username recorded as the applying operator. */
actor: string;
/** fixed mode only: the amount the operator grants (minor units, ≤ maxAmountMinor). */
amountMinor?: number;
/** Car Wash context — required by the wash-only modes (doneTolerance / washPrice), which
* are RESOLVED here into a plain timeCredit / fixed event the pricing fold already
* understands: `washMinutes` = the wash window (order intake → done), NOT the whole
* stay; `priceMinor` = the wash price. */
wash?: { washMinutes: number; priceMinor: number };
}
export type ApplyValidationResult =
| {
ok: true;
eventId: string;
programId: string;
label: string;
mode: string;
minutes?: number | null;
percent?: number | null;
amountMinor?: number;
}
| { ok: false; status: 400 | 404 | 409; error: string };
/**
* Apply a validation program to an open transient session and append the signed
* `validation` event with the RESOLVED values. The decision chain, in order: program
* live + active → open TRANSIENT session → not already carrying a live application of
* this program → per-day cap → fixed-amount bounds. The merchant route adds its own
* program↔user BINDING check before calling this; a module applying its own program
* (Car Wash sponsorship) has no binding — the actor is attributed on the event instead.
* Returns a result object rather than throwing so each caller maps to its own HTTP
* shape. See wiki/concepts/validation-discounts.md.
*/
export async function applyValidation(
db: Db,
eventLog: EventLog,
input: ApplyValidationInput,
): Promise<ApplyValidationResult> {
const { programId, identity, actor } = input;
const program = db
.select()
.from(validationPrograms)
.where(and(eq(validationPrograms.id, programId), isNull(validationPrograms.deletedAt)))
.get();
if (!program || !program.active) return { ok: false, status: 404, error: "program not found or inactive" };
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 { ok: false, status: 404, error: "no session for ticket" };
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, status: 409, error: "subscription sessions cannot be validated" };
}
if (rows.some((r) => r.type === "vehicle_exit" || r.type === "void")) {
return { ok: false, status: 409, error: "session is closed" };
}
if (liveValidations(db, identity).some((v) => v.programId === programId)) {
return { ok: false, status: 409, error: "this program is already applied to the ticket" };
}
// 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 { ok: false, status: 409, error: "daily cap reached for this program" };
}
// Resolve the program into the event's (mode, minutes/percent/amount). The wash-only
// modes become the plain modes the pricing fold knows; `programMode` keeps the original
// on the signed event for audit.
let mode: "comp" | "timeCredit" | "fixed" | "percent";
let minutes: number | undefined;
let percent: number | undefined;
let amountMinor: number | undefined;
switch (program.mode) {
case "comp":
mode = "comp";
break;
case "timeCredit":
mode = "timeCredit";
minutes = program.minutes ?? undefined;
break;
case "percent":
mode = "percent";
percent = program.percent ?? undefined;
break;
case "fixed": {
const a = input.amountMinor;
if (a == null || !Number.isInteger(a) || a <= 0) {
return { ok: false, status: 400, error: "amountMinor (positive integer) required for this program" };
}
if (program.maxAmountMinor != null && a > program.maxAmountMinor) {
return { ok: false, status: 400, error: `amount exceeds the program cap (${program.maxAmountMinor})` };
}
mode = "fixed";
amountMinor = a;
break;
}
case "doneTolerance": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (done time)" };
mode = "timeCredit";
minutes = Math.max(0, input.wash.washMinutes) + Math.max(0, program.minutes ?? 0);
break;
}
case "washPrice": {
if (!input.wash) return { ok: false, status: 400, error: "this program needs a car wash order (price)" };
mode = "fixed";
amountMinor = Math.max(0, input.wash.priceMinor);
break;
}
default:
return { ok: false, status: 400, error: `unknown program mode ${String(program.mode)}` };
}
const ev = await eventLog.append({
type: "validation",
source: "manual",
identity,
payload: {
sessionRef: identity,
programId,
programLabel: program.name,
mode,
...(program.mode !== mode ? { programMode: program.mode } : {}),
...(minutes != null ? { minutes } : {}),
...(percent != null ? { percent } : {}),
...(amountMinor != null ? { amountMinor } : {}),
operator: actor,
},
});
return {
ok: true,
eventId: ev.id,
programId,
label: program.name,
mode,
minutes,
percent,
amountMinor,
};
}