From b4d0dfadd6abfc9f69541eb093ac77f014a254e3 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Mon, 15 Jun 2026 19:35:33 +0200 Subject: [PATCH] tariff composer: admin publishes rate-card versions (pay station now operable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- apps/server/src/routes/tariffs.ts | 79 ++++++++++++ apps/server/src/server.ts | 5 + apps/web/src/App.tsx | 6 +- apps/web/src/TariffComposer.tsx | 207 ++++++++++++++++++++++++++++++ apps/web/src/api.ts | 43 +++++++ packages/shared/src/index.ts | 43 +++++++ wiki/concepts/tariff.md | 16 +++ wiki/log.md | 14 ++ 8 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/routes/tariffs.ts create mode 100644 apps/web/src/TariffComposer.tsx diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts new file mode 100644 index 0000000..8b7b433 --- /dev/null +++ b/apps/server/src/routes/tariffs.ts @@ -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 { + // 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, + createdBy: req.user?.username ?? null, + }; + db.insert(tariffVersions).values(row).run(); + return reply.code(201).send(row); + }, + ); +} diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index db374d6..752e125 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,6 +16,7 @@ import { authRoutes } from "./routes/auth.js"; import { deviceRoutes } from "./routes/devices.js"; import { eventRoutes } from "./routes/events.js"; import { payRoutes } from "./routes/pay.js"; +import { tariffRoutes } from "./routes/tariffs.js"; import { printerRoutes } from "./routes/printers.js"; import { setupRoutes } from "./routes/setup.js"; @@ -109,6 +110,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise { // Resolve which lane the device belongs to. -1 marks "device fired but isn't // mapped to a lane" (assigned without a lane, or a stale id) — still recorded diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 20d46d4..885da80 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { fetchMe, logout, type SessionUser } from "./api.js"; import { Login } from "./Login.js"; import { SetupWizard } from "./SetupWizard.js"; +import { TariffComposer } from "./TariffComposer.js"; // Operator UI shell. Plain React (no admin framework) — the operator UI is // simple enough that a framework's abstractions cost more than they save. @@ -39,7 +40,10 @@ export function App() { {user.role === "admin" ? ( - + <> + + + ) : (

Signed in. (Operator console coming soon.)

)} diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx new file mode 100644 index 0000000..4130ffd --- /dev/null +++ b/apps/web/src/TariffComposer.tsx @@ -0,0 +1,207 @@ +import { useEffect, useState } from "react"; +import { + ApiError, + fetchTariff, + publishTariffVersion, + type TariffBlock, + type TariffStructure, + type TariffState, +} from "./api.js"; + +// Tariff composer — the admin builds + edits the rate card at runtime. Publishing +// creates a new IMMUTABLE version (the active card); old versions are kept so past +// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for +// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md. + +// Editable form mirror of TariffStructure, but money in major-unit strings. +interface BlockForm { + uptoMin: string; // "" = open-ended (last block) + price: string; // major units, e.g. "2.00" +} +interface FormState { + currency: string; + gracePeriodEntryMin: string; + incrementMin: string; + dailyCap: string; // "" = no cap + lostTicket: string; + gracePeriodExitMin: string; + blocks: BlockForm[]; +} + +const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); +const toMajor = (minor: number): string => (minor / 100).toFixed(2); + +function emptyForm(): FormState { + return { + currency: "EUR", + gracePeriodEntryMin: "15", + incrementMin: "60", + dailyCap: "", + lostTicket: "20.00", + gracePeriodExitMin: "15", + blocks: [{ uptoMin: "60", price: "2.00" }, { uptoMin: "", price: "1.00" }], + }; +} + +function formFromActive(s: TariffState): FormState { + const v = s.active; + if (!v) return emptyForm(); + const st = v.structure; + return { + currency: v.currency, + gracePeriodEntryMin: String(st.gracePeriodEntryMin), + incrementMin: String(st.incrementMin), + dailyCap: st.dailyCapMinor == null ? "" : toMajor(st.dailyCapMinor), + lostTicket: toMajor(st.lostTicketMinor), + gracePeriodExitMin: String(st.gracePeriodExitMin), + blocks: st.blocks.map((b) => ({ + uptoMin: b.uptoMin == null ? "" : String(b.uptoMin), + price: toMajor(b.priceMinorPerIncrement), + })), + }; +} + +function toStructure(f: FormState): TariffStructure { + const blocks: TariffBlock[] = f.blocks.map((b) => ({ + uptoMin: b.uptoMin.trim() === "" ? null : Math.round(Number(b.uptoMin)), + priceMinorPerIncrement: toMinor(b.price), + })); + return { + gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), + incrementMin: Math.round(Number(f.incrementMin)), + blocks, + dailyCapMinor: f.dailyCap.trim() === "" ? null : toMinor(f.dailyCap), + lostTicketMinor: toMinor(f.lostTicket), + gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), + overstay: "reprice", + }; +} + +export function TariffComposer() { + const [state, setState] = useState(null); + const [form, setForm] = useState(emptyForm); + const [saving, setSaving] = useState(false); + const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); + + useEffect(() => { + fetchTariff() + .then((s) => { + setState(s); + setForm(formFromActive(s)); + }) + .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); + }, []); + + function set(key: K, value: FormState[K]) { + setForm((f) => ({ ...f, [key]: value })); + } + function setBlock(i: number, patch: Partial) { + setForm((f) => ({ ...f, blocks: f.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); + } + function addBlock() { + setForm((f) => ({ ...f, blocks: [...f.blocks, { uptoMin: "", price: "0.00" }] })); + } + function removeBlock(i: number) { + setForm((f) => ({ ...f, blocks: f.blocks.filter((_, j) => j !== i) })); + } + + async function publish() { + setSaving(true); + setMsg(null); + try { + await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); + const fresh = await fetchTariff(); + setState(fresh); + setMsg({ kind: "ok", text: "New tariff version published — it's now the active rate card." }); + } catch (e) { + const text = + e instanceof ApiError && (e as ApiError & { problems?: string[] }).problems + ? `${e.message}: ${((e as ApiError & { problems?: string[] }).problems ?? []).join("; ")}` + : (e as Error).message; + setMsg({ kind: "err", text }); + } finally { + setSaving(false); + } + } + + return ( +
+

Tariff

+ {!state?.active ? ( +

+ No rate card published yet — the pay station can't charge until you publish one. +

+ ) : ( +

+ Active since {new Date(state.active.effectiveFrom).toLocaleString()} ·{" "} + {state.versions.length} version(s) in history. Publishing creates a new version; past + sessions keep their original pricing. +

+ )} + +
+ + set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} /> + + set("gracePeriodEntryMin", e.target.value)} /> + + set("incrementMin", e.target.value)} /> + + set("dailyCap", e.target.value)} placeholder="e.g. 12.00" /> + + set("lostTicket", e.target.value)} /> + + set("gracePeriodExitMin", e.target.value)} /> +
+ +

Rate blocks

+

+ Consumed in order as time accrues. "Up to (min)" is the block's upper bound; leave the last + block's bound blank for "thereafter". Price is per billing increment. +

+ + + + + + + + + {form.blocks.map((b, i) => ( + + + + + + ))} + +
Up to (min)Price / increment +
+ setBlock(i, { uptoMin: e.target.value })} + placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"} + style={{ width: 110 }} + /> + + setBlock(i, { price: e.target.value })} style={{ width: 90 }} /> + + +
+ + +
+ +
+ {msg && ( +

{msg.text}

+ )} +
+ ); +} diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 068e9c1..c7f8cf8 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -197,3 +197,46 @@ export function fetchState(): Promise { export function unassignDevice(id: string): Promise { return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" }); } + +// --- Tariff composer ------------------------------------------------------ + +export interface TariffBlock { + uptoMin: number | null; + priceMinorPerIncrement: number; +} +export interface TariffStructure { + gracePeriodEntryMin: number; + incrementMin: number; + blocks: TariffBlock[]; + dailyCapMinor: number | null; + lostTicketMinor: number; + gracePeriodExitMin: number; + overstay: "reprice"; +} +export interface TariffVersion { + id: string; + tariffId: string; + effectiveFrom: string; + currency: string; + structure: TariffStructure; + createdBy?: string | null; + createdAt?: string; +} +export interface TariffState { + tariffId: string; + active: TariffVersion | null; + versions: TariffVersion[]; +} + +export function fetchTariff(): Promise { + return apiFetch("/api/tariff"); +} + +/** Publish a new immutable tariff version (becomes the active rate card). */ +export function publishTariffVersion(body: { + currency: string; + structure: TariffStructure; + effectiveFrom?: string; +}): Promise { + return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index fcde7e6..6af5268 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -154,6 +154,49 @@ export function computeFee( return total; } +/** + * Validate an admin-authored tariff structure. Returns [] if valid, else a list + * of human-readable problems. Pure — used by the composer route (and any caller) + * so a malformed rate card can never be published. See wiki/concepts/tariff.md. + */ +export function validateTariffStructure(s: unknown): string[] { + const errs: string[] = []; + if (!s || typeof s !== "object") return ["structure must be an object"]; + const t = s as Partial; + + const nonNegInt = (v: unknown, label: string) => { + if (typeof v !== "number" || !Number.isInteger(v) || v < 0) errs.push(`${label} must be a non-negative integer`); + }; + nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin"); + nonNegInt(t.gracePeriodExitMin, "gracePeriodExitMin"); + nonNegInt(t.lostTicketMinor, "lostTicketMinor"); + if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) { + errs.push("incrementMin must be a positive integer"); + } + if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor"); + if (t.overstay !== "reprice") errs.push('overstay must be "reprice"'); + + if (!Array.isArray(t.blocks) || t.blocks.length === 0) { + errs.push("blocks must be a non-empty array"); + } else { + let prevBound = 0; + t.blocks.forEach((b, i) => { + const last = i === t.blocks!.length - 1; + nonNegInt(b?.priceMinorPerIncrement, `blocks[${i}].priceMinorPerIncrement`); + if (b?.uptoMin == null) { + if (!last) errs.push(`blocks[${i}] is open-ended (uptoMin null) but not last`); + } else { + if (typeof b.uptoMin !== "number" || !Number.isInteger(b.uptoMin) || b.uptoMin <= prevBound) { + errs.push(`blocks[${i}].uptoMin must be an integer greater than the previous block's bound (${prevBound})`); + } else { + prevBound = b.uptoMin; + } + } + }); + } + return errs; +} + /** Price of the increment that starts at `cumulativeMin` — the block whose range * [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */ function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number { diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index b48084e..649c689 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -96,6 +96,22 @@ because the chain + reconciliation depend on the result being reproducible. **As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested across grace, block steps, daily cap, and multi-day reset. +### Composer (as-built 2026-06-15) + +The admin authors the rate card at runtime — no hand-seeding: + +- **API** (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active version + history; any + signed-in role) and `POST /api/tariff/versions` (publish a new immutable version; **admin only**). + Publishing validates the structure via `validateTariffStructure` (shared) — non-negative integers, + ordered/ascending block bounds, only the last block open-ended — so a malformed card can never be + published. The single site `tariffs` row is created lazily on first read/publish. +- **UI** (`apps/web/src/TariffComposer.tsx`, admin shell): edit currency, grace windows, increment, + daily cap, lost-ticket fee, and add/remove rate blocks; amounts entered in major units, converted + to integer minor units on submit. Shows the active version + history; "Publish" creates a new + version (past sessions keep their pricing). +- 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). + ## The pay-on-foot consequence Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two diff --git a/wiki/log.md b/wiki/log.md index 7c16108..d97a167 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -534,3 +534,17 @@ guarantee. Recorded in [[dingtian-relay]] (new Hardening section). raw-SQL backdate in one test correctly broke the chain — the tamper-evidence working, not a flow bug.) - Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full loop passes). + +## [2026-06-15] build | Tariff composer (makes the pay station operable) +- `validateTariffStructure` in `packages/shared` — non-negative ints, ascending block bounds, only + the last block open-ended; a malformed card can't be published. +- Routes (`apps/server/src/routes/tariffs.ts`): `GET /api/tariff` (active + history, any role) and + `POST /api/tariff/versions` (publish immutable version, ADMIN only). Single site `tariffs` row + created lazily. Editing = publish a new version (effective-dated, immutable). +- UI (`apps/web/src/TariffComposer.tsx`, admin shell next to SetupWizard): currency, grace windows, + increment, daily cap, lost-ticket, add/remove rate blocks; major-unit input → minor on submit; + shows active + history. +- VERIFIED via Fastify inject: GET empty→active null; invalid (out-of-order blocks)→400 w/ problem; + valid→201 createdBy=admin; readonly publish→403; after publish the pay station quote returns 404 + (session) not 409 (no tariff) — i.e. it now sees the active card. Full build 5/5. +- Updated [[tariff]] (composer as-built).