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