// 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 "drawer", // record cash receipts/disbursements (operator); review them (admin) "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 "recyclebin", // soft-deleted master data: view / restore / purge "backup", // encrypted DB backups: configure target + trigger a manual run ] as const; export type Resource = (typeof RESOURCES)[number]; /** CRUD plus domain verbs where CRUD doesn't fit: `void` (append a void event, NOT a * delete), `cash` (admin-grade shift scope — see all operators' shifts), `plan` (compose * the subscription plan catalog — admin-grade; selling stays `create`), and `review` * (admin authorizes/denies a drawer movement an operator recorded — a flag, not a * reversal; see wiki/concepts/shift.md). */ export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan" | "review"; /** 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", "subscription:plan", // compose the plan catalog (admin-grade); selling = subscription:create "site:read", "site:update", "device:read", "shift:read", "shift:create", "shift:cash", // Drawer cash movements: create (operator RECORDS a receipt/disbursement — freely, no // admin sign-off at creation; admin-revocable per role) and review (admin AUTHORIZES or // DENIES a recorded movement after the fact — a flag, never a cash reversal). A denial is // a judgment about the operator, settled outside the app. See wiki/concepts/shift.md. "drawer:create", "drawer:review", "payment:read", "payment:create", // 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", "report:read", "log:read", // Recycle bin: read (list soft-deleted items), update (restore), delete (purge). These // are admin-grade — a restore can revive a privileged user/role, a purge is permanent. "recyclebin:read", "recyclebin:update", "recyclebin:delete", // Backup: read (view config + last-run status), update (set target/schedule), create // (trigger a manual "back up now"). Admin-grade — a backup exposes the whole signed // ledger off-box. RESTORE is deliberately NOT a permission: it's an out-of-band runbook // action on a fresh appliance, never reachable from the running console. See // wiki/concepts/backup-recovery.md. "backup:read", "backup:update", "backup:create", ] 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"; /** A subscription plan's billing period. A span is priced as ceil(periods) × the * plan's per-period price — so a hotel's 1–N day stay is a `"day"` plan over a date * span. See wiki/entities/subscription.md. */ export type SubscriptionPeriod = "day" | "week" | "month"; export const SUBSCRIPTION_PERIODS: readonly SubscriptionPeriod[] = ["day", "week", "month"]; /** Composed allowed-time windows on a [[subscription]] plan. A scan OUTSIDE the window * is charged the transient tariff for the out-of-window minutes (the "tariff bridge"). * null/absent timeframes on a plan = 24/7, no charge ever. Evaluated in the site tz. * * The window applies ONLY on the selected `days` (0=Sun..6=Sat, mirroring the V2 tariff * day-of-week picker). On a NON-selected day the subscriber may park all day (no charge) * — so a "night plan" is days [Mon..Fri] with a 20:00→08:00 window, leaving the weekend * unrestricted. The window is [fromMin, toMin) minutes-of-local-midnight; `toMin ≤ fromMin` * WRAPS past midnight (a night window 20:00→08:00 = 1200..480). */ export interface PlanTimeframes { /** Days the window applies to (0=Sun..6=Sat). Empty/absent ⇒ every day. */ readonly days?: number[]; readonly fromMin: number; // window opens (minutes-of-day, local) readonly toMin: number; // window closes (minutes-of-day, local) /** Tolerance (minutes) around the window edges before a charge applies. */ readonly graceMin?: number; /** IANA tz the windows are wall-clock evaluated in (the site tz, captured at sale). */ readonly tz?: string; } /** One immutable VERSION of a subscription plan (admin-composed catalog; latest with * effectiveFrom ≤ sale instant prices a sale — the tariff-version pattern). The * operator SELLS from this catalog; they never type a price. */ export interface SubscriptionPlan { readonly id: string; // this version's id (persisted on the sale = planVersionId) readonly planId: string; // stable identity across versions (e.g. "hotel-daily") readonly name: string; readonly period: SubscriptionPeriod; readonly pricePerPeriodMinor: number; readonly currency: string; readonly effectiveFrom: string; readonly active: boolean; /** Allowed-time windows (tariff bridge). null/absent = 24/7, no time charge. */ readonly timeframes?: PlanTimeframes | null; readonly createdBy?: string | null; readonly createdAt?: string; } /** The result of pricing a date span against a plan version: how many (ceil) periods * it spans and the total to collect. Server-computed and shown to the operator as a * read-only quote — they can't override the amount. */ export interface SubscriptionQuote { readonly periods: number; readonly amountMinor: number; readonly currency: string; readonly period: SubscriptionPeriod; } /** Add whole months to an ISO datetime, clamping day overflow (e.g. Jan 31 +1mo → * Feb 28/29). Returns ISO. Shared by subscription pricing + the coverage window. */ export function addMonths(iso: string, months: number): string { const d = new Date(iso); const day = d.getUTCDate(); d.setUTCMonth(d.getUTCMonth() + months); // If the month rolled past (day 31 → a shorter month), clamp back to month-end. if (d.getUTCDate() < day) d.setUTCDate(0); return d.toISOString(); } const SUB_DAY_MS = 24 * 60 * 60 * 1000; const SUB_WEEK_MS = 7 * SUB_DAY_MS; /** How many whole periods (ceil) cover [from, to] — any STARTED period is a full one * (a guest checking out mid-day still owes that day). ≥ 1 for any positive span; 0 * for a non-positive/invalid span. Months walk whole-month steps so Jan-31 overflow * clamps consistently. Pure + deterministic. See wiki/entities/subscription.md. */ export function periodsBetween(period: SubscriptionPeriod, fromISO: string, toISO: string): number { const from = new Date(fromISO).getTime(); const to = new Date(toISO).getTime(); if (!Number.isFinite(from) || !Number.isFinite(to) || to <= from) return 0; if (period === "day") return Math.ceil((to - from) / SUB_DAY_MS); if (period === "week") return Math.ceil((to - from) / SUB_WEEK_MS); // month: smallest N whose (from + N months) ≥ to. let n = 0; while (n < 1200 && new Date(addMonths(fromISO, n)).getTime() < to) n += 1; return Math.max(1, n); } /** Price a date span against a plan version: ceil(periods) × per-period price. */ export function priceSubscriptionSpan( plan: Pick, fromISO: string, toISO: string, ): SubscriptionQuote { const periods = periodsBetween(plan.period, fromISO, toISO); return { periods, amountMinor: periods * plan.pricePerPeriodMinor, currency: plan.currency, period: plan.period, }; } /** 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; /** READ-TIME ENRICHMENT — not signed, not stored. A licence plate ADVISORILY * recognized for this session (ANPR-on-snapshot, device_events kind="read"), shown * next to entry/exit events. Uppercased, no confidence/region (those live on the * snapshot review panel). Absent when no plate was read or for non-entry/exit events. */ readonly plate?: 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. // SUPERSEDED 2026-06-20 by the directional voucher pair below — kept as a type so // historical events on the live chain still verify and still fold into the drawer. | "cash_movement" // Drawer cash vouchers (replace the signed-± cash_movement with two distinct // financial documents — the direction is the TYPE, not the sign of an amount): // cash_in = Mandat Arkëtimi (receipt / pay-IN): cash enters the drawer. // cash_out = Mandat Pagese (disbursement / pay-OUT): cash leaves the drawer. // Payload: { amountMinor (POSITIVE magnitude), reason, currency, operator (raised by), // voucherNo }. OPERATOR-RECORDED (freely; no admin sign-off at creation — 2026-07-01). // Folds into the drawer balance. Reviewed after the fact via cash_review (below). // See wiki/concepts/shift.md. | "cash_in" | "cash_out" // Admin's post-hoc REVIEW of a recorded cash_in/cash_out. Payload: { refId (the // reviewed movement's event id), decision: "authorize"|"deny", reviewedBy, note?, // currency? }. A FLAG only — it NEVER moves cash: a denial is a judgment about the // operator (settled outside the app), so it does NOT reverse the movement and does NOT // touch the drawer balance. Append-only, signed, so the decision is itself auditable. // See wiki/concepts/shift.md. | "cash_review" | "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; /** 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; /** cash_in / cash_out voucher: a human-facing voucher number printed on the slip * (Mandat Nr.). Sequential per type; signed for reproducibility. */ readonly voucherNo?: string; /** LEGACY cash_in / cash_out (pre-2026-07-01): the admin who AUTHORIZED the movement * at creation. The current flow records movements freely and reviews them AFTER via a * cash_review event, so new movements do NOT carry this. Kept so historical events * still verify + display. See wiki/concepts/shift.md. */ readonly authorizedBy?: string; /** cash_review: the id of the cash_in/cash_out event this review decides on. */ readonly refId?: string; /** cash_review: the admin's decision on the referenced movement. A FLAG only — * neither value moves cash or touches the drawer balance. */ readonly decision?: "authorize" | "deny"; /** cash_review: the admin (username) who made the decision. */ readonly reviewedBy?: string; /** cash_review: optional free-text admin note (e.g. why a movement was denied). */ readonly note?: string; /** subscription tariff-bridge: this occurrence opened OUTSIDE the plan's allowed window, * so the minutes actually parked out-of-window are charged at the transient tariff and * collected (gated) at exit. The AMOUNT is NOT fixed at entry — it depends on how long * they actually park out-of-window (capped at the window edges), so it's priced live at * settlement from minutesOutsideWindow(entry → pay-time). Only the marker + the tariff * version (for reproducible pricing) are stamped. See wiki/entities/subscription.md * ("tariff bridge"). */ readonly outOfWindow?: boolean; readonly windowTariffVersionId?: string; /** DEPRECATED stamp — a FIXED full-gap amount written by an earlier model. No longer * produced (it over-charged a subscriber who left before the window opened); retained * here only so historic signed events still type-check. Never read for pricing. */ readonly windowOwedMinor?: number; readonly windowCurrency?: string; readonly windowGapStart?: string; readonly windowGapEnd?: 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.` 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", // 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.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", // 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 "sub.refused.notFound", "sub.refused.outOfWindow", "sub.refused.noSession", "sub.refused.atCapacity", // a subscriber owes an out-of-window (early-entry / late-exit) transient charge and // hasn't paid it — exit is gated until they settle (the tariff-bridge gate). "sub.refused.unpaidWindow", // a wrongly-printed transient ticket cancelled by the operator (signed void event). "void.ticketCancelled", ] 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.`; this English copy stays here so the server can sign * a human fallback without importing a UI catalog. */ export const REASON_EN: Record = { "entry.refused.full": "entry refused — lot full ({count}/{capacity})", "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.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)", "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.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)", "sub.refused.unpaidWindow": "exit refused — out-of-window charge unpaid ({amount} {currency} owed); pay at the booth", "void.ticketCancelled": "ticket cancelled — {reason}", }; /** * 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 { 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 { 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, ): { reasonCode: ReasonCode; reasonParams?: Record; 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 | 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 | 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 = { 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[]; /** STEPPED ("up-to") pricing — a total-by-duration table. When present (non-empty) it * REPLACES `blocks`: the day's fee is the smallest tier whose `uptoMin ≥ elapsed`, and * the top tier's total becomes the per-day price beyond it. Mutually exclusive with the * marginal `blocks` ladder. Absent/empty ⇒ the ladder is used (back-compat). */ readonly steps?: readonly TariffStep[]; /** Cap per rolling 24h (null = no cap). Ignored for `steps` (the top tier IS the 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; } /** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay UP TO AND INCLUDING * `uptoMin` minutes. Unlike a {@link TariffBlock} (a marginal per-increment rate), this * is the cumulative total — the owner enters the price table directly (e.g. "0–3h → * 500"). The smallest `uptoMin ≥ duration` wins; the largest row's total acts as the * per-day price for stays beyond it (daily-cap repeat). See wiki/concepts/tariff.md. */ export interface TariffStep { /** Inclusive upper bound of this tier in minutes (e.g. 180 = "up to 3 hours"). */ readonly uptoMin: number; /** TOTAL charge for a stay within this tier (minor units), not a marginal rate. */ readonly totalMinor: 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`/`steps`). */ readonly flatMinor?: number; /** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */ readonly blocks?: readonly TariffBlock[]; /** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/ * `blocks`). The top tier's total is this card's per-day price. */ readonly steps?: readonly TariffStep[]; /** 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); } /** A signed payment as far as session pricing cares: when it happened and the * walk-back grace it granted. (The booth folds these from the ledger; the lab * supplies a hypothetical one.) */ export interface SessionPayment { readonly paidAt: string; // ISO-8601 readonly graceExitMin: number | null; } /** The full pricing outcome for a session at a moment in time — what the booth's * `quote()` and the exit flow compute, made PURE so it can be tested or previewed * without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */ export interface SessionPricing { /** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an * overstay (a paid session whose walk-back grace lapsed — a new period began). */ readonly periodStart: string; /** Fee for [periodStart, asOf]. */ readonly amountMinor: number; /** True when the latest payment's grace has lapsed (overstay = new period). */ readonly overstay: boolean; /** True when paid AND still inside the walk-back window (a settled, exitable stay). */ readonly withinGrace: boolean; /** ISO time the walk-back grace expires (lastPaid + graceExitMin), if paid. */ readonly graceExpiresAt: string | null; } /** * Price a session PURELY from its times + tariff structure — the single source of * truth shared by the live booth (`PayStation.quote`) and the Tariff Lab simulator, * so the two can never diverge. * * - Not yet paid → bill entry→asOf (the running total). * - Paid, still within walk-back grace → settled (amount 0; the car may exit). * - Paid, grace lapsed → OVERSTAY: bill a fresh period from grace-expiry→asOf with its * own daily-cap ladder (NOT "full stay minus paid", which a daily cap collapses to 0). * * `payments` is the session's payment history (only the LATEST matters for grace); * pass [] for an unpaid session. The tariff version is the one frozen at entry — the * customer keeps their rate card even across an overstay. See booth-exit-flow.md. */ export function priceSession( enteredAt: string, asOf: string, tariff: TariffStructure, payments: readonly SessionPayment[] = [], category?: string, ): SessionPricing { const last = payments.length ? payments[payments.length - 1] : null; const graceExpiryMs = last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null; const asOfMs = Date.parse(asOf); const overstay = graceExpiryMs != null && asOfMs > graceExpiryMs; const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs; const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt; // A settled (paid + within grace) session owes nothing more; otherwise bill the period. const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category); return { periodStart, amountMinor, overstay, withinGrace, graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null, }; } /** True when a structure/card uses STEPPED ("up-to") pricing (a non-empty `steps` * table), as opposed to the marginal `blocks` ladder or a flat rate. */ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean { return Array.isArray(s.steps) && s.steps.length > 0; } /** * Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule. * Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ / * inclusive boundary). Stays beyond the largest threshold charge that top total per * FULL day (a daily-cap repeat) and price the remainder on the next day's ladder. * `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md. */ function steppedFee(minutes: number, steps: readonly TariffStep[]): number { if (minutes <= 0 || steps.length === 0) return 0; const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin); const top = sorted[sorted.length - 1]!; const DAY = 24 * 60; let total = 0; for (let dayStart = 0; dayStart < minutes; dayStart += DAY) { const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day // Beyond the largest tier → the whole day is the top total (per-day cap repeat). const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top; total += tier.totalMinor; } return total; } /** 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. A `steps` table (when present) * REPLACES the ladder via {@link steppedFee}. */ 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 // STEPPED pricing: a total-by-duration table replaces the marginal ladder. if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!); 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; // STEPPED default card: a whole-stay "total by duration" model that does NOT compose // with per-increment windowed cards (a total isn't a per-increment rate). So when the // defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore // windowed cards (they have nothing to override at the increment level). This is the // only sound place for steps in V2. See wiki/concepts/tariff.md. if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!); 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).defaultCard != null ? validateTariffV2(s as Partial) : validateTariffV1(s as Partial); } 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, 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[])[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`, ); } } /** Validate a STEPPED ("up-to") table: ≥1 row, strictly-ascending positive `uptoMin`, * non-negative integer totals. Totals need NOT be monotonic (an owner may price a * longer stay cheaper if they wish), but each tier must be a clean total. `prefix` * labels errors (e.g. "steps" or "defaultCard.steps"). */ function validateSteps(steps: unknown, prefix: string, errs: string[]): void { if (!Array.isArray(steps) || steps.length === 0) { errs.push(`${prefix} must be a non-empty array`); return; } let prevBound = 0; steps.forEach((s: Partial, i: number) => { nonNegInt(s?.totalMinor, `${prefix}[${i}].totalMinor`, errs); if (typeof s?.uptoMin !== "number" || !Number.isInteger(s.uptoMin) || s.uptoMin <= prevBound) { errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous tier's bound (${prevBound})`); } else { prevBound = s.uptoMin; } }); } function validateTariffV1(t: Partial): 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"'); // STEPPED mode (a non-empty steps table) REPLACES the block ladder: validate steps // and forbid a daily cap (the top tier IS the per-day price). Otherwise validate the // ladder. A bare V1 with neither is invalid (validateBlocks reports the empty array). if (hasSteps(t as { steps?: readonly TariffStep[] })) { validateSteps(t.steps, "steps", errs); if (t.dailyCapMinor != null) errs.push("dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)"); } else { if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs); 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 | 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; const hasStepTable = c.steps != null; const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length; if (modes !== 1) { errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`); } 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 if (hasStepTable) { // Stepped pricing is only sound on the DEFAULT card (a whole-stay total can't be // sliced per-increment by a windowed card). Forbid it on a windowed card + the cap. if (!isDefault) errs.push(`${label}: stepped (steps) pricing is only allowed on the defaultCard`); validateSteps(c.steps, `${label}.steps`, errs); if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)`); } 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 | 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): 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)); } // A STEPPED base (an "up-to" total-by-duration table) prices the WHOLE stay as one // number — it cannot be sliced per-increment, so windowed (time/seasonal) tiers have // nothing to override and the engine ignores them entirely. Forbid the combination // rather than let an operator publish tiers that silently never fire. (Switch the base // to an hourly ladder / flat rate to use tiers, or remove the tiers.) if (t.defaultCard != null && hasSteps(t.defaultCard) && cards.length > 0) { errs.push( "time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or switch the base rate to an hourly ladder or flat price", ); } // 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[], 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 = { 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, }; } // --- Subscription plan timeframes — the "tariff bridge" gap (pure, tz-aware) ------- /** Minute-of-day is inside the window [fromMin, toMin)? A window with toMin ≤ fromMin * WRAPS past midnight (night window 20:00→08:00 ⇒ in = m ≥ 1200 OR m < 480). */ function inWindow(m: number, fromMin: number, toMin: number): boolean { return toMin <= fromMin ? m >= fromMin || m < toMin : m >= fromMin && m < toMin; } /** * The out-of-window GAP for a subscriber scan, or null when the scan is in-window (or the * scan falls on a day the window does NOT apply to). This is the portion charged at the * transient tariff (the "tariff bridge"): * - edge "entry" (early arrival): gap = [scan, next window-OPEN] — they pay transient * from arrival until their window starts (a 09:00 arrival to a 20:00 night window * owes 09:00→20:00, capped by the tariff's daily cap). * - edge "exit" (late departure): gap = [last window-CLOSE, scan] — they pay transient * from when their window ended until they actually leave (08:00→08:45). * The window applies only on `timeframes.days` (0=Sun..6=Sat; empty ⇒ every day); on a * day NOT in the set the subscriber parks free. Grace widens the allowed window by * `graceMin` on the relevant edge. Pure + tz-aware (wall-clock in `timeframes.tz` or the * passed `tz`); minutes-of-day arithmetic anchored on the scan's own local day keeps it * DST-robust for the short gaps involved. */ export function outOfWindowGap( timeframes: PlanTimeframes | null | undefined, tz: string, atISO: string, edge: "entry" | "exit", ): { start: string; end: string; minutes: number } | null { if (!timeframes) return null; if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return null; const atMs = Date.parse(atISO); if (Number.isNaN(atMs)) return null; const zone = timeframes.tz || tz; const wall = localBreakdown(atMs, zone); // The window applies only on the selected days; empty/absent = every day. On a day the // window doesn't cover, the subscriber may park all day (no charge). const days = timeframes.days; if (days && days.length > 0 && !days.includes(wall.dow)) return null; const grace = Math.max(0, timeframes.graceMin ?? 0); const nowMin = wall.hour * 60 + wall.minute; if (inWindow(nowMin, timeframes.fromMin, timeframes.toMin)) return null; // already allowed // Minutes (always ≥ 0) until the window OPENS, measured forward from the scan. const minsUntil = (target: number) => ((target - nowMin) % 1440 + 1440) % 1440; // Minutes (always ≥ 0) since the window CLOSED, measured backward from the scan. const minsSince = (target: number) => ((nowMin - target) % 1440 + 1440) % 1440; if (edge === "entry") { // Early: charge from the scan until the window opens (minus grace tolerance). const mins = minsUntil(timeframes.fromMin) - grace; if (mins <= 0) return null; // within grace of opening const end = new Date(atMs + mins * 60_000).toISOString(); return { start: atISO, end, minutes: mins }; } // Late exit: charge from when the window closed (plus grace) until the scan. const mins = minsSince(timeframes.toMin) - grace; if (mins <= 0) return null; // within grace of closing const start = new Date(atMs - mins * 60_000).toISOString(); return { start, end: atISO, minutes: mins }; } /** * Total minutes WITHIN the stay span `[fromISO, toISO)` that fall OUTSIDE the plan's * allowed window — the correct charge basis for a subscriber's out-of-window parking * (early entry AND/OR late exit, in one number, bounded by the actual stay). On days the * window doesn't apply (not in `days`) the whole day is allowed (0 outside minutes). The * window edges are widened by `graceMin`. Pure + tz-aware. Returns 0 for an unrestricted * plan / empty span. (Sampled per minute; capped so a pathological span can't spin.) */ export function minutesOutsideWindow( timeframes: PlanTimeframes | null | undefined, tz: string, fromISO: string, toISO: string, ): number { if (!timeframes) return 0; if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return 0; const fromMs = Date.parse(fromISO); const toMs = Date.parse(toISO); if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return 0; const zone = timeframes.tz || tz; const grace = Math.max(0, timeframes.graceMin ?? 0); const days = timeframes.days && timeframes.days.length > 0 ? new Set(timeframes.days) : null; // Widen the allowed window by grace on both edges (so a few minutes either side is free). const from = (timeframes.fromMin - grace + 1440) % 1440; const to = (timeframes.toMin + grace) % 1440; // Iterate minute-by-minute over the stay; count minutes outside the allowed window. const totalMin = Math.ceil((toMs - fromMs) / 60_000); const cap = 60 * 24 * 400; // ~400 days of minutes — a hard safety bound let outside = 0; for (let i = 0; i < totalMin && i < cap; i += 1) { const wall = localBreakdown(fromMs + i * 60_000, zone); // A day the window doesn't apply to ⇒ fully allowed (this minute is free). if (days && !days.has(wall.dow)) continue; const m = wall.hour * 60 + wall.minute; if (!inWindow(m, from, to)) outside += 1; } return outside; } /** "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; }