30e7fe85de
Three booth-integrity improvements that share the entry/exit flows and activity log. Refusal snapshots: previously only an accepted open captured a camera image; now every refusal/hold anomaly fires the directional camera too (a turned-away car is exactly the evidence wanted) — entry refused-full/held, exit refused closed/no-session/unpaid/grace-expired (booth + reader paths), refused subscription. A refused entry has no ticket id, so a synthetic REFUSED- ref keys the anomaly + photo together. Same fire-and-forget contract; failed captures still show as tiles. Subscriber access medium: the subscription flow already signed `via` (qr|card|plate) into entry/exit payloads; surface it as a typed LedgerPayload.via, a cyan chip in the ticker, and an "Entry medium" modal row (sq+en). Display-only. One car = one ticket: the entry button could be mashed to mint many tickets per car (corrupting occupancy + enabling ticket-shopping at exit) — the old #inFlight guard only blocked overlapping presses. Add a per-relay guard configured on the relay spec: PRESENCE mode (presenceInput ties ticketing to a vehicle loop on a Dingtian input — one ticket per car, re-armed when the loop clears) or COOLDOWN fallback (entryCooldownSec) when there's no barrier feedback. A suppressed press is unsigned device_events telemetry, not a signed anomaly. SetupWizard exposes both fields. Fail-closed entry and barrier-is-not-a-door invariants untouched; guard state is in-memory/rebuildable, starts armed after restart. Wiki: new entry-double-press; updated entry-exit-points, booth-console, index. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
880 lines
40 KiB
TypeScript
880 lines
40 KiB
TypeScript
// 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.
|
||
|
||
// --- Authorization: dynamic RBAC (resource × CRUD permissions) ---------------
|
||
// Roles are DATA (admin-composable rows in the DB), not a hardcoded enum. A role
|
||
// is a named bundle of PERMISSIONS; a permission is a `resource:action` pair drawn
|
||
// from the code-defined grid below. Route guards check a permission, never a role
|
||
// name. A built-in, locked `admin` role (id ADMIN_ROLE_ID) always holds every
|
||
// permission, so administration can never be locked out. See
|
||
// wiki/entities/local-jwt-auth.md and the RBAC plan.
|
||
|
||
/** The resources permissions are scoped to (code-defined; roles/assignments are data). */
|
||
export const RESOURCES = [
|
||
"user", // manage operators/cashiers + reset password
|
||
"role", // compose roles + assign permissions
|
||
"tariff", // read / publish a new version
|
||
"subscription", // the subscription registry
|
||
"site", // site_config + device setup/assign
|
||
"device", // device status / printers / snapshots / catalog
|
||
"shift", // open/close own shift; move the drawer float
|
||
"payment", // take payment, quote, voucher/receipt, exit, reopen
|
||
"session", // active sessions, lookup
|
||
"event", // the signed ledger feed + void
|
||
"report", // events feed, occupancy, future reports
|
||
"log", // application/diagnostic logs (app_logs) — view + retention
|
||
] as const;
|
||
export type Resource = (typeof RESOURCES)[number];
|
||
|
||
/** CRUD plus two domain verbs where CRUD doesn't fit: `void` (append a void event,
|
||
* NOT a delete) and `cash` (move the drawer float — an admin-grade shift action). */
|
||
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash";
|
||
|
||
/** A single permission, e.g. "tariff:update". The route guard checks one of these. */
|
||
export type Permission = `${Resource}:${Action}`;
|
||
|
||
/** The complete, code-defined permission grid. Only these strings are checkable by
|
||
* a guard — an admin composes roles by selecting from this set. Preserves today's
|
||
* exact authz semantics (e.g. void split from read; shift cash split from open). */
|
||
export const PERMISSIONS: readonly Permission[] = [
|
||
"user:create", "user:read", "user:update", "user:delete",
|
||
"role:create", "role:read", "role:update", "role:delete",
|
||
"tariff:read", "tariff:update",
|
||
"subscription:read", "subscription:create", "subscription:update", "subscription:delete",
|
||
"site:read", "site:update",
|
||
"device:read",
|
||
"shift:read", "shift:create", "shift:cash",
|
||
"payment:read", "payment:create",
|
||
"session:read",
|
||
"event:read", "event:void",
|
||
"report:read",
|
||
"log:read",
|
||
] as const;
|
||
|
||
/** The protected built-in role: non-deletable, non-editable, always = ALL
|
||
* permissions. At least one user must always hold it (no-lockout invariant). */
|
||
export const ADMIN_ROLE_ID = "admin";
|
||
|
||
/** Transitional alias. Roles are now DB rows keyed by a string id; `Role` is kept
|
||
* as `string` so any not-yet-migrated reference still compiles. */
|
||
export type Role = string;
|
||
|
||
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;
|
||
/** READ-TIME ENRICHMENT — not signed, not stored. When the event belongs to a
|
||
* subscription occurrence (payload.permitId), the server resolves the holder's
|
||
* name here so the UI shows "Aqif Kopertoni" instead of "SUBSESS-08cd1c52…".
|
||
* Absent on non-subscription events and on legacy serializers. */
|
||
readonly subscriberLabel?: string | null;
|
||
}
|
||
|
||
/** 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-readable English sentence, signed as the
|
||
* immutable fallback. Prefer `reasonCode` for display (it localizes); `reason` is
|
||
* what's shown for legacy events with no code, and what an English log records. */
|
||
readonly reason?: string;
|
||
/** Stable, language-neutral classification of WHY this event happened (e.g.
|
||
* "exit.refused.unpaid"). The presentation layer localizes it via REASONS; the
|
||
* signed bytes never change, so a language added later applies retroactively. */
|
||
readonly reasonCode?: ReasonCode;
|
||
/** Interpolation values for `reasonCode`'s message template (counts, ids, ratios).
|
||
* Signed alongside the code so the rendered sentence is reproducible. */
|
||
readonly reasonParams?: Record<string, string | number>;
|
||
/** subscription entry/exit: which credential the subscriber presented — `"qr"`
|
||
* (QR code), `"card"` (RFID/NFC card or chip), or `"plate"` (bound plate / LPR).
|
||
* Signed so the activity log can show HOW a subscriber entered/left (e.g. "via QR"),
|
||
* and so a lost-card investigation can trace which credential was used. */
|
||
readonly via?: "card" | "qr" | "plate";
|
||
/** plate/vehicle from the vision service (advisory). */
|
||
readonly plate?: string;
|
||
readonly plateConfidence?: number;
|
||
/** vehicle_entry: the vehicle/customer category, frozen at entry so V2 category
|
||
* pricing reprices identically at exit. Absent on legacy entries (= default). */
|
||
readonly category?: string;
|
||
/** Free-form for forward-compat without a schema change. */
|
||
readonly [k: string]: unknown;
|
||
}
|
||
|
||
/**
|
||
* The closed set of reasons an anomaly/payment/override can carry. These are STABLE
|
||
* language-neutral keys — the anti-fraud ledger signs the code (+ params), and the
|
||
* presentation layer translates it. Adding a language = adding catalog entries, with
|
||
* NO re-signing of past events. Codes are grouped by flow: entry.* / exit.* / sub.*.
|
||
*
|
||
* When you add a new reason at an append site, add its code here AND a message in
|
||
* BOTH web catalogs (`reason.<code>` in sq.ts + en.ts) — the type makes a missing
|
||
* code a compile error at the call site, and Catalog parity makes a missing
|
||
* translation a build error.
|
||
*/
|
||
export const REASON_CODES = [
|
||
// entry
|
||
"entry.refused.full",
|
||
"entry.held.noTicket",
|
||
// exit refusals
|
||
"exit.refused.closed",
|
||
"exit.refused.noSession",
|
||
"exit.refused.unpaid",
|
||
"exit.refused.graceExpired",
|
||
// exit recorded but the barrier could not be driven (operator must open by hand)
|
||
"exit.open.noBarrier",
|
||
"exit.open.unavailable",
|
||
"exit.open.failed",
|
||
// free $0 grace exit
|
||
"exit.freeGrace",
|
||
// manual / human-intervention barrier open
|
||
"exit.manualOpen",
|
||
// subscriptions
|
||
"sub.refused.notFound",
|
||
"sub.refused.outOfWindow",
|
||
"sub.refused.noSession",
|
||
"sub.refused.atCapacity",
|
||
] as const;
|
||
|
||
export type ReasonCode = (typeof REASON_CODES)[number];
|
||
|
||
/**
|
||
* English message templates for each reason code — the SINGLE source for the signed
|
||
* `reason` fallback string (server renders this) AND the en.ts catalog. `{name}`
|
||
* placeholders are filled from `reasonParams`. Other languages live in the web
|
||
* catalogs keyed `reason.<code>`; this English copy stays here so the server can sign
|
||
* a human fallback without importing a UI catalog.
|
||
*/
|
||
export const REASON_EN: Record<ReasonCode, string> = {
|
||
"entry.refused.full": "entry refused — lot full ({count}/{capacity})",
|
||
"entry.held.noTicket": "entry held — ticket not printed: {detail}",
|
||
"exit.refused.closed": "exit refused — session already closed",
|
||
"exit.refused.noSession": "exit refused — no open session for ticket",
|
||
"exit.refused.unpaid": "exit refused — not paid (take payment first)",
|
||
"exit.refused.graceExpired": "exit refused — walk-back grace expired (top-up required)",
|
||
"exit.open.noBarrier": "exit recorded, but no exit barrier is configured — open manually",
|
||
"exit.open.unavailable": "exit recorded, but the barrier is unavailable — open manually",
|
||
"exit.open.failed": "exit recorded, but the barrier did not open — open manually",
|
||
"exit.freeGrace": "free entry-grace (no charge)",
|
||
"exit.manualOpen": "manual barrier open (human intervention)",
|
||
"sub.refused.notFound": "subscription refused — not found",
|
||
"sub.refused.outOfWindow": "subscription refused — {status}/out-of-window",
|
||
"sub.refused.noSession": "subscription exit with no open session (already out / never entered)",
|
||
"sub.refused.atCapacity": "subscription refused — at capacity ({inUse}/{max} cars in)",
|
||
};
|
||
|
||
/**
|
||
* Fill a `{name}` template from params. Missing params are left as the literal token
|
||
* (defensive — a malformed event still renders something). Shared by the server (to
|
||
* sign the English fallback) and any caller that has a template string + params.
|
||
*/
|
||
export function fillTemplate(template: string, params?: Record<string, string | number>): string {
|
||
if (!params) return template;
|
||
return template.replace(/\{(\w+)\}/g, (whole, key: string) =>
|
||
key in params ? String(params[key]) : whole,
|
||
);
|
||
}
|
||
|
||
/** Render a reason code to its English sentence (the signed fallback). */
|
||
export function renderReasonEn(code: ReasonCode, params?: Record<string, string | number>): string {
|
||
return fillTemplate(REASON_EN[code], params);
|
||
}
|
||
|
||
/**
|
||
* Build the trio of reason fields to merge into a signed payload: the stable code,
|
||
* its params, and the rendered English `reason` (the immutable, localization-free
|
||
* fallback). Use at every anomaly/payment/override append site so the ledger is
|
||
* self-describing and the UI can localize without parsing free text. Spread it:
|
||
* payload: { ...reasonPayload("exit.refused.unpaid"), exitRefused: true }
|
||
*/
|
||
export function reasonPayload(
|
||
code: ReasonCode,
|
||
params?: Record<string, string | number>,
|
||
): { reasonCode: ReasonCode; reasonParams?: Record<string, string | number>; reason: string } {
|
||
return {
|
||
reasonCode: code,
|
||
...(params ? { reasonParams: params } : {}),
|
||
reason: renderReasonEn(code, params),
|
||
};
|
||
}
|
||
|
||
/** Operational device telemetry — UNSIGNED, prunable. NOT the ledger. */
|
||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||
|
||
/**
|
||
* Application/diagnostic logs — a THIRD unsigned, prunable stream (app_logs), distinct
|
||
* from the signed ledger and from device telemetry. Backend warn+ and frontend errors
|
||
* land here so a booth problem is queryable in one place. See
|
||
* wiki/concepts/app-logs.md, decisions/event-streams-split.md.
|
||
*/
|
||
export type LogLevel = "trace" | "debug" | "info" | "warn" | "error" | "fatal";
|
||
export type LogSource = "frontend" | "backend";
|
||
|
||
/** A persisted log record (the read shape returned by GET /api/logs). */
|
||
export interface AppLogRecord {
|
||
readonly id: string;
|
||
readonly level: LogLevel;
|
||
readonly source: LogSource;
|
||
readonly message: string;
|
||
readonly context: Record<string, unknown> | null;
|
||
readonly httpStatus: number | null;
|
||
readonly path: string | null;
|
||
readonly stack: string | null;
|
||
readonly userId: string | null;
|
||
readonly userAgent: string | null;
|
||
readonly createdAt: string;
|
||
}
|
||
|
||
/** One log entry POSTed by the frontend to /api/logs (server stamps id/userId/time). */
|
||
export interface ClientLogInput {
|
||
readonly level: LogLevel;
|
||
readonly message: string;
|
||
readonly context?: Record<string, unknown> | null;
|
||
readonly httpStatus?: number | null;
|
||
readonly path?: string | null;
|
||
readonly stack?: string | null;
|
||
/** Client-side capture time (ISO). The server records its own receive time too. */
|
||
readonly at?: string;
|
||
}
|
||
|
||
/** The numeric ordering of levels (pino-compatible), for threshold comparisons. */
|
||
export const LOG_LEVEL_ORDER: Record<LogLevel, number> = {
|
||
trace: 10,
|
||
debug: 20,
|
||
info: 30,
|
||
warn: 40,
|
||
error: 50,
|
||
fatal: 60,
|
||
};
|
||
|
||
/**
|
||
* The composable rate card stored in a tariff_version.structure.
|
||
*
|
||
* Two shapes, a discriminated union (see TariffStructure):
|
||
* - V1 (TariffStructureV1): a single block ladder + cap/grace at the top level —
|
||
* the original shape. Bare structures with no `defaultCard` are V1 and price
|
||
* via the verbatim V1 algorithm, UNCHANGED. The one live production version is
|
||
* V1 and must keep pricing identically.
|
||
* - V2 (TariffStructureV2): a default card + optional WINDOWED cards selected by
|
||
* wall-clock time-of-day / day-of-week / date and/or vehicle category, each card
|
||
* a flat rate OR a block ladder. Adds the legacy ParkSQL2017 pricing breadth on
|
||
* top of integer-minor-unit money + immutable versions. See wiki/concepts/tariff.md
|
||
* and wiki/concepts/tariff-time-tiers.md.
|
||
*
|
||
* Pure data the fee function interprets — no rates in code, integer minor units.
|
||
*/
|
||
export interface TariffStructureV1 {
|
||
/** 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;
|
||
}
|
||
|
||
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
|
||
* part is unconstrained. Evaluated in the version's frozen tz. */
|
||
export interface TariffWindow {
|
||
/** Days-of-week this card is active (0=Sun..6=Sat), local to tz. Absent/empty = every day. */
|
||
readonly dow?: readonly number[];
|
||
/** Inclusive local date window "YYYY-MM-DD" (seasonal/holiday). Absent = unbounded that side. */
|
||
readonly dateFrom?: string;
|
||
readonly dateTo?: string;
|
||
/** Local hour-of-day window "HH:MM". `toHour <= fromHour` means it WRAPS past
|
||
* midnight (e.g. 22:00→06:00 night rate). Absent pair = all day. */
|
||
readonly fromHour?: string;
|
||
readonly toHour?: string;
|
||
}
|
||
|
||
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
|
||
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
|
||
export interface TariffCard {
|
||
/** Human label (also the final, deterministic precedence tiebreak). */
|
||
readonly name: string;
|
||
/** Integer precedence tiebreak among equally-specific cards; higher wins. */
|
||
readonly priority: number;
|
||
/** Vehicle/customer category this card prices. Absent = applies to all categories. */
|
||
readonly category?: string;
|
||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||
readonly window?: TariffWindow;
|
||
/** Flat price per billing increment (mutually exclusive with `blocks`). */
|
||
readonly flatMinor?: number;
|
||
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
|
||
readonly blocks?: readonly TariffBlock[];
|
||
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
|
||
* a mixed day (see computeFeeV2). null = no cap. */
|
||
readonly dailyCapMinor?: number | null;
|
||
}
|
||
|
||
export interface TariffStructureV2 {
|
||
/** Schema marker; presence of `defaultCard` is the real discriminant. */
|
||
readonly version: 2;
|
||
/** IANA zone the wall-clock windows are evaluated in, FROZEN in the version for
|
||
* reproducibility — never read from the host clock. Copied from site config on
|
||
* publish (default "Europe/Tirane"). */
|
||
readonly tz: string;
|
||
// --- shared billing knobs (same meaning as V1) ---
|
||
readonly gracePeriodEntryMin: number;
|
||
readonly incrementMin: number;
|
||
readonly lostTicketMinor: number;
|
||
readonly gracePeriodExitMin: number;
|
||
readonly overstay: "reprice";
|
||
/** The always-applicable fallback (no window). Its dailyCapMinor governs the day. */
|
||
readonly defaultCard: TariffCard;
|
||
/** Ordered, optional windowed/category cards. Absent/empty ⇒ behaves like V1. */
|
||
readonly windowedCards?: readonly TariffCard[];
|
||
}
|
||
|
||
/** The stored/wire type: legacy-bare V1 or windowed V2. computeFee + validate accept
|
||
* both; the discriminant is the presence of `defaultCard`. */
|
||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||
|
||
/** True when a structure is the windowed V2 shape (has a defaultCard). */
|
||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||
return (t as TariffStructureV2).defaultCard != null;
|
||
}
|
||
|
||
/** The vehicle/customer category assigned to a transient entry when none is captured
|
||
* (every transient today). A V2 card with no `category` applies to all; a card WITH a
|
||
* category only applies to a matching session — so the default routes to the
|
||
* category-agnostic + default cards. See wiki/concepts/tariff-time-tiers.md. */
|
||
export const DEFAULT_VEHICLE_CATEGORY = "default";
|
||
|
||
/**
|
||
* 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,
|
||
category?: string,
|
||
): number {
|
||
return isTariffV2(tariff)
|
||
? computeFeeV2(enteredAt, asOf, tariff, category)
|
||
: computeFeeV1(enteredAt, asOf, tariff);
|
||
}
|
||
|
||
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
|
||
* VERBATIM so bare/legacy structures (incl. the live production version) price
|
||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||
* corrupt repricing of already-signed sessions. */
|
||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): 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;
|
||
}
|
||
|
||
/**
|
||
* The V2 fee algorithm — adds wall-clock time-of-day / day-of-week / date windows
|
||
* and vehicle-category cards on top of the V1 ladder. PURE + integer + deterministic
|
||
* (the signed ledger reprices against this; reproducibility is mandatory).
|
||
*
|
||
* Two decoupled clocks: ELAPSED minutes advance the block-ladder position (continuous
|
||
* across card switches — a happy-hour boundary mid-stay does NOT reset the ladder);
|
||
* WALL-CLOCK time (in the version's frozen tz) selects which card's rate applies to
|
||
* each increment. Stepping one increment at a time and re-selecting the card makes the
|
||
* boundary slicing implicit. The DEFAULT card's dailyCap governs each rolling-24h day
|
||
* (a windowed card lowers the rate but never the day ceiling). See tariff-time-tiers.md.
|
||
*/
|
||
function computeFeeV2(
|
||
enteredAt: string,
|
||
asOf: string,
|
||
tariff: TariffStructureV2,
|
||
category?: string,
|
||
): number {
|
||
const enteredMs = Date.parse(enteredAt);
|
||
const ms = Date.parse(asOf) - enteredMs;
|
||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||
const rawMinutes = ms / 60_000;
|
||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
||
const inc = Math.max(1, tariff.incrementMin);
|
||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||
|
||
// Cards in contention: the default plus any windowed card matching the category.
|
||
// (A card with no `category` applies to all; one with a category applies only to
|
||
// a matching session.) The defaultCard always matches and is the fallback.
|
||
const cards = [
|
||
tariff.defaultCard,
|
||
...(tariff.windowedCards ?? []).filter((c) => c.category == null || c.category === category),
|
||
];
|
||
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
||
|
||
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;
|
||
for (let within = segStart; within < segEnd; within += inc) {
|
||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||
const card = selectCard(cards, wall);
|
||
if (card.flatMinor != null) {
|
||
segFee += card.flatMinor;
|
||
} else {
|
||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
||
}
|
||
}
|
||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
||
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[] {
|
||
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||
// Discriminate: a `defaultCard` ⇒ the windowed V2 shape; otherwise legacy bare V1.
|
||
// The V1 branch is kept byte-identical (same messages) so the live version still
|
||
// validates the same on any future republish.
|
||
return (s as Partial<TariffStructureV2>).defaultCard != null
|
||
? validateTariffV2(s as Partial<TariffStructureV2>)
|
||
: validateTariffV1(s as Partial<TariffStructureV1>);
|
||
}
|
||
|
||
function nonNegInt(v: unknown, label: string, errs: string[]): void {
|
||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||
}
|
||
|
||
/** Validate the block ladder (ascending bounds, open-ended last). `prefix` labels
|
||
* errors (e.g. "blocks" or "defaultCard.blocks"). Shared by V1 + V2. */
|
||
function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
|
||
if (!Array.isArray(blocks) || blocks.length === 0) {
|
||
errs.push(`${prefix} must be a non-empty array`);
|
||
return;
|
||
}
|
||
let prevBound = 0;
|
||
blocks.forEach((b: Partial<TariffBlock>, i: number) => {
|
||
const last = i === blocks.length - 1;
|
||
nonNegInt(b?.priceMinorPerIncrement, `${prefix}[${i}].priceMinorPerIncrement`, errs);
|
||
if (b?.uptoMin == null) {
|
||
if (!last) errs.push(`${prefix}[${i}] is open-ended (uptoMin null) but not last`);
|
||
} else if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||
errs.push(`${prefix}[${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). See wiki/concepts/tariff.md.
|
||
const lastBlock = (blocks as Partial<TariffBlock>[])[blocks.length - 1];
|
||
if (lastBlock && lastBlock.uptoMin != null) {
|
||
errs.push(
|
||
prefix === "blocks"
|
||
? "the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly"
|
||
: `${prefix}: the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
|
||
const errs: string[] = [];
|
||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
|
||
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
|
||
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", errs);
|
||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||
validateBlocks(t.blocks, "blocks", errs);
|
||
return errs;
|
||
}
|
||
|
||
const HHMM = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||
const YMD = /^\d{4}-\d{2}-\d{2}$/;
|
||
|
||
/** Validate one V2 card's pricing body (flat XOR ladder) + window. */
|
||
function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefault: boolean, errs: string[]): void {
|
||
if (!c || typeof c !== "object") {
|
||
errs.push(`${label} must be an object`);
|
||
return;
|
||
}
|
||
if (typeof c.name !== "string" || c.name.length === 0) errs.push(`${label}.name is required`);
|
||
if (typeof c.priority !== "number" || !Number.isInteger(c.priority)) errs.push(`${label}.priority must be an integer`);
|
||
|
||
const hasFlat = c.flatMinor != null;
|
||
const hasBlocks = c.blocks != null;
|
||
if (hasFlat === hasBlocks) {
|
||
errs.push(`${label} must set exactly one of flatMinor or blocks`);
|
||
} else if (hasFlat) {
|
||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
|
||
} else {
|
||
validateBlocks(c.blocks, `${label}.blocks`, errs);
|
||
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
|
||
}
|
||
|
||
if (isDefault) {
|
||
if (c.window != null) errs.push("defaultCard must not have a window (it is the always-active fallback)");
|
||
if (c.category != null) errs.push("defaultCard must not have a category (it is the catch-all)");
|
||
} else {
|
||
validateWindow(c.window, `${label}.window`, errs);
|
||
if (c.category != null && (typeof c.category !== "string" || c.category.length === 0)) {
|
||
errs.push(`${label}.category must be a non-empty string when present`);
|
||
}
|
||
}
|
||
}
|
||
|
||
function validateWindow(w: Partial<TariffWindow> | undefined, label: string, errs: string[]): void {
|
||
if (w == null) return; // a windowed card with no window = always-on tier (allowed)
|
||
if (w.dow != null) {
|
||
if (!Array.isArray(w.dow) || w.dow.some((d) => !Number.isInteger(d) || d < 0 || d > 6)) {
|
||
errs.push(`${label}.dow must be integers 0-6 (0=Sun)`);
|
||
}
|
||
}
|
||
const hasFrom = w.fromHour != null;
|
||
const hasTo = w.toHour != null;
|
||
if (hasFrom !== hasTo) errs.push(`${label}: fromHour and toHour must be set together`);
|
||
if (hasFrom && hasTo) {
|
||
if (!HHMM.test(w.fromHour!)) errs.push(`${label}.fromHour must be "HH:MM"`);
|
||
if (!HHMM.test(w.toHour!)) errs.push(`${label}.toHour must be "HH:MM"`);
|
||
// toHour <= fromHour is allowed (overnight wrap) — not an error.
|
||
}
|
||
if (w.dateFrom != null && !YMD.test(w.dateFrom)) errs.push(`${label}.dateFrom must be "YYYY-MM-DD"`);
|
||
if (w.dateTo != null && !YMD.test(w.dateTo)) errs.push(`${label}.dateTo must be "YYYY-MM-DD"`);
|
||
if (w.dateFrom != null && w.dateTo != null && YMD.test(w.dateFrom) && YMD.test(w.dateTo) && w.dateFrom > w.dateTo) {
|
||
errs.push(`${label}.dateFrom must be ≤ dateTo`);
|
||
}
|
||
}
|
||
|
||
function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
|
||
const errs: string[] = [];
|
||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
|
||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin", errs);
|
||
nonNegInt(t.lostTicketMinor, "lostTicketMinor", errs);
|
||
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
|
||
errs.push("incrementMin must be a positive integer");
|
||
}
|
||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||
|
||
const cards = t.windowedCards ?? [];
|
||
// tz is required once there are windowed cards (wall-clock is meaningless without it).
|
||
if (cards.length > 0 && (typeof t.tz !== "string" || t.tz.length === 0)) {
|
||
errs.push("tz (IANA timezone) is required when windowedCards are present");
|
||
}
|
||
|
||
validateCard(t.defaultCard, "defaultCard", true, errs);
|
||
if (!Array.isArray(t.windowedCards) && t.windowedCards != null) {
|
||
errs.push("windowedCards must be an array");
|
||
} else {
|
||
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
|
||
}
|
||
|
||
// Precedence determinism: reject two cards (same category bucket) that tie on
|
||
// (specificity, priority) with overlapping windows — the operator must break the
|
||
// tie with priority rather than relying silently on the name tiebreak.
|
||
detectAmbiguousPrecedence(cards, errs);
|
||
return errs;
|
||
}
|
||
|
||
/** Flag pairs of windowed cards that could BOTH be the precedence winner for some
|
||
* instant (same category bucket, equal specificity + priority, overlapping windows).
|
||
* Conservative overlap test; false positives are safer than a silent tie. */
|
||
function detectAmbiguousPrecedence(cards: readonly Partial<TariffCard>[], errs: string[]): void {
|
||
for (let i = 0; i < cards.length; i++) {
|
||
for (let j = i + 1; j < cards.length; j++) {
|
||
const a = cards[i]!;
|
||
const b = cards[j]!;
|
||
if ((a.category ?? null) !== (b.category ?? null)) continue;
|
||
if (a.priority !== b.priority) continue;
|
||
const sa = specificity(a as TariffCard);
|
||
const sb = specificity(b as TariffCard);
|
||
if (sa[0] !== sb[0] || sa[1] !== sb[1] || sa[2] !== sb[2]) continue;
|
||
if (windowsOverlap(a.window, b.window)) {
|
||
errs.push(
|
||
`windowedCards "${a.name ?? i}" and "${b.name ?? j}" are equally specific with the same priority and overlapping windows — give one a higher priority to break the tie`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Conservative window-overlap: true unless a dimension provably disjoints them. */
|
||
function windowsOverlap(a: TariffWindow | undefined, b: TariffWindow | undefined): boolean {
|
||
if (!a || !b) return true; // an unconstrained window overlaps anything
|
||
// dow: disjoint only if both constrain dow and share no day.
|
||
if (a.dow && a.dow.length && b.dow && b.dow.length && !a.dow.some((d) => b.dow!.includes(d))) return false;
|
||
// date: disjoint only if both fully bounded and ranges don't intersect.
|
||
if (a.dateFrom && a.dateTo && b.dateFrom && b.dateTo && (a.dateTo < b.dateFrom || b.dateTo < a.dateFrom)) return false;
|
||
// hour: disjoint only if both have non-wrapping ranges that don't intersect.
|
||
if (a.fromHour && a.toHour && b.fromHour && b.toHour) {
|
||
const af = hourToMin(a.fromHour), at = hourToMin(a.toHour), bf = hourToMin(b.fromHour), bt = hourToMin(b.toHour);
|
||
if (at > af && bt > bf && (at <= bf || bt <= af)) return false; // both non-wrapping & disjoint
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** 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;
|
||
}
|
||
|
||
// --- V2 wall-clock helpers (pure, deterministic given the frozen tz) ----------
|
||
|
||
/** Wall-clock breakdown of an instant in a fixed IANA tz. Pure: the same (instant,
|
||
* tz) always yields the same result (tz is frozen in the tariff version, never the
|
||
* host). Uses Intl.DateTimeFormat — handles DST for the named zone. */
|
||
export interface WallClock {
|
||
readonly y: number;
|
||
readonly mo: number; // 1-12
|
||
readonly d: number; // 1-31
|
||
readonly hour: number; // 0-23
|
||
readonly minute: number; // 0-59
|
||
readonly dow: number; // 0=Sun..6=Sat
|
||
}
|
||
|
||
const DOW_INDEX: Record<string, number> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 };
|
||
|
||
export function localBreakdown(instantMs: number, tz: string): WallClock {
|
||
const fmt = new Intl.DateTimeFormat("en-US", {
|
||
timeZone: tz,
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hourCycle: "h23",
|
||
weekday: "short",
|
||
});
|
||
const parts = fmt.formatToParts(new Date(instantMs));
|
||
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";
|
||
return {
|
||
y: Number(get("year")),
|
||
mo: Number(get("month")),
|
||
d: Number(get("day")),
|
||
hour: Number(get("hour")),
|
||
minute: Number(get("minute")),
|
||
dow: DOW_INDEX[get("weekday")] ?? 0,
|
||
};
|
||
}
|
||
|
||
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
|
||
function hourToMin(hhmm: string): number {
|
||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||
if (!m) return NaN;
|
||
return Number(m[1]) * 60 + Number(m[2]);
|
||
}
|
||
|
||
/** "YYYY-MM-DD" → comparable integer YYYYMMDD. */
|
||
function dateKey(w: WallClock): number {
|
||
return w.y * 10000 + w.mo * 100 + w.d;
|
||
}
|
||
function isoDateKey(iso: string): number {
|
||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso);
|
||
return m ? Number(m[1]) * 10000 + Number(m[2]) * 100 + Number(m[3]) : NaN;
|
||
}
|
||
|
||
/** Does a card's window cover this wall-clock instant? Absent parts are unconstrained;
|
||
* an absent window (defaultCard) always matches. An hour range with `toHour <= fromHour`
|
||
* is an overnight wrap (active when hour ≥ fromHour OR hour < toHour). */
|
||
function matchesWindow(w: TariffWindow | undefined, wall: WallClock): boolean {
|
||
if (!w) return true;
|
||
if (w.dow && w.dow.length > 0 && !w.dow.includes(wall.dow)) return false;
|
||
if (w.dateFrom != null && dateKey(wall) < isoDateKey(w.dateFrom)) return false;
|
||
if (w.dateTo != null && dateKey(wall) > isoDateKey(w.dateTo)) return false;
|
||
if (w.fromHour != null && w.toHour != null) {
|
||
const from = hourToMin(w.fromHour);
|
||
const to = hourToMin(w.toHour);
|
||
const now = wall.hour * 60 + wall.minute;
|
||
if (to <= from) {
|
||
// overnight wrap, e.g. 22:00→06:00
|
||
if (!(now >= from || now < to)) return false;
|
||
} else {
|
||
if (!(now >= from && now < to)) return false;
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/** Specificity tuple (date, dow, hour) — more constrained windows win. Higher is
|
||
* more specific; compared lexicographically. */
|
||
function specificity(c: TariffCard): [number, number, number] {
|
||
const w = c.window;
|
||
const hasDate = w != null && (w.dateFrom != null || w.dateTo != null) ? 1 : 0;
|
||
const hasDow = w != null && w.dow != null && w.dow.length > 0 ? 1 : 0;
|
||
const hasHour = w != null && w.fromHour != null && w.toHour != null ? 1 : 0;
|
||
return [hasDate, hasDow, hasHour];
|
||
}
|
||
|
||
/** Pick the single active card for a wall-clock instant from the candidate cards
|
||
* (default + category-matched). TOTAL + order-independent: most-specific wins, then
|
||
* higher `priority`, then `name` lexicographically as the final deterministic tiebreak
|
||
* (never array index). The defaultCard has specificity (0,0,0) so it only wins when
|
||
* nothing more specific matches. */
|
||
function selectCard(cards: readonly TariffCard[], wall: WallClock): TariffCard {
|
||
let best: TariffCard | undefined;
|
||
let bestSpec: [number, number, number] = [-1, -1, -1];
|
||
for (const c of cards) {
|
||
if (!matchesWindow(c.window, wall)) continue;
|
||
const spec = specificity(c);
|
||
if (best === undefined || compareCard(spec, c, bestSpec, best) > 0) {
|
||
best = c;
|
||
bestSpec = spec;
|
||
}
|
||
}
|
||
// The defaultCard always matches, so `best` is never undefined in practice; the
|
||
// fallback keeps the function total even for a pathological empty card list.
|
||
return best ?? cards[0]!;
|
||
}
|
||
|
||
/** Order: specificity desc, then priority desc, then name asc. Returns >0 if (specA,a)
|
||
* should beat (specB,b). */
|
||
function compareCard(
|
||
specA: [number, number, number],
|
||
a: TariffCard,
|
||
specB: [number, number, number],
|
||
b: TariffCard,
|
||
): number {
|
||
for (let i = 0; i < 3; i++) {
|
||
if (specA[i]! !== specB[i]!) return specA[i]! - specB[i]!;
|
||
}
|
||
if (a.priority !== b.priority) return a.priority - b.priority;
|
||
// Name as the final, total tiebreak. Lower name wins → invert so >0 means a beats b.
|
||
if (a.name !== b.name) return a.name < b.name ? 1 : -1;
|
||
return 0;
|
||
}
|
||
|
||
/**
|
||
* 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;
|
||
}
|