Files
parking_solution/packages/shared/src/index.ts
T
julian 8a437d0c4b
CI / check (push) Failing after 56s
feat(booth): cancel wrongly-printed ticket (signed void) + refused-vs-anomaly display; fix CI uv
Cancel a misprinted/test/wrong-vehicle ticket via a SIGNED `void` event — the
vehicle_entry is never edited/deleted (append-only). VoidFlow appends void{
voidedEntryRef, voidReason, operator, reasonCode:"void.ticketCancelled" }; route
POST /api/tickets/void gated event:void + open shift; reason REQUIRED. Refuses a
subscription / already-exited / already-voided / paid ticket (refund out of scope).
The void folds the session CLOSED everywhere it's counted — occupancy (count +
reserved spots), pay-station (lookup/activeSessions), exit-flow (#sessionFor), and
reports (excluded from entries) — so a voided car stops occupying a spot, can't be
paid/exited, and doesn't inflate "cars entered". No barrier action. Booth UI: a
"Cancel ticket" action in the pay/exit lookup modal (transient + unpaid + open;
gated on event:void) with a preset-or-free reason prompt.

Reclassify the Live feed: refused-action events (exitRefused/entryRefused/
permitRefused — e.g. a double card-scan, at-capacity subscriber, exit on a closed
session) are benign warnings, not red anomalies. event-detail.tsx now shows them as
amber REFUZUAR/REFUSED, reserving red ANOMALI for genuine red-flags. Display-only —
no ledger change, so historical events reclassify too.

CI: install uv + sync vision deps before the Turbo run. @parking/vision's lint/
typecheck/test shell to `uv run …`, but CI set up only Node+pnpm, so `uv run ruff`
failed ("uv not found") and broke the whole Turbo run. The Python checks pass once
uv provisions the toolchain.

- new: void-flow.ts (+ tests, 8) ; occupancy void-fold test
- shared: reason code void.ticketCancelled ; both web catalogs (sq/en parity)
- wiki: parking-session (ticket-void folds + guards, refused/anomaly split), log

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
2026-06-22 20:13:21 +02:00

1308 lines
62 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
"recyclebin", // soft-deleted master data: view / restore / purge
] 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` (move the drawer float — admin-grade shift action), and `plan`
* (compose the subscription plan catalog — admin-grade; selling stays `create`). */
export type Action = "create" | "read" | "update" | "delete" | "void" | "cash" | "plan";
/** 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",
"payment:read", "payment:create",
"session:read",
"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",
] 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<SubscriptionPlan, "period" | "pricePerPeriodMinor" | "currency">,
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), authorizedBy (admin who signed off), voucherNo }. Operator-raised /
// admin-authorized. Folds into the drawer balance. See wiki/concepts/shift.md.
| "cash_in"
| "cash_out"
| "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;
/** cash_in / cash_out voucher: the admin who AUTHORIZED the drawer movement (the
* operator in `operator` raised it). Operator-raised / admin-authorized. */
readonly authorizedBy?: 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;
/** 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.<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",
// 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.<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)",
"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, 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[];
/** 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<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`,
);
}
}
/** 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<TariffStep>, 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<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.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<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;
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<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));
}
// 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<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,
};
}
// --- 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;
}