feat(tariff): Tariff Lab — pure session-pricing simulator

Test rates "in time" (overnight windows, daily caps, overstay) in seconds against
any tariff version, instead of waiting hours/days. No real ledger writes.

- Extract priceSession() into @parking/shared: the grace/overstay wrapper over
  computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired ->
  overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so
  the booth and the lab can never diverge.
- API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a
  hypothetical session (active/any version/inline structure) and returns the
  priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens);
  GET /api/tariff/simulate/session/:identity prefills from a real ledger session.
- UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times,
  optional payment+grace, category, and load-a-real-ticket. Admin-gated, available
  on-site (useful to quote a dispute).
- 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero
  regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI.

Wiki: tariff (priceSession + Tariff Lab as-built), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 12:05:30 +02:00
parent a4712774ab
commit 3d02134711
11 changed files with 669 additions and 17 deletions
+16 -14
View File
@@ -1,5 +1,5 @@
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db"; 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 { FastifyBaseLogger } from "fastify";
import type { EventLog } from "./event-log.js"; import type { EventLog } from "./event-log.js";
@@ -128,15 +128,6 @@ export class PayStation {
const entry = this.#openEntry(identity); const entry = this.#openEntry(identity);
if (!entry) throw new NoOpenSessionError(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 // 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. // for an overstay period — the customer keeps the rate card they entered under.
const tv = this.#tariffVersionFor(entry.occurredAt); 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 // 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. // booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
const category = (entry.payload as { category?: string } | null)?.category; 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 { return {
identity, identity,
enteredAt: entry.occurredAt, enteredAt: entry.occurredAt,
periodStart, periodStart: p.periodStart,
amountMinor, amountMinor: p.amountMinor,
overstay, overstay: p.overstay,
currency: tv.currency, currency: tv.currency,
tariffVersionId: tv.id, tariffVersionId: tv.id,
graceExitMin: structure.gracePeriodExitMin, graceExitMin: structure.gracePeriodExitMin,
+128 -2
View File
@@ -1,7 +1,14 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; import { desc, eq, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared"; import {
computeFee,
isTariffV2,
priceSession,
validateTariffStructure,
type SessionPayment,
type TariffStructure,
} from "@parking/shared";
import { requirePermission } from "../auth.js"; import { requirePermission } from "../auth.js";
/** Default site timezone for wall-clock tariff windows when none is configured. */ /** Default site timezone for wall-clock tariff windows when none is configured. */
@@ -22,6 +29,19 @@ interface PublishBody {
const SITE_TARIFF_NAME = "Site tariff"; 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<void> { export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Reading the rate card (pay station / operator UI needs it). // Reading the rate card (pay station / operator UI needs it).
const readGuard = requirePermission("tariff:read"); const readGuard = requirePermission("tariff:read");
@@ -114,4 +134,110 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
return reply.code(201).send(row); 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;
}
} }
+254
View File
@@ -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.
/** <input type="datetime-local"> 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<TariffState | null>(null);
const [err, setErr] = useState<string | null>(null);
// Inputs (datetime-local strings, local wall-clock).
const [entered, setEntered] = useState<string>(() => {
const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString());
});
const [asOf, setAsOf] = useState<string>(nowLocal);
const [category, setCategory] = useState("");
const [versionId, setVersionId] = useState<string>(""); // "" = active
// Optional single hypothetical payment (the latest grants the walk-back grace).
const [paid, setPaid] = useState(false);
const [paidAt, setPaidAt] = useState<string>(nowLocal);
const [graceMin, setGraceMin] = useState<string>("5");
// Load-a-real-ticket.
const [ticket, setTicket] = useState("");
const [loadMsg, setLoadMsg] = useState<string | null>(null);
const [result, setResult] = useState<SimulateResult | null>(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 (
<section className="mx-auto max-w-3xl px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
<p className="hint mb-4">{t("lab.intro")}</p>
{/* Load a real ticket */}
<div className="card card-body mb-4 flex flex-wrap items-end gap-2">
<div className="flex flex-col gap-1">
<label className="label">{t("lab.loadTicket")}</label>
<input
className="input w-56"
value={ticket}
onChange={(e) => setTicket(e.target.value)}
placeholder={t("lab.loadTicketPh")}
/>
</div>
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}>
{t("lab.load")}
</button>
{loadMsg && <span className="text-[12px] text-term-green">{loadMsg}</span>}
</div>
{/* Hypothetical session inputs */}
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("lab.tariffVersion")}</label>
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
<option value="">{t("lab.activeVersion")}</option>
{state?.versions.map((v) => (
<option key={v.id} value={v.id}>
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
</option>
))}
</select>
<label className="label">{t("lab.entered")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<label className="label">{t("lab.asOf")}</label>
<span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}>
{t("lab.now")}
</button>
</span>
<label className="label">{t("lab.category")}</label>
<input
className="input w-40"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder={t("lab.categoryPh")}
/>
<label className="label">{t("lab.payment")}</label>
<span className="flex flex-wrap items-center gap-2">
<label className="inline-flex items-center gap-1 text-[12px] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} />
{t("lab.paid")}
</label>
{paid && (
<>
<input
type="datetime-local"
className="input w-64"
value={paidAt}
onChange={(e) => setPaidAt(e.target.value)}
/>
<span className="text-term-muted">{t("lab.graceMin")}</span>
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} />
</>
)}
</span>
</div>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[12px] text-term-red">{err}</span>}
</div>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[13px]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[10px] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[10px] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[12px] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</section>
);
}
function labelMin(min: number): string {
if (min < 60) return `${min}m`;
if (min < 1440) return `${min / 60}h`;
return `${min / 1440}d`;
}
+48
View File
@@ -425,6 +425,54 @@ export function publishTariffVersion(body: {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(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<SimulateResult> {
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<SimSessionLoad> {
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
}
// --- Subscriptions -------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
export interface SubscriptionCredential { export interface SubscriptionCredential {
+31
View File
@@ -44,6 +44,7 @@ export const en: Catalog = {
setup: "Setup", setup: "Setup",
devices: "Devices", devices: "Devices",
tariff: "Tariff", tariff: "Tariff",
tariffLab: "Tariff Lab",
subscriptions: "Subscriptions", subscriptions: "Subscriptions",
site: "Site", site: "Site",
users: "Users", users: "Users",
@@ -330,6 +331,36 @@ export const en: Catalog = {
relayLabel: "Relay {{relay}} ({{direction}})", relayLabel: "Relay {{relay}} ({{direction}})",
noRelaysConfigured: "This controller has no relays configured.", 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: { subs: {
title: "Subscriptions", title: "Subscriptions",
unnamed: "(unnamed)", unnamed: "(unnamed)",
+31
View File
@@ -46,6 +46,7 @@ export const sq = {
setup: "Konfigurimi", setup: "Konfigurimi",
devices: "Pajisjet", devices: "Pajisjet",
tariff: "Tarifa", tariff: "Tarifa",
tariffLab: "Lab Tarife",
subscriptions: "Abonimet", subscriptions: "Abonimet",
site: "Park", site: "Park",
users: "Përdoruesit", users: "Përdoruesit",
@@ -341,6 +342,36 @@ export const sq = {
relayLabel: "Rele {{relay}} ({{direction}})", relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.", 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: { subs: {
title: "Abonimet", title: "Abonimet",
unnamed: "(pa emër)", unnamed: "(pa emër)",
+9
View File
@@ -21,6 +21,7 @@ import { StatusDot } from "./ui/StatusDot.js";
import { BoothScreen } from "./BoothScreen.js"; import { BoothScreen } from "./BoothScreen.js";
import { SetupWizard } from "./SetupWizard.js"; import { SetupWizard } from "./SetupWizard.js";
import { TariffComposer } from "./TariffComposer.js"; import { TariffComposer } from "./TariffComposer.js";
import { TariffLab } from "./TariffLab.js";
import { SubscriptionManager } from "./SubscriptionManager.js"; import { SubscriptionManager } from "./SubscriptionManager.js";
import { ShiftControl } from "./ShiftControl.js"; import { ShiftControl } from "./ShiftControl.js";
import { SiteSettings } from "./SiteSettings.js"; import { SiteSettings } from "./SiteSettings.js";
@@ -80,6 +81,7 @@ function SetupLayout() {
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border"> <nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />} {show("site:update") && <SetupTab to="/setup" label={t("nav.devices")} exact />}
{show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />} {show("tariff:read") && <SetupTab to="/setup/tariff" label={t("nav.tariff")} />}
{show("tariff:read") && <SetupTab to="/setup/tariff-lab" label={t("nav.tariffLab")} />}
{show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />} {show("subscription:read") && <SetupTab to="/setup/subscriptions" label={t("nav.subscriptions")} />}
{show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />} {show("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />} {show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
@@ -395,6 +397,12 @@ const tariffRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("tariff:read")(context), beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffComposer />, component: () => <TariffComposer />,
}); });
const tariffLabRoute = createRoute({
getParentRoute: () => setupRoute,
path: "tariff-lab",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
const subscriptionsRoute = createRoute({ const subscriptionsRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
path: "subscriptions", path: "subscriptions",
@@ -456,6 +464,7 @@ const routeTree = rootRoute.addChildren([
setupRoute.addChildren([ setupRoute.addChildren([
setupDevicesRoute, setupDevicesRoute,
tariffRoute, tariffRoute,
tariffLabRoute,
subscriptionsRoute, subscriptionsRoute,
siteRoute, siteRoute,
usersRoute, usersRoute,
+64
View File
@@ -442,6 +442,70 @@ export function computeFee(
: computeFeeV1(enteredAt, asOf, tariff); : computeFeeV1(enteredAt, asOf, tariff);
} }
/** A signed payment as far as session pricing cares: when it happened and the
* walk-back grace it granted. (The booth folds these from the ledger; the lab
* supplies a hypothetical one.) */
export interface SessionPayment {
readonly paidAt: string; // ISO-8601
readonly graceExitMin: number | null;
}
/** The full pricing outcome for a session at a moment in time — what the booth's
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
export interface SessionPricing {
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
readonly periodStart: string;
/** Fee for [periodStart, asOf]. */
readonly amountMinor: number;
/** True when the latest payment's grace has lapsed (overstay = new period). */
readonly overstay: boolean;
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (lastPaid + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
}
/**
* Price a session PURELY from its times + tariff structure — the single source of
* truth shared by the live booth (`PayStation.quote`) and the Tariff Lab simulator,
* so the two can never diverge.
*
* - Not yet paid → bill entry→asOf (the running total).
* - Paid, still within walk-back grace → settled (amount 0; the car may exit).
* - Paid, grace lapsed → OVERSTAY: bill a fresh period from grace-expiry→asOf with its
* own daily-cap ladder (NOT "full stay minus paid", which a daily cap collapses to 0).
*
* `payments` is the session's payment history (only the LATEST matters for grace);
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
*/
export function priceSession(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
payments: readonly SessionPayment[] = [],
category?: string,
): SessionPricing {
const last = payments.length ? payments[payments.length - 1] : null;
const graceExpiryMs =
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
const asOfMs = Date.parse(asOf);
const overstay = graceExpiryMs != null && asOfMs > graceExpiryMs;
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
return {
periodStart,
amountMinor,
overstay,
withinGrace,
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
};
}
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept /** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
* VERBATIM so bare/legacy structures (incl. the live production version) price * VERBATIM so bare/legacy structures (incl. the live production version) price
* identically. Do not "unify" this into the V2 path: a rounding divergence would * identically. Do not "unify" this into the V2 path: a rounding divergence would
+44
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { import {
computeFee, computeFee,
priceSession,
validateTariffStructure, validateTariffStructure,
type TariffStructureV1, type TariffStructureV1,
type TariffStructureV2, type TariffStructureV2,
@@ -255,3 +256,46 @@ describe("validate V2", () => {
expect(errs).toEqual([]); expect(errs).toEqual([]);
}); });
}); });
// ---------------------------------------------------------------------------
// (h) priceSession — the grace/overstay wrapper shared by the booth + Tariff Lab.
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
// daily cap 100000, exit grace 5 min.
// ---------------------------------------------------------------------------
describe("priceSession grace + overstay", () => {
const paidAt = (min: number) => at(min);
it("unpaid → bills entry→asOf (running total)", () => {
const r = priceSession(entered, at(120), liveV1, []);
expect(r.overstay).toBe(false);
expect(r.withinGrace).toBe(false);
expect(r.periodStart).toBe(entered);
expect(r.amountMinor).toBe(30000); // 2h: 20000 + 10000
});
it("paid and still within walk-back grace → settled (owes 0)", () => {
// Paid at 120 min with a 5-min grace; asOf 123 min is inside the window.
const r = priceSession(entered, at(123), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.withinGrace).toBe(true);
expect(r.overstay).toBe(false);
expect(r.amountMinor).toBe(0);
});
it("paid but grace expired → overstay priced as a NEW period from grace-expiry", () => {
// Paid at 120 min, grace 5 → expires at 125 min. asOf 245 min ⇒ a 2h new period.
const r = priceSession(entered, at(245), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.withinGrace).toBe(false);
expect(r.periodStart).toBe(at(125));
// The new period is its own ladder from 0: 2h ⇒ 20000 + 10000 = 30000.
expect(r.amountMinor).toBe(30000);
});
it("overstay does NOT collapse to 0 under a daily cap (regression: ticket 1245791632490)", () => {
// A ~2-day overstay: with 'full stay minus paid' the cap made this 0. The
// new-period model re-accrues — strictly positive.
const r = priceSession(entered, at(120 + 5 + 2880), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.amountMinor).toBe(200000); // 2 capped days
});
});
+25 -1
View File
@@ -101,7 +101,12 @@ because the chain + reconciliation depend on the result being reproducible.
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail. cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested **As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
across grace, block steps, daily cap, and multi-day reset. across grace, block steps, daily cap, and multi-day reset. A higher-level **`priceSession(enteredAt,
asOf, structure, payments[], category?)`** (also pure, shared) wraps `computeFee` with the
grace/overstay logic — unpaid → entry→now; paid+within-grace → settled (0); paid+grace-expired →
**overstay**, a fresh period from grace-expiry→now (see [[booth-exit-flow]]). The booth's
`PayStation.quote()` and the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] both call it, so
live pricing and the simulator can never diverge.
### Composer (as-built 2026-06-15) ### Composer (as-built 2026-06-15)
@@ -124,6 +129,25 @@ The admin authors the rate card at runtime — no hand-seeding:
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the - Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices). pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
### Tariff Lab (simulator, as-built 2026-06-20)
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
price a session at **any** instant against **any** tariff version in seconds.
- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available
on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical
session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and
returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you
SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity`
prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are
**read-only — no ledger writes.**
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any
historical), set entry / "as of" times, an optional payment (with its grace), and a category; or
"Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/
settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real
overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay).
## The pay-on-foot consequence ## The pay-on-foot consequence
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
+19
View File
@@ -1003,3 +1003,22 @@ pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp + !alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]] booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
(overstay section + naming history + partial-resolution note on the grace-renewal open question). (overstay section + naming history + partial-resolution note on the grace-renewal open question).
## [2026-06-20] feat | Tariff Lab — pure session-pricing simulator (test rates in time)
The tariff engine is pure but could only be EXERCISED by waiting (booth reads real
wall-clock). Added a simulator. Extracted priceSession(enteredAt, asOf, structure,
payments[], category?) into @parking/shared — the grace/overstay wrapper over
computeFee (unpaid→entry→now; within-grace→settled 0; grace-expired→overstay new
period from grace-expiry). PayStation.quote() now calls it, so booth + lab can't
diverge. New routes (tariffs.ts, tariff:read, no ledger writes): POST
/api/tariff/simulate (price a hypothetical session vs active/any version/inline
structure; returns priceSession outcome + a 30m..3d duration curve) and GET
/api/tariff/simulate/session/:identity (prefill from a real ledger session). UI
apps/web/src/TariffLab.tsx at Setup→"Tariff Lab": version picker, entry/asOf, optional
payment+grace, category, load-a-ticket; shows amount due, billed period, overstay/
settled, curve. i18n lab.* + nav.tariffLab (sq+en). 4 new priceSession unit tests
incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Verified
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
Updated [[tariff]] + [[booth-exit-flow]].