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>
)}