feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
CI / check (push) Failing after 56s
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:
+16
-2
@@ -31,9 +31,23 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Set up uv (Python toolchain for @parking/vision)
|
||||||
|
# The vision service is a Python package wired into the Turbo graph via a
|
||||||
|
# package.json shim; its lint/typecheck/test scripts shell to `uv run …`. CI
|
||||||
|
# has no Python by default, so `uv run` would fail with "uv: not found" and
|
||||||
|
# break the whole Turbo run. uv provisions the pinned Python (.python-version)
|
||||||
|
# itself. See wiki/decisions/vision-service-packaging.md.
|
||||||
|
uses: astral-sh/setup-uv@v5
|
||||||
|
|
||||||
|
- name: Sync vision deps
|
||||||
|
# Light deps + the dev group (ruff/mypy/pytest) only — NOT the optional `alpr`
|
||||||
|
# extra (heavy onnx/model stack), which isn't needed to lint/typecheck/test.
|
||||||
|
working-directory: apps/vision
|
||||||
|
run: uv sync --frozen
|
||||||
|
|
||||||
- name: Build + lint (Turbo)
|
- name: Build + lint (Turbo)
|
||||||
# Covers tsc typecheck, vite build, and i18n catalog type-parity (a missing
|
# Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en
|
||||||
# sq/en key fails the build). 14 tasks across the workspace.
|
# key fails the build), AND the vision service's ruff lint via uv.
|
||||||
run: pnpm turbo run build lint
|
run: pnpm turbo run build lint
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
|
|||||||
@@ -418,7 +418,9 @@ export class ExitFlow {
|
|||||||
|
|
||||||
const entry = rows.find((r) => r.type === "vehicle_entry");
|
const entry = rows.find((r) => r.type === "vehicle_entry");
|
||||||
if (!entry) return null;
|
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 paidAt: string | null = null;
|
||||||
let graceExitMin: number | null = null;
|
let graceExitMin: number | null = null;
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ function exit(identity: string) {
|
|||||||
signature: "x", keyId: "test",
|
signature: "x", keyId: "test",
|
||||||
}).run();
|
}).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>) {
|
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||||
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
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");
|
entry("A"); exit("A"); entry("A");
|
||||||
expect(occupancyCount(db)).toBe(1);
|
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", () => {
|
describe("getOccupancy — capacity + full gate", () => {
|
||||||
|
|||||||
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
|
|||||||
.all();
|
.all();
|
||||||
const balance = new Map<string, number>();
|
const balance = new Map<string, number>();
|
||||||
for (const r of rows) {
|
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);
|
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;
|
let open = 0;
|
||||||
for (const v of balance.values()) if (v > 0) open += 1;
|
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
|
if (pl.permitId == null) continue; // transient
|
||||||
net.set(id, (net.get(id) ?? 0) + 1);
|
net.set(id, (net.get(id) ?? 0) + 1);
|
||||||
subOf.set(id, pl.permitId);
|
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);
|
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -282,7 +282,9 @@ export class PayStation {
|
|||||||
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
const entryPl = (entry.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
const isSubscription = entryPl.permit === true || entryPl.permitId != null;
|
||||||
const subscriptionId = isSubscription ? (entryPl.permitId ?? null) : 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;
|
const open = !exitRow;
|
||||||
|
|
||||||
let paidAt: string | null = null;
|
let paidAt: string | null = null;
|
||||||
@@ -366,7 +368,8 @@ export class PayStation {
|
|||||||
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
const pl = (r.payload ?? {}) as { permit?: boolean; permitId?: string };
|
||||||
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
if (pl.permit === true || pl.permitId) a.subscriptionId = pl.permitId ?? null;
|
||||||
byId.set(id, a);
|
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);
|
const a = byId.get(id);
|
||||||
if (a) a.exitedAt = r.occurredAt;
|
if (a) a.exitedAt = r.occurredAt;
|
||||||
} else if (r.type === "payment") {
|
} else if (r.type === "payment") {
|
||||||
|
|||||||
@@ -183,10 +183,17 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
|||||||
return p;
|
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) {
|
for (const row of rows) {
|
||||||
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||||
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||||
if (row.type === "vehicle_entry") {
|
if (row.type === "vehicle_entry") {
|
||||||
|
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||||
totals.entries++;
|
totals.entries++;
|
||||||
p.entries++;
|
p.entries++;
|
||||||
const h = localParts(row.occurredAt, tz).h;
|
const h = localParts(row.occurredAt, tz).h;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type PayStation,
|
type PayStation,
|
||||||
} from "../pay-station.js";
|
} from "../pay-station.js";
|
||||||
import type { ExitFlow } from "../exit-flow.js";
|
import type { ExitFlow } from "../exit-flow.js";
|
||||||
|
import type { VoidFlow } from "../void-flow.js";
|
||||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||||
import { printPaymentReceipt } from "../booth-print.js";
|
import { printPaymentReceipt } from "../booth-print.js";
|
||||||
|
|
||||||
@@ -36,6 +37,10 @@ interface VoucherBody {
|
|||||||
interface ReceiptBody {
|
interface ReceiptBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
}
|
}
|
||||||
|
interface VoidBody {
|
||||||
|
identity: string;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function payRoutes(
|
export async function payRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
@@ -43,6 +48,7 @@ export async function payRoutes(
|
|||||||
payStation: PayStation,
|
payStation: PayStation,
|
||||||
exitFlow: ExitFlow,
|
exitFlow: ExitFlow,
|
||||||
shift: ShiftService,
|
shift: ShiftService,
|
||||||
|
voidFlow: VoidFlow,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
||||||
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
// 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.
|
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
||||||
const guard = requirePermission("payment:create");
|
const guard = requirePermission("payment:create");
|
||||||
const readGuard = requirePermission("session:read");
|
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/
|
// 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
|
// 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.)
|
// Quote: what does this session owe right now? (No side effect.)
|
||||||
app.get<{ Querystring: QuoteQuery }>(
|
app.get<{ Querystring: QuoteQuery }>(
|
||||||
"/api/pay/quote",
|
"/api/pay/quote",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
|
|||||||
import { EntryFlow } from "./entry-flow.js";
|
import { EntryFlow } from "./entry-flow.js";
|
||||||
import { EventLog } from "./event-log.js";
|
import { EventLog } from "./event-log.js";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
|
import { VoidFlow } from "./void-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||||
import { ShiftService } from "./shift-service.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
|
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||||
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||||
const payStation = new PayStation(db, eventLog, app.log);
|
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
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
boothExit,
|
boothExit,
|
||||||
|
can,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
lookupSession,
|
lookupSession,
|
||||||
openShift,
|
openShift,
|
||||||
@@ -11,8 +12,10 @@ import {
|
|||||||
printReceipt,
|
printReceipt,
|
||||||
printVoucher,
|
printVoucher,
|
||||||
reopenBarrier,
|
reopenBarrier,
|
||||||
|
voidTicket,
|
||||||
type SessionLookup,
|
type SessionLookup,
|
||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
import { formatDuration, formatMoney, formatTime, formatRelativeDateTime } from "./lib/format.js";
|
||||||
@@ -52,6 +55,11 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
// For a subscriber WINDOW CHARGE, payment and the barrier open are two steps: pay
|
||||||
// first, then the modal reveals "Open barrier". This flips true once paid.
|
// first, then the modal reveals "Open barrier". This flips true once paid.
|
||||||
const [windowPaid, setWindowPaid] = useState(false);
|
const [windowPaid, setWindowPaid] = useState(false);
|
||||||
|
// Cancel (void) a wrongly-printed ticket: a small reason prompt, then a signed void.
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const canVoid = can(user, "event:void");
|
||||||
|
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||||
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
// Checkbox default comes from config the first time it loads; operator can toggle.
|
// Checkbox default comes from config the first time it loads; operator can toggle.
|
||||||
@@ -127,6 +135,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A wrongly-printed ticket is cancellable only while it's a TRANSIENT, UNPAID, OPEN
|
||||||
|
// session (a subscription is closed via its own flow; a paid ticket is a refund). The
|
||||||
|
// server enforces all of this too; the UI just hides the action when it can't apply.
|
||||||
|
const canCancel = !!(canVoid && shiftReady && s?.found && s.open && !isSubscription && !alreadyPaid);
|
||||||
|
|
||||||
|
async function handleVoidTicket() {
|
||||||
|
const reason = voidReason.trim();
|
||||||
|
if (!reason) return;
|
||||||
|
setError(null);
|
||||||
|
setPhase("finishing");
|
||||||
|
try {
|
||||||
|
await voidTicket(identity, reason);
|
||||||
|
setResult(t("pay.ticketCancelled"));
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.activeSessions });
|
||||||
|
setPhase("done");
|
||||||
|
} catch (e) {
|
||||||
|
setError((e as Error).message);
|
||||||
|
setPhase("error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleReprintReceipt() {
|
async function handleReprintReceipt() {
|
||||||
setReprinting(true);
|
setReprinting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Cancel-ticket reason prompt (revealed by the "Cancel ticket" button).
|
||||||
|
A few presets + free text; a reason is REQUIRED. Voiding appends a
|
||||||
|
signed `void` event — the entry is never edited. */}
|
||||||
|
{voiding && phase !== "done" && (
|
||||||
|
<div className="rounded-term border border-term-amber/50 bg-term-amber/5 px-3 py-2">
|
||||||
|
<div className="text-[11px] font-semibold uppercase tracking-wider text-term-amber">
|
||||||
|
{t("pay.cancelTicketTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[12px] text-term-text">{t("pay.cancelTicketHint")}</div>
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{(["misprint", "test", "wrongVehicle"] as const).map((k) => (
|
||||||
|
<button
|
||||||
|
key={k}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVoidReason(t(`pay.cancelReason.${k}`))}
|
||||||
|
className={voidReason === t(`pay.cancelReason.${k}`) ? "btn btn-primary btn-sm" : "btn btn-sm"}
|
||||||
|
>
|
||||||
|
{t(`pay.cancelReason.${k}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input mt-2 w-full"
|
||||||
|
value={voidReason}
|
||||||
|
onChange={(e) => setVoidReason(e.target.value)}
|
||||||
|
placeholder={t("pay.cancelReasonPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
{error && <div className="rounded-term border border-term-red px-3 py-2 text-term-red">{error}</div>}
|
||||||
{result && (
|
{result && (
|
||||||
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
<div className="rounded-term border border-term-green px-3 py-2 text-term-green">{result}</div>
|
||||||
@@ -439,7 +500,29 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.assistOpenReveal")}
|
{t("pay.assistOpenReveal")}
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
) : voiding ? (
|
||||||
|
// Cancel-ticket confirm (reason prompt is shown above).
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleVoidTicket}
|
||||||
|
disabled={!voidReason.trim() || phase === "finishing"}
|
||||||
|
className="btn btn-danger btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "finishing" ? t("pay.cancelling") : t("pay.confirmCancelTicket")}
|
||||||
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Cancel a wrongly-printed ticket (transient, unpaid, open only;
|
||||||
|
gated on event:void). Reveals the reason prompt above. */}
|
||||||
|
{canCancel && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVoiding(true)}
|
||||||
|
className="btn btn-ghost btn-sm text-term-red"
|
||||||
|
>
|
||||||
|
{t("pay.cancelTicket")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handlePayAndExit}
|
onClick={handlePayAndExit}
|
||||||
@@ -460,6 +543,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
? t("pay.payAndVoucher")
|
? t("pay.payAndVoucher")
|
||||||
: t("pay.payAndOpen")}
|
: t("pay.payAndOpen")}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1085,6 +1085,13 @@ export function paySession(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event with
|
||||||
|
* the operator + a required reason; the entry itself is never edited (append-only).
|
||||||
|
* Refuses a subscription / already-exited / already-voided / paid ticket (409). */
|
||||||
|
export function voidTicket(identity: string, reason: string): Promise<{ ok: boolean; identity?: string }> {
|
||||||
|
return apiFetch("/api/tickets/void", { method: "POST", body: JSON.stringify({ identity, reason }) });
|
||||||
|
}
|
||||||
|
|
||||||
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
/** Booth-driven exit result. `opened:false` = exit recorded but barrier didn't
|
||||||
* open (payment stands; operator opens manually). */
|
* open (payment stands; operator opens manually). */
|
||||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
||||||
|
|||||||
@@ -156,6 +156,7 @@ export const en: Catalog = {
|
|||||||
evtCashIn: "PAY-IN",
|
evtCashIn: "PAY-IN",
|
||||||
evtCashOut: "PAY-OUT",
|
evtCashOut: "PAY-OUT",
|
||||||
evtAnomaly: "ANOMALY",
|
evtAnomaly: "ANOMALY",
|
||||||
|
evtRefused: "REFUSED",
|
||||||
// live-feed event detail line + classification badges (computed from payload)
|
// live-feed event detail line + classification badges (computed from payload)
|
||||||
evtNoReason: "no reason recorded",
|
evtNoReason: "no reason recorded",
|
||||||
badgeEntryRefused: "entry refused",
|
badgeEntryRefused: "entry refused",
|
||||||
@@ -224,6 +225,7 @@ export const en: Catalog = {
|
|||||||
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "Subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
"sub.refused.atCapacity": "Subscription refused — at capacity ({{inUse}}/{{max}} cars in)",
|
||||||
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
"sub.refused.unpaidWindow": "Exit refused — out-of-window charge unpaid ({{amount}} {{currency}}); pay at the booth",
|
||||||
|
"void.ticketCancelled": "Ticket cancelled — {{reason}}",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tariff",
|
title: "Tariff",
|
||||||
@@ -799,6 +801,18 @@ export const en: Catalog = {
|
|||||||
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
receiptReprinted: "Receipt reprinted on {{printer}}.",
|
||||||
reprintReceipt: "Reprint receipt",
|
reprintReceipt: "Reprint receipt",
|
||||||
reprinting: "printing…",
|
reprinting: "printing…",
|
||||||
|
cancelTicket: "Cancel ticket",
|
||||||
|
cancelTicketTitle: "Cancel this ticket",
|
||||||
|
cancelTicketHint: "Cancels a wrongly-printed ticket. A signed record is kept (operator + reason); the original entry is never deleted.",
|
||||||
|
cancelReason: {
|
||||||
|
misprint: "Misprint",
|
||||||
|
test: "Test",
|
||||||
|
wrongVehicle: "Wrong vehicle",
|
||||||
|
},
|
||||||
|
cancelReasonPlaceholder: "Reason for cancelling (required)…",
|
||||||
|
confirmCancelTicket: "Confirm cancellation",
|
||||||
|
cancelling: "cancelling…",
|
||||||
|
ticketCancelled: "Ticket cancelled.",
|
||||||
noSnapshots: "no snapshots",
|
noSnapshots: "no snapshots",
|
||||||
loadingSnapshots: "loading snapshots…",
|
loadingSnapshots: "loading snapshots…",
|
||||||
snapEntry: "entry",
|
snapEntry: "entry",
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ export const sq = {
|
|||||||
evtCashIn: "ARKËTIM",
|
evtCashIn: "ARKËTIM",
|
||||||
evtCashOut: "PAGESË",
|
evtCashOut: "PAGESË",
|
||||||
evtAnomaly: "ANOMALI",
|
evtAnomaly: "ANOMALI",
|
||||||
|
evtRefused: "REFUZUAR",
|
||||||
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
// rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload)
|
||||||
evtNoReason: "pa arsye të regjistruar",
|
evtNoReason: "pa arsye të regjistruar",
|
||||||
badgeEntryRefused: "hyrje e refuzuar",
|
badgeEntryRefused: "hyrje e refuzuar",
|
||||||
@@ -227,6 +228,7 @@ export const sq = {
|
|||||||
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
"sub.refused.noSession": "Dalje me abonim pa sesion të hapur (tashmë jashtë / nuk ka hyrë kurrë)",
|
||||||
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
"sub.refused.atCapacity": "Abonimi u refuzua — në kapacitet ({{inUse}}/{{max}} makina brenda)",
|
||||||
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
"sub.refused.unpaidWindow": "Dalja u refuzua — detyrim jashtë orarit i papaguar ({{amount}} {{currency}}); paguaje në kabinë",
|
||||||
|
"void.ticketCancelled": "Bileta u anulua — {{reason}}",
|
||||||
},
|
},
|
||||||
tariff: {
|
tariff: {
|
||||||
title: "Tarifa",
|
title: "Tarifa",
|
||||||
@@ -813,6 +815,18 @@ export const sq = {
|
|||||||
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
receiptReprinted: "Fatura u riprintua në {{printer}}.",
|
||||||
reprintReceipt: "Riprinto faturën",
|
reprintReceipt: "Riprinto faturën",
|
||||||
reprinting: "duke printuar…",
|
reprinting: "duke printuar…",
|
||||||
|
cancelTicket: "Anulo biletën",
|
||||||
|
cancelTicketTitle: "Anulo këtë biletë",
|
||||||
|
cancelTicketHint: "Anulon një biletë të printuar gabimisht. Ruhet një gjurmë e nënshkruar (operatori + arsyeja); hyrja origjinale nuk fshihet kurrë.",
|
||||||
|
cancelReason: {
|
||||||
|
misprint: "Printim i gabuar",
|
||||||
|
test: "Test",
|
||||||
|
wrongVehicle: "Automjet i gabuar",
|
||||||
|
},
|
||||||
|
cancelReasonPlaceholder: "Arsyeja e anulimit (e detyrueshme)…",
|
||||||
|
confirmCancelTicket: "Konfirmo anulimin",
|
||||||
|
cancelling: "duke anuluar…",
|
||||||
|
ticketCancelled: "Bileta u anulua.",
|
||||||
// snapshots
|
// snapshots
|
||||||
noSnapshots: "asnjë foto",
|
noSnapshots: "asnjë foto",
|
||||||
loadingSnapshots: "duke ngarkuar fotot…",
|
loadingSnapshots: "duke ngarkuar fotot…",
|
||||||
|
|||||||
@@ -45,7 +45,9 @@ export interface RouterContext {
|
|||||||
setUser: (u: SessionUser | null) => void;
|
setUser: (u: SessionUser | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRouteWithContext<RouterContext>()({
|
// Exported so a deep component (e.g. the booth pay modal) can read the signed-in user
|
||||||
|
// from route context without prop-threading through every layer.
|
||||||
|
export const rootRoute = createRootRouteWithContext<RouterContext>()({
|
||||||
component: RootLayout,
|
component: RootLayout,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,27 @@ export const EVENT_STYLE: Record<string, { labelKey: string; color: string }> =
|
|||||||
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
anomaly: { labelKey: "booth.evtAnomaly", color: "text-term-red" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A refused-ACTION event is a benign WARNING, not a red-flag anomaly. The ledger type is
|
||||||
|
* `anomaly` for both (immutable history), but a refused exit / refused subscription /
|
||||||
|
* refused entry (e.g. a double card-scan, an at-capacity subscriber, an already-closed
|
||||||
|
* session) is an EXPECTED outcome — not fraud. We classify it from the payload flags the
|
||||||
|
* flows already sign (`exitRefused` / `entryRefused` / `permitRefused`) and show it as an
|
||||||
|
* amber "REFUZUAR / REFUSED" warning, reserving red "ANOMALI" for genuine anomalies
|
||||||
|
* (barrier-open failure, opened-without-ticket, …). Display-only — no ledger change.
|
||||||
|
*/
|
||||||
|
export function isRefusedWarning(e: LedgerEvent): boolean {
|
||||||
|
if (e.type !== "anomaly") return false;
|
||||||
|
const p = e.payload;
|
||||||
|
return !!(p && (p.exitRefused || p.entryRefused || p.permitRefused));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The label key + colour to render for an event, applying the refused-warning split. */
|
||||||
|
export function eventStyleFor(e: LedgerEvent): { labelKey: string; color: string } {
|
||||||
|
if (isRefusedWarning(e)) return { labelKey: "booth.evtRefused", color: "text-term-amber" };
|
||||||
|
return EVENT_STYLE[e.type] ?? { labelKey: "", color: "text-term-text" };
|
||||||
|
}
|
||||||
|
|
||||||
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
/** Local time-of-day, terminal style. Defensive against a bad timestamp. */
|
||||||
function hhmmss(iso: string): string {
|
function hhmmss(iso: string): string {
|
||||||
const d = new Date(iso);
|
const d = new Date(iso);
|
||||||
@@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string {
|
|||||||
* its own row, indented under the identity column. */
|
* its own row, indented under the identity column. */
|
||||||
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = eventStyleFor(e);
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
const isAnomaly = e.type === "anomaly";
|
// A refused-action event is a benign WARNING (amber), distinct from a genuine red
|
||||||
|
// anomaly. Only true anomalies get the red row tint + the "no reason" fallback.
|
||||||
|
const refusedWarning = isRefusedWarning(e);
|
||||||
|
const isAnomaly = e.type === "anomaly" && !refusedWarning;
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const amount = paymentSummary(p);
|
const amount = paymentSummary(p);
|
||||||
@@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => onOpen(e)}
|
onClick={() => onOpen(e)}
|
||||||
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
className={`grid w-full grid-cols-[auto_5rem_1fr_auto] items-center gap-x-3 gap-y-0.5 border-b border-term-border/50 px-1 py-1 text-left text-[12px] tabular-nums hover:bg-term-panel-2 ${
|
||||||
isAnomaly ? "bg-term-red/5" : ""
|
isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
<span className="text-term-muted">{hhmmss(e.occurredAt)}</span>
|
||||||
<span className={`shrink-0 font-semibold ${style?.color ?? "text-term-text"}`}>{label}</span>
|
<span className={`shrink-0 font-semibold ${style.color}`}>{label}</span>
|
||||||
<span className="flex min-w-0 items-center gap-2">
|
<span className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
<span className="truncate text-term-text">{displayIdentity(e)}</span>
|
||||||
{e.plate && (
|
{e.plate && (
|
||||||
@@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode })
|
|||||||
* this only DISPLAYS the signed record. */
|
* this only DISPLAYS the signed record. */
|
||||||
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const style = EVENT_STYLE[e.type];
|
const style = eventStyleFor(e);
|
||||||
const label = style ? t(style.labelKey) : e.type.toUpperCase();
|
const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase();
|
||||||
const p = e.payload;
|
const p = e.payload;
|
||||||
const reason = renderReason(p, t);
|
const reason = renderReason(p, t);
|
||||||
const badges = eventBadges(p);
|
const badges = eventBadges(p);
|
||||||
const isAnomaly = e.type === "anomaly";
|
const isAnomaly = e.type === "anomaly" && !isRefusedWarning(e);
|
||||||
|
|
||||||
// Pretty money for any minor-unit amount in the payload.
|
// Pretty money for any minor-unit amount in the payload.
|
||||||
const money =
|
const money =
|
||||||
@@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
{/* Headline: the type + localized reason, prominent for anomalies. */}
|
||||||
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
<div className={`rounded-term border p-3 ${isAnomaly ? "border-term-red/50 bg-term-red/5" : "border-term-border bg-term-panel-2"}`}>
|
||||||
<div className={`text-sm font-bold uppercase tracking-widest ${style?.color ?? "text-term-text"}`}>{label}</div>
|
<div className={`text-sm font-bold uppercase tracking-widest ${style.color}`}>{label}</div>
|
||||||
{(reason || money) && (
|
{(reason || money) && (
|
||||||
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
<div className={`mt-1 text-[13px] ${isAnomaly ? "text-term-red/90" : "text-term-text"}`}>
|
||||||
{reason ?? money}
|
{reason ?? money}
|
||||||
|
|||||||
@@ -347,6 +347,8 @@ export const REASON_CODES = [
|
|||||||
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
// a subscriber owes an out-of-window (early-entry / late-exit) transient charge and
|
||||||
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
// hasn't paid it — exit is gated until they settle (the tariff-bridge gate).
|
||||||
"sub.refused.unpaidWindow",
|
"sub.refused.unpaidWindow",
|
||||||
|
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
|
||||||
|
"void.ticketCancelled",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type ReasonCode = (typeof REASON_CODES)[number];
|
export type ReasonCode = (typeof REASON_CODES)[number];
|
||||||
@@ -375,6 +377,7 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||||||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
||||||
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
"sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth",
|
||||||
|
"void.ticketCancelled": "ticket cancelled — {reason}",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -73,6 +73,34 @@ States, as derived from events:
|
|||||||
|
|
||||||
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
Permit sessions skip PAID: a valid [[subscription]] at exit is itself the authorization to close.
|
||||||
|
|
||||||
|
### Cancel a wrongly-printed ticket — BUILT (2026-06-22)
|
||||||
|
|
||||||
|
A ticket printed in error (misprint, test press, wrong vehicle) is cancelled by appending a **signed
|
||||||
|
`void`** event — the `vehicle_entry` is NEVER edited or deleted (append-only; [[append-only-event-chain]]).
|
||||||
|
`apps/server/src/void-flow.ts` (`VoidFlow`) appends `{ type:"void", identity, payload:{ sessionRef,
|
||||||
|
voidedEntryRef:<entry id>, voidReason, operator, reasonCode:"void.ticketCancelled" } }`. Traceable: the
|
||||||
|
operator (from the JWT) + a **REQUIRED reason** are signed in. Route `POST /api/tickets/void` gated on
|
||||||
|
`event:void` + an open shift. **No barrier action** — a misprinted ticket's car never entered.
|
||||||
|
|
||||||
|
- **Refused** for: a subscription occurrence (closed via its own flow), an already-exited session, an
|
||||||
|
already-voided ticket, or a **paid** ticket (a refund is a separate, out-of-scope action) → 409.
|
||||||
|
- **The void folds the session CLOSED everywhere it's counted** — this is the correctness crux. A
|
||||||
|
`void` decrements like a `vehicle_exit` in `occupancy.ts` (count + reserved-spots), and reads as
|
||||||
|
closed in `pay-station.ts` (`lookup`/`activeSessions`) and `exit-flow.ts` (`#sessionFor`), and is
|
||||||
|
excluded from the `reports.ts` entries stat. So a voided car stops occupying a spot, can't be
|
||||||
|
paid/exited, and doesn't inflate "cars entered". The booth surfaces it in the pay/exit lookup modal
|
||||||
|
(transient + unpaid + open only).
|
||||||
|
|
||||||
|
### Live-feed display: refused-action WARNING vs. genuine ANOMALY
|
||||||
|
|
||||||
|
The signed ledger `type:"anomaly"` is overloaded: it carries both benign **refused-action** events
|
||||||
|
(`exitRefused` / `entryRefused` / `permitRefused` — e.g. a double card-scan, an at-capacity
|
||||||
|
subscriber, an exit on an already-closed session) AND genuine red-flags (barrier-open failure,
|
||||||
|
opened-without-ticket). The booth feed now classifies from those existing payload flags
|
||||||
|
(`event-detail.tsx isRefusedWarning`) and shows the refused ones as an amber **REFUZUAR / REFUSED**
|
||||||
|
warning, reserving red **ANOMALI** for true anomalies. **Display-only** — no ledger type/data change,
|
||||||
|
so historical events reclassify correctly too.
|
||||||
|
|
||||||
## Edge cases the model must name (not yet designed in full)
|
## Edge cases the model must name (not yet designed in full)
|
||||||
|
|
||||||
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
- **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely
|
||||||
|
|||||||
+17
@@ -1446,3 +1446,20 @@ read flows are constructed before the hik-alarm registration. New env: `VISION_E
|
|||||||
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
`ANPR_DEBOUNCE_MS`. Tests: `anpr-entry.test.ts` (7) + `hikvision-alarm.test.ts` wiring (3); full
|
||||||
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
server suite 130 green, monorepo build+lint green. Flipped [[lane-presence-and-anpr-entry]] §2 +
|
||||||
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
table row PLANNED->BUILT; updated [[lpr-camera]]. STILL OPEN: booth-PC ANPR latency (~2026-06-23).
|
||||||
|
|
||||||
|
## [2026-06-22] build | Cancel (void) a wrongly-printed ticket + refused-vs-anomaly display split
|
||||||
|
Operator need: cancel a misprinted/test/wrong-vehicle ticket, traceably. Built it as a SIGNED `void`
|
||||||
|
(append-only — the vehicle_entry is never touched): new `apps/server/src/void-flow.ts` (`VoidFlow`)
|
||||||
|
appends void{ voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
|
||||||
|
POST /api/tickets/void gated event:void + open shift; operator from JWT, reason REQUIRED. Refuses a
|
||||||
|
subscription / already-exited / already-voided / PAID ticket (refund = out of scope). The CRUX: a
|
||||||
|
void must fold the session CLOSED everywhere it's counted — done in occupancy.ts (count +
|
||||||
|
reserved-spots, −1 like an exit), pay-station.ts (lookup/activeSessions), exit-flow.ts (#sessionFor),
|
||||||
|
and reports.ts (excluded from the entries stat). No barrier action (the car never entered). Booth UI:
|
||||||
|
"Cancel ticket" in the pay/exit lookup modal (transient + unpaid + open; gated on event:void) with a
|
||||||
|
preset-or-free reason prompt. Part 2 (display-only): the Live feed mislabeled benign refused-action
|
||||||
|
events (exitRefused/entryRefused/permitRefused — e.g. a double card-scan) as red ANOMALI; now
|
||||||
|
classified via event-detail.tsx isRefusedWarning and shown as amber REFUZUAR/REFUSED, reserving red
|
||||||
|
ANOMALI for genuine red-flags. No ledger change → historical events reclassify too. New reason code
|
||||||
|
void.ticketCancelled (shared + both web catalogs). Tests: void-flow.test.ts (8) + occupancy void fold;
|
||||||
|
141 server + 87 shared green; build+lint (TS + i18n parity) green. Updated [[parking-session]].
|
||||||
|
|||||||
Reference in New Issue
Block a user