tariff composer: admin publishes rate-card versions (pay station now operable)

validateTariffStructure (shared): non-negative ints, ascending block bounds,
only the last block open-ended — a malformed card can't be published.

Routes: GET /api/tariff (active + history, any signed-in role), POST
/api/tariff/versions (publish an immutable, effective-dated version; admin
only). The single site tariff row is created lazily. Editing = publish a new
version; past sessions keep their pricing.

Web: TariffComposer in the admin shell — edit currency, grace windows,
increment, daily cap, lost-ticket fee, and add/remove rate blocks (major-unit
input -> minor on submit); shows active version + history.

Verified via inject: empty -> active null; invalid blocks -> 400 with problem;
valid -> 201; readonly publish -> 403; after publishing, the pay station quote
returns 404 (no session) instead of 409 (no tariff) -- it now prices against the
active card.
This commit is contained in:
2026-06-15 19:35:33 +02:00
parent f18e28eeca
commit b4d0dfadd6
8 changed files with 412 additions and 1 deletions
+79
View File
@@ -0,0 +1,79 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { desc, eq, tariffVersions, tariffs, type Db } from "@parking/db";
import { validateTariffStructure, type TariffStructure } from "@parking/shared";
import { requireRole } from "../auth.js";
// 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";
export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void> {
// Any signed-in role may READ the tariff (the pay station / operator UI needs it).
const readGuard = requireRole("admin", "operator", "cashier", "readonly");
// Only an admin may PUBLISH a new version (it changes what customers are charged).
const writeGuard = requireRole("admin");
// 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" });
}
const problems = validateTariffStructure(structure);
if (problems.length) {
return reply.code(400).send({ error: "invalid tariff structure", problems });
}
const tariffId = ensureSiteTariff();
const id = randomUUID();
const row = {
id,
tariffId,
effectiveFrom: effectiveFrom ?? new Date().toISOString(),
currency,
structure: structure as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
};
db.insert(tariffVersions).values(row).run();
return reply.code(201).send(row);
},
);
}