50c18405b6
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
2142 lines
105 KiB
TypeScript
2142 lines
105 KiB
TypeScript
// Shared types and utilities across the parking system.
|
||
//
|
||
// The domain is offline-first and threat-model driven. The central integrity
|
||
// primitive is an append-only, hash-chained, ATECC608-signed event log: entry
|
||
// and exit events are never edited or deleted — a "void" is itself an appended
|
||
// event. See wiki/concepts/append-only-event-chain.md.
|
||
|
||
// --- Authorization: dynamic RBAC (resource × CRUD permissions) ---------------
|
||
// Roles are DATA (admin-composable rows in the DB), not a hardcoded enum. A role
|
||
// is a named bundle of PERMISSIONS; a permission is a `resource:action` pair drawn
|
||
// from the code-defined grid below. Route guards check a permission, never a role
|
||
// name. A built-in, locked `admin` role (id ADMIN_ROLE_ID) always holds every
|
||
// permission, so administration can never be locked out. See
|
||
// wiki/entities/local-jwt-auth.md and the RBAC plan.
|
||
|
||
/** The resources permissions are scoped to (code-defined; roles/assignments are data). */
|
||
export const RESOURCES = [
|
||
"user", // manage operators/cashiers + reset password
|
||
"role", // compose roles + assign permissions
|
||
"tariff", // read / publish a new version
|
||
"subscription", // the subscription registry
|
||
"site", // site_config + device setup/assign
|
||
"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));
|
||
}
|