import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, isTariffV2, priceSession, validateTariffStructure, type SessionPayment, 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; /** Optional human label (e.g. carried from the lab draft being published). */ name?: string; } const SITE_TARIFF_NAME = "Site tariff"; /** Body for saving a lab draft (create + update share the shape). */ interface DraftBody { name: string; currency: string; structure: TariffStructure; } /** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write. * Provide a structure source (one of): `tariffVersionId`, inline `structure`, or * neither (uses the active version). */ interface SimulateBody { enteredAt: string; // ISO-8601 asOf: string; // ISO-8601 (the "now"/exit instant being simulated) payments?: SessionPayment[]; // hypothetical payment history (latest grants grace) category?: string; tariffVersionId?: string; structure?: TariffStructure; currency?: string; } 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. A soft-deleted (recycle- // bin) tariff is ignored here so a fresh one is created — the deleted one waits in the // bin for restore/purge. (Tariffs have soft-delete support for completeness; today the // site runs one tariff and there's no delete button — recovery is via the recycle bin.) function ensureSiteTariff(): string { const existing = db.select().from(tariffs).where(and(eq(tariffs.scope, "site"), isNull(tariffs.deletedAt))).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, name } = 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. const toStore = stampSiteTz(structure); 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, name: typeof name === "string" && name.trim() ? name.trim() : null, 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); }, ); // --- Tariff Lab (simulator) ------------------------------------------------- // Price a HYPOTHETICAL session at arbitrary times against any tariff version — // pure, no ledger writes. Lets an admin test rates "in time" (overnight windows, // daily caps, overstay) in seconds instead of waiting hours. Also used to quote a // customer dispute on-site. tariff:read (admins always have it). See tariff.md. app.post<{ Body: SimulateBody }>("/api/tariff/simulate", { preHandler: readGuard }, async (req, reply) => { const b = req.body ?? ({} as SimulateBody); if (!b.enteredAt || !b.asOf) { return reply.code(400).send({ error: "enteredAt and asOf (ISO-8601) required" }); } if (!(Date.parse(b.enteredAt) <= Date.parse(b.asOf))) { return reply.code(400).send({ error: "asOf must be at or after enteredAt" }); } // Resolve the structure: an explicit version id, or the active version, or an // inline structure (preview unpublished edits). A version carries its currency. let structure: TariffStructure | undefined = b.structure; let currency = b.currency ?? null; if (b.tariffVersionId) { const v = db.select().from(tariffVersions).where(eq(tariffVersions.id, b.tariffVersionId)).get(); if (!v) return reply.code(404).send({ error: "tariff version not found" }); structure = v.structure as unknown as TariffStructure; currency = v.currency; } else if (!structure) { const tariffId = ensureSiteTariff(); const nowIso = new Date().toISOString(); const active = db .select() .from(tariffVersions) .where(eq(tariffVersions.tariffId, tariffId)) .orderBy(desc(tariffVersions.effectiveFrom)) .all() .find((v) => v.effectiveFrom <= nowIso) ?? null; if (!active) return reply.code(404).send({ error: "no active tariff to simulate against" }); structure = active.structure as unknown as TariffStructure; currency = active.currency; } const problems = validateTariffStructure(structure); if (problems.length) return reply.code(400).send({ error: "invalid tariff structure", problems }); const payments = Array.isArray(b.payments) ? b.payments : []; const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category); // A duration curve from entry: handy to SEE where the cap flattens / windows shift. const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320]; const enteredMs = Date.parse(b.enteredAt); const curve = SAMPLES_MIN.map((min) => ({ minutes: min, amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category), })); return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin }; }); // Prefill the lab from a REAL session: fold its ledger into entry + payments so the // admin can re-evaluate an actual ticket (e.g. an overstay) at any chosen `asOf`. app.get<{ Params: { identity: string } }>( "/api/tariff/simulate/session/:identity", { preHandler: readGuard }, async (req, reply) => { const id = (req.params.identity ?? "").trim(); if (!id) return reply.code(400).send({ error: "identity required" }); const rows = db .select() .from(ledgerEvents) .where(eq(ledgerEvents.identity, id)) .orderBy(ledgerEvents.index) .all(); const entry = rows.find((r) => r.type === "vehicle_entry"); if (!entry) return reply.code(404).send({ error: "no session for identity" }); const payments: { paidAt: string; graceExitMin: number | null }[] = []; for (const r of rows) { if (r.type !== "payment") continue; const g = (r.payload as { graceExitMin?: number } | null)?.graceExitMin; payments.push({ paidAt: r.occurredAt, graceExitMin: typeof g === "number" ? g : null }); } const exit = rows.find((r) => r.type === "vehicle_exit"); const category = (entry.payload as { category?: string } | null)?.category ?? null; return { identity: id, enteredAt: entry.occurredAt, exitedAt: exit?.occurredAt ?? null, payments, category, // The version frozen at entry — the rate card this session actually keeps. tariffVersionId: tariffVersionIdFor(entry.occurredAt), }; }, ); // --- Lab drafts --------------------------------------------------------------- // The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the // schema for why mutability is safe here — a draft prices nothing and signs // nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the // simulator can always price them and "publish this draft" can never surprise the // admin with a card that saved fine but won't go live. Publishing a draft is just // POST /api/tariff/versions with the draft's structure — same guard, same // validation, same immutability. app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => { const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all(); return { drafts }; }); app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => { const parsed = parseDraftBody(req.body); if ("error" in parsed) return reply.code(400).send(parsed); const now = new Date().toISOString(); const row = { id: randomUUID(), name: parsed.name, currency: parsed.currency, structure: parsed.structure as unknown as Record, createdBy: req.user?.username ?? null, createdAt: now, updatedAt: now, }; db.insert(tariffDrafts).values(row).run(); return reply.code(201).send(row); }); app.put<{ Params: { id: string }; Body: DraftBody }>( "/api/tariff/drafts/:id", { preHandler: writeGuard }, async (req, reply) => { const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get(); if (!existing) return reply.code(404).send({ error: "draft not found" }); const parsed = parseDraftBody(req.body); if ("error" in parsed) return reply.code(400).send(parsed); const patch = { name: parsed.name, currency: parsed.currency, structure: parsed.structure as unknown as Record, updatedAt: new Date().toISOString(), }; db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run(); return { ...existing, ...patch }; }, ); app.delete<{ Params: { id: string } }>( "/api/tariff/drafts/:id", { preHandler: writeGuard }, async (req, reply) => { const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get(); if (!existing) return reply.code(404).send({ error: "draft not found" }); db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run(); return reply.code(204).send(); }, ); /** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */ function parseDraftBody( body: DraftBody | undefined, ): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } { const b = body ?? ({} as DraftBody); const name = (b.name ?? "").trim(); if (!name) return { error: "name required" }; const currency = (b.currency ?? "").trim().toUpperCase(); if (currency.length < 3) return { error: "currency (ISO 4217) required" }; const structure = stampSiteTz(b.structure); const problems = validateTariffStructure(structure); if (problems.length) return { error: "invalid tariff structure", problems }; return { name, currency, structure }; } /** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the * client); a V1 (bare) structure passes through untouched. */ function stampSiteTz(structure: TariffStructure): TariffStructure { 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; return { ...structure, tz }; } return structure; } /** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */ function tariffVersionIdFor(whenIso: string): string | null { const tariffId = ensureSiteTariff(); const v = db .select() .from(tariffVersions) .where(eq(tariffVersions.tariffId, tariffId)) .orderBy(desc(tariffVersions.effectiveFrom)) .all() .find((row) => row.effectiveFrom <= whenIso) ?? null; return v?.id ?? null; } }