feat(entry): operator-issued entry + exit plate-swap reconciliation
Two halves of one anti-fraud design.
(A) Operator-issued entry — when the physical entry button is broken, an
operator can issue an entry ticket so a real car isn't blocked out of the lot.
This hands the operator-adversary a mint, so it is:
- PRESENCE-GATED like the physical button: a real car must be present (radar/
loop AND camera busy). Enforced BOTH sides — the server re-checks current
presence so a direct POST can't bypass a disabled button; no presence loop
=> feature unavailable; a no-presence attempt signs an anomaly.
- FLAGGED: vehicle_entry source=manual + operatorInitiated + operator, PLUS a
companion entry.operatorIssued anomaly (the adversary path always leaves a
red-flag row).
- capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap
a legit car).
New session:create permission (migration 0019 -> operator role, admin-
revocable), POST /api/entry/issue (open-shift gated), EntryFlow.
issueForOperator; the fraud-critical print->sign->open->snapshot sequence is
factored into one shared #issueTicket (button + operator). UI: the entry
BarrierLight becomes a clickable issue-control when presence+permission+shift
meet (confirm -> issue).
(B) Exit plate-swap reconciliation — defends the ticket-swap fraud the mint
enables (paid car let out on a fresh $0 ticket, original ticket lingers
"inside", occupancy drifts up by phantom cars). The plate is the invariant:
ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN
sessions' entry plates, EXACT + HIGH-CONFIDENCE only (>=0.85; a fuzzy read never
gates — ANPR is advisory). On a match under a DIFFERENT ticket:
- BOOTH path: returns swap_suspected + signs exit.plateSwapSuspected; the
pay/exit modal shows a red warning + "Override & release" (override signs an
attributed exit.plateSwapOverride). Flag+override, never a silent hard block
(exit fails-open; a plate is never the sole gate).
- READER path (no operator): log-only anomaly + fail-open.
Extended BoothExitResult + /api/exit (override); boothExit client returns a
structured swap result.
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new:
hold-on-swap, override-releases-with-attribution, low-confidence-no-warning,
own-plate-no-warning). New wiki: operator-issued-entry.md +
plate-reconciliation.md; cross-linked from entry-exit-points, capacity-
occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never
TRAPS a car alone either."
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+114
-17
@@ -15,7 +15,7 @@ import type { FastifyBaseLogger } from "fastify";
|
|||||||
import type { DeviceInputEvent } from "./device-events.js";
|
import type { DeviceInputEvent } from "./device-events.js";
|
||||||
import { getOccupancy } from "./occupancy.js";
|
import { getOccupancy } from "./occupancy.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
import { devicesByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
|
|
||||||
@@ -229,9 +229,29 @@ export class EntryFlow {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.#issueTicket(resolved, { source: "ticket" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared "issue a transient ticket" sequence used by BOTH the physical button
|
||||||
|
* (#runEntry) and the operator-initiated path (issueForOperator) — ONE copy of the
|
||||||
|
* fraud-critical ordering (print → sign vehicle_entry BEFORE open → open → snapshot →
|
||||||
|
* cache), never a divergent second copy. `opts.source` is "ticket" (button) or "booth"
|
||||||
|
* (operator). For an operator mint we stamp `operatorInitiated` + `operator` on the
|
||||||
|
* signed entry AND append a companion `anomaly` (the operator-adversary path always
|
||||||
|
* leaves a red-flag row); `overCapacity` records a full-lot override. Returns the
|
||||||
|
* outcome so the operator route can report it. See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async #issueTicket(
|
||||||
|
resolved: ResolvedRelay,
|
||||||
|
opts: { source: "ticket" | "manual"; operator?: string; overCapacity?: { count: number; capacity: number | null } },
|
||||||
|
): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> {
|
||||||
const ticketId = newTicketId();
|
const ticketId = newTicketId();
|
||||||
const issuedAt = new Date().toISOString();
|
const issuedAt = new Date().toISOString();
|
||||||
const printers = this.#loadPrinters();
|
const printers = this.#loadPrinters();
|
||||||
|
// Operator mint = ledger source "manual" (human intervention, like the barrier re-open)
|
||||||
|
// + operatorInitiated:true in the payload. The button path is source "ticket".
|
||||||
|
const operatorInitiated = opts.source === "manual";
|
||||||
|
|
||||||
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
// 1. PRINT FIRST. The ticket is the transient's session key — no ticket, no entry.
|
||||||
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() };
|
||||||
@@ -260,17 +280,14 @@ export class EntryFlow {
|
|||||||
// Capture who is held at the barrier (evidence for the operator handling the car).
|
// Capture who is held at the barrier (evidence for the operator handling the car).
|
||||||
this.#fireSnapshot("entry", ticketId);
|
this.#fireSnapshot("entry", ticketId);
|
||||||
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`);
|
||||||
return;
|
return { ok: false, reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||||
// later reprices the same way at exit. Today every transient takes the SITE
|
// later reprices the same way at exit. Today every transient takes the SITE
|
||||||
// default category (operator policy, site_config.default_vehicle_category;
|
// default category (operator policy, site_config.default_vehicle_category;
|
||||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
// falls back to the shared DEFAULT_VEHICLE_CATEGORY).
|
||||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
|
||||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
|
||||||
// ignores it; only V2 category cards consult it.
|
|
||||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||||
const category =
|
const category =
|
||||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||||
@@ -279,33 +296,113 @@ export class EntryFlow {
|
|||||||
await this.#log.append({
|
await this.#log.append({
|
||||||
type: "vehicle_entry",
|
type: "vehicle_entry",
|
||||||
direction: "entry",
|
direction: "entry",
|
||||||
source: "ticket",
|
source: opts.source,
|
||||||
identity: ticketId,
|
identity: ticketId,
|
||||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
payload: {
|
||||||
|
sessionRef: ticketId,
|
||||||
|
ticketPrinted: true,
|
||||||
|
category,
|
||||||
|
...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}),
|
||||||
|
},
|
||||||
occurredAt: issuedAt,
|
occurredAt: issuedAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 2b. For an operator mint, append a companion ANOMALY — the operator-adversary path
|
||||||
|
// always leaves a red-flag row in the tamper-evident record for reconciliation.
|
||||||
|
if (operatorInitiated) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: ticketId,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.operatorIssued", { operator: opts.operator ?? "?" }),
|
||||||
|
source: "booth",
|
||||||
|
operatorInitiated: true,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
...(opts.overCapacity ? { lotFull: true } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
// 3. OPEN the resolved entry barrier (intent only; the barrier owns the close).
|
||||||
const access = this.#buildAccess(resolved.controller);
|
const access = this.#buildAccess(resolved.controller);
|
||||||
if (access) await access.pulseOpen(resolved.relay);
|
let opened = false;
|
||||||
else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
if (access) {
|
||||||
|
await access.pulseOpen(resolved.relay);
|
||||||
|
opened = true;
|
||||||
|
} else this.#logger.warn(`entry signed for ${ticketId} but the entry relay won't build`);
|
||||||
|
|
||||||
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate;
|
// 3b. SNAPSHOT — fire the entry camera(s), never awaited (evidence, not a gate; a
|
||||||
// a camera failure must not delay or block the already-open barrier).
|
// camera failure must not delay or block the already-open barrier). This is ALSO
|
||||||
|
// what records the plate that plate-reconciliation reads at exit.
|
||||||
this.#fireSnapshot("entry", ticketId);
|
this.#fireSnapshot("entry", ticketId);
|
||||||
|
|
||||||
// 4. Update the session projection cache (rebuildable from the ledger; this is
|
// 4. Update the session projection cache (rebuildable from the ledger; a read-model).
|
||||||
// just a fast read-model, never the source of truth).
|
|
||||||
try {
|
try {
|
||||||
this.#db
|
this.#db
|
||||||
.insert(sessions)
|
.insert(sessions)
|
||||||
.values({ id: ticketId, identity: ticketId, source: "ticket", enteredAt: issuedAt, state: "open" })
|
.values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" })
|
||||||
.run();
|
.run();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Cache miss is non-fatal — the ledger is authoritative and the projection
|
|
||||||
// can be rebuilt. Log it; don't fail the (already-open) entry.
|
|
||||||
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
this.#logger.error(`session-cache insert failed for ${ticketId}: ${(err as Error).message}`);
|
||||||
}
|
}
|
||||||
|
return { ok: true, ticketId, opened };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OPERATOR-ISSUED entry (physical entry button broken). Gated exactly like the button:
|
||||||
|
* a REAL vehicle must be present at the entry — BOTH radar/loop presence AND camera
|
||||||
|
* confirmation. `cameraBusy` is the current LaneStatus.entry (passed by the route); loop
|
||||||
|
* presence is this flow's own per-relay guard state. If a site has no presence loop the
|
||||||
|
* feature is unavailable (we require both — no weaker fallback). Refuses (+ signs an
|
||||||
|
* anomaly) when no vehicle is present, so probing the endpoint is itself recorded. Over
|
||||||
|
* capacity is ALLOWED but flagged (a broken button mustn't trap a legit car). The mint
|
||||||
|
* itself is flagged (source:"booth" + operatorInitiated + a companion anomaly).
|
||||||
|
* See wiki/concepts/operator-issued-entry.md.
|
||||||
|
*/
|
||||||
|
async issueForOperator(operator: string, cameraBusy: boolean): Promise<
|
||||||
|
{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean } | { ok: false; reason: string }
|
||||||
|
> {
|
||||||
|
const resolved = firstRelayByDirection(this.#db, "entry");
|
||||||
|
if (!resolved) return { ok: false, reason: "no entry barrier configured" };
|
||||||
|
|
||||||
|
// PRESENCE GATE — require BOTH a presence loop (configured + currently occupied) AND
|
||||||
|
// the camera confirming a vehicle. No loop configured → feature unavailable here.
|
||||||
|
if (typeof resolved.presenceInput !== "number") {
|
||||||
|
return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable" };
|
||||||
|
}
|
||||||
|
const present = this.#guardState(resolved).present;
|
||||||
|
if (!present || !cameraBusy) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: `ENTRY-ATTEMPT-${randomUUID().replace(/-/g, "").slice(0, 12)}`,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("entry.issue.noPresence", { operator }),
|
||||||
|
source: "booth",
|
||||||
|
operator,
|
||||||
|
radarPresent: present,
|
||||||
|
cameraBusy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`operator entry refused by ${operator}: no vehicle present (radar=${present}, camera=${cameraBusy})`);
|
||||||
|
return { ok: false, reason: "no vehicle detected at the entry" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `operator-issue:${this.#relayKey(resolved)}`;
|
||||||
|
if (this.#inFlight.has(key)) return { ok: false, reason: "an entry is already in progress" };
|
||||||
|
this.#inFlight.add(key);
|
||||||
|
try {
|
||||||
|
const occ = getOccupancy(this.#db);
|
||||||
|
const res = await this.#issueTicket(resolved, {
|
||||||
|
source: "manual",
|
||||||
|
operator,
|
||||||
|
...(occ.full ? { overCapacity: { count: occ.count, capacity: occ.capacity ?? null } } : {}),
|
||||||
|
});
|
||||||
|
if (!res.ok) return res;
|
||||||
|
return { ok: true, ticketId: res.ticketId, opened: res.opened, overCapacity: occ.full };
|
||||||
|
} finally {
|
||||||
|
this.#inFlight.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
/** Fire the entry camera(s) for an identity; never awaited (evidence, not a gate).
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { createTestDb } from "@parking/db/testing";
|
import { createTestDb } from "@parking/db/testing";
|
||||||
import { ledgerEvents, eq, type Db } from "@parking/db";
|
import { ledgerEvents, deviceEvents as deviceEventsTable, sessions as sessionsTable, eq, type Db } from "@parking/db";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { ExitFlow } from "./exit-flow.js";
|
import { ExitFlow } from "./exit-flow.js";
|
||||||
import { PayStation } from "./pay-station.js";
|
import { PayStation } from "./pay-station.js";
|
||||||
import type { EventLog } from "./event-log.js";
|
import type { EventLog } from "./event-log.js";
|
||||||
@@ -33,6 +34,19 @@ async function enter(identity: string, enteredAt: string, payload?: Record<strin
|
|||||||
function exitsSigned(identity: string) {
|
function exitsSigned(identity: string) {
|
||||||
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.identity, identity)).all().filter((r) => r.type === "vehicle_exit");
|
||||||
}
|
}
|
||||||
|
function anomalies(reason?: string) {
|
||||||
|
return db.select().from(ledgerEvents).where(eq(ledgerEvents.type, "anomaly")).all()
|
||||||
|
.filter((r) => !reason || (r.payload as { reason?: string } | null)?.reason?.includes(reason));
|
||||||
|
}
|
||||||
|
/** Seed the projection-cache open-session row + an ANPR plate read (device_events) so the
|
||||||
|
* plate-reconciliation check can see this identity's plate against open sessions. */
|
||||||
|
function seedOpenWithPlate(identity: string, plate: string, confidence: number, enteredAt: string) {
|
||||||
|
db.insert(sessionsTable).values({ id: identity, identity, source: "ticket", enteredAt, state: "open" }).run();
|
||||||
|
db.insert(deviceEventsTable).values({
|
||||||
|
id: randomUUID(), deviceId: "cam-entry", category: "camera", kind: "read", occurredAt: enteredAt,
|
||||||
|
detail: { identity, direction: "entry", plate, confidence },
|
||||||
|
}).run();
|
||||||
|
}
|
||||||
|
|
||||||
describe("exitForBooth — refusal gates", () => {
|
describe("exitForBooth — refusal gates", () => {
|
||||||
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
it("refuses an unknown ticket (no session) and signs an anomaly", async () => {
|
||||||
@@ -116,3 +130,63 @@ describe("reopenBarrier — no unpaid re-open", () => {
|
|||||||
expect(exitsSigned("T1")).toHaveLength(1);
|
expect(exitsSigned("T1")).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("exitForBooth — plate-swap reconciliation (ticket-swap fraud)", () => {
|
||||||
|
// The fraud: a paid car is let out on a fresh $0 ticket while the original lingers "inside".
|
||||||
|
// The plate is the invariant — the exiting car's plate is already open under the old ticket.
|
||||||
|
it("HOLDS a paid exit when the plate is already open under a DIFFERENT ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
// Original car entered on 1234, plate AA123BB, still open (never paid/exited).
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
// A fresh ticket 1237 (same physical car, same plate) is paid and tries to exit.
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r).toMatchObject({ ok: false, status: "swap_suspected", plate: "AA123BB", otherIdentity: "1234" });
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(0); // NOT let out
|
||||||
|
expect(anomalies("plate AA123BB is already inside").length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("RELEASES on explicit operator override + signs an attributed override anomaly", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.99, minutesAgo(120));
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.99, minutesAgo(1));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237", { override: true, operator: "op1" });
|
||||||
|
expect(r.ok).toBe(true);
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1); // released
|
||||||
|
const ov = anomalies("released a suspected ticket-swap");
|
||||||
|
expect(ov.length).toBe(1);
|
||||||
|
expect((ov[0].payload as { operator?: string }).operator).toBe("op1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn on a LOW-confidence plate read (advisory, never a gate)", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1234", minutesAgo(120));
|
||||||
|
seedOpenWithPlate("1234", "AA123BB", 0.5, minutesAgo(120)); // low conf
|
||||||
|
await enter("1237", minutesAgo(1));
|
||||||
|
seedOpenWithPlate("1237", "AA123BB", 0.5, minutesAgo(1)); // low conf
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // no warning — exits normally
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT warn a normal exit whose OWN plate is only open under its OWN ticket", async () => {
|
||||||
|
seedTariff(db, { pricePerIncrementMinor: 10000, gracePeriodExitMin: 15 });
|
||||||
|
await enter("1237", minutesAgo(90));
|
||||||
|
seedOpenWithPlate("1237", "AA999ZZ", 0.99, minutesAgo(90));
|
||||||
|
await pay.pay("1237", "cash");
|
||||||
|
|
||||||
|
const r = await exit.exitForBooth("1237");
|
||||||
|
expect(r.ok).toBe(true); // its own plate under its own ticket is not a swap
|
||||||
|
expect(exitsSigned("1237")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
import { desc, eq, ledgerEvents, sessions, tariffVersions, tariffs, type Db, type DeviceRow } from "@parking/db";
|
||||||
import { registry, type AccessControlDevice } from "@parking/devices";
|
import { registry, type AccessControlDevice } from "@parking/devices";
|
||||||
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
import { firstRelayByDirection, type ResolvedRelay } from "./device-resolve.js";
|
||||||
|
import { plateForIdentity, platesForIdentities } from "./plate-lookup.js";
|
||||||
import { snapshotAsync } from "./snapshot.js";
|
import { snapshotAsync } from "./snapshot.js";
|
||||||
import type { VisionClient } from "./vision-client.js";
|
import type { VisionClient } from "./vision-client.js";
|
||||||
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
import { computeFee, reasonPayload, renderReasonEn, type LedgerPayload, type TariffStructure } from "@parking/shared";
|
||||||
@@ -47,6 +48,11 @@ interface SessionView {
|
|||||||
* the barrier didn't open (payment stands; operator opens manually). */
|
* the barrier didn't open (payment stands; operator opens manually). */
|
||||||
export type BoothExitResult =
|
export type BoothExitResult =
|
||||||
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
| { ok: false; status: "invalid" | "no_session" | "closed" | "unpaid" | "grace_expired"; reason: string }
|
||||||
|
// PLATE-SWAP suspected: the exiting car's plate is already OPEN under a DIFFERENT ticket
|
||||||
|
// (possible ticket-swap fraud / mixed-up tickets). Not opened — the operator must review
|
||||||
|
// and either resolve the tickets or consciously OVERRIDE (re-submit with override:true).
|
||||||
|
// See wiki/concepts/plate-reconciliation.md.
|
||||||
|
| { ok: false; status: "swap_suspected"; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null }
|
||||||
| { ok: true; opened: true }
|
| { ok: true; opened: true }
|
||||||
| { ok: true; opened: false; reason: string };
|
| { ok: true; opened: false; reason: string };
|
||||||
|
|
||||||
@@ -57,6 +63,11 @@ export type BoothReopenResult =
|
|||||||
| { ok: false; reason: string }
|
| { ok: false; reason: string }
|
||||||
| { ok: true; opened: boolean; reason?: string };
|
| { ok: true; opened: boolean; reason?: string };
|
||||||
|
|
||||||
|
/** Minimum ANPR confidence for a plate to participate in swap reconciliation, both for the
|
||||||
|
* exiting read and the matched open session's entry read. Below this, the read is advisory-
|
||||||
|
* only and never triggers a swap warning (a fuzzy read must not block a legit car). */
|
||||||
|
const PLATE_MATCH_MIN_CONFIDENCE = 0.85;
|
||||||
|
|
||||||
export class ExitFlow {
|
export class ExitFlow {
|
||||||
readonly #db: Db;
|
readonly #db: Db;
|
||||||
readonly #log: EventLog;
|
readonly #log: EventLog;
|
||||||
@@ -88,7 +99,7 @@ export class ExitFlow {
|
|||||||
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
* (money was taken, the car is owed an exit) and an `anomaly` is appended so the
|
||||||
* operator opens manually. Payment is never rolled back.
|
* operator opens manually. Payment is never rolled back.
|
||||||
*/
|
*/
|
||||||
async exitForBooth(identity: string): Promise<BoothExitResult> {
|
async exitForBooth(identity: string, opts?: { override?: boolean; operator?: string }): Promise<BoothExitResult> {
|
||||||
const id = identity.trim();
|
const id = identity.trim();
|
||||||
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
if (!id) return { ok: false, status: "invalid", reason: "ticket id required" };
|
||||||
|
|
||||||
@@ -122,6 +133,40 @@ export class ExitFlow {
|
|||||||
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
return { ok: false, status: paid ? "grace_expired" : "unpaid", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP CHECK — after the money/grace validation, before we sign the exit. If
|
||||||
|
// the plate is already open under a DIFFERENT ticket, HOLD for the operator to review
|
||||||
|
// (unless they consciously override). A denial here never traps the car — exit fails
|
||||||
|
// open and the operator can override; the anomaly is the control either way.
|
||||||
|
const swap = this.#reconcilePlateAtExit(id);
|
||||||
|
if (swap) {
|
||||||
|
if (!opts?.override) {
|
||||||
|
// Sign the SUSPICION even if the operator walks away (tamper-evident record).
|
||||||
|
const rp = reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity });
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: { ...rp, source: "booth", plateSwapSuspected: true, plate: swap.plate, otherIdentity: swap.otherIdentity },
|
||||||
|
});
|
||||||
|
this.#fireExitSnapshot(id);
|
||||||
|
this.#logger.warn(`booth exit HELD (${id}): plate ${swap.plate} already open under ${swap.otherIdentity}`);
|
||||||
|
return { ok: false, status: "swap_suspected", reason: rp.reason, plate: swap.plate, otherIdentity: swap.otherIdentity, otherEnteredAt: swap.otherEnteredAt };
|
||||||
|
}
|
||||||
|
// OVERRIDE: the operator consciously releases it. Sign the override (attributed).
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: id,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapOverride", { operator: opts.operator ?? "?", plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
source: "booth",
|
||||||
|
plateSwapOverride: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
...(opts.operator ? { operator: opts.operator } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`booth exit OVERRIDE (${id}) by ${opts.operator ?? "?"}: plate-swap released (${swap.plate}, also open under ${swap.otherIdentity})`);
|
||||||
|
}
|
||||||
|
|
||||||
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
// Free entry-grace path: mint the $0 payment first (ledger invariant), as the
|
||||||
// reader path does.
|
// reader path does.
|
||||||
if (freeGrace && view.freeGrace) {
|
if (freeGrace && view.freeGrace) {
|
||||||
@@ -336,6 +381,25 @@ export class ExitFlow {
|
|||||||
return { accepted: false, direction: "exit", reason: rp.reason };
|
return { accepted: false, direction: "exit", reason: rp.reason };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PLATE-SWAP (reader path): detect + LOG, but FAIL OPEN. There's no operator at an
|
||||||
|
// automated lane to make the override decision, and exit fails open for safety, so we
|
||||||
|
// sign the suspicion anomaly (the control here) and still let the car out. The booth
|
||||||
|
// path (operator-mediated) is where the hold + override lives.
|
||||||
|
const swap = this.#reconcilePlateAtExit(e.value);
|
||||||
|
if (swap) {
|
||||||
|
await this.#log.append({
|
||||||
|
type: "anomaly",
|
||||||
|
identity: e.value,
|
||||||
|
payload: {
|
||||||
|
...reasonPayload("exit.plateSwapSuspected", { plate: swap.plate, otherIdentity: swap.otherIdentity }),
|
||||||
|
plateSwapSuspected: true,
|
||||||
|
plate: swap.plate,
|
||||||
|
otherIdentity: swap.otherIdentity,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
this.#logger.warn(`reader exit: plate ${swap.plate} already open under ${swap.otherIdentity} (${e.value}) — logged, fail-open`);
|
||||||
|
}
|
||||||
|
|
||||||
// Valid (a real payment within walk-back grace): sign + open.
|
// Valid (a real payment within walk-back grace): sign + open.
|
||||||
return this.#signExitAndOpen(resolved, e);
|
return this.#signExitAndOpen(resolved, e);
|
||||||
}
|
}
|
||||||
@@ -406,6 +470,43 @@ export class ExitFlow {
|
|||||||
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
this.#logger.error(`booth exit open failed (${identity}): ${detail}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PLATE-SWAP reconciliation. The car's PLATE is the invariant a ticket-swap can't hide:
|
||||||
|
* if this exiting ticket's plate is already OPEN under a DIFFERENT ticket, someone let a
|
||||||
|
* paid car out on a fresh $0 ticket while the original lingers "inside" (occupancy fraud),
|
||||||
|
* or two tickets were mixed up. We compare the EXITING plate against every open session's
|
||||||
|
* ENTRY plate, EXACT normalized match, HIGH-CONFIDENCE reads only (a fuzzy/absent read is
|
||||||
|
* advisory — never a gate, so it can't trap a legit car). Returns the matched open session
|
||||||
|
* or null. See wiki/concepts/plate-reconciliation.md.
|
||||||
|
*/
|
||||||
|
#reconcilePlateAtExit(exitingId: string): { plate: string; otherIdentity: string; otherEnteredAt: string | null } | null {
|
||||||
|
// The exiting car's plate: prefer its own exit read, else its entry read.
|
||||||
|
const mine = plateForIdentity(this.#db, exitingId);
|
||||||
|
if (!mine || !mine.plate || (mine.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) return null;
|
||||||
|
const wanted = mine.plate.trim().toUpperCase();
|
||||||
|
|
||||||
|
// All currently-open sessions (from the projection cache — a fast read-model; the check
|
||||||
|
// is advisory so a slightly-stale cache is acceptable), excluding this ticket.
|
||||||
|
const openIds = this.#db
|
||||||
|
.select({ id: sessions.id })
|
||||||
|
.from(sessions)
|
||||||
|
.where(eq(sessions.state, "open"))
|
||||||
|
.all()
|
||||||
|
.map((r) => r.id)
|
||||||
|
.filter((id) => id !== exitingId);
|
||||||
|
if (openIds.length === 0) return null;
|
||||||
|
|
||||||
|
const plates = platesForIdentities(this.#db, openIds);
|
||||||
|
for (const [otherId, pv] of plates) {
|
||||||
|
if ((pv.confidence ?? 0) < PLATE_MATCH_MIN_CONFIDENCE) continue;
|
||||||
|
if (pv.plate.trim().toUpperCase() !== wanted) continue;
|
||||||
|
// A high-confidence exact match under a DIFFERENT open ticket → swap suspected.
|
||||||
|
const enteredAt = this.#db.select({ enteredAt: sessions.enteredAt }).from(sessions).where(eq(sessions.id, otherId)).get()?.enteredAt ?? null;
|
||||||
|
return { plate: wanted, otherIdentity: otherId, otherEnteredAt: enteredAt };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
/** Fold the signed ledger into a session view for one identity (authoritative). */
|
||||||
#sessionFor(identity: string): SessionView | null {
|
#sessionFor(identity: string): SessionView | null {
|
||||||
const rows = this.#db
|
const rows = this.#db
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import { requirePermission } from "../auth.js";
|
||||||
|
import type { EntryFlow } from "../entry-flow.js";
|
||||||
|
import type { LaneStatus } from "../lane-status.js";
|
||||||
|
import type { ShiftService } from "../shift-service.js";
|
||||||
|
import { NoShiftOpenError } from "../shift-service.js";
|
||||||
|
|
||||||
|
// Operator-issued entry (2026-07-01). When the physical entry button is broken, an operator
|
||||||
|
// may issue an entry ticket — a FLAGGED mint (vehicle_entry source=manual + operatorInitiated
|
||||||
|
// + a companion anomaly), gated EXACTLY like the physical button: a real vehicle must be
|
||||||
|
// present (radar/loop AND camera). The presence gate is enforced HERE (server-side), so a
|
||||||
|
// direct POST can't bypass a disabled UI button. Money-adjacent → requires an open shift.
|
||||||
|
// See wiki/concepts/operator-issued-entry.md.
|
||||||
|
|
||||||
|
export async function entryRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
entryFlow: EntryFlow,
|
||||||
|
laneStatus: LaneStatus,
|
||||||
|
shift: ShiftService,
|
||||||
|
): Promise<void> {
|
||||||
|
const guard = requirePermission("session:create");
|
||||||
|
|
||||||
|
app.post("/api/entry/issue", { preHandler: guard }, async (req, reply) => {
|
||||||
|
// Gate on an open shift (a minted entry belongs to an accountable operator).
|
||||||
|
if (!shift.currentOpenShift()) {
|
||||||
|
return reply.code(409).send({ error: new NoShiftOpenError().message });
|
||||||
|
}
|
||||||
|
// The camera side of the presence gate = the live entry lane-busy state; the radar/loop
|
||||||
|
// side is checked inside the flow (its per-relay presence guard).
|
||||||
|
const cameraBusy = laneStatus.snapshot().entry;
|
||||||
|
const res = await entryFlow.issueForOperator(req.user.username, cameraBusy);
|
||||||
|
if (!res.ok) return reply.code(409).send({ error: res.reason });
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -30,6 +30,9 @@ interface PayBody {
|
|||||||
}
|
}
|
||||||
interface ExitBody {
|
interface ExitBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
|
/** Operator consciously releases a suspected plate-swap exit (re-submit after the
|
||||||
|
* first call returned status "swap_suspected"). Signs an attributed override anomaly. */
|
||||||
|
override?: boolean;
|
||||||
}
|
}
|
||||||
interface VoucherBody {
|
interface VoucherBody {
|
||||||
identity: string;
|
identity: string;
|
||||||
@@ -109,8 +112,17 @@ export async function payRoutes(
|
|||||||
async (req, reply) => {
|
async (req, reply) => {
|
||||||
const identity = (req.body?.identity ?? "").trim();
|
const identity = (req.body?.identity ?? "").trim();
|
||||||
if (!identity) return reply.code(400).send({ error: "identity required" });
|
if (!identity) return reply.code(400).send({ error: "identity required" });
|
||||||
const res = await exitFlow.exitForBooth(identity);
|
const res = await exitFlow.exitForBooth(identity, {
|
||||||
if (!res.ok) return reply.code(409).send({ error: res.reason, status: res.status });
|
override: req.body?.override === true,
|
||||||
|
operator: req.user?.username,
|
||||||
|
});
|
||||||
|
// A suspected plate-swap returns the full detail so the modal can warn + offer override.
|
||||||
|
if (!res.ok) {
|
||||||
|
if (res.status === "swap_suspected") {
|
||||||
|
return reply.code(409).send({ error: res.reason, status: res.status, plate: res.plate, otherIdentity: res.otherIdentity, otherEnteredAt: res.otherEnteredAt });
|
||||||
|
}
|
||||||
|
return reply.code(409).send({ error: res.reason, status: res.status });
|
||||||
|
}
|
||||||
return reply.code(200).send(res);
|
return reply.code(200).send(res);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ import { subscriptionPlanRoutes } from "./routes/subscription-plans.js";
|
|||||||
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
import { qrReaderRoutes } from "./routes/qr-reader.js";
|
||||||
import { shiftRoutes } from "./routes/shift.js";
|
import { shiftRoutes } from "./routes/shift.js";
|
||||||
import { drawerRoutes } from "./routes/drawer.js";
|
import { drawerRoutes } from "./routes/drawer.js";
|
||||||
|
import { entryRoutes } from "./routes/entry.js";
|
||||||
import { siteRoutes } from "./routes/site.js";
|
import { siteRoutes } from "./routes/site.js";
|
||||||
import { snapshotRoutes } from "./routes/snapshots.js";
|
import { snapshotRoutes } from "./routes/snapshots.js";
|
||||||
import { tariffRoutes } from "./routes/tariffs.js";
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
@@ -270,6 +271,8 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
await shiftRoutes(app, shiftService);
|
await shiftRoutes(app, shiftService);
|
||||||
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
// Drawer cash movements — operator records, admin reviews (routes/drawer.ts).
|
||||||
await drawerRoutes(app, shiftService);
|
await drawerRoutes(app, shiftService);
|
||||||
|
// Operator-issued entry (broken physical button) — flagged mint, presence-gated.
|
||||||
|
await entryRoutes(app, entryFlow, laneStatus, shiftService);
|
||||||
|
|
||||||
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
// Site config (capacity) + live occupancy. The FULL gate (refuse transient entry
|
||||||
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
// at capacity) is in the entry flow. See wiki/concepts/capacity-occupancy.md.
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const { user } = rootRoute.useRouteContext();
|
const { user } = rootRoute.useRouteContext();
|
||||||
const canVoid = can(user, "event:void");
|
const canVoid = can(user, "event:void");
|
||||||
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
const [voiding, setVoiding] = useState(false); // reason prompt revealed
|
||||||
|
// Plate-swap: set when boothExit returns swap_suspected. Holds the detail for the warning
|
||||||
|
// panel; the operator must consciously "Override & release". See plate-reconciliation.md.
|
||||||
|
const [swap, setSwap] = useState<{ plate: string; otherIdentity: string; otherEnteredAt: string | null } | null>(null);
|
||||||
const [voidReason, setVoidReason] = useState("");
|
const [voidReason, setVoidReason] = useState("");
|
||||||
|
|
||||||
const s: SessionLookup | undefined = session.data;
|
const s: SessionLookup | undefined = session.data;
|
||||||
@@ -178,7 +181,7 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePayAndExit() {
|
async function handlePayAndExit(override = false) {
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
@@ -186,7 +189,8 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
// session is "already paid" but a new period accrued — we still charge (canPay
|
// session is "already paid" but a new period accrued — we still charge (canPay
|
||||||
// is true). A settled within-grace session is not payable (canPay false) and is
|
// is true). A settled within-grace session is not payable (canPay false) and is
|
||||||
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
// skipped. The server re-quotes authoritatively (overstay → from grace-expiry).
|
||||||
if (canPay) {
|
// On an OVERRIDE re-submit the payment already happened; don't double-charge.
|
||||||
|
if (canPay && !override) {
|
||||||
setPhase("paying");
|
setPhase("paying");
|
||||||
await paySession(identity, tender);
|
await paySession(identity, tender);
|
||||||
}
|
}
|
||||||
@@ -197,7 +201,14 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
const r = await printVoucher(identity);
|
const r = await printVoucher(identity);
|
||||||
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
setResult(t("pay.voucherPrinted", { printer: r.printedBy }));
|
||||||
} else {
|
} else {
|
||||||
const r = await boothExit(identity);
|
const r = await boothExit(identity, override);
|
||||||
|
// PLATE-SWAP suspected → don't exit; surface the warning + offer an override.
|
||||||
|
if (!r.ok) {
|
||||||
|
setSwap({ plate: r.plate, otherIdentity: r.otherIdentity, otherEnteredAt: r.otherEnteredAt });
|
||||||
|
setPhase("review");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSwap(null);
|
||||||
// No voucher → auto-print a standalone payment receipt for transparency.
|
// No voucher → auto-print a standalone payment receipt for transparency.
|
||||||
// Best-effort: a printer fault must NOT block the exit that already happened;
|
// Best-effort: a printer fault must NOT block the exit that already happened;
|
||||||
// the operator can reprint from the done screen.
|
// the operator can reprint from the done screen.
|
||||||
@@ -493,6 +504,25 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* PLATE-SWAP warning: the exiting plate is already inside under another
|
||||||
|
ticket. A prominent, deliberate hold — the operator must consciously
|
||||||
|
override to release. See wiki/concepts/plate-reconciliation.md. */}
|
||||||
|
{swap && (
|
||||||
|
<div className="rounded-term border border-term-red bg-term-red/10 px-3 py-2">
|
||||||
|
<div className="text-[0.75rem] font-semibold uppercase tracking-wider text-term-red">
|
||||||
|
{t("pay.swapTitle")}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.75rem] text-term-text">
|
||||||
|
{t("pay.swapBody", {
|
||||||
|
plate: swap.plate,
|
||||||
|
other: swap.otherIdentity,
|
||||||
|
when: swap.otherEnteredAt ? formatRelativeDateTime(swap.otherEnteredAt, t) : "—",
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.6875rem] text-term-muted">{t("pay.swapHint")}</div>
|
||||||
|
</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>
|
||||||
@@ -602,26 +632,39 @@ export function BoothPayModal({ identity, onClose }: { identity: string; onClose
|
|||||||
{t("pay.cancelTicket")}
|
{t("pay.cancelTicket")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{swap ? (
|
||||||
type="button"
|
// Plate-swap held → the only forward action is a conscious
|
||||||
onClick={handlePayAndExit}
|
// override (re-submit with override:true; payment already taken).
|
||||||
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
<button
|
||||||
className="btn btn-go btn-lg"
|
type="button"
|
||||||
>
|
onClick={() => handlePayAndExit(true)}
|
||||||
{phase === "paying"
|
disabled={!shiftReady || phase === "finishing"}
|
||||||
? t("pay.takingPayment")
|
className="btn btn-danger btn-lg"
|
||||||
: phase === "finishing"
|
>
|
||||||
? voucher
|
{phase === "finishing" ? t("pay.opening") : t("pay.swapOverride")}
|
||||||
? t("pay.printingVoucher")
|
</button>
|
||||||
: t("pay.opening")
|
) : (
|
||||||
: alreadyPaid
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handlePayAndExit()}
|
||||||
|
disabled={!shiftReady || phase === "paying" || phase === "finishing"}
|
||||||
|
className="btn btn-go btn-lg"
|
||||||
|
>
|
||||||
|
{phase === "paying"
|
||||||
|
? t("pay.takingPayment")
|
||||||
|
: phase === "finishing"
|
||||||
? voucher
|
? voucher
|
||||||
? t("pay.printVoucher")
|
? t("pay.printingVoucher")
|
||||||
: t("pay.openBarrier")
|
: t("pay.opening")
|
||||||
: voucher
|
: alreadyPaid
|
||||||
? t("pay.payAndVoucher")
|
? voucher
|
||||||
: t("pay.payAndOpen")}
|
? t("pay.printVoucher")
|
||||||
</button>
|
: t("pay.openBarrier")
|
||||||
|
: voucher
|
||||||
|
? t("pay.payAndVoucher")
|
||||||
|
: t("pay.payAndOpen")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchEvents, fetchOccupancy, type LedgerEvent, type Occupancy } from "./api.js";
|
import { can, fetchEvents, fetchOccupancy, issueEntryTicket, type LedgerEvent, type Occupancy } from "./api.js";
|
||||||
|
import { rootRoute } from "./router.js";
|
||||||
import { qk } from "./lib/query.js";
|
import { qk } from "./lib/query.js";
|
||||||
import { useLiveStore } from "./lib/live-store.js";
|
import { useLiveStore } from "./lib/live-store.js";
|
||||||
import { useShift } from "./lib/use-shift.js";
|
import { useShift } from "./lib/use-shift.js";
|
||||||
@@ -116,16 +117,40 @@ function TicketInput({ onSubmit }: { onSubmit: (identity: string) => void }) {
|
|||||||
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
* - radar present + camera NOT busy → BLINK green↔red (~1 Hz): "detected, not yet confirmed"
|
||||||
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
* - camera busy → SOLID red: a vehicle is confirmed at the lane vicinity
|
||||||
* - otherwise → SOLID green: free
|
* - otherwise → SOLID green: free
|
||||||
* Advisory only; it gates nothing. The blink uses the `.lane-blink` keyframe (index.css),
|
* Advisory only; it gates nothing. On the ENTRY light, when the operator holds `session:create`
|
||||||
* whose children inherit the alternating colour via `currentColor`. */
|
* and BOTH presence conditions meet (radar present AND camera busy = a real car at the entry),
|
||||||
function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; radar: boolean }) {
|
* the light becomes a CLICKABLE issue-ticket control (broken physical button). Same presence
|
||||||
|
* rule as the physical button; the server re-checks it. See operator-issued-entry.md. */
|
||||||
|
function BarrierLight({
|
||||||
|
label,
|
||||||
|
busy,
|
||||||
|
radar,
|
||||||
|
onIssue,
|
||||||
|
issuing,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
busy: boolean;
|
||||||
|
radar: boolean;
|
||||||
|
/** When set (entry light + permission), clicking issues an entry ticket — only enabled
|
||||||
|
* when both presence conditions meet (radar && busy). */
|
||||||
|
onIssue?: () => void;
|
||||||
|
issuing?: boolean;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
// Blink only when the radar sees something the camera hasn't confirmed.
|
// Blink only when the radar sees something the camera hasn't confirmed.
|
||||||
const blinking = radar && !busy;
|
const blinking = radar && !busy;
|
||||||
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
const solid = busy ? "border-term-red bg-term-red/10 text-term-red" : "border-term-green bg-term-green/10 text-term-green";
|
||||||
|
// The issue control is active only with a REAL car present (radar AND camera).
|
||||||
|
const canIssue = !!onIssue && radar && busy && !issuing;
|
||||||
|
const clickable = !!onIssue && radar && busy;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid}`}
|
className={`flex items-center gap-2 rounded-term border px-3 py-2 ${blinking ? "lane-blink" : solid} ${
|
||||||
title={label}
|
clickable ? "cursor-pointer hover:brightness-125" : ""
|
||||||
|
}`}
|
||||||
|
title={clickable ? t("booth.issueEntryTitle") : label}
|
||||||
|
onClick={canIssue ? onIssue : undefined}
|
||||||
|
role={clickable ? "button" : undefined}
|
||||||
>
|
>
|
||||||
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
{/* Barrier glyph: a post + an arm. `currentColor` follows the (possibly blinking) state. */}
|
||||||
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
<svg viewBox="0 0 24 24" className="h-5 w-5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
@@ -135,22 +160,58 @@ function BarrierLight({ label, busy, radar }: { label: string; busy: boolean; ra
|
|||||||
</svg>
|
</svg>
|
||||||
<div className="leading-tight">
|
<div className="leading-tight">
|
||||||
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
<div className="text-[0.625rem] uppercase tracking-wider text-term-muted">{label}</div>
|
||||||
<div className="text-xs font-bold">{busy ? "●" : blinking ? "◐" : "○"}</div>
|
<div className="text-xs font-bold">
|
||||||
|
{issuing ? "…" : clickable ? t("booth.issueEntry") : busy ? "●" : blinking ? "◐" : "○"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
/** The two lane barrier lights (entry / exit) fed by the live lane-status (camera busy/free)
|
||||||
* and lane-presence (radar). */
|
* and lane-presence (radar). The ENTRY light doubles as an operator issue-ticket control when
|
||||||
|
* the physical button is broken (permission + presence gated). */
|
||||||
function LaneIndicators() {
|
function LaneIndicators() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const lanes = useLiveStore((s) => s.lanes);
|
const lanes = useLiveStore((s) => s.lanes);
|
||||||
const radar = useLiveStore((s) => s.radar);
|
const radar = useLiveStore((s) => s.radar);
|
||||||
|
const { user } = rootRoute.useRouteContext();
|
||||||
|
const { isOpen: shiftOpen, isMine } = useShift();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const canIssue = can(user, "session:create") && shiftOpen && isMine;
|
||||||
|
const [msg, setMsg] = useState<{ text: string; ok: boolean } | null>(null);
|
||||||
|
|
||||||
|
const issue = useMutation({
|
||||||
|
mutationFn: issueEntryTicket,
|
||||||
|
onSuccess: (r) => {
|
||||||
|
setMsg({ ok: true, text: t("booth.issueEntryOk", { ticket: r.ticketId }) });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.events });
|
||||||
|
void qc.invalidateQueries({ queryKey: qk.occupancy });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
setMsg({ ok: false, text: (e as Error).message });
|
||||||
|
setTimeout(() => setMsg(null), 4000);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function onIssue() {
|
||||||
|
if (window.confirm(t("booth.issueEntryConfirm"))) issue.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BarrierLight label={t("booth.laneEntry")} busy={lanes?.entry ?? false} radar={radar?.entry ?? false} />
|
<BarrierLight
|
||||||
|
label={t("booth.laneEntry")}
|
||||||
|
busy={lanes?.entry ?? false}
|
||||||
|
radar={radar?.entry ?? false}
|
||||||
|
onIssue={canIssue ? onIssue : undefined}
|
||||||
|
issuing={issue.isPending}
|
||||||
|
/>
|
||||||
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
<BarrierLight label={t("booth.laneExit")} busy={lanes?.exit ?? false} radar={radar?.exit ?? false} />
|
||||||
|
{msg && (
|
||||||
|
<span className={`text-[0.6875rem] ${msg.ok ? "text-term-green" : "text-term-red"}`}>{msg.text}</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+41
-7
@@ -30,7 +30,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
}
|
}
|
||||||
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
const res = await fetch(apiUrl(path), { ...init, headers, credentials: "include" });
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[] };
|
const msg = (await res.json().catch(() => ({}))) as { error?: string; problems?: string[]; [k: string]: unknown };
|
||||||
const error = msg.error ?? `${path}: ${res.status}`;
|
const error = msg.error ?? `${path}: ${res.status}`;
|
||||||
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
// Ship the failed request to the backend log store (best-effort, loop-safe — the
|
||||||
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
// logger itself never logs the /api/logs call). 401s are normal pre-login churn,
|
||||||
@@ -38,7 +38,7 @@ export async function apiFetch<T>(path: string, init: RequestInit = {}): Promise
|
|||||||
if (res.status !== 401) {
|
if (res.status !== 401) {
|
||||||
logFailedRequest({ path, method, status: res.status, error });
|
logFailedRequest({ path, method, status: res.status, error });
|
||||||
}
|
}
|
||||||
throw new ApiError(error, res.status, msg.problems);
|
throw new ApiError(error, res.status, msg.problems, msg);
|
||||||
}
|
}
|
||||||
if (res.status === 204) return undefined as T;
|
if (res.status === 204) return undefined as T;
|
||||||
return res.json() as Promise<T>;
|
return res.json() as Promise<T>;
|
||||||
@@ -50,6 +50,9 @@ export class ApiError extends Error {
|
|||||||
readonly status: number,
|
readonly status: number,
|
||||||
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
/** Field-level problems from a validation error (e.g. tariff publish), if any. */
|
||||||
readonly problems?: string[],
|
readonly problems?: string[],
|
||||||
|
/** The full parsed error body, for callers that need extra fields (e.g. a booth
|
||||||
|
* exit's plate-swap detail: { status, plate, otherIdentity, otherEnteredAt }). */
|
||||||
|
readonly body?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
@@ -1284,12 +1287,43 @@ export function voidTicket(identity: string, reason: string): Promise<{ ok: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 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). `swapSuspected` = the exiting car's
|
||||||
export type BoothExitResult = { ok: true; opened: boolean; reason?: string };
|
* plate is already inside under a DIFFERENT ticket (possible ticket-swap); the operator
|
||||||
|
* must review and re-call with override:true to release. See plate-reconciliation.md. */
|
||||||
|
export type BoothExitResult =
|
||||||
|
| { ok: true; opened: boolean; reason?: string }
|
||||||
|
| { ok: false; swapSuspected: true; reason: string; plate: string; otherIdentity: string; otherEnteredAt: string | null };
|
||||||
|
|
||||||
/** Validate + open the barrier for a session from the booth (when near the exit). */
|
/** Validate + open the barrier for a session from the booth (when near the exit).
|
||||||
export function boothExit(identity: string): Promise<BoothExitResult> {
|
* Pass override:true to consciously release a suspected plate-swap exit. */
|
||||||
return apiFetch("/api/exit", { method: "POST", body: JSON.stringify({ identity }) });
|
export async function boothExit(identity: string, override = false): Promise<BoothExitResult> {
|
||||||
|
try {
|
||||||
|
return await apiFetch<{ ok: true; opened: boolean; reason?: string }>("/api/exit", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ identity, ...(override ? { override: true } : {}) }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// A suspected plate-swap comes back 409 with status:"swap_suspected" + detail — surface
|
||||||
|
// it as a structured result (not a thrown error) so the modal can warn + offer override.
|
||||||
|
if (e instanceof ApiError && e.body?.status === "swap_suspected") {
|
||||||
|
const b = e.body;
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
swapSuspected: true,
|
||||||
|
reason: String(b.error ?? ""),
|
||||||
|
plate: String(b.plate ?? ""),
|
||||||
|
otherIdentity: String(b.otherIdentity ?? ""),
|
||||||
|
otherEnteredAt: (b.otherEnteredAt as string | null) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Operator issues an entry ticket when the physical button is broken. A FLAGGED mint,
|
||||||
|
* server-gated on real vehicle presence (radar + camera). Returns the new ticket id. */
|
||||||
|
export function issueEntryTicket(): Promise<{ ok: true; ticketId: string; opened: boolean; overCapacity: boolean }> {
|
||||||
|
return apiFetch("/api/entry/issue", { method: "POST", body: JSON.stringify({}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
/** Print an exit voucher (paid ticket id reprinted as a barcode) + payment detail,
|
||||||
|
|||||||
@@ -191,6 +191,10 @@ export const en: Catalog = {
|
|||||||
fEvtAnomaly: "Anomaly",
|
fEvtAnomaly: "Anomaly",
|
||||||
openPayExit: "Open pay / exit",
|
openPayExit: "Open pay / exit",
|
||||||
openReopenBarrier: "Open — paid, awaiting barrier",
|
openReopenBarrier: "Open — paid, awaiting barrier",
|
||||||
|
issueEntry: "Issue ticket",
|
||||||
|
issueEntryTitle: "Issue an entry ticket & open the barrier (physical button broken)",
|
||||||
|
issueEntryConfirm: "A vehicle is at the entry. Issue an entry ticket and open the barrier?",
|
||||||
|
issueEntryOk: "Entry ticket {{ticket}} issued.",
|
||||||
exitedGrace: "exited · grace",
|
exitedGrace: "exited · grace",
|
||||||
exitedGraceLeft: "exited · {{time}}",
|
exitedGraceLeft: "exited · {{time}}",
|
||||||
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
exitedGraceTitle: "Paid and exited — barrier not confirmed; waiting out the grace period.",
|
||||||
@@ -275,6 +279,8 @@ export const en: Catalog = {
|
|||||||
reason: {
|
reason: {
|
||||||
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
"entry.refused.full": "Entry refused — lot full ({{count}}/{{capacity}})",
|
||||||
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
"entry.held.noTicket": "Entry held — ticket not printed: {{detail}}",
|
||||||
|
"entry.operatorIssued": "Entry ticket issued by operator {{operator}} (physical button broken)",
|
||||||
|
"entry.issue.noPresence": "Operator entry refused — no vehicle detected at the entry",
|
||||||
"exit.refused.closed": "Exit refused — session already closed",
|
"exit.refused.closed": "Exit refused — session already closed",
|
||||||
"exit.refused.noSession": "Exit refused — unknown ticket",
|
"exit.refused.noSession": "Exit refused — unknown ticket",
|
||||||
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
"exit.refused.unpaid": "Exit refused — not paid (take payment first)",
|
||||||
@@ -284,6 +290,8 @@ export const en: Catalog = {
|
|||||||
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
"exit.open.failed": "Exit recorded, but the barrier did not open — open manually",
|
||||||
"exit.freeGrace": "Free entry-grace (no charge)",
|
"exit.freeGrace": "Free entry-grace (no charge)",
|
||||||
"exit.manualOpen": "Manual barrier open (human intervention)",
|
"exit.manualOpen": "Manual barrier open (human intervention)",
|
||||||
|
"exit.plateSwapSuspected": "Possible ticket swap — plate {{plate}} is already inside under ticket {{otherIdentity}}",
|
||||||
|
"exit.plateSwapOverride": "Operator {{operator}} released a suspected ticket-swap exit (plate {{plate}}, also open under {{otherIdentity}})",
|
||||||
"sub.refused.notFound": "Subscription refused — not found",
|
"sub.refused.notFound": "Subscription refused — not found",
|
||||||
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
"sub.refused.outOfWindow": "Subscription refused — {{status}}/out-of-window",
|
||||||
"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)",
|
||||||
@@ -944,6 +952,10 @@ export const en: Catalog = {
|
|||||||
lookingUp: "looking up…",
|
lookingUp: "looking up…",
|
||||||
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
paidBarrierOpened: "Paid — barrier opened. Car may exit.",
|
||||||
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
paidExitRecorded: "Paid and exit recorded, but the barrier did not open: {{reason}}.",
|
||||||
|
swapTitle: "Possible ticket swap",
|
||||||
|
swapBody: "Plate {{plate}} is already inside under ticket {{other}} (entered {{when}}). This car may be exiting on a different ticket than it entered on.",
|
||||||
|
swapHint: "Verify the vehicle before releasing. Overriding is recorded against you.",
|
||||||
|
swapOverride: "Override & release",
|
||||||
subscription: "SUBSCRIPTION",
|
subscription: "SUBSCRIPTION",
|
||||||
plan: "Plan",
|
plan: "Plan",
|
||||||
prepaid: "PREPAID",
|
prepaid: "PREPAID",
|
||||||
|
|||||||
@@ -193,6 +193,10 @@ export const sq = {
|
|||||||
fEvtAnomaly: "Anomali",
|
fEvtAnomaly: "Anomali",
|
||||||
openPayExit: "Hap pagesën / daljen",
|
openPayExit: "Hap pagesën / daljen",
|
||||||
openReopenBarrier: "Hap — paguar, pret barrierën",
|
openReopenBarrier: "Hap — paguar, pret barrierën",
|
||||||
|
issueEntry: "Lësho biletë",
|
||||||
|
issueEntryTitle: "Lësho një biletë hyrjeje & hap barrierën (butoni fizik i prishur)",
|
||||||
|
issueEntryConfirm: "Një automjet është te hyrja. Të lëshohet një biletë hyrjeje dhe të hapet barriera?",
|
||||||
|
issueEntryOk: "Bileta e hyrjes {{ticket}} u lëshua.",
|
||||||
exitedGrace: "doli · në afat",
|
exitedGrace: "doli · në afat",
|
||||||
exitedGraceLeft: "doli · {{time}}",
|
exitedGraceLeft: "doli · {{time}}",
|
||||||
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
exitedGraceTitle: "Paguar dhe dalur — barriera nuk u konfirmua; po pret afatin kohor.",
|
||||||
@@ -278,6 +282,8 @@ export const sq = {
|
|||||||
reason: {
|
reason: {
|
||||||
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
"entry.refused.full": "Hyrja u refuzua — parkimi plot ({{count}}/{{capacity}})",
|
||||||
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
"entry.held.noTicket": "Hyrja u mbajt — bileta nuk u printua: {{detail}}",
|
||||||
|
"entry.operatorIssued": "Biletë hyrjeje e lëshuar nga operatori {{operator}} (butoni fizik i prishur)",
|
||||||
|
"entry.issue.noPresence": "Hyrja nga operatori u refuzua — asnjë automjet te hyrja",
|
||||||
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
"exit.refused.closed": "Dalja u refuzua — sesioni është mbyllur tashmë",
|
||||||
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
"exit.refused.noSession": "Dalja u refuzua — biletë e panjohur",
|
||||||
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
"exit.refused.unpaid": "Dalja u refuzua — e papaguar (bëj pagesën në fillim)",
|
||||||
@@ -287,6 +293,8 @@ export const sq = {
|
|||||||
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
"exit.open.failed": "Dalja u regjistrua, por barriera nuk u hap — hape manualisht",
|
||||||
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
"exit.freeGrace": "Periudhë pa pagesë në hyrje (pa tarifë)",
|
||||||
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
"exit.manualOpen": "Hapje manuale e barrierës (ndërhyrje njerëzore)",
|
||||||
|
"exit.plateSwapSuspected": "Mundësi ndërrimi biletash — targa {{plate}} është tashmë brenda me biletën {{otherIdentity}}",
|
||||||
|
"exit.plateSwapOverride": "Operatori {{operator}} lëshoi një dalje me dyshim ndërrimi biletash (targa {{plate}}, edhe e hapur me {{otherIdentity}})",
|
||||||
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
"sub.refused.notFound": "Abonimi u refuzua — nuk u gjet",
|
||||||
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
"sub.refused.outOfWindow": "Abonimi u refuzua — {{status}}/jashtë afatit",
|
||||||
"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ë)",
|
||||||
@@ -960,6 +968,10 @@ export const sq = {
|
|||||||
lookingUp: "Duke kërkuar…",
|
lookingUp: "Duke kërkuar…",
|
||||||
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
paidBarrierOpened: "Paguar — barriera u hap. Automjeti mund të dalë.",
|
||||||
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
paidExitRecorded: "Paguar dhe dalja u regjistrua, por barriera nuk u hap: {{reason}}.",
|
||||||
|
swapTitle: "Mundësi ndërrimi biletash",
|
||||||
|
swapBody: "Targa {{plate}} është tashmë brenda me biletën {{other}} (hyri {{when}}). Ky automjet mund të jetë duke dalë me një biletë tjetër nga ajo me të cilën hyri.",
|
||||||
|
swapHint: "Verifiko automjetin para se ta lëshosh. Anashkalimi regjistrohet në emrin tënd.",
|
||||||
|
swapOverride: "Anashkalo & lësho",
|
||||||
subscription: "ABONIM",
|
subscription: "ABONIM",
|
||||||
plan: "Plani",
|
plan: "Plani",
|
||||||
prepaid: "I PARAPAGUAR",
|
prepaid: "I PARAPAGUAR",
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Operator-issued entry (2026-07-01): when the physical entry button is broken, an operator
|
||||||
|
-- may ISSUE an entry ticket (a flagged mint, gated on real vehicle presence — radar + camera).
|
||||||
|
-- New permission `session:create` in @parking/shared. The built-in `admin` role gets ALL
|
||||||
|
-- permissions in code (auth.ts ADMIN_PERMS), so no seed row is needed for it. This grants the
|
||||||
|
-- default `operator` role the ability to issue — an admin can revoke it per-role in the Roles
|
||||||
|
-- UI (it's data). Idempotent via the UNIQUE(role_id, permission) index.
|
||||||
|
-- See wiki/concepts/operator-issued-entry.md.
|
||||||
|
INSERT OR IGNORE INTO `role_permissions` (`role_id`, `permission`) VALUES
|
||||||
|
('operator','session:create');
|
||||||
@@ -134,6 +134,13 @@
|
|||||||
"when": 1781886100000,
|
"when": 1781886100000,
|
||||||
"tag": "0018_drawer_permissions",
|
"tag": "0018_drawer_permissions",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 19,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1781886200000,
|
||||||
|
"tag": "0019_operator_session_create",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -62,7 +62,10 @@ export const PERMISSIONS: readonly Permission[] = [
|
|||||||
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
|
// a judgment about the operator, settled outside the app. See wiki/concepts/shift.md.
|
||||||
"drawer:create", "drawer:review",
|
"drawer:create", "drawer:review",
|
||||||
"payment:read", "payment:create",
|
"payment:read", "payment:create",
|
||||||
"session:read",
|
// session:create = the operator ISSUES an entry ticket when the physical entry button
|
||||||
|
// is broken (a flagged mint, gated on real vehicle presence). Admin-revocable per role.
|
||||||
|
// See wiki/concepts/operator-issued-entry.md.
|
||||||
|
"session:read", "session:create",
|
||||||
"event:read", "event:void",
|
"event:read", "event:void",
|
||||||
"report:read",
|
"report:read",
|
||||||
"log:read",
|
"log:read",
|
||||||
@@ -360,6 +363,10 @@ export const REASON_CODES = [
|
|||||||
// entry
|
// entry
|
||||||
"entry.refused.full",
|
"entry.refused.full",
|
||||||
"entry.held.noTicket",
|
"entry.held.noTicket",
|
||||||
|
// operator-issued entry (physical button broken) — a flagged mint, gated on real
|
||||||
|
// vehicle presence (radar + camera). See wiki/concepts/operator-issued-entry.md.
|
||||||
|
"entry.operatorIssued",
|
||||||
|
"entry.issue.noPresence",
|
||||||
// exit refusals
|
// exit refusals
|
||||||
"exit.refused.closed",
|
"exit.refused.closed",
|
||||||
"exit.refused.noSession",
|
"exit.refused.noSession",
|
||||||
@@ -373,6 +380,11 @@ export const REASON_CODES = [
|
|||||||
"exit.freeGrace",
|
"exit.freeGrace",
|
||||||
// manual / human-intervention barrier open
|
// manual / human-intervention barrier open
|
||||||
"exit.manualOpen",
|
"exit.manualOpen",
|
||||||
|
// plate reconciliation: the exiting car's plate is already OPEN under a DIFFERENT
|
||||||
|
// ticket (possible ticket-swap fraud). Suspected = flagged; Override = operator
|
||||||
|
// consciously released it. See wiki/concepts/plate-reconciliation.md.
|
||||||
|
"exit.plateSwapSuspected",
|
||||||
|
"exit.plateSwapOverride",
|
||||||
// subscriptions
|
// subscriptions
|
||||||
"sub.refused.notFound",
|
"sub.refused.notFound",
|
||||||
"sub.refused.outOfWindow",
|
"sub.refused.outOfWindow",
|
||||||
@@ -397,6 +409,8 @@ export type ReasonCode = (typeof REASON_CODES)[number];
|
|||||||
export const REASON_EN: Record<ReasonCode, string> = {
|
export const REASON_EN: Record<ReasonCode, string> = {
|
||||||
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
|
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
|
||||||
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
|
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
|
||||||
|
"entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
|
||||||
|
"entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry",
|
||||||
"exit.refused.closed": "exit refused — session already closed",
|
"exit.refused.closed": "exit refused — session already closed",
|
||||||
"exit.refused.noSession": "exit refused — no open session for ticket",
|
"exit.refused.noSession": "exit refused — no open session for ticket",
|
||||||
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
|
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
|
||||||
@@ -406,6 +420,8 @@ export const REASON_EN: Record<ReasonCode, string> = {
|
|||||||
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
|
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
|
||||||
"exit.freeGrace": "free entry-grace (no charge)",
|
"exit.freeGrace": "free entry-grace (no charge)",
|
||||||
"exit.manualOpen": "manual barrier open (human intervention)",
|
"exit.manualOpen": "manual barrier open (human intervention)",
|
||||||
|
"exit.plateSwapSuspected": "possible ticket swap — plate {plate} is already inside under ticket {otherIdentity}",
|
||||||
|
"exit.plateSwapOverride": "operator {operator} released a suspected ticket-swap exit (plate {plate}, also open under {otherIdentity})",
|
||||||
"sub.refused.notFound": "subscription refused — not found",
|
"sub.refused.notFound": "subscription refused — not found",
|
||||||
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
||||||
"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)",
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ editable and drifts; the chain is the truth). Spaces-free = `capacity − occupa
|
|||||||
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
|
diverge from physical reality. The count is the *system's* occupancy; periodic ground-truth (a
|
||||||
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
loop count, or the [[opencv-anpr-service|vision]] count) reconciles it — surfaced as an anomaly,
|
||||||
not silently corrected.
|
not silently corrected.
|
||||||
|
- **A DELIBERATE drift attack — the ticket swap:** a paid car let out on a fresh $0 ticket leaves its
|
||||||
|
original ticket "inside" forever, inflating occupancy by phantom cars. Defended by
|
||||||
|
[[plate-reconciliation]] (the exiting plate is already open under the original ticket → flag/hold).
|
||||||
|
|
||||||
## Reserved subscriber spots (admin toggle, built 2026-06-20)
|
## Reserved subscriber spots (admin toggle, built 2026-06-20)
|
||||||
|
|
||||||
|
|||||||
@@ -157,4 +157,5 @@ as "⚠ camera unreachable" tiles (see [[booth-console]]).
|
|||||||
|
|
||||||
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
[[entry-exit-readers]] · [[device-events]] · [[parking-session]] · [[anti-passback]] ·
|
||||||
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
|
[[append-only-event-chain]] · [[barrier-not-a-door]] · [[opencv-anpr-service]] ·
|
||||||
[[dingtian-relay]] · [[first-run-setup]]
|
[[dingtian-relay]] · [[first-run-setup]] · [[operator-issued-entry]] (mint when the button
|
||||||
|
is broken) · [[plate-reconciliation]] (the entry snapshot's plate defends the exit)
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, booth, entry, threat-model, anpr, presence]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-01
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Operator-issued entry (broken entry button)
|
||||||
|
|
||||||
|
When the physical entry button is broken, an operator can **issue an entry ticket** from the booth so
|
||||||
|
a real car isn't blocked out of the lot. This hands the [[threat-model|operator (the adversary)]] a way
|
||||||
|
to mint entries — so it is **flagged, presence-gated, and paired with an exit defense**
|
||||||
|
([[plate-reconciliation]]). Built 2026-07-01. Companion to [[entry-exit-points]] (the entry flow it
|
||||||
|
reuses) and [[capacity-occupancy]].
|
||||||
|
|
||||||
|
## Why give the operator this at all
|
||||||
|
An operator *could* mint tickets to defraud — but a broken entry button otherwise **blocks the whole
|
||||||
|
lot**, which is worse and more common. So the feature exists, and the fraud it enables is defended
|
||||||
|
downstream (see the "ticket-swap" scenario in [[plate-reconciliation]]) rather than by withholding the
|
||||||
|
capability.
|
||||||
|
|
||||||
|
## The three controls that make it safe
|
||||||
|
|
||||||
|
### 1. PRESENCE-GATED — a real car must be there (radar AND camera)
|
||||||
|
The operator button obeys the **same rule as the physical button**: it is only active when **BOTH**
|
||||||
|
presence conditions meet —
|
||||||
|
- **radar/loop present** (a presence input is shorted at the entry barrier), AND
|
||||||
|
- **camera confirms** a vehicle in the zone (the entry lane is "busy").
|
||||||
|
|
||||||
|
This ties every mint to a **real vehicle physically at the entry** — the operator can't pad occupancy
|
||||||
|
with phantom tickets, and (crucially) it guarantees the entry snapshot captures a **plate**, which is
|
||||||
|
what [[plate-reconciliation]] reads at exit. **No presence loop configured → the feature is
|
||||||
|
unavailable** at that site (we require both; no weaker camera-only fallback).
|
||||||
|
|
||||||
|
**Enforced on BOTH sides.** The UI only enables the entry [[booth-console|BarrierLight]] as a clickable
|
||||||
|
issue-control when `radar.entry && lanes.entry` (both true) and the operator holds `session:create`.
|
||||||
|
The **server re-checks** current presence (`LaneStatus.snapshot().entry === true` AND the entry relay's
|
||||||
|
guard `present === true`) and **refuses** otherwise — so a direct `POST /api/entry/issue` by the
|
||||||
|
operator-adversary can't bypass a disabled button. A refused (no-presence) attempt signs an
|
||||||
|
`anomaly` (`entry.issue.noPresence`) so probing the endpoint is itself in the tamper-evident record.
|
||||||
|
|
||||||
|
### 2. FLAGGED — every operator mint leaves a red-flag row
|
||||||
|
The issued entry is a **real** `vehicle_entry` (so occupancy/tariff/exit all work), but:
|
||||||
|
- `source: "manual"` + `operatorInitiated: true` + `operator` on the signed payload, AND
|
||||||
|
- a **companion `anomaly`** (`entry.operatorIssued`) — mirroring the [[booth-exit-flow|barrier
|
||||||
|
re-open]]: the operator-adversary path always leaves an explicit anomaly for [[reconciliation]].
|
||||||
|
|
||||||
|
### 3. Capacity OVERRIDE is allowed but recorded
|
||||||
|
Unlike the physical button (which refuses transient entry when the lot is [[capacity-occupancy|full]]),
|
||||||
|
the operator **can** issue over capacity — a broken button mustn't trap a legit car when the count is
|
||||||
|
near/at the cap (and the count may itself be inflated by the very fraud this defends). But an over-cap
|
||||||
|
mint stamps `lotFull: true` + the occupancy on the events, so the override is visible.
|
||||||
|
|
||||||
|
## Wiring
|
||||||
|
- **Permission:** `session:create` (new; migration 0019 grants it to the default `operator` role;
|
||||||
|
admin-revocable per role, so an admin can turn off an operator's ability to mint). Admin has it in code.
|
||||||
|
- **Route:** `POST /api/entry/issue` — `session:create` + an **open shift** (a minted entry belongs to
|
||||||
|
an accountable operator, like the money path).
|
||||||
|
- **Server:** `EntryFlow.issueForOperator(operator, cameraBusy)`. The fraud-critical
|
||||||
|
print → sign(vehicle_entry) → pulseOpen → snapshot → cache sequence is a **single shared
|
||||||
|
`#issueTicket`** used by both the physical button and this path (no divergent copy).
|
||||||
|
- **UI:** the entry `BarrierLight` becomes clickable (confirm → issue) only when presence + permission +
|
||||||
|
shift are satisfied; the exit light stays a pure indicator.
|
||||||
|
|
||||||
|
## Relates
|
||||||
|
- [[plate-reconciliation]] — the exit-side defense against the ticket-swap this capability enables.
|
||||||
|
- [[entry-exit-points]] — the entry flow + snapshot/ANPR path reused here.
|
||||||
|
- [[capacity-occupancy]] — why occupancy integrity matters (the swap fraud drifts it upward).
|
||||||
|
- [[threat-model]] — the operator-adversary framing all three controls serve.
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
---
|
||||||
|
type: concept
|
||||||
|
tags: [parking, anpr, exit, threat-model, reconciliation, fraud]
|
||||||
|
sources: []
|
||||||
|
updated: 2026-07-01
|
||||||
|
status: settled
|
||||||
|
---
|
||||||
|
|
||||||
|
# Plate reconciliation at exit (ticket-swap defense)
|
||||||
|
|
||||||
|
Uses the ANPR **plate as an invariant** to catch a **ticket-swap fraud**: the car's plate is the same
|
||||||
|
regardless of which ticket it holds, so if a car tries to exit on a ticket whose plate is **already
|
||||||
|
inside under a different ticket**, something is wrong. Built 2026-07-01 alongside
|
||||||
|
[[operator-issued-entry]] (the capability that makes the fraud easy). The [[threat-model|adversary is
|
||||||
|
the operator]], but the same swap happens innocently (two people mix up tickets).
|
||||||
|
|
||||||
|
## The fraud (worked scenario)
|
||||||
|
A lot with 1000 spots:
|
||||||
|
1. Real car enters on ticket **1234** → ANPR records plate **AA123BB** at entry.
|
||||||
|
2. Car comes to exit owing 10,000 ALL. Operator scans 1234, **pockets the cash, does NOT record the
|
||||||
|
payment**.
|
||||||
|
3. Operator **mints a fresh ticket 1237** (age ≈ 0 → owes ~0) and lets the car out on 1237.
|
||||||
|
4. **1234 lingers "inside" forever** — a phantom car. Repeat → +100, +200 phantom cars; occupancy
|
||||||
|
becomes meaningless and the operator skims cash while the books look internally consistent (a ticket
|
||||||
|
was "paid" — 1237 for 0; a ticket is "inside" — 1234).
|
||||||
|
|
||||||
|
The plate is what the swap can't hide: entry-1234 = AA123BB, and the car exiting on 1237 **is** AA123BB.
|
||||||
|
|
||||||
|
## The check
|
||||||
|
`ExitFlow.#reconcilePlateAtExit(exitingId)`:
|
||||||
|
1. Resolve the **exiting** ticket's plate (its own exit read, else its entry read).
|
||||||
|
2. Enumerate all **currently-open** sessions (projection cache) and their **entry** plates
|
||||||
|
(`platesForIdentities`).
|
||||||
|
3. If the exiting plate **exactly** matches an open session under a **DIFFERENT** identity → **swap
|
||||||
|
suspected**, returning `{ plate, otherIdentity, otherEnteredAt }`.
|
||||||
|
|
||||||
|
**EXACT, HIGH-CONFIDENCE only.** Both the exiting read AND the matched session's entry read must be
|
||||||
|
≥ `PLATE_MATCH_MIN_CONFIDENCE` (0.85), normalized exact string match. No fuzzy/edit-distance matching.
|
||||||
|
Rationale: ANPR is **advisory and misses** (G3H snapshot 503s, camera-side push failures, no-plate
|
||||||
|
reads — see the ANPR memory notes). A fuzzy/low-confidence read must **never** be the reason a car is
|
||||||
|
held — so a shaky read simply doesn't trigger the warning (fails toward not-annoying).
|
||||||
|
|
||||||
|
## What happens on a suspected swap
|
||||||
|
|
||||||
|
### Booth path (operator-mediated) — FLAG LOUDLY + require an override
|
||||||
|
Exit fails-OPEN for safety and a plate is **never the sole gate**, so we do **not** silently hard-block
|
||||||
|
(that would trap a legit car on a bad read). Instead:
|
||||||
|
- `exitForBooth` returns status **`swap_suspected`** with the detail; the barrier does **not** open.
|
||||||
|
- A **`anomaly` (`exit.plateSwapSuspected`)** is signed immediately — so even if the operator walks
|
||||||
|
away, the suspicion is in the tamper-evident record.
|
||||||
|
- The pay/exit modal shows a **prominent red warning** ("Plate AA123BB is already inside under ticket
|
||||||
|
1234, entered 3h ago") with an explicit **"Override & release"** action.
|
||||||
|
- On override, `exitForBooth(id, { override, operator })` proceeds AND signs an attributed
|
||||||
|
**`anomaly` (`exit.plateSwapOverride`)** — the override is itself a signed, named decision.
|
||||||
|
|
||||||
|
### Reader path (automated, no operator) — LOG-ONLY, fail-open
|
||||||
|
At an unmanned exit lane there's no one to make the override decision, and exit fails-open, so the
|
||||||
|
reader path **signs the `exit.plateSwapSuspected` anomaly and still lets the car out**. The anomaly is
|
||||||
|
the control there (a manager reconciles it later). This is a smaller surface — the fraud scenario is
|
||||||
|
booth-mediated.
|
||||||
|
|
||||||
|
## Why this is the right shape
|
||||||
|
- **Occupancy stops drifting.** A swap can no longer silently strand ticket 1234 "inside" — the exit
|
||||||
|
attempt on 1237 surfaces it. Directly serves [[capacity-occupancy]] integrity.
|
||||||
|
- **The signed anomaly is the audit signal** a manager reconciles ([[reconciliation]]) — consistent
|
||||||
|
with "the fraud control lives in the signed chain + human review, not a real-time hard gate".
|
||||||
|
- **Advisory-not-a-gate is preserved both ways:** a plate never *opens* a barrier by itself, and now a
|
||||||
|
plate never *traps* a car by itself either (flag + override, never a silent hard block).
|
||||||
|
|
||||||
|
## Relates
|
||||||
|
- [[operator-issued-entry]] — the capability whose fraud this defends.
|
||||||
|
- [[capacity-occupancy]] — occupancy integrity the swap attacks.
|
||||||
|
- [[reconciliation]] — where the signed anomalies are ultimately settled.
|
||||||
|
- [[entry-exit-points]] — the ANPR-on-snapshot path that records the plates compared here.
|
||||||
|
- [[threat-model]] — operator-as-adversary.
|
||||||
@@ -88,6 +88,8 @@ Counts: 4 sources · 19 entities · 46 concepts · 7 decision records.
|
|||||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||||
|
- [[operator-issued-entry]] — operator mints an entry ticket when the physical button is broken; presence-gated (radar AND camera, both sides), flagged (source=manual + operatorInitiated + anomaly), capacity-override allowed; needs `session:create` (2026-07-01).
|
||||||
|
- [[plate-reconciliation]] — ANPR plate-as-invariant catches the ticket-swap fraud (paid car let out on a fresh $0 ticket, original lingers "inside"); exact/high-conf match vs open sessions; booth = flag + operator override, reader = log-only fail-open (2026-07-01).
|
||||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01).
|
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts. Drawer cash movements (operator records, admin reviews via signed cash_review — a flag, not a reversal) live at the /drawer route (2026-07-01).
|
||||||
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
|
- [[card-payments]] — card tender DISABLED (no P2PE POS on-site yet, 2026-07-01); cash-only UI gate (`CARD_PAYMENTS_ENABLED`); future POS keeps PCI scope out of the app; how to re-enable.
|
||||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||||
|
|||||||
+31
@@ -2108,3 +2108,34 @@ DrawerManager.tsx, drawer.* i18n (sq+en). Verified: full monorepo build/lint/tes
|
|||||||
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
|
tests incl. the op1-denied → op2-drawer-unchanged regression); Playwright end-to-end on /drawer
|
||||||
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
|
(record disbursement → pending → authorize → status flips, ledger shows cash_out + cash_review with no
|
||||||
authorizedBy). Recorded in shift.md "Drawer review".
|
authorizedBy). Recorded in shift.md "Drawer review".
|
||||||
|
|
||||||
|
## [2026-07-01] feat | Operator-issued entry + exit plate-swap reconciliation (one anti-fraud design)
|
||||||
|
|
||||||
|
Two halves of one design. (A) When the physical entry button is broken, an operator can ISSUE an entry
|
||||||
|
ticket so a real car isn't blocked out of the lot — but this hands the operator-adversary a mint, so
|
||||||
|
it's (1) PRESENCE-GATED exactly like the physical button (radar/loop present AND camera busy = a real
|
||||||
|
car; enforced BOTH sides, server re-checks so a direct POST can't bypass a disabled button; no presence
|
||||||
|
loop → feature unavailable; a no-presence attempt signs an entry.issue.noPresence anomaly), (2) FLAGGED
|
||||||
|
(vehicle_entry source=manual + operatorInitiated + operator, PLUS a companion entry.operatorIssued
|
||||||
|
anomaly), (3) capacity-OVERRIDE allowed but stamped lotFull (a broken button mustn't trap a legit car).
|
||||||
|
New session:create permission (migration 0019 → operator role; admin-revocable), POST /api/entry/issue
|
||||||
|
(open-shift gated), EntryFlow.issueForOperator; the fraud-critical print→sign→open→snapshot sequence
|
||||||
|
factored into one shared #issueTicket (button + operator). UI: the entry BarrierLight becomes a
|
||||||
|
clickable issue-control when presence+permission+shift meet (confirm → issue).
|
||||||
|
|
||||||
|
(B) Plate-swap fraud (user's scenario): operator scans exiting ticket 1234 (owes 10000), pockets cash
|
||||||
|
WITHOUT recording payment, mints fresh 1237 (owes ~0), lets the car out on 1237 → 1234 lingers "inside"
|
||||||
|
forever, occupancy drifts up by phantom cars. Defense = ANPR plate as invariant: the car's plate is the
|
||||||
|
same either way. ExitFlow.#reconcilePlateAtExit compares the exiting plate against all OPEN sessions'
|
||||||
|
entry plates — EXACT, HIGH-CONFIDENCE only (≥0.85; a fuzzy/low read never gates, ANPR is advisory). On
|
||||||
|
a match under a DIFFERENT ticket: BOOTH path returns swap_suspected + signs exit.plateSwapSuspected
|
||||||
|
anomaly + the pay/exit modal shows a red warning with "Override & release" (override signs an attributed
|
||||||
|
exit.plateSwapOverride) — flag+override, never a silent hard block (exit fails-open, plate never the
|
||||||
|
sole gate). READER path (no operator) = log-only anomaly + fail-open (user's call). Extended
|
||||||
|
BoothExitResult + /api/exit (override param), boothExit client returns a structured swap result.
|
||||||
|
|
||||||
|
Verified: full monorepo build/lint/test green (229 server tests incl. 4 new: hold-on-swap,
|
||||||
|
override-releases-with-attribution, low-confidence-no-warning, own-plate-no-warning). New wiki pages
|
||||||
|
operator-issued-entry.md + plate-reconciliation.md; cross-linked from entry-exit-points,
|
||||||
|
capacity-occupancy, index. Preserves "a plate never OPENS a barrier alone — and now never TRAPS a car
|
||||||
|
alone either."
|
||||||
|
|||||||
Reference in New Issue
Block a user