import { randomInt, randomUUID } from "node:crypto"; import { deviceEvents as deviceEventsTable, eq, sessions, siteConfig, type Db, type DeviceRow } from "@parking/db"; import { NoPrinterAvailableError, printWithFailover, registry, type AccessControlDevice, type PrinterDevice, type PrinterInstance, type TicketData, type TicketHeader, } from "@parking/devices"; import { DEFAULT_VEHICLE_CATEGORY, reasonPayload } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { DeviceInputEvent, LaneStatusEvent } from "./device-events.js"; import { getOccupancy } from "./occupancy.js"; import type { EventLog } from "./event-log.js"; import { devicesByDirection, firstRelayByDirection, relayForButton, relayForPresence, type ResolvedRelay } from "./device-resolve.js"; import { snapshotAsync } from "./snapshot.js"; import type { VisionClient } from "./vision-client.js"; // The transient ENTRY flow: a button press → print a ticket → sign a vehicle_entry // → open the barrier. The button is wired into an access controller's input; the // admin maps that input terminal to a relay (config.relays[].button), so a press // resolves to exactly the entry relay it should open. See entry-exit-points.md. // // Two invariants from the threat model + safety analysis: // 1. SIGNED BEFORE OPEN — the vehicle_entry is appended to the signed ledger // BEFORE pulseOpen fires; an open with no matching signed event is the fraud // signal (wiki/concepts/append-only-event-chain.md). // 2. HOLD ON PRINT FAILURE — a transient with no ticket can't pay on exit, so if // all printers are down we do NOT open. We sign an `anomaly` (attempt, ticket // unprinted) and leave the barrier closed; the operator handles the held car. // Crucially, NO vehicle_entry is written in that case — we never record an // "entered" event for a car that didn't get in (decision 2026-06-15). // // Ordering: print → (ok) sign vehicle_entry → pulseOpen → snapshot → cache session. // (fail) sign anomaly, stop. // // ONE CAR = ONE TICKET (anti-double-press). The entry button can be physically held // or mashed; without a guard each press mints a fresh ticket + signed vehicle_entry // (corrupting occupancy and letting a transient shop the cheapest ticket at exit). The // guard is per-relay and CONFIGURED on the relay spec (config.relays[]), chosen by what // barrier feedback exists at the lane: // - PRESENCE loop (preferred): `presenceInput` ties ticketing to a real vehicle. A // press prints only while a car is present, and NO second ticket issues until the // loop CLEARS (car drove in) and a new car re-occupies it. We observe the loop's // input edges to track presence + "armed" per relay. // - COOLDOWN (fallback, no feedback): `entryCooldownSec` suppresses repeat presses on // the relay for N seconds after a ticket. A timer — mitigation, not a guarantee. // When a loop IS wired the cooldown still runs as a BACKSTOP behind it: a motion // radar can drop a STATIONARY car (no doppler return) and spuriously re-arm, and the // cooldown bounds how fast that re-armed press can mint a second ticket. // - CAMERA (when an entry camera is configured): a press is live only while the entry // lane camera confirms a vehicle — the button lamp's SOLID state (button-light.ts). // A radar false-positive (rain, a pedestrian) blinks the lamp but prints nothing. // Camera-less sites keep the radar-only gate; a faulty camera is dropped via the // admin bypass (wiki/concepts/entry-presence-bypass.md). // A suppressed press is recorded as UNSIGNED telemetry (a no-op, not a fraud anomaly). // See wiki/concepts/entry-double-press.md. /** A presence signal the entry gate can require (or, when a device is faulty, the admin * can bypass): the radar/loop presence input, or the camera vehicle-detection. */ export type PresenceSignal = "radar" | "camera"; /** Per-relay anti-double-press state, keyed `controllerId:relay`. */ interface RelayGuardState { /** Last successful ticket time (ms epoch) — drives the cooldown check. */ lastTicketAt: number; /** PRESENCE mode: is a vehicle currently on the loop? (from loop input edges) */ present: boolean; /** PRESENCE mode: ready to issue a ticket for a NEW car. Set false after a ticket * prints; re-armed when the loop CLEARS (the car drove through). */ armed: boolean; } export class EntryFlow { readonly #db: Db; readonly #log: EventLog; readonly #logger: FastifyBaseLogger; /** Guard against double-fire from the same physical press (on edge only). */ readonly #inFlight = new Set(); /** Per-relay one-car-one-ticket state (presence + cooldown), keyed controllerId:relay. */ readonly #guard = new Map(); /** Optional vision client — passed to snapshotAsync so ANPR runs on the entry image. */ readonly #vision: VisionClient | null; /** Live entry-lane camera state (LaneStatus mirror, fed by onLaneStatus). Gates the * physical press when an entry camera is configured — advisory sensor, but here it * only ever SUPPRESSES a reprint; it never opens a barrier or traps a car. */ #entryBusy = false; constructor(db: Db, log: EventLog, logger: FastifyBaseLogger, vision: VisionClient | null = null) { this.#db = db; this.#log = log; this.#logger = logger; this.#vision = vision; } /** Handle a device input edge. Two kinds of edge matter to this flow: * (1) an ENTRY BUTTON press (rising edge) → run entry, subject to the per-relay * anti-double-press guard; (2) a PRESENCE LOOP edge (either direction) → update * presence state so the guard knows when a car arrives/leaves. The same physical * input is never both, so we resolve each independently. */ async onInput(e: DeviceInputEvent): Promise { // Presence-loop edge (both directions matter): keep the per-relay state current. const presence = relayForPresence(this.#db, e.deviceId, e.input); if (presence) { this.#onPresenceEdge(presence, e.edge); return; // a loop input is not a button — nothing else to do } if (e.edge !== "on") return; // for buttons, the release edge is just telemetry // The firing device must be an access controller, and the pressed input terminal // must map to an ENTRY (or both) relay — that's an entry button. Anything else // (reader/printer edge, exit-only relay's input) is not a transient-entry trigger. const resolved = relayForButton(this.#db, e.deviceId, e.input); if (!resolved) return; // ANTI-DOUBLE-PRESS: is this press allowed to issue a ticket? (presence/cooldown) const suppressed = this.#suppressReason(resolved); if (suppressed) { this.#recordSuppressedPress(e, resolved, suppressed); this.#logger.info(`entry press suppressed (${this.#relayKey(resolved)}): ${suppressed}`); return; } const key = `${e.deviceId}:${e.input}`; if (this.#inFlight.has(key)) return; // ignore re-fire while one is processing this.#inFlight.add(key); try { await this.#runEntry(resolved); } catch (err) { this.#logger.error(`entry-flow failed: ${(err as Error).message}`); } finally { this.#inFlight.delete(key); } } /** Track the entry lane's camera state (wired to deviceEvents.onLaneStatus in * server.ts). LaneStatus emits on every flip, so this mirror stays current. */ onLaneStatus(s: LaneStatusEvent): void { this.#entryBusy = s.entry; } /** Stable per-relay key for the guard map. */ #relayKey(r: ResolvedRelay): string { return `${r.controller.id}:${r.relay}`; } /** Lazily get (or create) the guard state for a relay. New relays start ARMED and * with no car present, so the first press on a fresh lane works immediately. */ #guardState(r: ResolvedRelay): RelayGuardState { const key = this.#relayKey(r); let s = this.#guard.get(key); if (!s) { s = { lastTicketAt: 0, present: false, armed: true }; this.#guard.set(key, s); } return s; } /** Apply a presence-loop edge to a relay's state. The car ARRIVING re-arms ticketing; * the car LEAVING the loop (after its entry) re-arms for the NEXT car. */ #onPresenceEdge(r: ResolvedRelay, edge: "on" | "off"): void { const s = this.#guardState(r); if (edge === "on") { s.present = true; // a vehicle is at the barrier } else { // Loop cleared: the car drove through (or backed off). Re-arm for the next car — // this is the gate that makes a *new* car necessary before another ticket. s.present = false; s.armed = true; } } /** Why a press should be SUPPRESSED (no ticket), or null if it may proceed. * Three layered gates: CAMERA (when an entry camera is configured), PRESENCE * (when a loop is wired), and COOLDOWN — no longer alternatives: the cooldown * runs as a backstop BEHIND presence, because a motion radar can drop a * stationary car and spuriously re-arm one-car-one-ticket. */ #suppressReason(r: ResolvedRelay): string | null { const s = this.#guardState(r); const bypass = this.#presenceBypass(); // CAMERA GATE — the lamp's blink-vs-solid rule, enforced at the press: with an entry // camera configured, a press is live only once the camera confirms a vehicle in the // entry zone (SOLID). Blink (radar-only — rain, a pedestrian, a reflection) prints // nothing. Only ever suppresses a ticket; never opens or traps (advisory rule kept). // A camera-less site skips this; a faulty camera is dropped via the admin bypass. if (!bypass.camera && !this.#entryBusy && this.#entryCameraConfigured()) { return "no camera-confirmed vehicle in the entry zone"; } // Admin bypass for a FAULTY radar/loop: skip the presence-loop check so a press prints. // A dead loop can't re-arm one-car-one-ticket, so the cooldown below is what stops a // held button minting a burst. If no cooldown is configured there's no anti-double-press // left — that's the admin's accepted tradeoff while bypassed. See // wiki/concepts/entry-presence-bypass.md. if (typeof r.presenceInput === "number" && !bypass.radar) { // Physical one-car-one-ticket: a car must be present AND we must be armed (no // ticket already issued for this still-present car). if (!s.present) return "no vehicle at the barrier (presence loop clear)"; if (!s.armed) return "ticket already issued for the car at the barrier"; // Fall THROUGH to the cooldown backstop: a presence-approved press can still be the // SAME stationary car after a radar dropout re-armed the guard. } if (typeof r.entryCooldownSec === "number" && r.entryCooldownSec > 0) { const elapsed = Date.now() - s.lastTicketAt; if (elapsed < r.entryCooldownSec * 1000) { const remain = Math.ceil((r.entryCooldownSec * 1000 - elapsed) / 1000); return `within ${r.entryCooldownSec}s entry cooldown (${remain}s left)`; } } return null; } /** Is at least one enabled camera bound to the entry lane? The camera gate applies only * then — a site with no entry camera keeps the radar-only press gate. Read live (like * the bypass flags) so adding/removing a camera needs no restart. */ #entryCameraConfigured(): boolean { return devicesByDirection(this.#db, "camera", "entry").length > 0; } /** Record a suppressed (repeat/no-car) entry press as UNSIGNED telemetry — a no-op, * not a fraud anomaly, so the signed ledger stays clean (the operator's choice). */ #recordSuppressedPress(e: DeviceInputEvent, r: ResolvedRelay, reason: string): void { try { this.#db .insert(deviceEventsTable) .values({ id: randomUUID(), deviceId: e.deviceId, category: "access", kind: "input", detail: { driverId: e.driverId, input: e.input, edge: e.edge, entrySuppressed: true, relay: r.relay, reason, }, occurredAt: e.at, }) .run(); } catch (err) { this.#logger.error(`suppressed-press telemetry insert failed: ${(err as Error).message}`); } } async #runEntry(resolved: ResolvedRelay): Promise { // CAPACITY GATE (transient only). When the lot is full, refuse transient entry: // no ticket, no vehicle_entry, no open — sign an anomaly. Subscribers are NOT // gated here (their flow ignores site-full; their own maxConcurrent applies), so // they aren't locked out. "Full" is a soft policy seam for valet over- // capacity later. See wiki/concepts/capacity-occupancy.md. const occ = getOccupancy(this.#db); if (occ.full) { // No ticket id exists for a refused entry, so mint a synthetic ref to key the // anomaly + its evidence snapshot together. The operator wants the photo of WHO // was turned away (a fraud/dispute signal), so we still fire the entry camera. const refusedRef = `REFUSED-${randomUUID().replace(/-/g, "").slice(0, 12)}`; await this.#log.append({ type: "anomaly", identity: refusedRef, payload: { ...reasonPayload("entry.refused.full", { count: occ.count, capacity: occ.capacity ?? 0 }), entryRefused: true, full: true, }, }); this.#fireSnapshot("entry", refusedRef); this.#logger.warn(`transient entry REFUSED: full (${occ.count}/${occ.capacity})`); 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 }; /** Presence signals that were BYPASSED (admin dropped them due to faulty hardware). * Recorded on the signed entry so a ticket issued under a weakened gate is auditable. */ presenceBypassed?: PresenceSignal[]; }, ): Promise<{ ok: true; ticketId: string; opened: boolean } | { ok: false; reason: string }> { const ticketId = newTicketId(); const issuedAt = new Date().toISOString(); 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. const ticket: TicketData = { ticketId, issuedAt, header: this.#ticketHeader() }; try { const printedBy = await printWithFailover(printers, "entry-dispenser", (d: PrinterDevice) => d.printTicket(ticket), ); this.#logger.info(`entry ticket ${ticketId} printed on ${printedBy}`); // ONE CAR = ONE TICKET: a ticket is now out for the car at this barrier. Disarm + // stamp the cooldown so a repeat press (held button / mashing) issues no second // ticket. PRESENCE mode re-arms when the loop clears (car drove in); COOLDOWN mode // re-allows after entryCooldownSec. Done on the print success, NOT the open. const guard = this.#guardState(resolved); guard.lastTicketAt = Date.now(); guard.armed = false; } catch (err) { // HOLD: do not open, do not record a vehicle_entry. Sign an anomaly so the // failed attempt is in the tamper-evident record for the operator. const reason = err instanceof NoPrinterAvailableError ? err.message : (err as Error).message; await this.#log.append({ type: "anomaly", identity: ticketId, payload: { ...reasonPayload("entry.held.noTicket", { detail: reason }), ticketPrinted: false }, }); // Capture who is held at the barrier (evidence for the operator handling the car). this.#fireSnapshot("entry", ticketId); this.#logger.warn(`entry HELD: ${reason} (barrier NOT opened)`); return { ok: false, reason }; } // 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 // later reprices the same way at exit. Today every transient takes the SITE // default category (operator policy, site_config.default_vehicle_category; // falls back to the shared DEFAULT_VEHICLE_CATEGORY). const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const category = cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0 ? cfg.defaultVehicleCategory : DEFAULT_VEHICLE_CATEGORY; await this.#log.append({ type: "vehicle_entry", direction: "entry", source: opts.source, identity: ticketId, payload: { sessionRef: ticketId, ticketPrinted: true, category, ...(operatorInitiated ? { operatorInitiated: true, operator: opts.operator } : {}), ...(opts.overCapacity ? { lotFull: true, occupancy: `${opts.overCapacity.count}/${opts.overCapacity.capacity ?? "∞"}` } : {}), ...(opts.presenceBypassed && opts.presenceBypassed.length > 0 ? { presenceBypassed: opts.presenceBypassed } : {}), }, 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). const access = this.#buildAccess(resolved.controller); let opened = false; 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; a // 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); // 4. Update the session projection cache (rebuildable from the ledger; a read-model). try { this.#db .insert(sessions) .values({ id: ticketId, identity: ticketId, source: opts.source, enteredAt: issuedAt, state: "open" }) .run(); } catch (err) { 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 — normally require BOTH radar/loop presence AND camera detection. An // admin may BYPASS a signal when its device is faulty (site_config, signed config_change); // the bypassed signal is dropped as a requirement and RECORDED on the issued ticket. const bypass = this.#presenceBypass(); const bypassed: PresenceSignal[] = []; // Radar/loop side. A configured loop is only mandatory while radar is still REQUIRED; // if radar is bypassed we skip the loop entirely (a dead loop is exactly why they bypass). const radarRequired = !bypass.radar; let radarPresent: boolean | null = null; if (radarRequired) { if (typeof resolved.presenceInput !== "number") { return { ok: false, reason: "no presence loop on the entry barrier — operator issue unavailable (or bypass radar)" }; } radarPresent = this.#guardState(resolved).present; } else { bypassed.push("radar"); } // Camera side. const cameraRequired = !bypass.camera; if (!cameraRequired) bypassed.push("camera"); // Refuse only when a STILL-REQUIRED signal fails to confirm a vehicle. const radarOk = !radarRequired || radarPresent === true; const cameraOk = !cameraRequired || cameraBusy; if (!radarOk || !cameraOk) { 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, cameraBusy, ...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}), }, }); this.#logger.warn( `operator entry refused by ${operator}: no vehicle present (radar=${radarPresent}, camera=${cameraBusy}, bypassed=[${bypassed.join(",")}])`, ); 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 } } : {}), ...(bypassed.length > 0 ? { presenceBypassed: bypassed } : {}), }); 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). * Used on both the OPEN path and the refused/held anomaly paths — a turned-away or * held car is exactly when the operator wants the photo. */ #fireSnapshot(direction: "entry", identity: string): void { // `log` lets the ANPR ride-along flag a duplicate-plate entry (a signed anomaly) — // still fire-and-forget; recognition never gates the open. See snapshot.ts. void snapshotAsync({ db: this.#db, direction, identity, logger: this.#logger, vision: this.#vision, log: this.#log }).catch( (err) => this.#logger.error(`entry snapshot error: ${(err as Error).message}`), ); } /** Current admin presence-gate bypass (site_config), read LIVE so a toggle takes effect * with no restart. Default: nothing bypassed (the normal both-required gate). */ #presenceBypass(): { radar: boolean; camera: boolean } { const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); return { radar: cfg?.bypassPresenceRadar ?? false, camera: cfg?.bypassPresenceCamera ?? false }; } /** Build a live access adapter from a resolved controller row, or null. */ #buildAccess(row: DeviceRow): AccessControlDevice | null { const driver = registry.get(row.driverId); if (!driver) return null; try { return driver.create(row.config as never) as AccessControlDevice; } catch { return null; } } /** Build live ENTRY printer instances (for failover selection). */ #loadPrinters(): PrinterInstance[] { const rows = devicesByDirection(this.#db, "printer", "entry"); // already enabled-filtered const out: PrinterInstance[] = []; for (const row of rows) { const driver = registry.get(row.driverId); if (!driver) continue; const cfg = row.config as Record; const role = cfg.role === "booth-receipt" ? "booth-receipt" : "entry-dispenser"; try { out.push({ id: row.id, role, failoverRank: typeof cfg.failoverRank === "number" ? cfg.failoverRank : 0, device: driver.create(cfg as never) as PrinterDevice, }); } catch { // skip a printer whose config won't build } } return out; } /** Park identity for the ticket header, from site_config (all fields optional; * the driver prints only what's set). See wiki/concepts/site-metadata.md. */ #ticketHeader(): TicketHeader | undefined { const row = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); if (!row) return undefined; return { parkName: row.parkName, operatorName: row.operatorName, nius: row.nius, address: row.address, phone: row.phone, }; } } /** * Opaque, unguessable transient ticket id (wiki/concepts/ticket-encoding.md). * * Format: 11 digits = 10 cryptographically-random digits + 1 trailing Luhn check * digit. All-numeric so the booth can read it on ANY legacy 1D barcode scanner and * an operator can hand-key it if every reader is down. RANDOM (not sequential): the * id must stay unguessable so an attacker can't iterate to claim a cheaper session * — the anti-fraud property the wiki settles. * * Length is driven by GUESS-RESISTANCE, not volume: with 10^10 valid ids and the * Luhn digit rejecting 9/10 of malformed guesses, a blind attempt at a currently-OPEN * ticket lands at ~1-in-10^7 even with thousands parked — comfortably safe — while * being two digits (≈2 barcode modules) narrower than the old 13. Collisions are * negligible at lot scale; the unique constraints on ledger_events.index / sessions.id * are the backstop. (Older 13-digit ids stay valid — the id is opaque, length-agnostic.) * The Luhn digit lets a manual entry reject a typo (validateTicketCode) instead of * failing as "session not found". */ function newTicketId(): string { let body = ""; for (let i = 0; i < 10; i += 1) body += String(randomInt(10)); return body + luhnCheckDigit(body); } /** The Luhn (mod-10) check digit for an all-digit string. */ function luhnCheckDigit(digits: string): string { let sum = 0; // Walk right-to-left; the check digit sits at position 0 from the right, so the // last body digit is an "even" position that gets doubled. let double = true; for (let i = digits.length - 1; i >= 0; i -= 1) { let d = digits.charCodeAt(i) - 48; if (double) { d *= 2; if (d > 9) d -= 9; } sum += d; double = !double; } return String((10 - (sum % 10)) % 10); } /** * True if `code` is a well-formed ticket code: all digits and a valid Luhn checksum. * Lets a manual-entry path (operator types the code off the ticket when readers are * down) reject a typo up front. A scanned/looked-up id that predates this format * (e.g. legacy `T-`) won't pass — callers should only gate MANUAL entry on it, * never reject an id that already exists in the ledger. See ticket-encoding.md. */ export function validateTicketCode(code: string): boolean { // Length-agnostic: an all-digit code whose last digit is the Luhn check of the rest. // Accepts the current 11-digit ids AND any legacy 13-digit ones still in circulation // (the id is opaque; only the digits+checksum shape matters). The 10..14 bound keeps // a stray short/long string from being mistaken for a ticket. See ticket-encoding.md. if (!/^\d{10,14}$/.test(code)) return false; const body = code.slice(0, -1); return luhnCheckDigit(body) === code[code.length - 1]; }