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:
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
// 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<void>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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<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 --------------------------------------------------------
|
||||
|
||||
export interface SubscriptionCredential {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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() {
|
||||
<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("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("site:read") && <SetupTab to="/setup/site" label={t("nav.site")} />}
|
||||
{show("user:read") && <SetupTab to="/setup/users" label={t("nav.users")} />}
|
||||
@@ -395,6 +397,12 @@ const tariffRoute = createRoute({
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffComposer />,
|
||||
});
|
||||
const tariffLabRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "tariff-lab",
|
||||
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
|
||||
component: () => <TariffLab />,
|
||||
});
|
||||
const subscriptionsRoute = createRoute({
|
||||
getParentRoute: () => setupRoute,
|
||||
path: "subscriptions",
|
||||
@@ -456,6 +464,7 @@ const routeTree = rootRoute.addChildren([
|
||||
setupRoute.addChildren([
|
||||
setupDevicesRoute,
|
||||
tariffRoute,
|
||||
tariffLabRoute,
|
||||
subscriptionsRoute,
|
||||
siteRoute,
|
||||
usersRoute,
|
||||
|
||||
Reference in New Issue
Block a user