diff --git a/apps/server/src/entry-flow.ts b/apps/server/src/entry-flow.ts index 77c95fa..84a14d9 100644 --- a/apps/server/src/entry-flow.ts +++ b/apps/server/src/entry-flow.ts @@ -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, }); diff --git a/apps/server/src/exit-flow.ts b/apps/server/src/exit-flow.ts index 2eb7375..5ce6573 100644 --- a/apps/server/src/exit-flow.ts +++ b/apps/server/src/exit-flow.ts @@ -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, diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index 8fdf8aa..0e1750b 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -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, diff --git a/apps/server/src/routes/site.ts b/apps/server/src/routes/site.ts index 13de6a6..c7a5cd2 100644 --- a/apps/server/src/routes/site.ts +++ b/apps/server/src/routes/site.ts @@ -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]; diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index d43947e..6cea217 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -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 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 tariffId, effectiveFrom: effective, currency, - structure: structure as unknown as Record, + structure: toStore as unknown as Record, createdBy: req.user?.username ?? null, }; db.insert(tariffVersions).values(row).run(); diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx index 4d29f16..daf2ddb 100644 --- a/apps/web/src/TariffComposer.tsx +++ b/apps/web/src/TariffComposer.tsx @@ -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 { + 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(key: K, value: FormState[K]) { setForm((f) => ({ ...f, [key]: value })); } - function setBlock(i: number, patch: Partial) { - 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) { + 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) { + 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() { set("gracePeriodEntryMin", e.target.value)} /> set("incrementMin", e.target.value)} /> - - set("dailyCap", e.target.value)} placeholder={t("tariff.dailyCapPh")} /> set("lostTicket", e.target.value)} /> set("gracePeriodExitMin", e.target.value)} /> -

{t("tariff.rateBlocks")}

-

{t("tariff.rateBlocksHint")}

- - - - - - - - - {form.blocks.map((b, i) => { - const isTail = i === form.blocks.length - 1; - return ( - - - - - - ); - })} - -
{t("tariff.bandDuration")}{t("tariff.pricePerIncrement")} -
- {isTail ? ( - {t("tariff.thereafter")} - ) : ( - - setBlock(i, { hours: e.target.value })} - placeholder={t("tariff.egHours")} - style={{ width: 70 }} - /> - {t("tariff.hoursUnit")} - - )} - - setBlock(i, { price: e.target.value })} style={{ width: 90 }} /> - - {!isTail && ( - - )} -
- + {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never + wants tiers just edits this and publishes a bare V1 structure. */} +

{t("tariff.defaultCard")}

+

{t("tariff.defaultCardHint")}

+ 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. */} +
0}> + {t("tariff.tiersAdvanced")} +

{t("tariff.tiersHint")}

+ {form.tiers.map((tr, i) => ( +
+ + setTier(i, { name: e.target.value })} + placeholder={t("tariff.tierName")} + style={{ width: 140 }} + /> + + +
+ + setTier(i, { priority: e.target.value })} style={{ width: 70 }} /> + + setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} style={{ width: 140 }} /> + + + {[1, 2, 3, 4, 5, 6, 0].map((d) => ( + + ))} + + + + setTier(i, { fromHour: e.target.value })} placeholder="22:00" style={{ width: 70 }} /> + – + setTier(i, { toHour: e.target.value })} placeholder="06:00" style={{ width: 70 }} /> + {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( + {t("tariff.tierOvernight")} + )} + + + + setTier(i, { dateFrom: e.target.value })} /> + – + setTier(i, { dateTo: e.target.value })} /> + +
+
+ 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)} + /> +
+
+ ))} + +
+ + {t("tariff.dailyCap")} + props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} style={{ width: 90 }} /> + +
+ + )} + + ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index c500d5b..255830a 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -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 { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 2139700..5f1638a 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 9a61e93..81bf4b5 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/packages/db/drizzle/0005_site_timezone.sql b/packages/db/drizzle/0005_site_timezone.sql new file mode 100644 index 0000000..5b1873c --- /dev/null +++ b/packages/db/drizzle/0005_site_timezone.sql @@ -0,0 +1 @@ +ALTER TABLE `site_config` ADD `timezone` text; \ No newline at end of file diff --git a/packages/db/drizzle/0006_site_default_category.sql b/packages/db/drizzle/0006_site_default_category.sql new file mode 100644 index 0000000..68685db --- /dev/null +++ b/packages/db/drizzle/0006_site_default_category.sql @@ -0,0 +1 @@ +ALTER TABLE `site_config` ADD `default_vehicle_category` text; \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 183c392..88ed106 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 879a3f6..f3516f8 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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)`), diff --git a/packages/shared/package.json b/packages/shared/package.json index e40fc5c..581be45 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -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" } } diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 521c1f8..9de47f7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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; + // 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).defaultCard != null + ? validateTariffV2(s as Partial) + : validateTariffV1(s as Partial); +} - 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, 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[])[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): 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 | 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 | 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): 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[], 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 = { 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", diff --git a/packages/shared/src/tariff.test.ts b/packages/shared/src/tariff.test.ts new file mode 100644 index 0000000..5591215 --- /dev/null +++ b/packages/shared/src/tariff.test.ts @@ -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 = { + 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 { + 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([]); + }); +}); diff --git a/packages/shared/vitest.config.ts b/packages/shared/vitest.config.ts new file mode 100644 index 0000000..3a85ef4 --- /dev/null +++ b/packages/shared/vitest.config.ts @@ -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"], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 549d765..ad4ccd3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {} diff --git a/wiki/concepts/tariff-time-tiers.md b/wiki/concepts/tariff-time-tiers.md index bd2b1dd..a32e2b5 100644 --- a/wiki/concepts/tariff-time-tiers.md +++ b/wiki/concepts/tariff-time-tiers.md @@ -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, , 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. diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index 5f6404e..b5dc4ba 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -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 diff --git a/wiki/index.md b/wiki/index.md index cfa9970..44a0006 100644 --- a/wiki/index.md +++ b/wiki/index.md @@ -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.