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:
@@ -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;
|
||||
|
||||
@@ -36,6 +36,14 @@ function exit(identity: string) {
|
||||
signature: "x", keyId: "test",
|
||||
}).run();
|
||||
}
|
||||
function voidEvt(identity: string) {
|
||||
idx += 1;
|
||||
db.insert(ledgerEvents).values({
|
||||
id: `e${idx}`, index: idx, type: "void",
|
||||
identity, payload: { sessionRef: identity, voidReason: "misprint" }, occurredAt: new Date().toISOString(),
|
||||
signature: "x", keyId: "test",
|
||||
}).run();
|
||||
}
|
||||
function setSite(v: Partial<typeof siteConfig.$inferInsert>) {
|
||||
db.insert(siteConfig).values({ id: 1, ...v }).onConflictDoUpdate({ target: siteConfig.id, set: v }).run();
|
||||
}
|
||||
@@ -57,6 +65,12 @@ describe("occupancyCount", () => {
|
||||
entry("A"); exit("A"); entry("A");
|
||||
expect(occupancyCount(db)).toBe(1);
|
||||
});
|
||||
|
||||
it("a voided (cancelled) entry does NOT count inside", () => {
|
||||
entry("A"); entry("B");
|
||||
voidEvt("B"); // B's ticket was a misprint — cancelled
|
||||
expect(occupancyCount(db)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOccupancy — capacity + full gate", () => {
|
||||
|
||||
@@ -30,8 +30,11 @@ export function occupancyCount(db: Db): number {
|
||||
.all();
|
||||
const balance = new Map<string, number>();
|
||||
for (const r of rows) {
|
||||
// A `void` (cancelled ticket) closes the session like an exit — the car never entered
|
||||
// (misprint), so it must not count inside. See void-flow.ts.
|
||||
if (r.type === "vehicle_entry") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) + 1);
|
||||
else if (r.type === "vehicle_exit") balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||
else if (r.type === "vehicle_exit" || r.type === "void")
|
||||
balance.set(r.identity ?? "", (balance.get(r.identity ?? "") ?? 0) - 1);
|
||||
}
|
||||
let open = 0;
|
||||
for (const v of balance.values()) if (v > 0) open += 1;
|
||||
@@ -68,7 +71,7 @@ export function reservedSubscriberSpots(db: Db): number {
|
||||
if (pl.permitId == null) continue; // transient
|
||||
net.set(id, (net.get(id) ?? 0) + 1);
|
||||
subOf.set(id, pl.permitId);
|
||||
} else if (r.type === "vehicle_exit") {
|
||||
} else if (r.type === "vehicle_exit" || r.type === "void") {
|
||||
if (net.has(id)) net.set(id, (net.get(id) ?? 0) - 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -183,10 +183,17 @@ export function reportSummary(db: Db, q: ReportQuery): ReportSummary {
|
||||
return p;
|
||||
}
|
||||
|
||||
// Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly-
|
||||
// printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's
|
||||
// entry is normally in the same window; this skips it when both are in range.)
|
||||
const voided = new Set<string>();
|
||||
for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity);
|
||||
|
||||
for (const row of rows) {
|
||||
const label = bucketLabel(row.occurredAt, tz, q.bucket);
|
||||
const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] };
|
||||
if (row.type === "vehicle_entry") {
|
||||
if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry
|
||||
totals.entries++;
|
||||
p.entries++;
|
||||
const h = localParts(row.occurredAt, tz).h;
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type PayStation,
|
||||
} from "../pay-station.js";
|
||||
import type { ExitFlow } from "../exit-flow.js";
|
||||
import type { VoidFlow } from "../void-flow.js";
|
||||
import { NoShiftOpenError, type ShiftService } from "../shift-service.js";
|
||||
import { printPaymentReceipt } from "../booth-print.js";
|
||||
|
||||
@@ -36,6 +37,10 @@ interface VoucherBody {
|
||||
interface ReceiptBody {
|
||||
identity: string;
|
||||
}
|
||||
interface VoidBody {
|
||||
identity: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export async function payRoutes(
|
||||
app: FastifyInstance,
|
||||
@@ -43,6 +48,7 @@ export async function payRoutes(
|
||||
payStation: PayStation,
|
||||
exitFlow: ExitFlow,
|
||||
shift: ShiftService,
|
||||
voidFlow: VoidFlow,
|
||||
): Promise<void> {
|
||||
// Reads (lookup, active sessions, quote) need session/payment read; the booth
|
||||
// money actions (pay, exit, voucher, receipt, reopen) need payment:create. A
|
||||
@@ -50,6 +56,7 @@ export async function payRoutes(
|
||||
// sessions. Read-only callers (a viewer role) get the reads but not the actions.
|
||||
const guard = requirePermission("payment:create");
|
||||
const readGuard = requirePermission("session:read");
|
||||
const voidGuard = requirePermission("event:void");
|
||||
|
||||
// Money-path gate: a shift must be open site-wide before any payment/exit/voucher/
|
||||
// re-open is processed, so every taking is attributed to a shift (one operator's
|
||||
@@ -125,6 +132,28 @@ export async function payRoutes(
|
||||
},
|
||||
);
|
||||
|
||||
// Cancel (void) a wrongly-printed transient ticket. Appends a SIGNED `void` event
|
||||
// referencing the entry, with the operator + a REQUIRED reason — the entry itself is
|
||||
// never edited/deleted (append-only). The session projection folds the void to CLOSED,
|
||||
// so the voided car stops counting inside and can't be paid/exited. Opens NO barrier
|
||||
// (the misprinted ticket's car never entered). Gated on event:void + an open shift
|
||||
// (the booth accountability period). Refusals (subscription / already exited / already
|
||||
// voided / already paid) → 409. See void-flow.ts, wiki/concepts/append-only-event-chain.md.
|
||||
app.post<{ Body: VoidBody }>(
|
||||
"/api/tickets/void",
|
||||
{ preHandler: [voidGuard, requireShift] },
|
||||
async (req, reply) => {
|
||||
const identity = (req.body?.identity ?? "").trim();
|
||||
const reason = (req.body?.reason ?? "").trim();
|
||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||
if (!reason) return reply.code(400).send({ error: "a cancellation reason is required" });
|
||||
const operator = req.user?.username ?? "unknown";
|
||||
const res = await voidFlow.voidTicket({ identity, reason, operator });
|
||||
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||
return reply.code(201).send(res);
|
||||
},
|
||||
);
|
||||
|
||||
// Quote: what does this session owe right now? (No side effect.)
|
||||
app.get<{ Querystring: QuoteQuery }>(
|
||||
"/api/pay/quote",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { deviceEvents } from "./device-events.js";
|
||||
import { EntryFlow } from "./entry-flow.js";
|
||||
import { EventLog } from "./event-log.js";
|
||||
import { ExitFlow } from "./exit-flow.js";
|
||||
import { VoidFlow } from "./void-flow.js";
|
||||
import { PayStation } from "./pay-station.js";
|
||||
import { SubscriptionFlow } from "./subscription-flow.js";
|
||||
import { ShiftService } from "./shift-service.js";
|
||||
@@ -231,7 +232,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
||||
// take payment → signed `payment` event. The booth pay/exit/voucher/re-open
|
||||
// endpoints require an open shift (passed in). See wiki/concepts/tariff.md.
|
||||
const payStation = new PayStation(db, eventLog, app.log);
|
||||
await payRoutes(app, db, payStation, exitFlow, shiftService);
|
||||
// Ticket-void (cancel a wrongly-printed ticket): appends a signed `void` referencing the
|
||||
// entry; the session projection folds it closed. See void-flow.ts.
|
||||
const voidFlow = new VoidFlow(db, eventLog, app.log);
|
||||
await payRoutes(app, db, payStation, exitFlow, shiftService, voidFlow);
|
||||
|
||||
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user