Files
parking_solution/apps/server/src/routes/tariffs.ts
T
julian 3d02134711 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
2026-06-20 12:05:30 +02:00

244 lines
11 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
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. */
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
// the `payment` event records the tariffVersionId. "One active tariff per site" for
// now (a single `tariffs` row, lazily created). See wiki/concepts/tariff.md.
interface PublishBody {
currency: string;
structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string;
}
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");
// Publishing a new version changes what customers are charged.
const writeGuard = requirePermission("tariff:update");
// The single site tariff row, created on first read/publish.
function ensureSiteTariff(): string {
const existing = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (existing) return existing.id;
const id = randomUUID();
db.insert(tariffs).values({ id, scope: "site", name: SITE_TARIFF_NAME }).run();
return id;
}
// Current state: the active (latest-effective, ≤ now) version + the full history.
app.get("/api/tariff", { preHandler: readGuard }, async () => {
const tariffId = ensureSiteTariff();
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariffId))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
const now = new Date().toISOString();
const active = versions.find((v) => v.effectiveFrom <= now) ?? null;
return { tariffId, active, versions };
});
// Publish a new immutable version. Validates the structure first — a malformed
// rate card can never be published (the fee calc + the chain depend on it).
app.post<{ Body: PublishBody }>(
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" });
}
// 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 });
}
// effectiveFrom must NOT be in the past. A version is selected by
// "latest effectiveFrom <= entry time", so a backdated effectiveFrom would
// retroactively reprice already-entered sessions — exactly the immutability
// the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid
// backdating: a new version applies only from publish (now) forward; a future
// effectiveFrom (scheduling a price change) is allowed. A small skew tolerance
// absorbs client/server clock drift + request round-trip. Once a car has
// entered, no later publish can reprice it (no effectiveFrom can predate it).
const now = Date.now();
const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack
let effective = new Date().toISOString();
if (effectiveFrom != null) {
const t = Date.parse(effectiveFrom);
if (Number.isNaN(t)) {
return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" });
}
if (t < now - SKEW_MS) {
return reply.code(400).send({
error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions",
});
}
effective = new Date(t).toISOString();
}
const tariffId = ensureSiteTariff();
const id = randomUUID();
const row = {
id,
tariffId,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
};
db.insert(tariffVersions).values(row).run();
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;
}
}