import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { desc, eq, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; import { isTariffV2, validateTariffStructure, type TariffStructure } from "@parking/shared"; import { requirePermission } from "../auth.js"; /** Default site timezone for wall-clock tariff windows when none is configured. */ const DEFAULT_TZ = "Europe/Tirane"; // 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 { // Reading the rate card (pay station / operator UI needs it). const readGuard = requirePermission("tariff:read"); // Publishing a new version changes what customers are charged. const writeGuard = requirePermission("tariff:update"); // 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" }); } // For a windowed (V2) structure, stamp the wall-clock timezone from SITE config // (not the client) BEFORE validating — so the frozen tz is authoritative and the // validation that requires tz passes. A V1 (bare) structure is left untouched. let toStore: TariffStructure = structure; if (structure && isTariffV2(structure)) { const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ; toStore = { ...structure, tz }; } const problems = validateTariffStructure(toStore); if (problems.length) { return reply.code(400).send({ error: "invalid tariff structure", problems }); } // effectiveFrom must NOT be in the past. A version is selected by // "latest effectiveFrom <= entry time", so a backdated effectiveFrom would // retroactively reprice already-entered sessions — exactly the immutability // the versioning exists to prevent (wiki/concepts/tariff.md). So we forbid // backdating: a new version applies only from publish (now) forward; a future // effectiveFrom (scheduling a price change) is allowed. A small skew tolerance // absorbs client/server clock drift + request round-trip. Once a car has // entered, no later publish can reprice it (no effectiveFrom can predate it). const now = Date.now(); const SKEW_MS = 60_000; // 1 min: clock skew + round-trip slack let effective = new Date().toISOString(); if (effectiveFrom != null) { const t = Date.parse(effectiveFrom); if (Number.isNaN(t)) { return reply.code(400).send({ error: "effectiveFrom must be a valid ISO-8601 timestamp" }); } if (t < now - SKEW_MS) { return reply.code(400).send({ error: "effectiveFrom cannot be in the past — backdating a tariff would retroactively reprice entered sessions", }); } effective = new Date(t).toISOString(); } const tariffId = ensureSiteTariff(); const id = randomUUID(); const row = { id, tariffId, effectiveFrom: effective, currency, structure: toStore as unknown as Record, createdBy: req.user?.username ?? null, }; db.insert(tariffVersions).values(row).run(); return reply.code(201).send(row); }, ); }