diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 6c50bae..55fcae8 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,9 +31,23 @@ jobs: - name: Install dependencies 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) - # Covers tsc typecheck, vite build, and i18n catalog type-parity (a missing - # sq/en key fails the build). 14 tasks across the workspace. + # Covers tsc typecheck, vite build, i18n catalog type-parity (a missing sq/en + # key fails the build), AND the vision service's ruff lint via uv. run: pnpm turbo run build lint - name: Test diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 5f19815..24f6bd3 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -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; diff --git a/apps/server/src/occupancy.test.ts b/apps/server/src/occupancy.test.ts index 90065a8..868681c 100644 --- a/apps/server/src/occupancy.test.ts +++ b/apps/server/src/occupancy.test.ts @@ -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) { 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", () => { diff --git a/apps/server/src/occupancy.ts b/apps/server/src/occupancy.ts index f59c018..9cb39e5 100644 --- a/apps/server/src/occupancy.ts +++ b/apps/server/src/occupancy.ts @@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number { .all(); const balance = new Map(); 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); } } diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index bd9e02a..e8d87a3 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -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") { diff --git a/apps/server/src/reports.ts b/apps/server/src/reports.ts index ef6f7b8..b8830ba 100644 --- a/apps/server/src/reports.ts +++ b/apps/server/src/reports.ts @@ -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(); + 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; diff --git a/apps/server/src/routes/pay.ts b/apps/server/src/routes/pay.ts index d683f30..c7adc85 100644 --- a/apps/server/src/routes/pay.ts +++ b/apps/server/src/routes/pay.ts @@ -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 { // 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", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 280505a..f89d35c 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -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 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) { + 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; + 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(); + }); +}); diff --git a/apps/server/src/void-flow.ts b/apps/server/src/void-flow.ts new file mode 100644 index 0000000..c0e9134 --- /dev/null +++ b/apps/server/src/void-flow.ts @@ -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(); + + 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 { + 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 { + 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 }; + } +} diff --git a/apps/web/src/BoothPayModal.tsx b/apps/web/src/BoothPayModal.tsx index 0c3d1de..249c7c1 100644 --- a/apps/web/src/BoothPayModal.tsx +++ b/apps/web/src/BoothPayModal.tsx @@ -4,6 +4,7 @@ import * as Dialog from "@radix-ui/react-dialog"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { boothExit, + can, fetchSiteConfig, lookupSession, openShift, @@ -11,8 +12,10 @@ import { printReceipt, printVoucher, reopenBarrier, + voidTicket, type SessionLookup, } from "./api.js"; +import { rootRoute } from "./router.js"; import { qk } from "./lib/query.js"; import { useShift } from "./lib/use-shift.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 // first, then the modal reveals "Open barrier". This flips true once paid. 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; // 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() { setReprinting(true); setError(null); @@ -365,6 +396,36 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose )} + {/* 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" && ( +
+
+ {t("pay.cancelTicketTitle")} +
+
{t("pay.cancelTicketHint")}
+
+ {(["misprint", "test", "wrongVehicle"] as const).map((k) => ( + + ))} +
+ setVoidReason(e.target.value)} + placeholder={t("pay.cancelReasonPlaceholder")} + /> +
+ )} + {error &&
{error}
} {result && (
{result}
@@ -439,27 +500,50 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose {t("pay.assistOpenReveal")} ) - ) : ( + ) : voiding ? ( + // Cancel-ticket confirm (reason prompt is shown above). + ) : ( + <> + {/* Cancel a wrongly-printed ticket (transient, unpaid, open only; + gated on event:void). Reveals the reason prompt above. */} + {canCancel && ( + + )} + + )} )} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 5400a68..7558393 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 * open (payment stands; operator opens manually). */ export type BoothExitResult = { ok: true; opened: boolean; reason?: string }; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 362a990..6884338 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -156,6 +156,7 @@ export const en: Catalog = { evtCashIn: "PAY-IN", evtCashOut: "PAY-OUT", evtAnomaly: "ANOMALY", + evtRefused: "REFUSED", // live-feed event detail line + classification badges (computed from payload) evtNoReason: "no reason recorded", 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.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", + "void.ticketCancelled": "Ticket cancelled — {{reason}}", }, tariff: { title: "Tariff", @@ -799,6 +801,18 @@ export const en: Catalog = { receiptReprinted: "Receipt reprinted on {{printer}}.", reprintReceipt: "Reprint receipt", 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", loadingSnapshots: "loading snapshots…", snapEntry: "entry", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index c35868a..0d71e99 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -160,6 +160,7 @@ export const sq = { evtCashIn: "ARKËTIM", evtCashOut: "PAGESË", evtAnomaly: "ANOMALI", + evtRefused: "REFUZUAR", // rreshti i detajeve të eventit live + etiketat e klasifikimit (nga payload) evtNoReason: "pa arsye të regjistruar", 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.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ë", + "void.ticketCancelled": "Bileta u anulua — {{reason}}", }, tariff: { title: "Tarifa", @@ -813,6 +815,18 @@ export const sq = { receiptReprinted: "Fatura u riprintua në {{printer}}.", reprintReceipt: "Riprinto faturën", 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 noSnapshots: "asnjë foto", loadingSnapshots: "duke ngarkuar fotot…", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 6c42a33..bb25d4d 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -45,7 +45,9 @@ export interface RouterContext { setUser: (u: SessionUser | null) => void; } -const rootRoute = createRootRouteWithContext()({ +// 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()({ component: RootLayout, }); diff --git a/apps/web/src/ui/event-detail.tsx b/apps/web/src/ui/event-detail.tsx index ec223da..250e11e 100644 --- a/apps/web/src/ui/event-detail.tsx +++ b/apps/web/src/ui/event-detail.tsx @@ -26,6 +26,27 @@ export const EVENT_STYLE: Record = 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. */ function hhmmss(iso: string): string { const d = new Date(iso); @@ -79,9 +100,12 @@ export function displayIdentity(e: LedgerEvent): string { * its own row, indented under the identity column. */ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEvent) => void }) { const { t } = useTranslation(); - const style = EVENT_STYLE[e.type]; - const label = style ? t(style.labelKey) : e.type.toUpperCase(); - const isAnomaly = e.type === "anomaly"; + const style = eventStyleFor(e); + const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase(); + // 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 reason = renderReason(p, t); const amount = paymentSummary(p); @@ -95,11 +119,11 @@ export function EventRow({ e, onOpen }: { e: LedgerEvent; onOpen: (e: LedgerEven type="button" 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 ${ - isAnomaly ? "bg-term-red/5" : "" + isAnomaly ? "bg-term-red/5" : refusedWarning ? "bg-term-amber/5" : "" }`} > {hhmmss(e.occurredAt)} - {label} + {label} {displayIdentity(e)} {e.plate && ( @@ -152,12 +176,12 @@ function DetailRow({ label, children }: { label: string; children: ReactNode }) * this only DISPLAYS the signed record. */ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () => void }) { const { t } = useTranslation(); - const style = EVENT_STYLE[e.type]; - const label = style ? t(style.labelKey) : e.type.toUpperCase(); + const style = eventStyleFor(e); + const label = style.labelKey ? t(style.labelKey) : e.type.toUpperCase(); const p = e.payload; const reason = renderReason(p, t); 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. const money = @@ -177,7 +201,7 @@ export function EventDetailModal({ e, onClose }: { e: LedgerEvent; onClose: () =
{/* Headline: the type + localized reason, prominent for anomalies. */}
-
{label}
+
{label}
{(reason || money) && (
{reason ?? money} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 376e8ef..042d6a5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -347,6 +347,8 @@ export const REASON_CODES = [ // 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). "sub.refused.unpaidWindow", + // a wrongly-printed transient ticket cancelled by the operator (signed void event). + "void.ticketCancelled", ] as const; export type ReasonCode = (typeof REASON_CODES)[number]; @@ -375,6 +377,7 @@ export const REASON_EN: Record = { "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.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth", + "void.ticketCancelled": "ticket cancelled — {reason}", }; /** diff --git a/wiki/concepts/parking-session.md b/wiki/concepts/parking-session.md index 3a7ff15..a36aac8 100644 --- a/wiki/concepts/parking-session.md +++ b/wiki/concepts/parking-session.md @@ -73,6 +73,34 @@ States, as derived from events: 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:, 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) - **Overstay after payment** — exited the grace window; needs a top-up payment. The one genuinely diff --git a/wiki/log.md b/wiki/log.md index 0bcd55b..de61ea7 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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 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). + +## [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]].