diff --git a/apps/server/src/pay-station.ts b/apps/server/src/pay-station.ts index d27740e..6273229 100644 --- a/apps/server/src/pay-station.ts +++ b/apps/server/src/pay-station.ts @@ -1,5 +1,5 @@ import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db"; -import { computeFee, type TariffStructure, type Tender } from "@parking/shared"; +import { priceSession, type TariffStructure, type Tender } from "@parking/shared"; import type { FastifyBaseLogger } from "fastify"; import type { EventLog } from "./event-log.js"; @@ -128,15 +128,6 @@ export class PayStation { const entry = this.#openEntry(identity); if (!entry) throw new NoOpenSessionError(identity); - // If the latest payment's walk-back grace has expired, this is an overstay: anchor - // the new billing period at grace-expiry (paidAt + graceExitMin). Otherwise price - // from entry (first payment, or a still-within-grace re-quote of the same stay). - const last = this.#lastPayment(identity); - const graceExpiryMs = - last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null; - const overstay = graceExpiryMs != null && Date.now() > graceExpiryMs; - const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : entry.occurredAt; - // The tariff in force is keyed to ENTRY (the version frozen for this session), even // for an overstay period — the customer keeps the rate card they entered under. const tv = this.#tariffVersionFor(entry.occurredAt); @@ -147,13 +138,24 @@ export class PayStation { // 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(periodStart, new Date().toISOString(), structure, category); + + // Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment + // matters for grace/overstay; pass it through. Overstay → fresh period from + // grace-expiry; within-grace → settled; unpaid → entry→now running total. + const last = this.#lastPayment(identity); + const p = priceSession( + entry.occurredAt, + new Date().toISOString(), + structure, + last ? [last] : [], + category, + ); return { identity, enteredAt: entry.occurredAt, - periodStart, - amountMinor, - overstay, + periodStart: p.periodStart, + amountMinor: p.amountMinor, + overstay: p.overstay, currency: tv.currency, tariffVersionId: tv.id, graceExitMin: structure.gracePeriodExitMin, diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index 5749fc0..383eb99 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -1,7 +1,14 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; -import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared"; +import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; +import { + computeFee, + isTariffV2, + priceSession, + validateTariffStructure, + type SessionPayment, + type TariffStructure, +} from "@parking/shared"; import { requirePermission } from "../auth.js"; /** Default site timezone for wall-clock tariff windows when none is configured. */ @@ -22,6 +29,19 @@ interface PublishBody { const SITE_TARIFF_NAME = "Site tariff"; +/** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write. + * Provide a structure source (one of): `tariffVersionId`, inline `structure`, or + * neither (uses the active version). */ +interface SimulateBody { + enteredAt: string; // ISO-8601 + asOf: string; // ISO-8601 (the "now"/exit instant being simulated) + payments?: SessionPayment[]; // hypothetical payment history (latest grants grace) + category?: string; + tariffVersionId?: string; + structure?: TariffStructure; + currency?: string; +} + export async function tariffRoutes(app: FastifyInstance, db: Db): Promise { // Reading the rate card (pay station / operator UI needs it). const readGuard = requirePermission("tariff:read"); @@ -114,4 +134,110 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise return reply.code(201).send(row); }, ); + + // --- Tariff Lab (simulator) ------------------------------------------------- + // Price a HYPOTHETICAL session at arbitrary times against any tariff version — + // pure, no ledger writes. Lets an admin test rates "in time" (overnight windows, + // daily caps, overstay) in seconds instead of waiting hours. Also used to quote a + // customer dispute on-site. tariff:read (admins always have it). See tariff.md. + app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => { + const b = req.body ?? ({} as SimulateBody); + if (!b.enteredAt || !b.asOf) { + return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" }); + } + if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) { + return reply.code(400).send({ error: "asOf must be at or after enteredAt" }); + } + + // Resolve the structure: an explicit version id, or the active version, or an + // inline structure (preview unpublished edits). A version carries its currency. + let structure: TariffStructure | undefined = b.structure; + let currency = b.currency ?? null; + if (b.tariffVersionId) { + const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get(); + if (!v) return reply.code(404).send({ error: "tariff version not found" }); + structure = v.structure as unknown as TariffStructure; + currency = v.currency; + } else if (!structure) { + const tariffId = ensureSiteTariff(); + const nowIso = new Date().toISOString(); + const active = + db + .select() + .from(tariffVersions) + .where(eq(tariffVersions.tariffId, tariffId)) + .orderBy(desc(tariffVersions.effectiveFrom)) + .all() + .find((v) => v.effectiveFrom <= nowIso) ?? null; + if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" }); + structure = active.structure as unknown as TariffStructure; + currency = active.currency; + } + + const problems = validateTariffStructure(structure); + if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems }); + + const payments = Array.isArray(b.payments) ? b.payments : []; + const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category); + + // A duration curve from entry: handy to SEE where the cap flattens / windows shift. + const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320]; + const enteredMs = Date.parse(b.enteredAt); + const curve = SAMPLES_MIN.map((min) => ({ + minutes: min, + amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category), + })); + + return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin }; + }); + + // Prefill the lab from a REAL session: fold its ledger into entry + payments so the + // admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`. + app.get<{ Params: { identity: string } }>( + "/api/tariff/simulate/session/:identity", + { preHandler: readGuard }, + async (req, reply) => { + const id = (req.params.identity ?? "").trim(); + if (!id) return reply.code(400).send({ error: "identity required" }); + const rows = db + .select() + .from(ledgerEvents) + .where(eq(ledgerEvents.identity, id)) + .orderBy(ledgerEvents.index) + .all(); + const entry = rows.find((r) => r.type === "vehicle_entry"); + if (!entry) return reply.code(404).send({ error: "no session for identity" }); + const payments: { paidAt: string; graceExitMin: number | null }[] = []; + for (const r of rows) { + if (r.type !== "payment") continue; + const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin; + payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null }); + } + const exit = rows.find((r) => r.type === "vehicle_exit"); + const category = (entry.payload as { category?: string } | null)?.category ?? null; + return { + identity: id, + enteredAt: entry.occurredAt, + exitedAt: exit?.occurredAt ?? null, + payments, + category, + // The version frozen at entry — the rate card this session actually keeps. + tariffVersionId: tariffVersionIdFor(entry.occurredAt), + }; + }, + ); + + /** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */ + function tariffVersionIdFor(whenIso: string): string | null { + const tariffId = ensureSiteTariff(); + const v = + db + .select() + .from(tariffVersions) + .where(eq(tariffVersions.tariffId, tariffId)) + .orderBy(desc(tariffVersions.effectiveFrom)) + .all() + .find((row) => row.effectiveFrom <= whenIso) ?? null; + return v?.id ?? null; + } } diff --git a/apps/web/src/TariffLab.tsx b/apps/web/src/TariffLab.tsx new file mode 100644 index 0000000..46807d7 --- /dev/null +++ b/apps/web/src/TariffLab.tsx @@ -0,0 +1,254 @@ +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { + fetchTariff, + loadSimSession, + simulateTariff, + type SimulateResult, + type SimPayment, + type TariffState, +} from "./api.js"; +import { formatMoney, formatDuration } from "./lib/format.js"; + +// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight +// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY +// published tariff version, with no real ledger writes. Build a hypothetical session +// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any +// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and +// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md. + +/** wants "YYYY-MM-DDTHH:mm" in LOCAL time. */ +function toLocalInput(iso: string): string { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return ""; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`; +} +/** A local datetime-local value → ISO-8601 (treats the value as local wall-clock). */ +function fromLocalInput(v: string): string { + const d = new Date(v); + return Number.isNaN(d.getTime()) ? "" : d.toISOString(); +} +function nowLocal(): string { + return toLocalInput(new Date().toISOString()); +} + +export function TariffLab() { + const { t } = useTranslation(); + const [state, setState] = useState(null); + const [err, setErr] = useState(null); + + // Inputs (datetime-local strings, local wall-clock). + const [entered, setEntered] = useState(() => { + const d = new Date(); + d.setHours(d.getHours() - 3); // default: a 3h-ago entry + return toLocalInput(d.toISOString()); + }); + const [asOf, setAsOf] = useState(nowLocal); + const [category, setCategory] = useState(""); + const [versionId, setVersionId] = useState(""); // "" = active + // Optional single hypothetical payment (the latest grants the walk-back grace). + const [paid, setPaid] = useState(false); + const [paidAt, setPaidAt] = useState(nowLocal); + const [graceMin, setGraceMin] = useState("5"); + // Load-a-real-ticket. + const [ticket, setTicket] = useState(""); + const [loadMsg, setLoadMsg] = useState(null); + + const [result, setResult] = useState(null); + const [busy, setBusy] = useState(false); + + useEffect(() => { + fetchTariff() + .then(setState) + .catch((e) => setErr((e as Error).message)); + }, []); + + async function run() { + setErr(null); + setBusy(true); + try { + const payments: SimPayment[] = paid + ? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }] + : []; + const r = await simulateTariff({ + enteredAt: fromLocalInput(entered), + asOf: fromLocalInput(asOf), + payments, + category: category.trim() || undefined, + tariffVersionId: versionId || undefined, + }); + setResult(r); + } catch (e) { + setErr((e as Error).message); + setResult(null); + } finally { + setBusy(false); + } + } + + async function loadTicket() { + setLoadMsg(null); + setErr(null); + try { + const s = await loadSimSession(ticket.trim()); + setEntered(toLocalInput(s.enteredAt)); + setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal()); + setCategory(s.category ?? ""); + setVersionId(s.tariffVersionId ?? ""); + const last = s.payments.at(-1); + if (last) { + setPaid(true); + setPaidAt(toLocalInput(last.paidAt)); + setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : ""); + } else { + setPaid(false); + } + setLoadMsg(t("lab.loaded", { id: s.identity })); + } catch (e) { + setErr((e as Error).message); + } + } + + const currency = result?.currency ?? state?.active?.currency ?? "ALL"; + + return ( +
+

{t("lab.title")}

+

{t("lab.intro")}

+ + {/* Load a real ticket */} +
+
+ + setTicket(e.target.value)} + placeholder={t("lab.loadTicketPh")} + /> +
+ + {loadMsg && {loadMsg}} +
+ + {/* Hypothetical session inputs */} +
+ + + + + setEntered(e.target.value)} /> + + + + setAsOf(e.target.value)} /> + + + + + setCategory(e.target.value)} + placeholder={t("lab.categoryPh")} + /> + + + + + {paid && ( + <> + setPaidAt(e.target.value)} + /> + {t("lab.graceMin")} + setGraceMin(e.target.value)} /> + + )} + +
+ +
+ + {err && {err}} +
+ + {result && ( +
+ {/* Outcome */} +
+

{t("lab.outcome")}

+
+
{t("lab.amountDue")}
+
{formatMoney(result.pricing.amountMinor, currency)}
+
{t("lab.billedPeriod")}
+
+ {formatDuration(result.pricing.periodStart, fromLocalInput(asOf))} + {result.pricing.overstay && ( + + {t("lab.overstay")} + + )} + {result.pricing.withinGrace && ( + + {t("lab.settled")} + + )} +
+
{t("lab.periodStart")}
+
{new Date(result.pricing.periodStart).toLocaleString()}
+ {result.pricing.graceExpiresAt && ( + <> +
{t("lab.graceExpires")}
+
{new Date(result.pricing.graceExpiresAt).toLocaleString()}
+ + )} +
+
+ + {/* Duration curve from entry — see where the cap flattens / windows shift. */} +
+

{t("lab.curve")}

+

{t("lab.curveHint")}

+ + + {result.curve.map((c) => ( + + + + + ))} + +
{labelMin(c.minutes)}{formatMoney(c.amountMinor, currency)}
+
+
+ )} +
+ ); +} + +function labelMin(min: number): string { + if (min < 60) return `${min}m`; + if (min < 1440) return `${min / 60}h`; + return `${min / 1440}d`; +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 35021ae..51753b5 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -425,6 +425,54 @@ export function publishTariffVersion(body: { return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); } +// --- Tariff Lab (simulator) ----------------------------------------------- + +export interface SimPayment { + paidAt: string; + graceExitMin: number | null; +} +export interface SimSessionPricing { + periodStart: string; + amountMinor: number; + overstay: boolean; + withinGrace: boolean; + graceExpiresAt: string | null; +} +export interface SimulateResult { + currency: string | null; + pricing: SimSessionPricing; + curve: { minutes: number; amountMinor: number }[]; + gracePeriodExitMin: number; +} +export interface SimulateBody { + enteredAt: string; + asOf: string; + payments?: SimPayment[]; + category?: string; + tariffVersionId?: string; + structure?: TariffStructure; + currency?: string; +} + +/** Price a hypothetical session — pure, no ledger write. See Tariff Lab. */ +export function simulateTariff(body: SimulateBody): Promise { + return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) }); +} + +export interface SimSessionLoad { + identity: string; + enteredAt: string; + exitedAt: string | null; + payments: SimPayment[]; + category: string | null; + tariffVersionId: string | null; +} + +/** Prefill the lab from a real ledger session. */ +export function loadSimSession(identity: string): Promise { + return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`); +} + // --- Subscriptions -------------------------------------------------------- export interface SubscriptionCredential { diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 3c8bfce..d9adc39 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -44,6 +44,7 @@ export const en: Catalog = { setup: "Setup", devices: "Devices", tariff: "Tariff", + tariffLab: "Tariff Lab", subscriptions: "Subscriptions", site: "Site", users: "Users", @@ -330,6 +331,36 @@ export const en: Catalog = { relayLabel: "Relay {{relay}} ({{direction}})", noRelaysConfigured: "This controller has no relays configured.", }, + lab: { + title: "Tariff Lab", + intro: + "Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.", + loadTicket: "Load from a real ticket", + loadTicketPh: "Ticket number / identity", + load: "Load", + loaded: "Loaded session {{id}}", + tariffVersion: "Tariff version", + activeVersion: "Active version (current)", + entered: "Entered", + asOf: "As of (now/exit)", + now: "Now", + category: "Category", + categoryPh: "e.g. bus (blank = car)", + payment: "Payment", + paid: "paid", + graceMin: "grace (min)", + price: "Compute price", + pricing: "Pricing…", + outcome: "Outcome", + amountDue: "Amount due", + billedPeriod: "Billed period", + overstay: "overstay", + settled: "settled", + periodStart: "Period start", + graceExpires: "Grace expires", + curve: "Duration curve", + curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.", + }, subs: { title: "Subscriptions", unnamed: "(unnamed)", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 3913f15..dc83f8c 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -46,6 +46,7 @@ export const sq = { setup: "Konfigurimi", devices: "Pajisjet", tariff: "Tarifa", + tariffLab: "Lab Tarife", subscriptions: "Abonimet", site: "Park", users: "Përdoruesit", @@ -341,6 +342,36 @@ export const sq = { relayLabel: "Rele {{relay}} ({{direction}})", noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.", }, + lab: { + title: "Lab Tarife", + intro: + "Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.", + loadTicket: "Ngarko nga një biletë reale", + loadTicketPh: "Numri i biletës / identiteti", + load: "Ngarko", + loaded: "U ngarkua sesioni {{id}}", + tariffVersion: "Versioni i tarifës", + activeVersion: "Versioni aktiv (i tanishëm)", + entered: "Hyrja", + asOf: "Deri më (tani/dalja)", + now: "Tani", + category: "Kategoria", + categoryPh: "p.sh. bus (bosh = makinë)", + payment: "Pagesa", + paid: "u pagua", + graceMin: "afati (min)", + price: "Llogarit çmimin", + pricing: "Duke llogaritur…", + outcome: "Rezultati", + amountDue: "Shuma për pagesë", + billedPeriod: "Periudha e faturuar", + overstay: "tej afatit", + settled: "i shlyer", + periodStart: "Fillimi i periudhës", + graceExpires: "Afati skadon", + curve: "Kurba sipas kohëzgjatjes", + curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.", + }, subs: { title: "Abonimet", unnamed: "(pa emër)", diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 7a3c7cb..9ebada0 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -21,6 +21,7 @@ import { StatusDot } from "./ui/StatusDot.js"; import { BoothScreen } from "./BoothScreen.js"; import { SetupWizard } from "./SetupWizard.js"; import { TariffComposer } from "./TariffComposer.js"; +import { TariffLab } from "./TariffLab.js"; import { SubscriptionManager } from "./SubscriptionManager.js"; import { ShiftControl } from "./ShiftControl.js"; import { SiteSettings } from "./SiteSettings.js"; @@ -80,6 +81,7 @@ function SetupLayout() {