// Shared types and utilities across the parking system. // // The domain is offline-first and threat-model driven. The central integrity // primitive is an append-only, hash-chained, ATECC608-signed event log: entry // and exit events are never edited or deleted — a "void" is itself an appended // event. See wiki/concepts/append-only-event-chain.md. export type Role = "admin" | "operator" | "cashier" | "readonly"; export type Direction = "entry" | "exit"; /** What kind of identity source produced a read. */ export type IdentitySource = "wiegand" | "lpr" | "qr" | "ticket" | "manual"; /** * A signed business-LEDGER event. Records are never mutated; corrections are new * events. `prevHash` chains each event to the previous one; `signature` is the * ATECC608 signature over the canonical contents (which INCLUDE `payload`). * Distinct from device telemetry — see wiki/decisions/event-streams-split.md. */ export interface LedgerEvent { readonly id: string; readonly index: number; readonly type: LedgerEventType; readonly direction: Direction | null; readonly lane: number; readonly source: IdentitySource | null; /** Card number, plate, ticket id, etc. — depends on `source`. */ readonly identity: string | null; /** Type-specific business data (amount, tariffVersionId, sessionRef…). Signed. */ readonly payload: LedgerPayload | null; readonly occurredAt: string; // ISO-8601 /** Hash of the previous event in the chain (hex). Null only for genesis. */ readonly prevHash: string | null; /** ATECC608 signature over the canonical event payload (hex). */ readonly signature: string; /** Which signer/key produced `signature` (verifiable across a signer swap). */ readonly keyId: string; } /** Business/accountability events that live in the SIGNED, hash-chained ledger. */ export type LedgerEventType = | "vehicle_entry" | "vehicle_exit" | "payment" | "void" // Witness-grade: a host-commanded open, and an independently-observed open // (loop/sensor) — reconciled against each other. | "barrier_open_command" | "barrier_open_observed" // Manned-mode shift boundary: an operator takes over (shift_open) / hands over // with a takings summary (shift_z_report). See wiki/concepts/shift.md. | "shift_open" | "shift_z_report" // Admin loads/removes physical drawer cash (the float). Signed payload: // { amountMinor (signed: + load, − removal), reason, currency, operator }. // Folds into the drawer balance carried across shifts. See wiki/concepts/shift.md. | "cash_movement" | "anomaly"; /** How money was tendered (for payment events + the shift Z-report). */ export type Tender = "cash" | "card"; /** * Type-specific data carried on a ledger event's `payload`. All amounts are * integer minor units in the named currency — never floats. Fields are optional * because they're event-type-specific; the producer fills what applies. */ export interface LedgerPayload { /** The parking_session this event concerns (entry/exit/payment/void). */ readonly sessionRef?: string; /** payment: amount in minor units, its currency, and how it was tendered. */ readonly amountMinor?: number; readonly currency?: string; readonly tender?: Tender; /** payment: which tariff_version priced it (reproducible repricing). */ readonly tariffVersionId?: string; /** payment: gross/discount/net split when a validation applied. */ readonly grossMinor?: number; readonly discountMinor?: number; /** FX-ready, deferred: rate applied (null/absent now). See open-questions #8. */ readonly fxRate?: number | null; /** void / anomaly / override: a human/machine reason code. */ readonly reason?: string; /** plate/vehicle from the vision service (advisory). */ readonly plate?: string; readonly plateConfidence?: number; /** Free-form for forward-compat without a schema change. */ readonly [k: string]: unknown; } /** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */ export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot"; /** * The composable rate card stored in a tariff_version.structure. Pure data the * fee function interprets — no rates in code. Stepped duration blocks + caps/grace; * a flat rate is just one block. See wiki/concepts/tariff.md. */ export interface TariffStructure { /** Free if exited within this (drop-off/turnaround). */ readonly gracePeriodEntryMin: number; /** Billing granularity; partial increments round UP. */ readonly incrementMin: number; /** Consumed in order as duration accrues; last block may be open-ended. */ readonly blocks: readonly TariffBlock[]; /** Cap per rolling 24h (null = no cap). */ readonly dailyCapMinor: number | null; /** Flat charge when there's no entry id (admin may override at the moment). */ readonly lostTicketMinor: number; /** Pay-on-foot walk-back window: minutes after payment to reach the car. */ readonly gracePeriodExitMin: number; /** How an overstay top-up is charged. "reprice" = recompute(entry→now) − paid. */ readonly overstay: "reprice"; } export interface TariffBlock { /** Upper bound of this block in minutes; null = open-ended (thereafter). */ readonly uptoMin: number | null; readonly priceMinorPerIncrement: number; } /** * Compute the parking fee (integer minor units) for a stay, from a TariffStructure. * PURE + deterministic + offline — the pay station calls it with asOf = now; the * result is fixed into a signed `payment` event, so it must be reproducible. * * Algorithm (wiki/concepts/tariff.md): round duration UP to incrementMin; free if * within entry grace; else walk the stay one rolling-24h segment at a time, charging * each increment at its block's rate (blocks consumed in order by cumulative minutes), * capping each segment at dailyCapMinor. Times are ISO-8601; bad input → 0 (caller * validates the tariff exists first). */ export function computeFee( enteredAt: string, asOf: string, tariff: TariffStructure, ): number { const ms = Date.parse(asOf) - Date.parse(enteredAt); if (!Number.isFinite(ms) || ms <= 0) return 0; const rawMinutes = ms / 60_000; // Grace uses the RAW duration (a 10-min stay is free even if the increment is // 60 min — otherwise rounding-up would defeat the grace window). if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; const inc = Math.max(1, tariff.incrementMin); const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment const DAY = 24 * 60; let total = 0; for (let segStart = 0; segStart < minutes; segStart += DAY) { const segEnd = Math.min(segStart + DAY, minutes); let segFee = 0; // The block ladder RESETS each rolling-24h day: `within` is minutes elapsed // WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15). for (let within = 0; segStart + within < segEnd; within += inc) { segFee += rateAt(tariff.blocks, within); } if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor); total += segFee; } return total; } /** * Validate an admin-authored tariff structure. Returns [] if valid, else a list * of human-readable problems. Pure — used by the composer route (and any caller) * so a malformed rate card can never be published. See wiki/concepts/tariff.md. */ export function validateTariffStructure(s: unknown): string[] { const errs: string[] = []; if (!s || typeof s !== "object") return ["structure must be an object"]; const t = s as Partial; const nonNegInt = (v: unknown, label: string) => { if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`); }; nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin"); nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin"); nonNegInt(t.lostTicketMinor, "lostTicketMinor"); if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) { errs.push("incrementMin must be a positive integer"); } if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor"); if (t.overstay !== "reprice") errs.push('overstay must be "reprice"'); if (!Array.isArray(t.blocks) || t.blocks.length === 0) { errs.push("blocks must be a non-empty array"); } else { let prevBound = 0; t.blocks.forEach((b, i) => { const last = i === t.blocks!.length - 1; nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`); if (b?.uptoMin == null) { if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`); } else { if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) { errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`); } else { prevBound = b.uptoMin; } } }); // The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is // always explicit. A bounded final block silently inherits its own rate past // its bound (a hidden, never-stated price) — forbidden on publish so the admin // must state what time beyond the ladder costs. See wiki/concepts/tariff.md. // (Read/pricing of already-published versions is unaffected — validation runs // only on publish; rateAt() still gracefully handles legacy bounded tails.) const lastBlock = t.blocks[t.blocks.length - 1]; if (lastBlock && lastBlock.uptoMin != null) { errs.push("the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly"); } } return errs; } /** Price of the increment that starts at `cumulativeMin` — the block whose range * [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number { let prev = 0; for (const b of blocks) { if (b.uptoMin == null || cumulativeMin < b.uptoMin) return b.priceMinorPerIncrement; prev = b.uptoMin; void prev; } // No open-ended block and past the last bound: charge the last block's rate. return blocks.length ? blocks[blocks.length - 1]!.priceMinorPerIncrement : 0; } export const ROLES: readonly Role[] = [ "admin", "operator", "cashier", "readonly", ] as const; /** * Signs the canonical bytes of an event for the append-only chain. This is the * abstraction over the [[atecc608]] secure element: the real, non-extractable * hardware key is ONE implementation. Whether the chip is wired is still * open-question #6, so the server ships a software signer in the meantime — * same interface, swappable with no business-logic change (the device-adapter * philosophy applied to signing). See wiki/concepts/append-only-event-chain.md. * * IMPORTANT: a software signer makes the chain self-consistent and detectably * tamper-evident, but NOT unforgeable by someone who owns the machine — only the * ATECC608 provides that. Don't conflate the two. */ export interface Signer { /** Stable id of the signer/key (e.g. "sw-hmac-v1", "atecc608-slot0"). Stored * alongside events so verification knows which key to check against. */ readonly keyId: string; /** Sign the canonical payload; returns a hex signature. */ sign(payload: string): string; /** Verify a signature over the payload (software signers can; the ATECC608 * verifies via its public key). */ verify(payload: string, signature: string): boolean; }