Files
parking_solution/packages/shared/src/index.ts
T
julian 50c18405b6 feat(roles): roles remember the jobs they follow (re-appliable), every role edit is signed
Closes the permissions-matrix loose ends (venue-modules.md §Permissions matrix):

- `role_jobs` (migration 0029): a role stores the manifest jobs it was composed from
  (chips on at save + any bundle fully present). `jobById` / `jobsBehind` in
  @parking/shared surface a followed job whose bundle grew past the role in a later
  release; the roles list shows a "behind <job>" badge with a one-click "Update to job"
  (the union, nothing removed) and the editor lints it. Never a runtime union: the grid
  stays the explicit enforcement layer and an update never widens a role without a click.
- Every role create/update/delete appends a `config_change` (`role.<id>`, prev/value =
  name + sorted permissions + jobs, operator); a no-op resave signs nothing. roleRoutes
  now takes the ledger.
- booth-supervisor already carries subscription:*; the stale open note is closed.

Tests: routes/roles.test.ts. Wiki: venue-modules status, local-jwt-auth, log.

Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
2026-09-06 12:52:47 +02:00

2142 lines
105 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
"validation", // merchant validations: apply a discount to a session (bar/lavazh)
"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
"carwash", // Car Wash module: orders/queue (read), intake (create), done/pay/void (update)
] 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",
// Merchant validations (bar/lavazh): create = APPLY a validation to a session (the
// merchant user's one permission — guarded further by the program↔user binding, so a
// bar user can never apply the lavazh program) + void their OWN unused validation;
// read = see applied validations (reports/history). Program COMPOSITION needs no new
// permission — it lives on /setup/site behind site:update. See
// wiki/concepts/validation-discounts.md.
"validation:create", "validation:read",
"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",
// Car Wash module (venue-modules.md): read = the wash desk's queue + ticket lookup
// (+ the wash till's shift state and the wash live feed); create = intake an order;
// update = mark done / take a bay payment / void; cash = WORK the wash till — open and
// close its shift, record its cash in/out (the wash's own `shift:create` +
// `drawer:create`; see ModuleManifest.tillGuards). Settings (categories, services,
// price matrix, sponsorship program) ride site:update.
"carwash:read", "carwash:create", "carwash:update", "carwash:cash",
] 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),
// 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"
// Signed record of an admin changing a FRAUD-RELEVANT setting, so the change is
// itself in the tamper-evident chain (who/when/what). Payload: { setting, value,
// operator, prev? }. First use: the entry presence-gate bypass (a faulty radar/
// camera lets the admin drop that signal as a requirement until support fixes it —
// the admin is NOT the adversary, but weakening an anti-fraud gate must still be
// attributed + auditable). See wiki/concepts/entry-presence-bypass.md.
| "config_change"
// A merchant validation applied to (or voided from) a transient session: the bar/
// lavazh user scanned the customer's ticket, so the booth settlement discounts the
// fee. Payload carries the RESOLVED values (programId, label, mode, minutes/
// amountMinor/percent) — reproducible even if the program config later changes —
// plus `operator` (the merchant username). A payload with `refId` set is a VOID of
// the referenced validation event (append-only correction, mirrors cash_review).
// See wiki/concepts/validation-discounts.md.
| "validation"
// Car Wash module (venue-modules.md). `carwash_order` is the order's life on the
// chain — payload.action = "created" | "done" | "void", with the category/service/
// price FROZEN at intake so renames never rewrite history. `carwash_payment` is
// money taken AT THE BAY (payAt = "bay"); a wash paid AT THE BOOTH rides the
// parking `payment` as chargeLines instead (see PayStation charge providers).
| "carwash_order"
| "carwash_payment"
| "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. `amountMinor` is the
* NET collected; grossMinor the pre-discount fee; discountMinor what validations took
* off. `validationIds` = the validation event ids this payment CONSUMED (so an
* overstay's fresh period never re-applies them). */
readonly grossMinor?: number;
readonly discountMinor?: number;
readonly validationIds?: string[];
/** payment: the per-validation receipt lines as settled (label + amount taken off) —
* stamped so the printed receipt reproduces without re-deriving the fold. */
readonly validationLines?: { programId: string; label: string; mode: string; discountMinor: number }[];
/** payment: non-parking charges a module folded into this settlement (e.g. a wash
* paid at the booth). `amountMinor` (NET) INCLUDES them; `parkingMinor` is the
* parking-only net; `chargesMinor` their sum. See PayStation charge providers. */
readonly chargeLines?: ChargeLine[];
readonly chargesMinor?: number;
readonly parkingMinor?: number;
/** carwash_order / carwash_payment: the order + what was frozen at intake. */
readonly orderId?: string;
readonly action?: string;
readonly categoryName?: string;
readonly serviceName?: string;
readonly priceMinor?: number;
readonly payAt?: string;
/** validation: which program (bar/lavazh) + its receipt label, frozen at apply time. */
readonly programId?: string;
readonly programLabel?: string;
/** validation: resolved values by mode — timeCredit's free minutes / percent off.
* A fixed amount rides the shared `amountMinor`. */
readonly minutes?: number;
readonly percent?: number;
/** validation / cash vouchers: the username of the user who recorded it. */
readonly operator?: string;
/** MONEY events (payment, carwash_payment, cash_in/out, shift_open, shift_z_report):
* the TILL the money belongs to. A shift is opened on a till; every taking and
* voucher names one; the drawer fold and the Z-report filter by it. ABSENT = the
* booth (every event before tills existed, 2026-09-05, is booth money — so the
* chain re-folds identically). See wiki/concepts/shift.md "Tills". */
readonly till?: TillId;
/** 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: 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.
* validation: set = this event VOIDS the referenced validation event. */
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.<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",
// 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",
// entry-side plate reconciliation: the plate recognized on a fresh transient entry is
// already OPEN under another recent session — likely the SAME car minting a second
// ticket (e.g. a motion radar dropped the stationary car and re-armed the button).
// Post-hoc + advisory (ANPR never gates); the operator voids the duplicate.
"entry.duplicatePlate",
// 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 credential value arrived on the WRONG physical channel (e.g. an RF card's UID
// presented as a printed barcode — a cloned-credential attempt). Channel comes from
// the reader's output prefixes; see wiki/entities/dingtian-dt008-reader.md.
"sub.refused.channelMismatch",
// a wrongly-printed transient ticket cancelled by the operator (signed void event).
"void.ticketCancelled",
// an admin fired a barrier relay from Setup to test the wiring. The physical open is
// DELIBERATE — signing it keeps reconciliation from reading it as an out-of-band open.
"setup.relayTest",
] 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}",
"entry.operatorIssued": "entry ticket issued by operator {operator} (physical button)",
"entry.issue.noPresence": "operator entry refused — no vehicle detected at the entry",
"entry.duplicatePlate": "possible duplicate entry — plate {plate} is already inside under ticket {otherIdentity}",
"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",
"sub.refused.channelMismatch": "credential refused — a {credentialKind} credential arrived via the {channel} channel (possible cloned credential)",
"void.ticketCancelled": "ticket cancelled — {reason}",
"setup.relayTest": "relay test — admin {operator} pulsed relay {relay} on controller {controller} from Setup",
};
/**
* 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 per-increment flat rate, a block ladder, a stepped table
* (defaultCard only), or a whole-window package (windowed cards only). The pricing
* fields are mutually exclusive — exactly one. 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 (an hourly flat rate at increment 60) —
* mutually exclusive with the other pricing fields. NOT a whole-stay price;
* for "one total for the whole window" use `packageMinor`. */
readonly flatMinor?: number;
/** Marginal block ladder (mutually exclusive with the other pricing fields); last open-ended. */
readonly blocks?: readonly TariffBlock[];
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
* pricing fields; defaultCard only). The top tier's total is the per-day price. */
readonly steps?: readonly TariffStep[];
/** WINDOW PACKAGE (windowed cards only, 2026-07-05): ONE total charged per
* contiguous occurrence of this card winning increments — e.g. "any presence in
* the 20:00–07:00 window = 400, leave earlier and it's still 400". Any touch of
* the window pays the full package; a stay spanning two nights pays it twice
* (once per occurrence). Mutually exclusive with the other pricing fields. */
readonly packageMinor?: number;
/** 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;
}
// --- Merchant validations (bar / lavazh discounts) ---------------------------
// An in-park merchant validates a customer's ticket so the BOOTH settlement charges
// less or nothing. The program is admin-composed MUTABLE master data (no versioning:
// the applied validation is a signed ledger event carrying the RESOLVED values, so
// reproducibility never depends on the row). All money stays at the booth — the
// merchant only validates. See wiki/concepts/validation-discounts.md.
/** How a program discounts: full comp / first-N-minutes free / a fixed amount (typed
* by the merchant at scan time, capped) / a percentage off. */
/** How a validation program discounts the parking fee. The first four are the merchant
* modes (applied at scan). The last two are RESOLVED at apply time by the Car Wash module
* and can only be applied through a wash order (a merchant scan refuses them):
* - doneTolerance: the WASH WINDOW is free — from the order's intake until it is marked
* DONE, plus `minutes` tolerance — resolved into a timeCredit of (window + minutes).
* Parking before the order and after the tolerance stays at the tariff;
* - washPrice: the wash price comes off the parking fee, floored at 0 — resolved into a
* fixed discount of the order's price. */
export type ValidationMode = "comp" | "timeCredit" | "fixed" | "percent" | "doneTolerance" | "washPrice";
export const VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent", "doneTolerance", "washPrice"];
/** Modes a MERCHANT may apply at scan (the wash-only modes need a wash order's context). */
export const MERCHANT_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "timeCredit", "fixed", "percent"];
/** Modes the Car Wash discount editor offers (no typed amounts, no percent — see venue-modules.md). */
export const CARWASH_VALIDATION_MODES: readonly ValidationMode[] = ["comp", "doneTolerance", "washPrice", "timeCredit"];
/** An admin-composed validation program (one per merchant station; `bar` and `lavazh`
* are the well-known ids the /setup/site checkboxes toggle). */
export interface ValidationProgram {
readonly id: string; // well-known slug ("bar" | "lavazh"); generic for future merchants
/** Receipt label, e.g. "Lavazh — 1 orë falas". Printed on the booth receipt line. */
readonly name: string;
readonly mode: ValidationMode;
/** timeCredit: the free minutes. */
readonly minutes: number | null;
/** percent: 1..100 off the fee. */
readonly percent: number | null;
/** fixed: cap on the amount the merchant may type at scan time (minor units). */
readonly maxAmountMinor: number | null;
/** Cap: max applications of this program per local day (null = unlimited). */
readonly maxPerDay: number | null;
readonly active: boolean;
}
/** An APPLIED validation as pricing cares about it — the RESOLVED values folded off
* the signed validation event (never the mutable program row). */
export interface SessionValidation {
/** The validation event id (payments record which ids they consumed). */
readonly eventId?: string;
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
readonly minutes?: number; // timeCredit
readonly amountMinor?: number; // fixed
readonly percent?: number; // percent
}
/** One receipt/display line: what a validation actually saved on this settlement. */
export interface ValidationLine {
readonly programId: string;
readonly label: string;
readonly mode: ValidationMode;
/** The (positive) amount this line took off the fee. */
readonly discountMinor: number;
}
/** 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;
/** Amount DUE for [periodStart, asOf] — NET of any merchant validations. */
readonly amountMinor: number;
/** The pre-validation fee for the same period (= amountMinor when no validations). */
readonly grossMinor: number;
/** Total the validations took off (grossMinor − amountMinor). */
readonly discountMinor: number;
/** Per-validation receipt lines, in the canonical application order. */
readonly validationLines: ValidationLine[];
/** 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.
*
* `validations` are the UNCONSUMED merchant validations on the session (the caller
* filters out ids already recorded on a prior payment's `validationIds`, so an
* overstay's fresh period never re-applies them). Canonical application order —
* deterministic regardless of scan order: timeCredit (shifts the billed period's
* start forward, so "first hour free" is literal and windowed/stepped cards price
* the remainder correctly) → percent (of the remaining fee) → fixed amounts
* (clamped to the remainder) → comp (zeroes whatever is left). Net never goes
* below 0. See wiki/concepts/validation-discounts.md.
*/
export function priceSession(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
payments: readonly SessionPayment[] = [],
category?: string,
validations: readonly SessionValidation[] = [],
): 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 grossMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
// Fold the validations (nothing to discount on a settled session or a zero fee is
// still folded so the receipt can show "Lavazh — falas" even when gross is 0-adjacent).
const lines: ValidationLine[] = [];
let net = grossMinor;
if (!withinGrace && validations.length) {
const byMode = (m: ValidationMode) => validations.filter((v) => v.mode === m);
// 1. Time credits: bill as if the period started later (clamped at asOf). The
// marginal saving of each credit is its line amount.
let startMs = Date.parse(periodStart);
for (const v of byMode("timeCredit")) {
const minutes = v.minutes ?? 0;
const shiftedMs = Math.min(startMs + minutes * 60_000, asOfMs);
const newFee = computeFee(new Date(shiftedMs).toISOString(), asOf, tariff, category);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net - newFee });
startMs = shiftedMs;
net = newFee;
}
// 2. Percent of the remaining fee (floor — integer minor units).
for (const v of byMode("percent")) {
const off = Math.floor((net * Math.min(Math.max(v.percent ?? 0, 0), 100)) / 100);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 3. Fixed amounts, clamped to the remainder so Σ lines ≡ gross − net.
for (const v of byMode("fixed")) {
const off = Math.min(Math.max(v.amountMinor ?? 0, 0), net);
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: off });
net -= off;
}
// 4. Comp: zero whatever is left.
for (const v of byMode("comp")) {
lines.push({ programId: v.programId, label: v.label, mode: v.mode, discountMinor: net });
net = 0;
}
}
return {
periodStart,
amountMinor: net,
grossMinor,
discountMinor: grossMinor - net,
validationLines: lines,
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;
}
// --- Fee breakdown (explainability) -------------------------------------------
// One line item per priced "reason": a run of same-priced increments, a window
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
// Produced by the SAME walk computeFee runs (an optional trace collector inside
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
// never tell a different story than the bill. Built for the Tariff Lab's "how is
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
// period's start.
export type FeeBreakdownItem =
/** The whole stay fit inside the free entry-grace window (fee 0). */
| { readonly kind: "grace"; readonly minutes: number }
/** A contiguous run of increments billed at one unit price by one card.
* `card` is the windowed card's name, or null for the base/default rate. */
| {
readonly kind: "band";
readonly card: string | null;
readonly fromMin: number;
readonly toMin: number;
readonly increments: number;
readonly unitMinor: number;
readonly amountMinor: number;
}
/** One window-package occurrence (charged once per contiguous run the card wins). */
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
| {
readonly kind: "step";
readonly day: number;
readonly dayMinutes: number;
readonly uptoMin: number;
readonly amountMinor: number;
readonly repeated: boolean;
}
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
export interface FeeBreakdown {
/** Actual stay length in whole minutes (before increment rounding). */
readonly rawMinutes: number;
/** Minutes billed after rounding UP to the increment (0 within grace). */
readonly billedMinutes: number;
readonly incrementMin: number;
readonly items: FeeBreakdownItem[];
/** Σ item amounts — always equals computeFee for the same arguments. */
readonly totalMinor: number;
}
/**
* Explain a fee: run the exact computeFee walk with a trace collector and return
* the line items plus the total. Same arguments as computeFee; the total returned
* here IS computeFee's answer (one code path, not a parallel calculation).
*/
export function explainFee(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
category?: string,
): FeeBreakdown {
const items: FeeBreakdownItem[] = [];
const totalMinor = isTariffV2(tariff)
? computeFeeV2(enteredAt, asOf, tariff, category, items)
: computeFeeV1(enteredAt, asOf, tariff, items);
const ms = Date.parse(asOf) - Date.parse(enteredAt);
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
const inc = Math.max(1, tariff.incrementMin);
const inGrace = items.length === 1 && items[0]!.kind === "grace";
const billedMinutes =
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
? 0
: Math.ceil(ms / 60_000 / inc) * inc;
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
}
/** Band-merging helper for the trace: accumulate consecutive increments that share
* a (card, unit price) and flush them as one `band` item. */
class BandTracer {
#card: string | null = null;
#unit = 0;
#from = 0;
#count = 0;
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
add(card: string | null, unitMinor: number, atMin: number): void {
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
this.#count++;
return;
}
this.flush();
this.#card = card;
this.#unit = unitMinor;
this.#from = atMin;
this.#count = 1;
}
flush(): void {
if (this.#count === 0) return;
this.items.push({
kind: "band",
card: this.#card,
fromMin: this.#from,
toMin: this.#from + this.#count * this.inc,
increments: this.#count,
unitMinor: this.#unit,
amountMinor: this.#count * this.#unit,
});
this.#count = 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[], trace?: FeeBreakdownItem[]): 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 found = sorted.find((s) => dayMin <= s.uptoMin);
const tier = found ?? top;
total += tier.totalMinor;
trace?.push({
kind: "step",
day: dayStart / DAY + 1,
dayMinutes: dayMin,
uptoMin: tier.uptoMin,
amountMinor: tier.totalMinor,
repeated: found == null,
});
}
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,
trace?: FeeBreakdownItem[],
): 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) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
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!, trace);
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;
const bands = trace ? new BandTracer(trace, inc) : null;
// 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) {
const unit = rateAt(tariff.blocks, within);
segFee += unit;
bands?.add(null, unit, segStart + within);
}
bands?.flush();
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
trace?.push({
kind: "cap",
day: segStart / DAY + 1,
capMinor: tariff.dailyCapMinor,
amountMinor: tariff.dailyCapMinor - segFee,
});
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,
trace?: FeeBreakdownItem[],
): 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) {
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
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!, trace);
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
// its name.
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
let total = 0;
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
// contiguous run of increments it wins (an "occurrence" — e.g. one night), however
// little of the window the car actually used. The tracker survives the day-segment
// loop so a night run crossing the rolling-24h boundary charges once, not twice;
// the charge lands in the segment where the occurrence starts (that day's cap
// applies to it). A stay touching the window on two different nights = two
// occurrences = two charges.
let prevWinner: TariffCard | null = null;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
const bands = trace ? new BandTracer(trace, inc) : null;
for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall);
if (card.packageMinor != null) {
// First increment of a new occurrence pays the package; the rest ride free.
if (prevWinner !== card) {
segFee += card.packageMinor;
bands?.flush();
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
}
} else if (card.flatMinor != null) {
segFee += card.flatMinor;
bands?.add(traceName(card), card.flatMinor, within);
} else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
const unit = rateAt(card.blocks ?? [], within - segStart);
segFee += unit;
bands?.add(traceName(card), unit, within);
}
prevWinner = card;
}
bands?.flush();
if (dayCap != null && segFee > dayCap) {
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
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 hasPackage = c.packageMinor != null;
const modes = [hasFlat, hasBlocks, hasStepTable, hasPackage].filter(Boolean).length;
if (modes !== 1) {
errs.push(`${label} must set exactly one of flatMinor, blocks, steps, or packageMinor`);
} else if (hasPackage) {
// A whole-window package needs a window to be an occurrence of — meaningless on
// the always-active defaultCard (a base "one price per stay/day" is a 1-row
// stepped table there). See wiki/concepts/tariff-time-tiers.md.
if (isDefault) errs.push(`${label}: packageMinor (whole-window package) is only allowed on a windowed card`);
nonNegInt(c.packageMinor, `${label}.packageMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to a window package (the package IS the window's total)`);
} 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;
}
// --- Venue modules -------------------------------------------------------------
// Optional per-site features (Car Wash, Bar, …) and — deliberately — the parking
// product itself are MODULES on a shared venue core (identity/roles, the signed
// ledger, devices, shift/cash, printing, reports, site config). One binary; a module
// is enabled per site at RUNTIME as `entitled ∩ activated`:
// - entitled = what the vendor deployed for this site (MODULES_ENTITLED env, set in
// the Komodo stack; unset = every registered module — existing
// deployments keep working unchanged);
// - activated = what the site admin has switched on in Setup → Site
// (site_config.modules_json; null = everything entitled).
// The server ENFORCES the effective set (requireModule guard, apps/server/src/
// modules.ts); the web only HIDES nav/routes from it. Disabling never deletes:
// tables stay migrated, history stays, role grants stay; routes reject and UI hides.
// Design + rationale: wiki/decisions/venue-modules.md.
export const MODULE_IDS = ["parking", "validation", "carwash"] as const;
export type ModuleId = (typeof MODULE_IDS)[number];
// --- Tills --------------------------------------------------------------------
// A TILL is a physical cash drawer with its own accountability: shifts are opened on
// a till, money events name their till, and the Z-report reconciles one till. The
// booth is the till that has always existed; a money-taking module declares its own
// (Car Wash → "carwash") so its operator counts THEIR drawer against THEIR expected
// figure — the wash operator and the booth operator do not share a shift. A till is
// available when the module that declares it is effective. See wiki/concepts/shift.md.
export const TILL_IDS = ["booth", "carwash"] as const;
export type TillId = (typeof TILL_IDS)[number];
/** The till every pre-till event and every un-tagged money event belongs to. */
export const BOOTH_TILL: TillId = "booth";
export function isTillId(v: unknown): v is TillId {
return typeof v === "string" && (TILL_IDS as readonly string[]).includes(v);
}
/** The till a money event belongs to: its payload's `till`, else the booth. ONE rule,
* shared by the drawer fold, the Z-report, and the UI — never re-derive it elsewhere. */
export function tillOf(payload: { till?: TillId } | null | undefined): TillId {
return payload?.till ?? BOOTH_TILL;
}
export interface ModuleManifest {
readonly id: ModuleId;
/** Cannot be deactivated (and is always entitled). Parking is the product today. */
readonly required: boolean;
/** Modules that must be effective for this one to be activated. Enforced at the point
* of change (activating with a dependency off is refused; deactivating a dependency of
* an active module is refused) and again when computing the effective set. */
readonly dependsOn: readonly ModuleId[];
/** Permission resources this module contributes to the catalog (informational for the
* role composer; the core resources belong to no module). */
readonly resources: readonly Resource[];
/** Ledger event types this module appends (informational; the union stays ONE
* append-only type — see LedgerEventType). */
readonly ledgerEventTypes: readonly LedgerEventType[];
/** The TILL this module takes money on, if it takes money at its own desk. Its
* operators open shifts on that till and reconcile that drawer. Absent = the
* module has no money of its own (validation) — or, for parking, the booth. */
readonly till?: TillId;
/** Who may SEE and WORK this module's till — each desk's money is guarded by that
* desk's own permissions (permissions-matrix decision, 2026-09-05): `read` = see the
* shift state / X-report / balance / history; `shift` = open + close the shift;
* `cash` = record cash in/out. The booth's are parking's `shift:*` / `drawer:*`; the
* wash's are `carwash:read` / `carwash:cash`. A wash role holds no `shift:*` at all,
* so it cannot touch the booth by construction. Required when `till` is set. */
readonly tillGuards?: TillGuards;
/** The permission that admits this module's ledger events to a role's live feed
* (`ledgerEventTypes` above). Absent = the core `event:read`. */
readonly feedPermission?: Permission;
/** JOBS — named permission bundles the role composer offers as one click ("Booth
* operator", "Wash operator"). The grid stays the enforcement layer; a job is only a
* starting point the admin may fine-tune. Names live in the web i18n (`jobs.<id>`). */
readonly jobs: readonly JobPreset[];
}
export interface TillGuards {
readonly read: Permission;
readonly shift: Permission;
readonly cash: Permission;
}
export interface JobPreset {
readonly id: string;
readonly permissions: readonly Permission[];
}
/** The registry. Adding a module = one entry here + its server/web folders
* (apps/server/src/modules/<id>, apps/web/src/modules/<id>). Order = display order. */
export const MODULES: readonly ModuleManifest[] = [
{
id: "parking",
required: true,
dependsOn: [],
resources: ["tariff", "subscription", "payment", "session"],
ledgerEventTypes: ["vehicle_entry", "vehicle_exit", "payment", "barrier_open_command", "barrier_open_observed"],
till: "booth",
tillGuards: { read: "shift:read", shift: "shift:create", cash: "drawer:create" },
jobs: [
{
// Runs the booth: sessions, payments, own shift + drawer, the live feed, devices.
id: "booth-operator",
permissions: [
"session:read", "session:create", "payment:read", "payment:create", "event:read",
"shift:read", "shift:create", "drawer:create", "device:read",
],
},
{
// Everything the operator has, plus what an operator must NOT: voids, every
// operator's shifts, drawer review, reports, subscriptions, tariff reading.
id: "booth-supervisor",
permissions: [
"session:read", "session:create", "payment:read", "payment:create", "event:read",
"shift:read", "shift:create", "drawer:create", "device:read",
"event:void", "shift:cash", "drawer:review", "report:read",
"subscription:read", "subscription:create", "subscription:update", "tariff:read", "validation:read",
],
},
],
},
{
// Merchant-scan ticket validation, kept for the Bar until a Bar module absorbs it
// (wiki/decisions/venue-modules.md, decision 1).
id: "validation",
required: false,
dependsOn: ["parking"],
resources: ["validation"],
ledgerEventTypes: ["validation"],
// A merchant's whole role: scan-and-validate, nothing else. Their validation events
// ride the booth log (event:read), so no feed permission of their own.
jobs: [{ id: "merchant", permissions: ["validation:create"] }],
},
{
// The pilot module. Depends on parking only (the wash sits inside the park; the
// ticket IS the customer identity). The parking-discount ENGINE (validation programs +
// applyValidation) is CORE — the `validation` module is just the merchant's scan
// screen — so a site can run Car Wash without any merchant validation (2026-09-06).
id: "carwash",
required: false,
dependsOn: ["parking"],
resources: ["carwash"],
ledgerEventTypes: ["carwash_order", "carwash_payment"],
// Money taken AT THE BAY lands on the wash operator's own till, never the booth's.
till: "carwash",
tillGuards: { read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" },
feedPermission: "carwash:read",
jobs: [
// Runs the wash desk and its own till; sees nothing of the booth.
{ id: "wash-operator", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] },
],
},
];
/** The guards of a till (its module's `tillGuards`). */
export function tillGuards(till: TillId): TillGuards {
const m = MODULES.find((x) => x.till === till);
if (!m?.tillGuards) throw new Error(`till without guards: ${till}`);
return m.tillGuards;
}
/** Which permission admits a ledger event type to a role's live feed: the owning
* module's `feedPermission`, else the core `event:read`. */
export function feedPermissionFor(type: LedgerEventType): Permission {
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
return m?.feedPermission ?? "event:read";
}
/** Every permission that admits a role to the live WebSocket at all (it then receives
* only what each permission covers): the core feed/occupancy/device permissions plus
* each effective module's own feed permission. `report:read` is NOT among them — the
* reports screen and the live feed are different things (user, 2026-09-05). */
export function watchPermissions(effective: readonly ModuleId[]): Permission[] {
const out = new Set<Permission>(["event:read", "session:read", "device:read"]);
for (const m of MODULES) if (m.feedPermission && effective.includes(m.id)) out.add(m.feedPermission);
return [...out];
}
/** Which tills a role may work more than one of — the composer's "mixes desks" lint. */
/** The till an event's ACTIVITY belongs to, for a shift's log: a money event names its
* till (`tillOf`); any other event belongs to the till of the module that owns its type
* (a `carwash_order` is wash-desk activity even though no money moved); everything
* else — entries, exits, barrier commands, pre-till events — is the booth's. The
* server's `/api/events?till=` filter and the web feeds share this one rule. */
export function tillOfEvent(type: LedgerEventType, payload: { till?: TillId } | null | undefined): TillId {
if (payload?.till) return payload.till;
const m = MODULES.find((x) => x.ledgerEventTypes.includes(type));
return m?.till ?? BOOTH_TILL;
}
/** A job preset by id, with the module that declares it (null = no such job — e.g. a
* job remembered by a role whose module was removed from the registry). */
export function jobById(id: string): { module: ModuleId; job: JobPreset } | null {
for (const m of MODULES) for (const job of m.jobs) if (job.id === id) return { module: m.id, job };
return null;
}
/** The jobs a role FOLLOWS whose bundle has grown past what the role holds: the role
* was built from the chip, a later release added a permission to the job, and the
* role fell behind. The admin re-applies with one click (or drops the job); the grid
* is never expanded silently. Jobs no longer in the registry are ignored. */
export function jobsBehind(
jobs: readonly string[],
has: (p: Permission) => boolean,
): { job: string; missing: Permission[] }[] {
const out: { job: string; missing: Permission[] }[] = [];
for (const id of jobs) {
const found = jobById(id);
if (!found) continue;
const missing = found.job.permissions.filter((p) => !has(p));
if (missing.length > 0) out.push({ job: id, missing });
}
return out;
}
export function tillsWorkableBy(effective: readonly ModuleId[], has: (p: Permission) => boolean): TillId[] {
return tillsFor(effective, has, "shift");
}
/** The tills available given the EFFECTIVE modules — the booth always (parking is
* required), plus each effective module's own till. Registry order. */
export function tillsOf(effective: readonly ModuleId[]): TillId[] {
const out = new Set<TillId>([BOOTH_TILL]);
for (const m of MODULES) if (m.till && effective.includes(m.id)) out.add(m.till);
return TILL_IDS.filter((t) => out.has(t));
}
/** The tills a ROLE may SEE (`kind` = read, default) or WORK (`shift` / `cash`) at
* this site: the effective tills whose module guard the role holds. What the
* shift/drawer routes enforce and what the UI offers (header button, start buttons,
* drawer switch). */
export function tillsFor(
effective: readonly ModuleId[],
has: (p: Permission) => boolean,
kind: keyof TillGuards = "read",
): TillId[] {
return tillsOf(effective).filter((t) => has(tillGuards(t)[kind]));
}
// --- Car Wash module ----------------------------------------------------------
// Types shared by apps/server/src/modules/carwash and apps/web/src/modules/carwash.
// Data model + rules: wiki/decisions/venue-modules.md ("Car Wash — the pilot module").
/** Where the wash is paid — a per-order choice at intake. `booth`: the wash is a charge
* line on the parking settlement at the booth (the exit barrier opens after that
* payment as usual). `bay`: the wash operator collects at the bay; the parking session
* is then settled to zero-due (via the sponsorship program) so the exit READER opens. */
export type CarWashPayAt = "booth" | "bay";
export const CARWASH_PAY_AT: readonly CarWashPayAt[] = ["booth", "bay"];
/** Where wash money is taken is a SITE setting (Setup → Car wash), not a per-order
* choice: the site either settles washes at the booth (on the parking ticket) or at
* the bay (the wash operator's own till). Every order freezes the policy in force. */
export const CARWASH_PAY_AT_DEFAULT: CarWashPayAt = "booth";
/** An order's working state. `paid` is tracked separately (paidAt / payment ref) since a
* bay order may be paid before or after the wash is done. */
export type CarWashOrderStatus = "open" | "done" | "void";
/** The validation-program row id the Car Wash module uses for its parking sponsorship —
* the same shape a merchant validation has (comp / timeCredit / fixed / percent,
* maxPerDay), composed on Setup → Car wash, applied automatically when a wash is done. */
export const CARWASH_PROGRAM_ID = "carwash";
/** A non-parking charge folded into a booth settlement by a module (today: a wash
* ordered with payAt = "booth"). Frozen onto the `payment` payload as `chargeLines`. */
export interface ChargeLine {
/** Who owns the line — the module id. */
readonly module: ModuleId;
/** The module's own record this settles (e.g. the wash order id). */
readonly ref: string;
/** Receipt/display label, e.g. "Car wash — SUV · Standard". */
readonly label: string;
readonly amountMinor: number;
}
/** Setup → Car wash: the admin-maintained master data, as read/written by
* GET/PUT /api/carwash/settings. Ids are stable; names are display text. */
export interface CarwashSettingsView {
readonly categories: { id: string; name: string; sortOrder: number; active: boolean }[];
readonly services: { id: string; name: string; sortOrder: number; active: boolean }[];
/** One entry per priced (category, service) pair. */
readonly prices: { categoryId: string; serviceId: string; priceMinor: number }[];
readonly currency: string | null;
/** Where wash money is taken at this site (booth = on the parking ticket; bay = the
* wash operator's till). Site-level; the desk no longer asks per order. */
readonly payAt: CarWashPayAt;
}
/** A wash order as the desk sees it (GET /api/carwash/orders). */
export interface CarwashOrderView {
readonly id: string;
readonly identity: string;
readonly plate: string | null;
readonly categoryId: string;
readonly categoryName: string;
readonly serviceId: string;
readonly serviceName: string;
readonly priceMinor: number;
readonly currency: string;
readonly payAt: CarWashPayAt;
readonly status: CarWashOrderStatus;
readonly createdAt: string;
readonly createdBy: string;
readonly doneAt: string | null;
readonly doneBy: string | null;
readonly paidAt: string | null;
readonly paidBy: string | null;
readonly tender: Tender | null;
/** True once the order needs nothing more (done + paid, or void). */
readonly closed: boolean;
readonly validationEventId: string | null;
readonly voidBy: string | null;
readonly voidReason: string | null;
}
export function isModuleId(v: unknown): v is ModuleId {
return typeof v === "string" && (MODULE_IDS as readonly string[]).includes(v);
}
export function moduleManifest(id: ModuleId): ModuleManifest {
const m = MODULES.find((x) => x.id === id);
if (!m) throw new Error(`unknown module: ${id}`);
return m;
}
/** Ids of the modules that can never be off. */
export const REQUIRED_MODULE_IDS: readonly ModuleId[] = MODULES.filter((m) => m.required).map((m) => m.id);
/** Parse a comma-separated entitlement list (the MODULES_ENTITLED env). Unknown ids
* are dropped (returned in `unknown` so the caller can warn); required modules are
* always included; unset/blank = everything registered. */
export function parseEntitledModules(raw: string | undefined | null): { entitled: ModuleId[]; unknown: string[] } {
const trimmed = (raw ?? "").trim();
if (trimmed === "") return { entitled: [...MODULE_IDS], unknown: [] };
const entitled = new Set<ModuleId>(REQUIRED_MODULE_IDS);
const unknown: string[] = [];
for (const part of trimmed.split(",")) {
const id = part.trim();
if (id === "") continue;
if (isModuleId(id)) entitled.add(id);
else unknown.push(id);
}
return { entitled: MODULE_IDS.filter((id) => entitled.has(id)), unknown };
}
export type ModuleActivationResult =
| { ok: true; modules: ModuleId[] }
| { ok: false; error: string };
/** Validate a requested activation set against the entitlement. Required modules are
* always included; anything not entitled or with an inactive dependency is refused
* with a human-readable reason (the UI shows it verbatim). Returns the normalized set
* in registry order. */
export function resolveModuleActivation(
entitled: readonly ModuleId[],
requested: readonly ModuleId[],
): ModuleActivationResult {
const active = new Set<ModuleId>(REQUIRED_MODULE_IDS);
for (const id of requested) active.add(id);
for (const id of active) {
if (!entitled.includes(id)) return { ok: false, error: `module "${id}" is not entitled for this site` };
}
for (const id of active) {
for (const dep of moduleManifest(id).dependsOn) {
if (!active.has(dep)) return { ok: false, error: `module "${id}" requires "${dep}" to be enabled` };
}
}
return { ok: true, modules: MODULE_IDS.filter((id) => active.has(id)) };
}
/** The effective set = required ∪ (entitled ∩ activated), then any module whose
* dependency is not effective is dropped (defensive: an entitlement can shrink after
* activation was recorded). `activated === null` means "never set" → everything
* entitled. Registry order. */
export function effectiveModules(entitled: readonly ModuleId[], activated: readonly ModuleId[] | null): ModuleId[] {
const on = new Set<ModuleId>(REQUIRED_MODULE_IDS);
for (const id of activated ?? entitled) {
if (entitled.includes(id)) on.add(id);
}
// Drop dependency-broken modules until stable (the registry is tiny; a loop is fine).
let changed = true;
while (changed) {
changed = false;
for (const id of [...on]) {
if (moduleManifest(id).dependsOn.some((dep) => !on.has(dep))) {
on.delete(id);
changed = true;
}
}
}
return MODULE_IDS.filter((id) => on.has(id));
}