feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s

Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-22 20:13:21 +02:00
parent 65328b8c11
commit 8a437d0c4b
19 changed files with 534 additions and 35 deletions
+3 -1
View File
@@ -418,7 +418,9 @@ export class ExitFlow {
const entry = rows.find((r) => r.type === "vehicle_entry");
if (!entry) return null;
const exited = rows.some((r) => r.type === "vehicle_exit");
// A `void` (cancelled ticket) closes the session like an exit, so a voided ticket
// presented at exit reads as "already closed" — never re-opens. See void-flow.ts.
const exited = rows.some((r) => r.type === "vehicle_exit" || r.type === "void");
let paidAt: string | null = null;
let graceExitMin: number | null = null;
+14
View File
@@ -36,6 +36,14 @@ function exit(identity: string) {
signature: "x", keyId: "test",
}).run();
}
function voidEvt(identity: string) {
idx += 1;
db.insert(ledgerEvents).values({
id: `e${idx}`, index: idx, type: "void",
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
signature: "x", keyId: "test",
}).run();
}
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
}
@@ -57,6 +65,12 @@ describe("occupancyCount", () => {
entry("A"); exit("A"); entry("A");
expect(occupancyCount(db)).toBe(1);
});
it("a voided (cancelled) entry does NOT count inside", () => {
entry("A"); entry("B");
voidEvt("B"); // B's ticket was a misprint — cancelled
expect(occupancyCount(db)).toBe(1);
});
});
describe("getOccupancy — capacity + full gate", () => {
+5 -2
View File
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
.all();
const balance = new Map<string, number>();
for (const r of rows) {
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
// (misprint), so it must not count inside. See void-flow.ts.
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
else if (r.type === "vehicle_exit" || r.type === "void")
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
}
let open = 0;
for (const v of balance.values()) if (v > 0) open += 1;
@@ -68,7 +71,7 @@ export function reservedSubscriberSpots(db: Db): number {
if (pl.permitId == null) continue; // transient
net.set(id, (net.get(id) ?? 0) + 1);
subOf.set(id, pl.permitId);
} else if (r.type === "vehicle_exit") {
} else if (r.type === "vehicle_exit" || r.type === "void") {
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
}
}
+5 -2
View File
@@ -282,7 +282,9 @@ export class PayStation {
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : null;
const exitRow = rows.find((r) => r.type === "vehicle_exit");
// A `void` (cancelled ticket) closes the session like an exit — a voided ticket is no
// longer open and can't be paid/exited. See void-flow.ts.
const exitRow = rows.find((r) => r.type === "vehicle_exit" || r.type === "void");
const open = !exitRow;
let paidAt: string | null = null;
@@ -366,7 +368,8 @@ export class PayStation {
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
byId.set(id, a);
} else if (r.type === "vehicle_exit") {
} else if (r.type === "vehicle_exit" || r.type === "void") {
// A `void` closes the session like an exit — drop it from the active list.
const a = byId.get(id);
if (a) a.exitedAt = r.occurredAt;
} else if (r.type === "payment") {
+7
View File
@@ -183,10 +183,17 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
return p;
}
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
// entry is normally in the same window; this skips it when both are in range.)
const voided = new Set<string>();
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
for (const row of rows) {
const label = bucketLabel(row.occurredAt, tz, q.bucket);
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
if (row.type === "vehicle_entry") {
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
totals.entries++;
p.entries++;
const h = localParts(row.occurredAt, tz).h;
+29
View File
@@ -8,6 +8,7 @@ import {
type PayStation,
} from "../pay-station.js";
import type { ExitFlow } from "../exit-flow.js";
import type { VoidFlow } from "../void-flow.js";
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
import { printPaymentReceipt } from "../booth-print.js";
@@ -36,6 +37,10 @@ interface VoucherBody {
interface ReceiptBody {
identity: string;
}
interface VoidBody {
identity: string;
reason: string;
}
export async function payRoutes(
app: FastifyInstance,
@@ -43,6 +48,7 @@ export async function payRoutes(
payStation: PayStation,
exitFlow: ExitFlow,
shift: ShiftService,
voidFlow: VoidFlow,
): Promise<void> {
// Reads (lookup, active sessions, quote) need session/payment read; the booth
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
@@ -50,6 +56,7 @@ export async function payRoutes(
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
const guard = requirePermission("payment:create");
const readGuard = requirePermission("session:read");
const voidGuard = requirePermission("event:void");
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
// re-open is processed, so every taking is attributed to a shift (one operator's
@@ -125,6 +132,28 @@ export async function payRoutes(
},
);
// Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event
// referencing the entry, with the operator + a REQUIRED reason — the entry itself is
// never edited/deleted (append-only). The session projection folds the void to CLOSED,
// so the voided car stops counting inside and can't be paid/exited. Opens NO barrier
// (the misprinted ticket's car never entered). Gated on event:void + an open shift
// (the booth accountability period). Refusals (subscription / already exited / already
// voided / already paid) → 409. See void-flow.ts, wiki/concepts/append-only-event-chain.md.
app.post<{ Body: VoidBody }>(
"/api/tickets/void",
{ preHandler: [voidGuard, requireShift] },
async (req, reply) => {
const identity = (req.body?.identity ?? "").trim();
const reason = (req.body?.reason ?? "").trim();
if (!identity) return reply.code(400).send({ error: "identity required" });
if (!reason) return reply.code(400).send({ error: "a cancellation reason is required" });
const operator = req.user?.username ?? "unknown";
const res = await voidFlow.voidTicket({ identity, reason, operator });
if (!res.ok) return reply.code(409).send({ error: res.reason });
return reply.code(201).send(res);
},
);
// Quote: what does this session owe right now? (No side effect.)
app.get<{ Querystring: QuoteQuery }>(
"/api/pay/quote",
+5 -1
View File
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
import { EntryFlow } from "./entry-flow.js";
import { EventLog } from "./event-log.js";
import { ExitFlow } from "./exit-flow.js";
import { VoidFlow } from "./void-flow.js";
import { PayStation } from "./pay-station.js";
import { SubscriptionFlow } from "./subscription-flow.js";
import { ShiftService } from "./shift-service.js";
@@ -231,7 +232,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
const payStation = new PayStation(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow, shiftService);
// Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the
// entry; the session projection folds it closed. See void-flow.ts.
const voidFlow = new VoidFlow(db, eventLog, app.log);
await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow);
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
// the pay station prices against. See wiki/concepts/tariff.md.
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { ledgerEvents, eq, type Db } from "@parking/db";
import { VoidFlow } from "./void-flow.js";
import { PayStation } from "./pay-station.js";
import { occupancyCount } from "./occupancy.js";
import type { EventLog } from "./event-log.js";
import { makeLog, silentLogger, seedTariff } from "./test-helpers.js";
// Cancel (void) a wrongly-printed ticket: a SIGNED `void` event that references the entry
// and folds the session CLOSED. The entry itself is never edited/deleted (append-only).
let db: Db;
let close: () => void;
let log: EventLog;
let voidFlow: VoidFlow;
let pay: PayStation;
beforeEach(() => {
const t = createTestDb();
db = t.db;
close = t.close;
log = makeLog(db);
voidFlow = new VoidFlow(db, log, silentLogger());
pay = new PayStation(db, log, silentLogger());
});
afterEach(() => close());
async function enter(identity: string, payload?: Record<string, unknown>) {
await log.append({ type: "vehicle_entry", direction: "entry", identity, payload: payload ?? null });
}
function voids(identity: string) {
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "void");
}
describe("VoidFlow.voidTicket", () => {
it("voids an open transient ticket: signs a void, closes the session, drops occupancy", async () => {
await enter("T1");
expect(occupancyCount(db)).toBe(1);
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(true);
const v = voids("T1");
expect(v).toHaveLength(1);
const pl = v[0]!.payload as Record<string, unknown>;
expect(pl.voidReason).toBe("misprint");
expect(pl.operator).toBe("alice");
expect(pl.voidedEntryRef).toBeDefined();
expect(pl.reasonCode).toBe("void.ticketCancelled");
// Folds: not inside, not an active session, no longer "open".
expect(occupancyCount(db)).toBe(1 - 1);
expect(pay.activeSessions().some((s) => s.identity === "T1")).toBe(false);
expect(pay.lookup("T1").open).toBe(false);
});
it("requires a reason", async () => {
await enter("T1");
const r = await voidFlow.voidTicket({ identity: "T1", reason: " ", operator: "alice" });
expect(r.ok).toBe(false);
expect(voids("T1")).toHaveLength(0);
});
it("refuses an unknown ticket", async () => {
const r = await voidFlow.voidTicket({ identity: "ghost", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/no such ticket/i);
});
it("refuses a second void (already cancelled)", async () => {
await enter("T1");
await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "again", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already cancelled/i);
expect(voids("T1")).toHaveLength(1);
});
it("refuses an already-exited session", async () => {
await enter("T1");
await log.append({ type: "vehicle_exit", direction: "exit", identity: "T1" });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already exited/i);
});
it("refuses a PAID ticket (refund is a separate action)", async () => {
await enter("T1");
await log.append({ type: "payment", identity: "T1", payload: { sessionRef: "T1", amountMinor: 100, currency: "ALL" } });
const r = await voidFlow.voidTicket({ identity: "T1", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/already paid/i);
});
it("refuses a subscription occurrence (closed via its own flow)", async () => {
await enter("SUBSESS-x", { permit: true, permitId: "sub-1" });
const r = await voidFlow.voidTicket({ identity: "SUBSESS-x", reason: "misprint", operator: "alice" });
expect(r.ok).toBe(false);
expect(r.reason).toMatch(/subscription/i);
});
it("keeps the signed chain verifiable after a void", async () => {
seedTariff(db);
await enter("T1");
await voidFlow.voidTicket({ identity: "T1", reason: "test", operator: "alice" });
// The void is the newest signed row; the chain is intact (verifier is exercised by
// the event-log on append — a broken chain would have thrown).
const rows = db.select().from(ledgerEvents).orderBy(ledgerEvents.index).all();
const last = rows[rows.length - 1]!;
expect(last.type).toBe("void");
expect(last.prevHash).toBeTruthy();
expect(last.signature).toBeTruthy();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { eq, ledgerEvents, sessions, type Db } from "@parking/db";
import { reasonPayload } from "@parking/shared";
import type { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js";
// Cancel a wrongly-printed transient ticket by appending a SIGNED `void` event that
// references the entry. The signed ledger is append-only and hash-chained — the
// vehicle_entry is NEVER edited or deleted; the void is a new appended row that the
// session projection folds to CLOSE the session (so a voided car stops counting inside
// and can't be paid/exited). Fully traceable: the operator + a required reason are signed
// into the void payload. A misprinted ticket's car never entered, so voiding opens NO
// barrier. See wiki/concepts/append-only-event-chain.md, parking-session.md.
export interface VoidResult {
readonly ok: boolean;
/** English reason on refusal (localized client-side via the reasonCode it mirrors). */
readonly reason?: string;
/** The void event's identity on success (= the entry identity). */
readonly identity?: string;
}
export class VoidFlow {
readonly #db: Db;
readonly #log: EventLog;
readonly #logger: FastifyBaseLogger;
/** Serialize concurrent voids of the SAME ticket (double-click / double-scan). */
readonly #inFlight = new Set<string>();
constructor(db: Db, log: EventLog, logger: FastifyBaseLogger) {
this.#db = db;
this.#log = log;
this.#logger = logger;
}
/**
* Void (cancel) a transient ticket. Guards, then appends a signed `void`. Refuses:
* unknown ticket, a subscription occurrence (use the subscription flow), an already-
* exited or already-voided session, or a session that has a payment (a paid ticket is a
* refund situation — out of scope). `reason` is REQUIRED (the route enforces non-empty).
*/
async voidTicket(args: { identity: string; reason: string; operator: string }): Promise<VoidResult> {
const identity = args.identity.trim();
const reason = args.reason.trim();
if (!identity) return { ok: false, reason: "missing ticket id" };
if (!reason) return { ok: false, reason: "a cancellation reason is required" };
if (this.#inFlight.has(identity)) return { ok: false, reason: "cancel already in flight" };
this.#inFlight.add(identity);
try {
return await this.#run(identity, reason, args.operator);
} catch (err) {
this.#logger.error(`void-flow failed (${identity}): ${(err as Error).message}`);
return { ok: false, reason: (err as Error).message };
} finally {
this.#inFlight.delete(identity);
}
}
async #run(identity: string, reason: string, operator: string): Promise<VoidResult> {
const rows = this.#db
.select()
.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, reason: "no such ticket (no entry for this id)" };
// Subscriptions are closed via their own flow — ticket-void would double-mean permitId.
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
if (entryPl.permit === true || entryPl.permitId != null) {
return { ok: false, reason: "this is a subscription occurrence — cancel it via the subscription, not a ticket void" };
}
if (rows.some((r) => r.type === "vehicle_exit")) {
return { ok: false, reason: "session already exited — nothing to cancel" };
}
if (rows.some((r) => r.type === "void")) {
return { ok: false, reason: "ticket already cancelled" };
}
// A paid ticket is a refund, not a misprint cancel — out of scope.
if (rows.some((r) => r.type === "payment")) {
return { ok: false, reason: "ticket already paid — a refund is a separate action, not a cancellation" };
}
await this.#log.append({
type: "void",
identity,
// `sessionRef` + `voidedEntryRef` tie the void to the entry; `voidReason` + `operator`
// make it traceable. The reasonCode localizes; the free-text reason is the operator's note.
payload: {
...reasonPayload("void.ticketCancelled", { reason }),
sessionRef: identity,
voidedEntryRef: entry.id,
voidReason: reason,
operator,
},
});
// Best-effort close the projection cache (the ledger fold is the truth either way).
try {
this.#db
.update(sessions)
.set({ exitedAt: new Date().toISOString(), state: "voided" })
.where(eq(sessions.id, identity))
.run();
} catch (err) {
this.#logger.error(`void session-cache close failed for ${identity}: ${(err as Error).message}`);
}
this.#logger.info(`ticket ${identity} cancelled by ${operator}: ${reason}`);
// NO barrier action — the misprinted ticket's car never entered.
return { ok: true, identity };
}
}