fd9885e9ec
Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:
- tariff_drafts table (migration 0021): MUTABLE by design — the one
exception to "editing publishes a version"; a draft prices nothing and
signs nothing. Drafts are validated + tz-stamped on save exactly like a
publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
tariff:update); publishing a draft goes through the normal immutable
POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
(click any to price against it); main pane cut to pure entry/exit
(ticket loader, payment, category inputs dropped); the composer form is
extracted to TariffEditorForm.tsx and reused in a modal (new drafts
prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
publish — carried from the lab draft, or typed in the composer's new
optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
landed in d9e6c13) and the "Flat price / hour" relabel.
5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
338 lines
15 KiB
TypeScript
338 lines
15 KiB
TypeScript
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<void> {
|
|
// 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<string, unknown>,
|
|
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<string, unknown>,
|
|
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<string, unknown>,
|
|
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;
|
|
}
|
|
}
|