feat(tariff): V2 — legacy-parity pricing (time-of-day, category, seasonal, flat)
Bring the legacy ParkSQL2017 pricing BREADTH onto our engine while keeping
integer-minor-unit money + immutable signed versions (rejecting legacy's
float money / mutable rows). TariffStructure becomes a discriminated union:
V1 = the original bare ladder (UNCHANGED, verbatim algorithm, golden-
regression-tested against the live version); V2 = {version:2, tz, shared
knobs, defaultCard, windowedCards[]} where each card is flat OR a block
ladder and may be scoped by wall-clock hour window / day-of-week / date
range / vehicle category.
computeFeeV2 prices by stepping one increment at a time, advancing the
ladder by ELAPSED minutes (continuous) while selecting the active card by
WALL-CLOCK time in the version's FROZEN tz. Decisions: tz is a per-site
setting (site_config.timezone, default Europe/Tirane) stamped server-side
into each version on publish — never the host clock (reproducibility);
default-card cap governs a mixed day; precedence = specificity
(date>dow>hour) -> priority -> name (total, order-independent), validation
rejects ambiguous ties; category = a card FIELD, frozen in the signed
vehicle_entry payload (site_config.default_vehicle_category default), read
at both pricing call-sites.
Composer: default card front-and-centre (flat/ladder toggle), tiers under
an "Advanced" disclosure; emits BARE V1 when no tiers (back-compat). DB:
migrations 0005 (timezone) + 0006 (default_vehicle_category). Stood up
vitest in @parking/shared (was zero tests on the ledger-feeding fee fn);
36 tests incl. golden V1 regression, happy-hour/overnight/dow/flat/category/
cap edges, precedence shuffle-invariance, Europe/Tirane DST determinism,
validation matrix — all green. No event-chain change.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
type TicketData,
|
||||
type TicketHeader,
|
||||
} from "@parking/devices";
|
||||
import { DEFAULT_VEHICLE_CATEGORY } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { DeviceInputEvent } from "./device-events.js";
|
||||
import { getOccupancy } from "./occupancy.js";
|
||||
@@ -113,12 +114,24 @@ export class EntryFlow {
|
||||
}
|
||||
|
||||
// 2. SIGN the vehicle_entry — BEFORE the relay fires (the core invariant).
|
||||
// `category` is FROZEN here (in the signed payload) so the tariff prices and
|
||||
// later reprices the same way at exit. Today every transient takes the SITE
|
||||
// default category (operator policy, site_config.default_vehicle_category;
|
||||
// falls back to the shared DEFAULT_VEHICLE_CATEGORY). Per-relay capture (a
|
||||
// "bus lane" relay, mirroring how direction is per-relay in device-resolve.ts)
|
||||
// is the future seam — source it from `resolved` then. A V1/no-category tariff
|
||||
// ignores it; only V2 category cards consult it.
|
||||
const cfg = this.#db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const category =
|
||||
cfg?.defaultVehicleCategory && cfg.defaultVehicleCategory.length > 0
|
||||
? cfg.defaultVehicleCategory
|
||||
: DEFAULT_VEHICLE_CATEGORY;
|
||||
await this.#log.append({
|
||||
type: "vehicle_entry",
|
||||
direction: "entry",
|
||||
source: "ticket",
|
||||
identity: ticketId,
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true },
|
||||
payload: { sessionRef: ticketId, ticketPrinted: true, category },
|
||||
occurredAt: issuedAt,
|
||||
});
|
||||
|
||||
|
||||
@@ -424,7 +424,10 @@ export class ExitFlow {
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
if (tv) {
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
// Same frozen-at-entry category the pay station uses, so the free-grace
|
||||
// check agrees with the booth quote for V2 category tariffs.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const fee = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
if (fee === 0) {
|
||||
freeGrace = {
|
||||
tariffVersionId: tv.id,
|
||||
|
||||
@@ -106,7 +106,11 @@ export class PayStation {
|
||||
if (!tv) throw new NoTariffError();
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure);
|
||||
// Category was frozen in the signed vehicle_entry payload — pricing AND repricing
|
||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const amountMinor = computeFee(entry.occurredAt, new Date().toISOString(), structure, category);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
|
||||
@@ -15,6 +15,10 @@ const TEXT_FIELDS = [
|
||||
"address",
|
||||
"phone",
|
||||
"email",
|
||||
// IANA timezone for tariff wall-clock windows (copied into each published version).
|
||||
"timezone",
|
||||
// Default vehicle/customer category frozen onto each transient entry.
|
||||
"defaultVehicleCategory",
|
||||
] as const;
|
||||
type TextField = (typeof TEXT_FIELDS)[number];
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared";
|
||||
import { requireRole } from "../auth.js";
|
||||
|
||||
/** Default site timezone for wall-clock tariff windows when none is configured. */
|
||||
const DEFAULT_TZ = "Europe/Tirane";
|
||||
|
||||
// Tariff composer API — the admin builds + edits the rate card at runtime. Tariffs
|
||||
// are EFFECTIVE-DATED IMMUTABLE VERSIONS: editing publishes a new version, never
|
||||
// mutates one; a session reprices against the version in force at its entry, and
|
||||
@@ -58,7 +61,17 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
if (!currency || typeof currency !== "string" || currency.length < 3) {
|
||||
return reply.code(400).send({ error: "currency (ISO 4217) required" });
|
||||
}
|
||||
const problems = validateTariffStructure(structure);
|
||||
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config
|
||||
// (not the client) BEFORE validating — so the frozen tz is authoritative and the
|
||||
// validation that requires tz passes. A V1 (bare) structure is left untouched.
|
||||
let toStore: TariffStructure = structure;
|
||||
if (structure && isTariffV2(structure)) {
|
||||
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
|
||||
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
|
||||
toStore = { ...structure, tz };
|
||||
}
|
||||
|
||||
const problems = validateTariffStructure(toStore);
|
||||
if (problems.length) {
|
||||
return reply.code(400).send({ error: "invalid tariff structure", problems });
|
||||
}
|
||||
@@ -94,7 +107,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
||||
tariffId,
|
||||
effectiveFrom: effective,
|
||||
currency,
|
||||
structure: structure as unknown as Record<string, unknown>,
|
||||
structure: toStore as unknown as Record<string, unknown>,
|
||||
createdBy: req.user?.username ?? null,
|
||||
};
|
||||
db.insert(tariffVersions).values(row).run();
|
||||
|
||||
+324
-94
@@ -3,8 +3,10 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
ApiError,
|
||||
fetchTariff,
|
||||
isTariffV2,
|
||||
publishTariffVersion,
|
||||
type TariffBlock,
|
||||
type TariffCard,
|
||||
type TariffStructure,
|
||||
type TariffState,
|
||||
} from "./api.js";
|
||||
@@ -24,37 +26,63 @@ interface BlockForm {
|
||||
hours: string; // duration of THIS band, in hours (ignored for the last block)
|
||||
price: string; // major units, e.g. "2.00"
|
||||
}
|
||||
// A pricing body the form edits: either a flat rate or a block ladder.
|
||||
interface PricingForm {
|
||||
mode: "ladder" | "flat";
|
||||
flat: string; // major units (used when mode==="flat")
|
||||
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
|
||||
dailyCap: string; // "" = no cap (ladder only)
|
||||
}
|
||||
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
|
||||
interface TierForm {
|
||||
name: string;
|
||||
priority: string;
|
||||
category: string; // "" = applies to all categories
|
||||
dow: number[]; // selected days 0..6; empty = every day
|
||||
fromHour: string; // "" = all day
|
||||
toHour: string;
|
||||
dateFrom: string; // "" = unbounded
|
||||
dateTo: string;
|
||||
pricing: PricingForm;
|
||||
}
|
||||
interface FormState {
|
||||
currency: string;
|
||||
gracePeriodEntryMin: string;
|
||||
incrementMin: string;
|
||||
dailyCap: string; // "" = no cap
|
||||
lostTicket: string;
|
||||
gracePeriodExitMin: string;
|
||||
blocks: BlockForm[];
|
||||
// The default (always-active) card — its own flat/ladder body + daily cap.
|
||||
base: PricingForm;
|
||||
// Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
|
||||
tiers: TierForm[];
|
||||
}
|
||||
|
||||
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
|
||||
const toMajor = (minor: number): string => (minor / 100).toFixed(2);
|
||||
|
||||
function emptyLadder(): PricingForm {
|
||||
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] };
|
||||
}
|
||||
function emptyTier(): TierForm {
|
||||
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } };
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
currency: "EUR",
|
||||
gracePeriodEntryMin: "15",
|
||||
incrementMin: "60",
|
||||
dailyCap: "",
|
||||
lostTicket: "20.00",
|
||||
gracePeriodExitMin: "15",
|
||||
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
|
||||
base: emptyLadder(),
|
||||
tiers: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Convert a published structure's cumulative `uptoMin` (minutes) back into the
|
||||
// per-band hours the form edits. Each band's hours = (its bound − previous bound)
|
||||
// / 60; the open-ended last band has no hours. Legacy versions whose last block is
|
||||
// bounded (pre-2026-06-18, before open-ended was required) still load: the bounded
|
||||
// tail simply shows as its own band and the operator adds/keeps an open-ended one.
|
||||
function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
|
||||
// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
|
||||
// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
|
||||
// load (shown as their own band).
|
||||
function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
|
||||
let prev = 0;
|
||||
return blocks.map((b) => {
|
||||
if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
|
||||
@@ -64,41 +92,112 @@ function blocksToForm(blocks: TariffStructure["blocks"]): BlockForm[] {
|
||||
});
|
||||
}
|
||||
|
||||
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder).
|
||||
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm {
|
||||
if (c.flatMinor != null) {
|
||||
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks };
|
||||
}
|
||||
return {
|
||||
mode: "ladder",
|
||||
flat: "0.00",
|
||||
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
|
||||
blocks: blocksToForm(c.blocks ?? []),
|
||||
};
|
||||
}
|
||||
|
||||
function tierFromCard(c: TariffCard): TierForm {
|
||||
const w = c.window ?? {};
|
||||
return {
|
||||
name: c.name,
|
||||
priority: String(c.priority),
|
||||
category: c.category ?? "",
|
||||
dow: w.dow ? [...w.dow] : [],
|
||||
fromHour: w.fromHour ?? "",
|
||||
toHour: w.toHour ?? "",
|
||||
dateFrom: w.dateFrom ?? "",
|
||||
dateTo: w.dateTo ?? "",
|
||||
pricing: pricingFromCard(c),
|
||||
};
|
||||
}
|
||||
|
||||
function formFromActive(s: TariffState): FormState {
|
||||
const v = s.active;
|
||||
if (!v) return emptyForm();
|
||||
const st = v.structure;
|
||||
return {
|
||||
const common = {
|
||||
currency: v.currency,
|
||||
gracePeriodEntryMin: String(st.gracePeriodEntryMin),
|
||||
incrementMin: String(st.incrementMin),
|
||||
dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor),
|
||||
lostTicket: toMajor(st.lostTicketMinor),
|
||||
gracePeriodExitMin: String(st.gracePeriodExitMin),
|
||||
blocks: blocksToForm(st.blocks),
|
||||
};
|
||||
if (isTariffV2(st)) {
|
||||
return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
|
||||
}
|
||||
// V1: the bare ladder becomes the default card body; no tiers.
|
||||
return { ...common, base: pricingFromCard(st), tiers: [] };
|
||||
}
|
||||
|
||||
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
|
||||
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
|
||||
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
|
||||
const last = p.blocks.length - 1;
|
||||
let cum = 0;
|
||||
const blocks: TariffBlock[] = p.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cum += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
|
||||
}
|
||||
|
||||
function tierToCard(tr: TierForm): TariffCard {
|
||||
const window: TariffCard["window"] = {};
|
||||
if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
|
||||
if (tr.fromHour && tr.toHour) {
|
||||
window.fromHour = tr.fromHour;
|
||||
window.toHour = tr.toHour;
|
||||
}
|
||||
if (tr.dateFrom) window.dateFrom = tr.dateFrom;
|
||||
if (tr.dateTo) window.dateTo = tr.dateTo;
|
||||
const card: TariffCard = {
|
||||
name: tr.name.trim() || "tier",
|
||||
priority: Math.round(Number(tr.priority || "0")),
|
||||
...pricingToCardBody(tr.pricing),
|
||||
};
|
||||
if (tr.category.trim()) card.category = tr.category.trim();
|
||||
if (Object.keys(window).length > 0) card.window = window;
|
||||
return card;
|
||||
}
|
||||
|
||||
function toStructure(f: FormState): TariffStructure {
|
||||
// Accumulate each band's DURATION (hours) into the engine's cumulative `uptoMin`
|
||||
// (minutes). The LAST band is always open-ended (uptoMin null) — its hours are
|
||||
// ignored — so the published structure always satisfies the "last block must be
|
||||
// open-ended" rule (the thereafter-rate is explicit). See wiki/concepts/tariff.md.
|
||||
const last = f.blocks.length - 1;
|
||||
let cumulativeMin = 0;
|
||||
const blocks: TariffBlock[] = f.blocks.map((b, i) => {
|
||||
if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
|
||||
cumulativeMin += Math.round(Number(b.hours || "0") * 60);
|
||||
return { uptoMin: cumulativeMin, priceMinorPerIncrement: toMinor(b.price) };
|
||||
});
|
||||
return {
|
||||
const common = {
|
||||
gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
|
||||
incrementMin: Math.round(Number(f.incrementMin)),
|
||||
blocks,
|
||||
dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap),
|
||||
lostTicketMinor: toMinor(f.lostTicket),
|
||||
gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
|
||||
overstay: "reprice",
|
||||
overstay: "reprice" as const,
|
||||
};
|
||||
const baseBody = pricingToCardBody(f.base);
|
||||
|
||||
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
|
||||
// tiers gets exactly today's shape; the server leaves it untouched).
|
||||
if (f.tiers.length === 0) {
|
||||
if (f.base.mode === "flat") {
|
||||
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
|
||||
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
|
||||
}
|
||||
return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
|
||||
}
|
||||
|
||||
// Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
|
||||
return {
|
||||
...common,
|
||||
version: 2,
|
||||
tz: "",
|
||||
defaultCard: { name: "default", priority: 0, ...baseBody },
|
||||
windowedCards: f.tiers.map(tierToCard),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,27 +220,49 @@ export function TariffComposer() {
|
||||
function set<K extends keyof FormState>(key: K, value: FormState[K]) {
|
||||
setForm((f) => ({ ...f, [key]: value }));
|
||||
}
|
||||
function setBlock(i: number, patch: Partial<BlockForm>) {
|
||||
setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a new bounded band just BEFORE the open-ended "thereafter" tail, so the
|
||||
// last block always stays open-ended.
|
||||
function addBlock() {
|
||||
|
||||
// --- pricing-body editing (used by the default card AND each tier) ---
|
||||
// `update` maps the old PricingForm to a new one; `target` selects which body:
|
||||
// the base card, or tier index N.
|
||||
function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
|
||||
setForm((f) => {
|
||||
const tailIdx = f.blocks.length - 1;
|
||||
const next = [...f.blocks];
|
||||
next.splice(tailIdx, 0, { hours: "1", price: "0.00" });
|
||||
return { ...f, blocks: next };
|
||||
if (target === "base") return { ...f, base: update(f.base) };
|
||||
return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
|
||||
});
|
||||
}
|
||||
// Remove a bounded band. The open-ended tail (last row) can't be removed (it's the
|
||||
// required thereafter-rate); the guard also keeps at least the tail present.
|
||||
function removeBlock(i: number) {
|
||||
setForm((f) => {
|
||||
if (i === f.blocks.length - 1 || f.blocks.length <= 1) return f;
|
||||
return { ...f, blocks: f.blocks.filter((_, j) => j !== i) };
|
||||
function setBlock(target: "base" | number, i: number, patch: Partial<BlockForm>) {
|
||||
updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
|
||||
}
|
||||
// Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
|
||||
function addBlock(target: "base" | number) {
|
||||
updatePricing(target, (p) => {
|
||||
const next = [...p.blocks];
|
||||
next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
|
||||
return { ...p, blocks: next };
|
||||
});
|
||||
}
|
||||
function removeBlock(target: "base" | number, i: number) {
|
||||
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
|
||||
}
|
||||
|
||||
// --- tier editing ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
|
||||
}
|
||||
function addTier() {
|
||||
setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
|
||||
}
|
||||
function removeTier(i: number) {
|
||||
setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
|
||||
}
|
||||
function toggleDow(i: number, d: number) {
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
tiers: f.tiers.map((tr, j) =>
|
||||
j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
setSaving(true);
|
||||
@@ -183,62 +304,92 @@ export function TariffComposer() {
|
||||
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
|
||||
<label>{t("tariff.billingIncrement")}</label>
|
||||
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
|
||||
<label>{t("tariff.dailyCap")}</label>
|
||||
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} />
|
||||
<label>{t("tariff.lostTicketFee")}</label>
|
||||
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
|
||||
<label>{t("tariff.exitGrace")}</label>
|
||||
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
|
||||
</div>
|
||||
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.rateBlocks")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.rateBlocksHint")}</p>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.blocks.map((b, i) => {
|
||||
const isTail = i === form.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
{isTail ? (
|
||||
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<input
|
||||
value={b.hours}
|
||||
onChange={(e) => setBlock(i, { hours: e.target.value })}
|
||||
placeholder={t("tariff.egHours")}
|
||||
style={{ width: 70 }}
|
||||
/>
|
||||
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>
|
||||
{!isTail && (
|
||||
<button type="button" onClick={() => removeBlock(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
|
||||
wants tiers just edits this and publishes a bare V1 structure. */}
|
||||
<h3 style={{ marginBottom: "0.25rem" }}>{t("tariff.defaultCard")}</h3>
|
||||
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>{t("tariff.defaultCardHint")}</p>
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(i, patch) => setBlock("base", i, patch)}
|
||||
onAddBlock={() => addBlock("base")}
|
||||
onRemoveBlock={(i) => removeBlock("base", i)}
|
||||
/>
|
||||
|
||||
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
|
||||
<details style={{ marginTop: "1.25rem" }} open={form.tiers.length > 0}>
|
||||
<summary style={{ cursor: "pointer", fontWeight: 600 }}>{t("tariff.tiersAdvanced")}</summary>
|
||||
<p style={{ color: "#777", margin: "0.4rem 0", fontSize: "0.9em" }}>{t("tariff.tiersHint")}</p>
|
||||
{form.tiers.map((tr, i) => (
|
||||
<fieldset key={i} style={{ border: "1px solid #ddd", borderRadius: 6, padding: "0.6rem 0.8rem", marginBottom: "0.75rem" }}>
|
||||
<legend style={{ display: "flex", gap: "0.5rem", alignItems: "center" }}>
|
||||
<input
|
||||
value={tr.name}
|
||||
onChange={(e) => setTier(i, { name: e.target.value })}
|
||||
placeholder={t("tariff.tierName")}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
<button type="button" onClick={() => removeTier(i)}>
|
||||
{t("tariff.remove")}
|
||||
</button>
|
||||
</legend>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.35rem 0.75rem", alignItems: "center", maxWidth: 520 }}>
|
||||
<label>{t("tariff.tierPriority")}</label>
|
||||
<input value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} style={{ width: 70 }} />
|
||||
<label>{t("tariff.tierCategory")}</label>
|
||||
<input value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} />
|
||||
<label>{t("tariff.tierDays")}</label>
|
||||
<span style={{ display: "flex", gap: "0.3rem", flexWrap: "wrap" }}>
|
||||
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
|
||||
<label key={d} style={{ display: "inline-flex", alignItems: "center", gap: "0.15rem", fontSize: "0.85em" }}>
|
||||
<input type="checkbox" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
|
||||
{t(`tariff.dow${d}`)}
|
||||
</label>
|
||||
))}
|
||||
</span>
|
||||
<label>{t("tariff.tierHours")}</label>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
|
||||
<input value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} />
|
||||
<span>–</span>
|
||||
<input value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} />
|
||||
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
|
||||
<span style={{ color: "#777", fontSize: "0.8em" }}>{t("tariff.tierOvernight")}</span>
|
||||
)}
|
||||
</span>
|
||||
<label>{t("tariff.tierDates")}</label>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center" }}>
|
||||
<input type="date" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
|
||||
<span>–</span>
|
||||
<input type="date" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ marginTop: "0.5rem" }}>
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={tr.pricing}
|
||||
onMode={(mode) => updatePricing(i, (p) => ({ ...p, mode }))}
|
||||
onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
|
||||
onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
|
||||
onBlock={(bi, patch) => setBlock(i, bi, patch)}
|
||||
onAddBlock={() => addBlock(i)}
|
||||
onRemoveBlock={(bi) => removeBlock(i, bi)}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
))}
|
||||
<button type="button" onClick={addTier}>
|
||||
{t("tariff.addTier")}
|
||||
</button>
|
||||
</details>
|
||||
|
||||
<div style={{ marginTop: "1rem" }}>
|
||||
<button type="button" onClick={publish} disabled={saving}>
|
||||
@@ -251,3 +402,82 @@ export function TariffComposer() {
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
// A reusable flat/ladder pricing-body editor — used by the default card and each tier.
|
||||
function PricingEditor(props: {
|
||||
t: (k: string) => string;
|
||||
pricing: PricingForm;
|
||||
onMode: (m: "ladder" | "flat") => void;
|
||||
onFlat: (v: string) => void;
|
||||
onCap: (v: string) => void;
|
||||
onBlock: (i: number, patch: Partial<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: "flex", gap: "1rem", marginBottom: "0.4rem", fontSize: "0.9em" }}>
|
||||
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
|
||||
<input type="radio" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
|
||||
{t("tariff.modeLadder")}
|
||||
</label>
|
||||
<label style={{ display: "inline-flex", gap: "0.25rem", alignItems: "center" }}>
|
||||
<input type="radio" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{p.mode === "flat" ? (
|
||||
<div style={{ display: "inline-flex", gap: "0.4rem", alignItems: "center" }}>
|
||||
<span style={{ color: "#777" }}>{t("tariff.pricePerIncrement")}</span>
|
||||
<input value={p.flat} onChange={(e) => props.onFlat(e.target.value)} style={{ width: 90 }} />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<table style={{ borderCollapse: "collapse" }}>
|
||||
<thead>
|
||||
<tr style={{ textAlign: "left", color: "#555" }}>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.bandDuration")}</th>
|
||||
<th style={{ padding: "0 0.5rem" }}>{t("tariff.pricePerIncrement")}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{p.blocks.map((b, i) => {
|
||||
const isTail = i === p.blocks.length - 1;
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
{isTail ? (
|
||||
<span style={{ color: "#777", fontStyle: "italic" }}>{t("tariff.thereafter")}</span>
|
||||
) : (
|
||||
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.3rem" }}>
|
||||
<input value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} style={{ width: 70 }} />
|
||||
<span style={{ color: "#777" }}>{t("tariff.hoursUnit")}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ padding: "0.15rem 0.5rem" }}>
|
||||
<input value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} style={{ width: 90 }} />
|
||||
</td>
|
||||
<td>{!isTail && <button type="button" onClick={() => props.onRemoveBlock(i)}>{t("tariff.remove")}</button>}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{ marginTop: "0.4rem", display: "flex", gap: "1rem", alignItems: "center" }}>
|
||||
<button type="button" onClick={props.onAddBlock}>
|
||||
{t("tariff.addBlock")}
|
||||
</button>
|
||||
<span style={{ display: "inline-flex", gap: "0.3rem", alignItems: "center", fontSize: "0.9em" }}>
|
||||
<span style={{ color: "#777" }}>{t("tariff.dailyCap")}</span>
|
||||
<input value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} style={{ width: 90 }} />
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+42
-1
@@ -240,7 +240,10 @@ export interface TariffBlock {
|
||||
uptoMin: number | null;
|
||||
priceMinorPerIncrement: number;
|
||||
}
|
||||
export interface TariffStructure {
|
||||
// Mirrors @parking/shared. Two shapes: V1 (bare ladder) and V2 (default + windowed
|
||||
// cards by time-of-day / dow / date / category, flat or laddered). The discriminant
|
||||
// is the presence of `defaultCard`. See wiki/concepts/tariff-time-tiers.md.
|
||||
export interface TariffStructureV1 {
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
blocks: TariffBlock[];
|
||||
@@ -249,6 +252,39 @@ export interface TariffStructure {
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
}
|
||||
export interface TariffWindow {
|
||||
dow?: number[];
|
||||
dateFrom?: string;
|
||||
dateTo?: string;
|
||||
fromHour?: string;
|
||||
toHour?: string;
|
||||
}
|
||||
export interface TariffCard {
|
||||
name: string;
|
||||
priority: number;
|
||||
category?: string;
|
||||
window?: TariffWindow;
|
||||
flatMinor?: number;
|
||||
blocks?: TariffBlock[];
|
||||
dailyCapMinor?: number | null;
|
||||
}
|
||||
export interface TariffStructureV2 {
|
||||
version: 2;
|
||||
tz: string;
|
||||
gracePeriodEntryMin: number;
|
||||
incrementMin: number;
|
||||
lostTicketMinor: number;
|
||||
gracePeriodExitMin: number;
|
||||
overstay: "reprice";
|
||||
defaultCard: TariffCard;
|
||||
windowedCards?: TariffCard[];
|
||||
}
|
||||
export type TariffStructure = TariffStructureV1 | TariffStructureV2;
|
||||
|
||||
/** True when a structure is the windowed V2 shape (mirrors @parking/shared isTariffV2). */
|
||||
export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
|
||||
return (t as TariffStructureV2).defaultCard != null;
|
||||
}
|
||||
export interface TariffVersion {
|
||||
id: string;
|
||||
tariffId: string;
|
||||
@@ -446,6 +482,11 @@ export interface SiteConfig {
|
||||
address: string | null;
|
||||
phone: string | null;
|
||||
email: string | null;
|
||||
/** IANA timezone for tariff wall-clock windows (e.g. "Europe/Tirane"). Copied into
|
||||
* each published tariff version so its windows are frozen. */
|
||||
timezone: string | null;
|
||||
/** Default vehicle/customer category frozen onto each transient entry (V2 pricing). */
|
||||
defaultVehicleCategory: string | null;
|
||||
}
|
||||
|
||||
export function fetchOccupancy(): Promise<Occupancy> {
|
||||
|
||||
@@ -120,6 +120,28 @@ export const en: Catalog = {
|
||||
publishNewVersion: "Publish new version",
|
||||
publishing: "Publishing…",
|
||||
publishedOk: "New tariff version published — it's now the active rate card.",
|
||||
defaultCard: "Default card (always active)",
|
||||
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
|
||||
modeLadder: "Hourly ladder",
|
||||
modeFlat: "Flat price",
|
||||
tiersAdvanced: "Advanced: time & seasonal tiers",
|
||||
tiersHint: "Optional. Add cards that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, the simple card is published.",
|
||||
tierName: "Name",
|
||||
tierPriority: "Priority",
|
||||
tierCategory: "Category",
|
||||
tierCategoryPh: "e.g. bus",
|
||||
tierDays: "Days",
|
||||
tierHours: "Hours",
|
||||
tierDates: "Dates",
|
||||
tierOvernight: "(crosses midnight)",
|
||||
addTier: "+ Add tier",
|
||||
dow1: "Mon",
|
||||
dow2: "Tue",
|
||||
dow3: "Wed",
|
||||
dow4: "Thu",
|
||||
dow5: "Fri",
|
||||
dow6: "Sat",
|
||||
dow0: "Sun",
|
||||
},
|
||||
subs: {
|
||||
title: "Subscriptions",
|
||||
|
||||
@@ -122,6 +122,28 @@ export const sq = {
|
||||
publishNewVersion: "Publiko version të ri",
|
||||
publishing: "Duke publikuar…",
|
||||
publishedOk: "U publikua versioni i ri i tarifës — tani është karta tarifore aktive.",
|
||||
defaultCard: "Karta e parazgjedhur (gjithmonë aktive)",
|
||||
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
|
||||
modeLadder: "Shkallë orësh",
|
||||
modeFlat: "Çmim fiks",
|
||||
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
|
||||
tiersHint: "Opsionale. Shto karta që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet karta e thjeshtë.",
|
||||
tierName: "Emri",
|
||||
tierPriority: "Përparësia",
|
||||
tierCategory: "Kategoria",
|
||||
tierCategoryPh: "p.sh. autobus",
|
||||
tierDays: "Ditët",
|
||||
tierHours: "Orët",
|
||||
tierDates: "Datat",
|
||||
tierOvernight: "(kalon mesnatën)",
|
||||
addTier: "+ Shto nivel",
|
||||
dow1: "Hën",
|
||||
dow2: "Mar",
|
||||
dow3: "Mër",
|
||||
dow4: "Enj",
|
||||
dow5: "Pre",
|
||||
dow6: "Sht",
|
||||
dow0: "Die",
|
||||
},
|
||||
subs: {
|
||||
title: "Abonimet",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `site_config` ADD `timezone` text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `site_config` ADD `default_vehicle_category` text;
|
||||
@@ -36,6 +36,20 @@
|
||||
"when": 1781800000000,
|
||||
"tag": "0004_subscriptions_rename",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1781884800000,
|
||||
"tag": "0005_site_timezone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 6,
|
||||
"version": "6",
|
||||
"when": 1781884900000,
|
||||
"tag": "0006_site_default_category",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -171,6 +171,19 @@ export const siteConfig = sqliteTable("site_config", {
|
||||
* own price and may differ. null = no site default set. See
|
||||
* wiki/entities/subscription.md. */
|
||||
subscriptionMonthlyPriceMinor: integer("subscription_monthly_price_minor"),
|
||||
/** IANA timezone the site operates in (e.g. "Europe/Tirane"). Used to evaluate a
|
||||
* tariff's wall-clock pricing windows (happy hour / night / seasonal). COPIED into
|
||||
* each published tariff version's structure.tz so the windows are frozen/immutable
|
||||
* per version — historical sessions reprice deterministically regardless of any
|
||||
* later config change. null/absent ⇒ default "Europe/Tirane" at publish time.
|
||||
* See wiki/concepts/tariff-time-tiers.md. */
|
||||
timezone: text("timezone"),
|
||||
/** Default vehicle/customer category assigned to a transient entry when none is
|
||||
* captured at the lane (every transient today). Operator policy — a plain car park
|
||||
* leaves it "default"; a mixed lot might set "car". Frozen into each vehicle_entry
|
||||
* payload so V2 category pricing reprices identically at exit. null ⇒ the shared
|
||||
* DEFAULT_VEHICLE_CATEGORY fallback. See wiki/concepts/tariff-time-tiers.md. */
|
||||
defaultVehicleCategory: text("default_vehicle_category"),
|
||||
updatedAt: text("updated_at")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
|
||||
@@ -15,9 +15,11 @@
|
||||
"build": "tsc -b",
|
||||
"dev": "tsc -b --watch",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "6.0.3"
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
+448
-38
@@ -85,6 +85,9 @@ export interface LedgerPayload {
|
||||
/** 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;
|
||||
/** Free-form for forward-compat without a schema change. */
|
||||
readonly [k: string]: unknown;
|
||||
}
|
||||
@@ -93,11 +96,22 @@ export interface LedgerPayload {
|
||||
export type DeviceEventKind = "input" | "relay" | "status" | "read" | "snapshot";
|
||||
|
||||
/**
|
||||
* The composable rate card stored in a tariff_version.structure. Pure data the
|
||||
* fee function interprets — no rates in code. Stepped duration blocks + caps/grace;
|
||||
* a flat rate is just one block. See wiki/concepts/tariff.md.
|
||||
* 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 TariffStructure {
|
||||
export interface TariffStructureV1 {
|
||||
/** Free if exited within this (drop-off/turnaround). */
|
||||
readonly gracePeriodEntryMin: number;
|
||||
/** Billing granularity; partial increments round UP. */
|
||||
@@ -120,6 +134,74 @@ export interface TariffBlock {
|
||||
readonly priceMinorPerIncrement: number;
|
||||
}
|
||||
|
||||
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
|
||||
* part is unconstrained. Evaluated in the version's frozen tz. */
|
||||
export interface TariffWindow {
|
||||
/** Days-of-week this card is active (0=Sun..6=Sat), local to tz. Absent/empty = every day. */
|
||||
readonly dow?: readonly number[];
|
||||
/** Inclusive local date window "YYYY-MM-DD" (seasonal/holiday). Absent = unbounded that side. */
|
||||
readonly dateFrom?: string;
|
||||
readonly dateTo?: string;
|
||||
/** Local hour-of-day window "HH:MM". `toHour <= fromHour` means it WRAPS past
|
||||
* midnight (e.g. 22:00→06:00 night rate). Absent pair = all day. */
|
||||
readonly fromHour?: string;
|
||||
readonly toHour?: string;
|
||||
}
|
||||
|
||||
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
|
||||
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
|
||||
export interface TariffCard {
|
||||
/** Human label (also the final, deterministic precedence tiebreak). */
|
||||
readonly name: string;
|
||||
/** Integer precedence tiebreak among equally-specific cards; higher wins. */
|
||||
readonly priority: number;
|
||||
/** Vehicle/customer category this card prices. Absent = applies to all categories. */
|
||||
readonly category?: string;
|
||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||
readonly window?: TariffWindow;
|
||||
/** Flat price per billing increment (mutually exclusive with `blocks`). */
|
||||
readonly flatMinor?: number;
|
||||
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */
|
||||
readonly blocks?: readonly TariffBlock[];
|
||||
/** 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
|
||||
@@ -135,7 +217,18 @@ export function computeFee(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructure,
|
||||
category?: string,
|
||||
): number {
|
||||
return isTariffV2(tariff)
|
||||
? computeFeeV2(enteredAt, asOf, tariff, category)
|
||||
: computeFeeV1(enteredAt, asOf, tariff);
|
||||
}
|
||||
|
||||
/** 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. */
|
||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
@@ -161,59 +254,251 @@ export function computeFee(
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The V2 fee algorithm — adds wall-clock time-of-day / day-of-week / date windows
|
||||
* and vehicle-category cards on top of the V1 ladder. PURE + integer + deterministic
|
||||
* (the signed ledger reprices against this; reproducibility is mandatory).
|
||||
*
|
||||
* Two decoupled clocks: ELAPSED minutes advance the block-ladder position (continuous
|
||||
* across card switches — a happy-hour boundary mid-stay does NOT reset the ladder);
|
||||
* WALL-CLOCK time (in the version's frozen tz) selects which card's rate applies to
|
||||
* each increment. Stepping one increment at a time and re-selecting the card makes the
|
||||
* boundary slicing implicit. The DEFAULT card's dailyCap governs each rolling-24h day
|
||||
* (a windowed card lowers the rate but never the day ceiling). See tariff-time-tiers.md.
|
||||
*/
|
||||
function computeFeeV2(
|
||||
enteredAt: string,
|
||||
asOf: string,
|
||||
tariff: TariffStructureV2,
|
||||
category?: string,
|
||||
): number {
|
||||
const enteredMs = Date.parse(enteredAt);
|
||||
const ms = Date.parse(asOf) - enteredMs;
|
||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||
const rawMinutes = ms / 60_000;
|
||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
||||
const inc = Math.max(1, tariff.incrementMin);
|
||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||
|
||||
// Cards in contention: the default plus any windowed card matching the category.
|
||||
// (A card with no `category` applies to all; one with a category applies only to
|
||||
// a matching session.) The defaultCard always matches and is the fallback.
|
||||
const cards = [
|
||||
tariff.defaultCard,
|
||||
...(tariff.windowedCards ?? []).filter((c) => c.category == null || c.category === category),
|
||||
];
|
||||
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
||||
|
||||
const DAY = 24 * 60;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
const segEnd = Math.min(segStart + DAY, minutes);
|
||||
let segFee = 0;
|
||||
for (let within = segStart; within < segEnd; within += inc) {
|
||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||
const card = selectCard(cards, wall);
|
||||
if (card.flatMinor != null) {
|
||||
segFee += card.flatMinor;
|
||||
} else {
|
||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
||||
}
|
||||
}
|
||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
||||
total += segFee;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an admin-authored tariff structure. Returns [] if valid, else a list
|
||||
* of human-readable problems. Pure — used by the composer route (and any caller)
|
||||
* so a malformed rate card can never be published. See wiki/concepts/tariff.md.
|
||||
*/
|
||||
export function validateTariffStructure(s: unknown): string[] {
|
||||
const errs: string[] = [];
|
||||
if (!s || typeof s !== "object") return ["structure must be an object"];
|
||||
const t = s as Partial<TariffStructure>;
|
||||
// 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>);
|
||||
}
|
||||
|
||||
const nonNegInt = (v: unknown, label: string) => {
|
||||
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`);
|
||||
};
|
||||
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin");
|
||||
nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin");
|
||||
nonNegInt(t.lostTicketMinor, "lostTicketMinor");
|
||||
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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor");
|
||||
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
|
||||
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
|
||||
validateBlocks(t.blocks, "blocks", errs);
|
||||
return errs;
|
||||
}
|
||||
|
||||
if (!Array.isArray(t.blocks) || t.blocks.length === 0) {
|
||||
errs.push("blocks must be a non-empty array");
|
||||
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;
|
||||
if (hasFlat === hasBlocks) {
|
||||
errs.push(`${label} must set exactly one of flatMinor or blocks`);
|
||||
} 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 {
|
||||
let prevBound = 0;
|
||||
t.blocks.forEach((b, i) => {
|
||||
const last = i === t.blocks!.length - 1;
|
||||
nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`);
|
||||
if (b?.uptoMin == null) {
|
||||
if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`);
|
||||
} else {
|
||||
if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) {
|
||||
errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`);
|
||||
} else {
|
||||
prevBound = b.uptoMin;
|
||||
}
|
||||
}
|
||||
});
|
||||
// The LAST block must be open-ended (uptoMin null) so the "thereafter" rate is
|
||||
// always explicit. A bounded final block silently inherits its own rate past
|
||||
// its bound (a hidden, never-stated price) — forbidden on publish so the admin
|
||||
// must state what time beyond the ladder costs. See wiki/concepts/tariff.md.
|
||||
// (Read/pricing of already-published versions is unaffected — validation runs
|
||||
// only on publish; rateAt() still gracefully handles legacy bounded tails.)
|
||||
const lastBlock = t.blocks[t.blocks.length - 1];
|
||||
if (lastBlock && lastBlock.uptoMin != null) {
|
||||
errs.push("the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly");
|
||||
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));
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -227,6 +512,131 @@ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** "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;
|
||||
}
|
||||
|
||||
export const ROLES: readonly Role[] = [
|
||||
"admin",
|
||||
"operator",
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
computeFee,
|
||||
validateTariffStructure,
|
||||
type TariffStructureV1,
|
||||
type TariffStructureV2,
|
||||
type TariffCard,
|
||||
} from "./index.js";
|
||||
|
||||
const entered = "2026-06-18T00:00:00.000Z";
|
||||
const at = (min: number) => new Date(Date.parse(entered) + min * 60_000).toISOString();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (a) GOLDEN V1 regression — the live production structure must reprice to these
|
||||
// exact integers. Captured from the pre-V2 engine. This is the most important
|
||||
// test: it proves a signed historical session reprices identically.
|
||||
// ---------------------------------------------------------------------------
|
||||
const liveV1: TariffStructureV1 = {
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
blocks: [
|
||||
{ uptoMin: 60, priceMinorPerIncrement: 20000 },
|
||||
{ uptoMin: 180, priceMinorPerIncrement: 10000 },
|
||||
],
|
||||
dailyCapMinor: 100000,
|
||||
lostTicketMinor: 100000,
|
||||
gracePeriodExitMin: 5,
|
||||
overstay: "reprice",
|
||||
};
|
||||
|
||||
describe("V1 golden regression", () => {
|
||||
const golden: Record<number, number> = {
|
||||
3: 0, 30: 20000, 60: 20000, 61: 30000, 120: 30000, 180: 40000,
|
||||
181: 50000, 240: 50000, 1440: 100000, 1500: 120000, 2880: 200000,
|
||||
};
|
||||
for (const [min, want] of Object.entries(golden)) {
|
||||
it(`${min} min → ${want}`, () => {
|
||||
expect(computeFee(entered, at(Number(min)), liveV1)).toBe(want);
|
||||
});
|
||||
}
|
||||
it("a V1 structure ignores the category argument", () => {
|
||||
expect(computeFee(entered, at(120), liveV1, "bus")).toBe(30000);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V2 building blocks
|
||||
// ---------------------------------------------------------------------------
|
||||
const ladder = (open: number, first?: { uptoMin: number; rate: number }) =>
|
||||
first
|
||||
? [{ uptoMin: first.uptoMin, priceMinorPerIncrement: first.rate }, { uptoMin: null, priceMinorPerIncrement: open }]
|
||||
: [{ uptoMin: null, priceMinorPerIncrement: open }];
|
||||
|
||||
const defaultCard: TariffCard = {
|
||||
name: "default",
|
||||
priority: 0,
|
||||
blocks: ladder(20000), // flat 200/h ladder (open-ended)
|
||||
dailyCapMinor: null,
|
||||
};
|
||||
|
||||
function v2(windowedCards: TariffCard[], tz = "Europe/Tirane", over: Partial<TariffStructureV2> = {}): TariffStructureV2 {
|
||||
return {
|
||||
version: 2,
|
||||
tz,
|
||||
gracePeriodEntryMin: 5,
|
||||
incrementMin: 60,
|
||||
lostTicketMinor: 100000,
|
||||
gracePeriodExitMin: 5,
|
||||
overstay: "reprice",
|
||||
defaultCard,
|
||||
windowedCards,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
describe("V2 back-compat: a V2 with no windowed cards prices like its default ladder", () => {
|
||||
it("default-only V2 == equivalent V1", () => {
|
||||
const s = v2([]);
|
||||
// 200/h flat ladder, 3h
|
||||
expect(computeFee(entered, at(180), s)).toBe(60000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 time-of-day window (happy hour)", () => {
|
||||
// Tirane is UTC+2 in June (DST). entered 00:00Z = 02:00 local.
|
||||
// Happy hour 04:00–06:00 local = 02:00–04:00Z. Default 200/h, happy 50/h.
|
||||
const happy: TariffCard = {
|
||||
name: "happy",
|
||||
priority: 10,
|
||||
window: { fromHour: "04:00", toHour: "06:00" },
|
||||
blocks: ladder(5000),
|
||||
};
|
||||
const s = v2([happy]);
|
||||
it("a stay crossing into happy hour bills each increment by its wall-clock card", () => {
|
||||
// 0-120min elapsed = local 02:00-04:00 (default 200/h ×2 = 400),
|
||||
// 120-240min = local 04:00-06:00 (happy 50/h ×2 = 100). Total 500 = 50000.
|
||||
expect(computeFee(entered, at(240), s)).toBe(50000);
|
||||
});
|
||||
it("a stay entirely before happy hour is all default", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(40000); // 2h × 200
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 overnight wrap window", () => {
|
||||
// night 22:00→06:00 local (wraps midnight), cheap 50/h.
|
||||
const night: TariffCard = {
|
||||
name: "night",
|
||||
priority: 10,
|
||||
window: { fromHour: "22:00", toHour: "06:00" },
|
||||
blocks: ladder(5000),
|
||||
};
|
||||
const s = v2([night]);
|
||||
it("an early-morning stay (local 02:00-04:00) is inside the wrap → night rate", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(10000); // 2h × 50
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 day-of-week tested at the increment's wall-clock day", () => {
|
||||
// 2026-06-18 is a Thursday (dow 4). A Friday-only card must NOT apply.
|
||||
const friOnly: TariffCard = { name: "fri", priority: 10, window: { dow: [5] }, blocks: ladder(5000) };
|
||||
it("Thursday stay does not get the Friday card", () => {
|
||||
expect(computeFee(entered, at(120), v2([friOnly]))).toBe(40000); // default 200×2
|
||||
});
|
||||
const thuOnly: TariffCard = { name: "thu", priority: 10, window: { dow: [4] }, blocks: ladder(5000) };
|
||||
it("Thursday stay gets the Thursday card", () => {
|
||||
expect(computeFee(entered, at(120), v2([thuOnly]))).toBe(10000); // 50×2
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 flat card", () => {
|
||||
const flatNight: TariffCard = {
|
||||
name: "flat",
|
||||
priority: 10,
|
||||
window: { fromHour: "00:00", toHour: "23:59" }, // effectively all day here
|
||||
flatMinor: 3000,
|
||||
};
|
||||
it("flat card charges flatMinor per increment", () => {
|
||||
expect(computeFee(entered, at(180), v2([flatNight]))).toBe(9000); // 3h × 30
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 category filter", () => {
|
||||
const busCard: TariffCard = { name: "bus", priority: 10, category: "bus", blocks: ladder(40000) };
|
||||
const s = v2([busCard]);
|
||||
it("a bus session uses the bus card (400/h)", () => {
|
||||
expect(computeFee(entered, at(120), s, "bus")).toBe(80000);
|
||||
});
|
||||
it("a car session ignores the bus card → default (200/h)", () => {
|
||||
expect(computeFee(entered, at(120), s, "car")).toBe(40000);
|
||||
});
|
||||
it("no category given ignores the bus card → default", () => {
|
||||
expect(computeFee(entered, at(120), s)).toBe(40000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 daily cap uses the DEFAULT card's cap on a mixed day", () => {
|
||||
// default cap 1000/day; a cheap night card present. 24h elapsed.
|
||||
const night: TariffCard = { name: "night", priority: 10, window: { fromHour: "22:00", toHour: "06:00" }, blocks: ladder(5000) };
|
||||
const s = v2([night], "Europe/Tirane", { defaultCard: { ...defaultCard, dailyCapMinor: 100000 } });
|
||||
it("a 24h stay is capped at the default card's 1000/day", () => {
|
||||
expect(computeFee(entered, at(1440), s)).toBe(100000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 precedence is total + order-independent", () => {
|
||||
// Specificity order is date > dow > hour-only (see plan / tariff-time-tiers.md).
|
||||
// So a dow-constrained card beats an hour-only card at an overlapping instant.
|
||||
const dowCard: TariffCard = { name: "a-dow", priority: 5, window: { dow: [4] }, blocks: ladder(10000) }; // Thu, 100/h
|
||||
const hourCard: TariffCard = { name: "b-hour", priority: 5, window: { fromHour: "02:00", toHour: "04:00" }, blocks: ladder(5000) }; // local 02-04, 50/h
|
||||
it("dow (more specific than hour-only) wins at an overlapping instant", () => {
|
||||
// local 02:00-04:00 = elapsed 0-120; both match, dow ranks above hour → 100/h
|
||||
expect(computeFee(entered, at(120), v2([dowCard, hourCard]))).toBe(20000);
|
||||
});
|
||||
it("a date window beats a dow window (date is most specific)", () => {
|
||||
const dateCard: TariffCard = { name: "c-date", priority: 1, window: { dateFrom: "2026-06-18", dateTo: "2026-06-18" }, blocks: ladder(5000) }; // 50/h
|
||||
// date beats dow even with LOWER priority (specificity dominates priority)
|
||||
expect(computeFee(entered, at(120), v2([dowCard, dateCard]))).toBe(10000);
|
||||
});
|
||||
it("fee is identical when windowedCards order is shuffled", () => {
|
||||
const a = computeFee(entered, at(120), v2([dowCard, hourCard]));
|
||||
const b = computeFee(entered, at(120), v2([hourCard, dowCard]));
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe("V2 DST determinism (Europe/Tirane)", () => {
|
||||
// Spring forward 2026-03-29 03:00 local (clocks 02:00→03:00). Fall back 2026-10-25.
|
||||
const cheap: TariffCard = { name: "c", priority: 10, window: { fromHour: "00:00", toHour: "23:59" }, flatMinor: 1000 };
|
||||
it("a stay across the spring-forward boundary prices deterministically", () => {
|
||||
const e = "2026-03-29T00:00:00.000Z"; // 01:00 local pre-jump
|
||||
const a1 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
|
||||
const a2 = computeFee(e, new Date(Date.parse(e) + 240 * 60_000).toISOString(), v2([cheap]));
|
||||
expect(a1).toBe(a2); // determinism
|
||||
expect(a1).toBe(4000); // 4h × flat 10
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (e) validation accept/reject matrix
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("validate V1 (unchanged messages)", () => {
|
||||
it("accepts the live structure", () => {
|
||||
expect(validateTariffStructure({ ...liveV1, blocks: [...liveV1.blocks, { uptoMin: null, priceMinorPerIncrement: 5000 }] })).toEqual([]);
|
||||
});
|
||||
it("rejects a bounded last block", () => {
|
||||
expect(validateTariffStructure(liveV1)).toContain(
|
||||
"the last block must be open-ended (uptoMin: null) — the thereafter-rate must be stated explicitly",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validate V2", () => {
|
||||
const okDefault: TariffCard = { name: "d", priority: 0, blocks: ladder(20000) };
|
||||
const base = { version: 2 as const, tz: "Europe/Tirane", gracePeriodEntryMin: 5, incrementMin: 60, lostTicketMinor: 0, gracePeriodExitMin: 5, overstay: "reprice" as const };
|
||||
|
||||
it("accepts a minimal default-only V2", () => {
|
||||
expect(validateTariffStructure({ ...base, defaultCard: okDefault })).toEqual([]);
|
||||
});
|
||||
it("requires tz when windowedCards present", () => {
|
||||
const errs = validateTariffStructure({ ...base, tz: "", defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }] });
|
||||
expect(errs).toContain("tz (IANA timezone) is required when windowedCards are present");
|
||||
});
|
||||
it("rejects a card with both flat and blocks", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
|
||||
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks");
|
||||
});
|
||||
it("rejects defaultCard with a window", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
||||
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
|
||||
});
|
||||
it("rejects a bad hour format", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
|
||||
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
|
||||
});
|
||||
it("rejects ambiguous precedence (equal specificity+priority, overlapping)", () => {
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: okDefault,
|
||||
windowedCards: [
|
||||
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
|
||||
{ name: "y", priority: 5, window: { dow: [2, 3] }, blocks: ladder(6000) },
|
||||
],
|
||||
});
|
||||
expect(errs.some((e) => e.includes("higher priority to break the tie"))).toBe(true);
|
||||
});
|
||||
it("allows the tie to be broken by priority", () => {
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: okDefault,
|
||||
windowedCards: [
|
||||
{ name: "x", priority: 5, window: { dow: [1, 2] }, blocks: ladder(5000) },
|
||||
{ name: "y", priority: 6, window: { dow: [2, 3] }, blocks: ladder(6000) },
|
||||
],
|
||||
});
|
||||
expect(errs).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
// Only run tests from src (TypeScript source). Without this, the compiled copies
|
||||
// in dist/ get picked up as duplicate (stale) test files.
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Generated
+252
@@ -168,6 +168,9 @@ importers:
|
||||
typescript:
|
||||
specifier: 6.0.3
|
||||
version: 6.0.3
|
||||
vitest:
|
||||
specifier: ^4.1.9
|
||||
version: 4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
|
||||
packages:
|
||||
|
||||
@@ -1109,6 +1112,9 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.1':
|
||||
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
resolution: {integrity: sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==}
|
||||
|
||||
@@ -1292,6 +1298,15 @@ packages:
|
||||
'@types/better-sqlite3@7.6.13':
|
||||
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@25.9.3':
|
||||
resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==}
|
||||
|
||||
@@ -1316,6 +1331,35 @@ packages:
|
||||
babel-plugin-react-compiler:
|
||||
optional: true
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
resolution: {integrity: sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==}
|
||||
|
||||
'@vitest/mocker@4.1.9':
|
||||
resolution: {integrity: sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==}
|
||||
peerDependencies:
|
||||
msw: ^2.4.9
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
msw:
|
||||
optional: true
|
||||
vite:
|
||||
optional: true
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
resolution: {integrity: sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==}
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
resolution: {integrity: sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==}
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
resolution: {integrity: sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==}
|
||||
|
||||
'@vitest/spy@4.1.9':
|
||||
resolution: {integrity: sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
resolution: {integrity: sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
@@ -1337,6 +1381,10 @@ packages:
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -1378,6 +1426,10 @@ packages:
|
||||
buffer@5.7.1:
|
||||
resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
@@ -1389,6 +1441,9 @@ packages:
|
||||
resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
cookie-es@3.1.1:
|
||||
resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==}
|
||||
|
||||
@@ -1531,6 +1586,9 @@ packages:
|
||||
resolution: {integrity: sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
es-module-lexer@2.1.0:
|
||||
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
|
||||
|
||||
esbuild@0.18.20:
|
||||
resolution: {integrity: sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==}
|
||||
engines: {node: '>=12'}
|
||||
@@ -1549,10 +1607,17 @@ packages:
|
||||
escape-html@1.0.3:
|
||||
resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
expect-type@1.3.0:
|
||||
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-decode-uri-component@1.0.1:
|
||||
resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==}
|
||||
|
||||
@@ -1814,6 +1879,10 @@ packages:
|
||||
obliterator@2.0.5:
|
||||
resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==}
|
||||
|
||||
obug@2.1.3:
|
||||
resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
on-exit-leak-free@2.1.2:
|
||||
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -1825,6 +1894,9 @@ packages:
|
||||
resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==}
|
||||
engines: {node: 18 || 20 || >=22}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -1998,6 +2070,9 @@ packages:
|
||||
setprototypeof@1.2.0:
|
||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
@@ -2022,10 +2097,16 @@ packages:
|
||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||
engines: {node: '>= 10.x'}
|
||||
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
statuses@2.0.2:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
steed@1.1.3:
|
||||
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
|
||||
|
||||
@@ -2057,10 +2138,21 @@ packages:
|
||||
resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.2.4:
|
||||
resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
tinyrainbow@3.1.0:
|
||||
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
toad-cache@3.7.1:
|
||||
resolution: {integrity: sha512-5DXWzE4Vz7xNHsv+xQ+MGfJYyC78Aok3tEr0MNwHoRf7vZnga1mQXZ4/Nsodld4VR6Wd+VhfmqnNrsRJyYPfrQ==}
|
||||
engines: {node: '>=20'}
|
||||
@@ -2163,10 +2255,56 @@ packages:
|
||||
yaml:
|
||||
optional: true
|
||||
|
||||
vitest@4.1.9:
|
||||
resolution: {integrity: sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==}
|
||||
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
'@edge-runtime/vm': '*'
|
||||
'@opentelemetry/api': ^1.9.0
|
||||
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
|
||||
'@vitest/browser-playwright': 4.1.9
|
||||
'@vitest/browser-preview': 4.1.9
|
||||
'@vitest/browser-webdriverio': 4.1.9
|
||||
'@vitest/coverage-istanbul': 4.1.9
|
||||
'@vitest/coverage-v8': 4.1.9
|
||||
'@vitest/ui': 4.1.9
|
||||
happy-dom: '*'
|
||||
jsdom: '*'
|
||||
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
|
||||
peerDependenciesMeta:
|
||||
'@edge-runtime/vm':
|
||||
optional: true
|
||||
'@opentelemetry/api':
|
||||
optional: true
|
||||
'@types/node':
|
||||
optional: true
|
||||
'@vitest/browser-playwright':
|
||||
optional: true
|
||||
'@vitest/browser-preview':
|
||||
optional: true
|
||||
'@vitest/browser-webdriverio':
|
||||
optional: true
|
||||
'@vitest/coverage-istanbul':
|
||||
optional: true
|
||||
'@vitest/coverage-v8':
|
||||
optional: true
|
||||
'@vitest/ui':
|
||||
optional: true
|
||||
happy-dom:
|
||||
optional: true
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
void-elements@3.1.0:
|
||||
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wrappy@1.0.2:
|
||||
resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
|
||||
|
||||
@@ -2904,6 +3042,8 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@tailwindcss/node@4.3.1':
|
||||
dependencies:
|
||||
'@jridgewell/remapping': 2.3.5
|
||||
@@ -3056,6 +3196,15 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.9.3
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
assertion-error: 2.0.1
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@25.9.3':
|
||||
dependencies:
|
||||
undici-types: 7.24.6
|
||||
@@ -3073,6 +3222,47 @@ snapshots:
|
||||
'@rolldown/pluginutils': 1.0.1
|
||||
vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
|
||||
'@vitest/expect@4.1.9':
|
||||
dependencies:
|
||||
'@standard-schema/spec': 1.1.0
|
||||
'@types/chai': 5.2.3
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.9
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
|
||||
'@vitest/pretty-format@4.1.9':
|
||||
dependencies:
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/runner@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/utils': 4.1.9
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/snapshot@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
magic-string: 0.30.21
|
||||
pathe: 2.0.3
|
||||
|
||||
'@vitest/spy@4.1.9': {}
|
||||
|
||||
'@vitest/utils@4.1.9':
|
||||
dependencies:
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.20.0):
|
||||
@@ -3097,6 +3287,8 @@ snapshots:
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
avvio@9.2.0:
|
||||
@@ -3141,12 +3333,16 @@ snapshots:
|
||||
base64-js: 1.5.1
|
||||
ieee754: 1.2.1
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
clsx@2.1.1: {}
|
||||
|
||||
content-disposition@1.1.0: {}
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
cookie-es@3.1.1: {}
|
||||
|
||||
cookie@1.1.1: {}
|
||||
@@ -3199,6 +3395,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
es-module-lexer@2.1.0: {}
|
||||
|
||||
esbuild@0.18.20:
|
||||
optionalDependencies:
|
||||
'@esbuild/android-arm': 0.18.20
|
||||
@@ -3284,8 +3482,14 @@ snapshots:
|
||||
|
||||
escape-html@1.0.3: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
fast-decode-uri-component@1.0.1: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
@@ -3519,6 +3723,8 @@ snapshots:
|
||||
|
||||
obliterator@2.0.5: {}
|
||||
|
||||
obug@2.1.3: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
once@1.4.0:
|
||||
@@ -3530,6 +3736,8 @@ snapshots:
|
||||
lru-cache: 11.5.1
|
||||
minipass: 7.1.3
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.4: {}
|
||||
@@ -3705,6 +3913,8 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
@@ -3728,8 +3938,12 @@ snapshots:
|
||||
|
||||
split2@4.2.0: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
steed@1.1.3:
|
||||
dependencies:
|
||||
fastfall: 1.5.1
|
||||
@@ -3769,11 +3983,17 @@ snapshots:
|
||||
dependencies:
|
||||
real-require: 1.0.0
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.2.4: {}
|
||||
|
||||
tinyglobby@0.2.17:
|
||||
dependencies:
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
|
||||
tinyrainbow@3.1.0: {}
|
||||
|
||||
toad-cache@3.7.1: {}
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
@@ -3838,8 +4058,40 @@ snapshots:
|
||||
jiti: 2.7.0
|
||||
tsx: 4.22.4
|
||||
|
||||
vitest@4.1.9(@types/node@25.9.3)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.9
|
||||
'@vitest/mocker': 4.1.9(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4))
|
||||
'@vitest/pretty-format': 4.1.9
|
||||
'@vitest/runner': 4.1.9
|
||||
'@vitest/snapshot': 4.1.9
|
||||
'@vitest/spy': 4.1.9
|
||||
'@vitest/utils': 4.1.9
|
||||
es-module-lexer: 2.1.0
|
||||
expect-type: 1.3.0
|
||||
magic-string: 0.30.21
|
||||
obug: 2.1.3
|
||||
pathe: 2.0.3
|
||||
picomatch: 4.0.4
|
||||
std-env: 4.1.0
|
||||
tinybench: 2.9.0
|
||||
tinyexec: 1.2.4
|
||||
tinyglobby: 0.2.17
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 25.9.3
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
void-elements@3.1.0: {}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wrappy@1.0.2: {}
|
||||
|
||||
ws@8.21.0: {}
|
||||
|
||||
@@ -2,19 +2,21 @@
|
||||
type: concept
|
||||
tags: [parking, domain, business, pricing, design]
|
||||
sources: [parksql2017-legacy-schema]
|
||||
updated: 2026-06-17
|
||||
status: open
|
||||
updated: 2026-06-18
|
||||
status: settled
|
||||
---
|
||||
|
||||
# Tariff Time Tiers — happy hour, off-peak, weekend, seasonal
|
||||
|
||||
Design for **time-of-day / day-of-week / seasonal pricing** on top of the existing [[tariff]] engine.
|
||||
**Time-of-day / day-of-week / seasonal / category pricing** on top of the existing [[tariff]] engine.
|
||||
Resolves the `tariff.md` open question *"Time-of-day / weekday tiers — not in the block model yet."*
|
||||
Driven by two concrete operator asks: a **happy-hour** rate, and (from [[parksql2017-legacy-schema|the
|
||||
legacy schema]]) **vehicle/customer categories**.
|
||||
Driven by the ask to match the legacy [[parksql2017-legacy-schema|ParkSQL2017]] pricing breadth
|
||||
(happy hour, weekend/seasonal windows, vehicle/customer category, flat rate) — but on our
|
||||
integer-minor-unit money + immutable signed-version engine, NOT legacy's float money / mutable rows.
|
||||
|
||||
> Status: **design, not built.** No schema/code committed yet — this records the chosen shape and
|
||||
> the rejected alternatives so implementation is a transcription.
|
||||
> Status: **BUILT 2026-06-18 (V2 tariff).** This page records the as-built shape + the decisions.
|
||||
> The engine is the "V2" arm of `TariffStructure` in `@parking/shared`; a bare V1 structure (no
|
||||
> `defaultCard`) still prices via the unchanged V1 algorithm. See the as-built section at the end.
|
||||
|
||||
## The two real-world models we looked at
|
||||
|
||||
@@ -106,10 +108,56 @@ A bare `defaultCard` (no `windowedCards`) is exactly today's tariff — so this
|
||||
site that never wants tiers never sees them. Keeps the **intuitive-for-operators** goal: the common
|
||||
case stays one rate card; tiers are opt-in.
|
||||
|
||||
## As-built (2026-06-18) — resolved decisions
|
||||
|
||||
- **Shape**: `TariffStructure` is a discriminated union. **V1** = the original bare ladder (unchanged,
|
||||
verbatim algorithm). **V2** = `{ version:2, tz, <shared knobs>, defaultCard, windowedCards[] }`.
|
||||
Discriminant = presence of `defaultCard`. Grace/increment/lostTicket/exit-grace are **top-level
|
||||
(shared)**; the flat-XOR-ladder body + per-card `dailyCapMinor` live on each card.
|
||||
- **Ladder accrual = elapsed-continuous** (decided). Elapsed minutes advance the block-ladder
|
||||
position; wall-clock selects the card per increment. A happy-hour boundary mid-stay does NOT reset
|
||||
the ladder or the daily cap. Implemented by stepping one `incrementMin` at a time and re-selecting
|
||||
the card (boundary slicing is implicit).
|
||||
- **Timezone is FROZEN in the version** (`structure.tz`), sourced from **site config**
|
||||
(`site_config.timezone`, default `Europe/Tirane`) and stamped server-side on publish — NEVER read
|
||||
from the host clock, or historical repricing would drift and break the signed ledger. Tested for
|
||||
DST determinism (`Europe/Tirane` spring-forward/fall-back).
|
||||
- **Daily cap on a mixed day = the DEFAULT card's `dailyCapMinor`** governs the whole rolling-24h
|
||||
segment (decided). Windowed cards lower the rate, never the day ceiling. Predictable + easy to
|
||||
explain.
|
||||
- **Precedence** = specificity tuple **(date > dow > hour-only)**, then integer `priority` (higher
|
||||
wins), then `name` lexicographically as the **final, total, order-independent** tiebreak. Validation
|
||||
*rejects* two cards tied on (category, specificity, priority) with overlapping windows, forcing the
|
||||
operator to disambiguate with `priority`. (Property-tested: shuffling `windowedCards` yields an
|
||||
identical fee.)
|
||||
- **Category = a FIELD on each card** (`card.category`), NOT a tariff scope (reversed the earlier
|
||||
lean). Justification: both pricing call-sites hardcode the single `scope:"site"` tariff; a card-field
|
||||
keeps the whole category→price mapping inside the one immutable `structure` the `payment` event
|
||||
already pins via `tariffVersionId` — fewer frozen moving parts, no `tariffs`-table rework. A card
|
||||
with no `category` applies to all; the `defaultCard` is category-agnostic. The session's category is
|
||||
**frozen in the signed `vehicle_entry` payload** (`payload.category`), so exit reprices identically.
|
||||
Sourced today from `site_config.default_vehicle_category` (operator policy; default
|
||||
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
|
||||
seam, mirroring per-relay direction.
|
||||
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
|
||||
is published as a single open-ended block (V1 has no flat field).
|
||||
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
|
||||
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
|
||||
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
|
||||
body reusing the default editor). `toStructure` emits a **bare V1 when there are no tiers**
|
||||
(back-compat: untouched sites publish exactly today's shape).
|
||||
|
||||
**As-built code**: `computeFee`/`computeFeeV2`/`validateTariffStructure`/`selectCard`/`localBreakdown`
|
||||
in `packages/shared/src/index.ts` (+ `tariff.test.ts`, 36 cases incl. the golden V1 regression);
|
||||
`routes/tariffs.ts` (tz stamping), `routes/site.ts` (tz + default-category fields), `entry-flow.ts`
|
||||
(category frozen at entry), `pay-station.ts` + `exit-flow.ts` (read category, pass to `computeFee`);
|
||||
`schema.ts` + migrations `0005`/`0006` (`site_config.timezone`, `default_vehicle_category`);
|
||||
`TariffComposer.tsx` + `api.ts` + i18n.
|
||||
|
||||
## Open
|
||||
- Elapsed-continuous vs. per-window ladder reset (lean: elapsed-continuous).
|
||||
- Holiday/special-event calendar: a date list per version, or a separate editable calendar table?
|
||||
- Precedence model — confirm most-specific + explicit `priority` tiebreak.
|
||||
- Category axis — confirm "category = tariff scope" vs. window dimension (deferred).
|
||||
- UI: how to author windows without confusing operators (the notoriously-hard part — keep default
|
||||
card front-and-center, tiers as an "advanced" add).
|
||||
- Holiday/special-event calendar: today a date range per card (`dateFrom`/`dateTo`); a reusable named
|
||||
holiday calendar (one date list, referenced by cards) is a future nicety, not built.
|
||||
- Per-relay/lane **category capture** at a transient gate (the "bus lane") — seam noted in
|
||||
`entry-flow.ts`; today every transient takes the site default category.
|
||||
- A composer **price preview** ("at 14:30 Tue a 2h stay costs …") — high-value for operator trust,
|
||||
deferred.
|
||||
|
||||
@@ -217,15 +217,17 @@ Unlike the event log, tariff data is **mutable master data** in the sense that n
|
||||
on the network — [[offline-first]]), a base currency, and a rounding policy. Deferred to
|
||||
[[open-questions]].
|
||||
|
||||
## Extensions under design
|
||||
## Extensions
|
||||
|
||||
Two operator asks extend this engine; both have design pages (not yet built), grounded in
|
||||
[[parksql2017-legacy-schema|the legacy schema]] + external research:
|
||||
Grounded in [[parksql2017-legacy-schema|the legacy schema]] + external research:
|
||||
|
||||
- **Time-of-day / weekday / seasonal tiers** (happy hour, off-peak, weekend, vehicle category) —
|
||||
see [[tariff-time-tiers]]. Chosen shape: **time-windowed rate cards** selected by wall-clock window,
|
||||
layered additively on this structure (a bare default card = today's behaviour). The hard part is
|
||||
slicing a stay at window boundaries while keeping the block ladder + daily cap continuous.
|
||||
- **Time-of-day / weekday / seasonal tiers + vehicle category + flat rate** — **BUILT 2026-06-18**
|
||||
as the **V2 tariff** (the "V2" arm of `TariffStructure`). A `defaultCard` plus optional windowed
|
||||
cards selected by wall-clock window / day-of-week / date / category, each flat or laddered; a stay
|
||||
is sliced at window boundaries while the block ladder + daily cap stay continuous (elapsed-
|
||||
continuous). A bare V1 structure (no `defaultCard`) is unchanged. The wall-clock tz is **frozen in
|
||||
the version** (from site config) for reproducibility. Full as-built decisions in
|
||||
[[tariff-time-tiers]].
|
||||
- **Validation & sponsorship** (merchant comps, coupons, **postpaid B2B** "enter/exit free, bill the
|
||||
business monthly") — see [[validation-sponsorship]]. A validation is a **typed modifier applied as a
|
||||
signed event** on a transient session, distinct from a [[subscription]]; postpaid sponsors accrue a
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ Counts: 4 sources · 19 entities · 42 concepts · 5 decision records.
|
||||
## Concepts — business domain
|
||||
- [[parking-session]] — the core domain entity; a projection over the signed log, never a mutable table.
|
||||
- [[tariff]] — fee model; pure, data-driven, offline; pay-on-foot adds a walk-back grace window.
|
||||
- [[tariff-time-tiers]] — design: happy-hour/off-peak/weekend/seasonal + vehicle categories via time-windowed rate cards.
|
||||
- [[tariff-time-tiers]] — BUILT (V2 tariff): happy-hour/off-peak/weekend/seasonal + vehicle category + flat rate via wall-clock windowed cards; tz frozen per version.
|
||||
- [[booth-exit-flow]] — manned booth: pay → voucher (self-exit later) or immediate exit; active sessions; audited barrier re-open.
|
||||
- [[shift]] — manned-only accountability period; explicit Start/End; End → signed + printed Z-report; drawer float carries across shifts.
|
||||
- [[capacity-occupancy]] — live count = open sessions; refuse entry + FULL sign when full (soft policy); exit never blocked.
|
||||
|
||||
Reference in New Issue
Block a user