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:
@@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { authRoutes } from "./routes/auth.js";
|
|||||||
import { deviceRoutes } from "./routes/devices.js";
|
import { deviceRoutes } from "./routes/devices.js";
|
||||||
import { eventRoutes } from "./routes/events.js";
|
import { eventRoutes } from "./routes/events.js";
|
||||||
import { payRoutes } from "./routes/pay.js";
|
import { payRoutes } from "./routes/pay.js";
|
||||||
|
import { tariffRoutes } from "./routes/tariffs.js";
|
||||||
import { printerRoutes } from "./routes/printers.js";
|
import { printerRoutes } from "./routes/printers.js";
|
||||||
import { setupRoutes } from "./routes/setup.js";
|
import { setupRoutes } from "./routes/setup.js";
|
||||||
|
|
||||||
@@ -109,6 +110,10 @@ export async function buildServer(opts: BuildOptions = {}): Promise<FastifyInsta
|
|||||||
const payStation = new PayStation(db, eventLog, app.log);
|
const payStation = new PayStation(db, eventLog, app.log);
|
||||||
await payRoutes(app, payStation);
|
await payRoutes(app, payStation);
|
||||||
|
|
||||||
|
// Tariff composer: admin publishes effective-dated, immutable rate-card versions
|
||||||
|
// the pay station prices against. See wiki/concepts/tariff.md.
|
||||||
|
await tariffRoutes(app, db);
|
||||||
|
|
||||||
const unsubscribeInput = deviceEvents.onInput((e) => {
|
const unsubscribeInput = deviceEvents.onInput((e) => {
|
||||||
// Resolve which lane the device belongs to. -1 marks "device fired but isn't
|
// 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
|
// mapped to a lane" (assigned without a lane, or a stale id) — still recorded
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { fetchMe, logout, type SessionUser } from "./api.js";
|
import { fetchMe, logout, type SessionUser } from "./api.js";
|
||||||
import { Login } from "./Login.js";
|
import { Login } from "./Login.js";
|
||||||
import { SetupWizard } from "./SetupWizard.js";
|
import { SetupWizard } from "./SetupWizard.js";
|
||||||
|
import { TariffComposer } from "./TariffComposer.js";
|
||||||
|
|
||||||
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
// Operator UI shell. Plain React (no admin framework) — the operator UI is
|
||||||
// simple enough that a framework's abstractions cost more than they save.
|
// simple enough that a framework's abstractions cost more than they save.
|
||||||
@@ -39,7 +40,10 @@ export function App() {
|
|||||||
</span>
|
</span>
|
||||||
</header>
|
</header>
|
||||||
{user.role === "admin" ? (
|
{user.role === "admin" ? (
|
||||||
<SetupWizard />
|
<>
|
||||||
|
<SetupWizard />
|
||||||
|
<TariffComposer />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
<p style={{ marginTop: "1rem" }}>Signed in. (Operator console coming soon.)</p>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -197,3 +197,46 @@ export function fetchState(): Promise<SetupState> {
|
|||||||
export function unassignDevice(id: string): Promise<void> {
|
export function unassignDevice(id: string): Promise<void> {
|
||||||
return apiFetch(`/api/setup/assign/${id}`, { method: "DELETE" });
|
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) });
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,6 +154,49 @@ export function computeFee(
|
|||||||
return total;
|
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
|
/** 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. */
|
* [prevUpto, uptoMin) contains it; the open-ended (uptoMin=null) block catches the rest. */
|
||||||
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
function rateAt(blocks: readonly TariffBlock[], cumulativeMin: number): number {
|
||||||
|
|||||||
@@ -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
|
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||||
across grace, block steps, daily cap, and multi-day reset.
|
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
|
## The pay-on-foot consequence
|
||||||
|
|
||||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||||
|
|||||||
+14
@@ -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.)
|
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
|
- Updated [[tariff]] (settled edges + as-built), [[parking-session]] (pay station as-built; full
|
||||||
loop passes).
|
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).
|
||||||
|
|||||||
Reference in New Issue
Block a user