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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user