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
+43
View File
@@ -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<TariffStructure>;
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 {