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
+5 -1
View File
@@ -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() {
</span>
</header>
{user.role === "admin" ? (
<SetupWizard />
<>
<SetupWizard />
<TariffComposer />
</>
) : (
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
)}
+207
View File
@@ -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<TariffState | null>(null);
const [form, setForm] = useState<FormState>(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<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
function setBlock(i: number, patch: Partial<BlockForm>) {
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 (
<section style={{ marginTop: "2rem" }}>
<h2>Tariff</h2>
{!state?.active ? (
<p style={{ color: "#b45309" }}>
No rate card published yet — the pay station can't charge until you publish one.
</p>
) : (
<p style={{ color: "#555" }}>
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.
</p>
)}
<div style={{ display: "grid", gridTemplateColumns: "max-content 1fr", gap: "0.4rem 0.75rem", alignItems: "center", maxWidth: 460 }}>
<label>Currency</label>
<input value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} style={{ width: 80 }} />
<label>Free entry grace (min)</label>
<input value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label>Billing increment (min)</label>
<input value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label>Daily cap (blank = none)</label>
<input value={form.dailyCap} onChange={(e) => set("dailyCap", e.target.value)} placeholder="e.g. 12.00" />
<label>Lost-ticket fee</label>
<input value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label>Exit walk-back grace (min)</label>
<input value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
<h3 style={{ marginBottom: "0.25rem" }}>Rate blocks</h3>
<p style={{ color: "#777", margin: "0 0 0.5rem", fontSize: "0.9em" }}>
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.
</p>
<table style={{ borderCollapse: "collapse" }}>
<thead>
<tr style={{ textAlign: "left", color: "#555" }}>
<th style={{ padding: "0 0.5rem" }}>Up to (min)</th>
<th style={{ padding: "0 0.5rem" }}>Price / increment</th>
<th />
</tr>
</thead>
<tbody>
{form.blocks.map((b, i) => (
<tr key={i}>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input
value={b.uptoMin}
onChange={(e) => setBlock(i, { uptoMin: e.target.value })}
placeholder={i === form.blocks.length - 1 ? "thereafter" : "e.g. 60"}
style={{ width: 110 }}
/>
</td>
<td style={{ padding: "0.15rem 0.5rem" }}>
<input value={b.price} onChange={(e) => setBlock(i, { price: e.target.value })} style={{ width: 90 }} />
</td>
<td>
<button type="button" onClick={() => removeBlock(i)} disabled={form.blocks.length <= 1}>
Remove
</button>
</td>
</tr>
))}
</tbody>
</table>
<button type="button" onClick={addBlock} style={{ marginTop: "0.4rem" }}>
+ Add block
</button>
<div style={{ marginTop: "1rem" }}>
<button type="button" onClick={publish} disabled={saving}>
{saving ? "Publishing…" : "Publish new version"}
</button>
</div>
{msg && (
<p style={{ color: msg.kind === "ok" ? "#16a34a" : "crimson", marginTop: "0.5rem" }}>{msg.text}</p>
)}
</section>
);
}
+43
View File
@@ -197,3 +197,46 @@ export function fetchState(): Promise<SetupState> {
export function unassignDevice(id: string): Promise<void> {
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<TariffState> {
return apiFetch<TariffState>("/api/tariff");
}
/** Publish a new immutable tariff version (becomes the active rate card). */
export function publishTariffVersion(body: {
currency: string;
structure: TariffStructure;
effectiveFrom?: string;
}): Promise<TariffVersion> {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}