feat(tariff-lab): DB-backed draft tariffs + named published versions

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
This commit is contained in:
2026-07-05 14:31:42 +02:00
parent 52a89bfa56
commit fd9885e9ec
14 changed files with 1457 additions and 782 deletions
@@ -0,0 +1,177 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { createTestDb } from "@parking/db/testing";
import { type Db } from "@parking/db";
import type { FastifyInstance } from "fastify";
import { buildServer } from "../server.js";
import { seedUser, login } from "../test-helpers.js";
// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable
// published versions. The contract under test: drafts are validated + tz-stamped on
// save exactly like a publish (so "publish this draft" can never fail on a card that
// saved fine), mutations need tariff:update, and publishing a draft goes through the
// normal immutable-version path untouched.
let db: Db;
let close: () => void;
let app: FastifyInstance;
beforeEach(async () => {
const t = createTestDb();
db = t.db;
close = t.close;
app = await buildServer({ db });
await app.ready();
});
afterEach(async () => {
await app.close();
close();
});
const V1_STRUCTURE = {
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }],
dailyCapMinor: null,
};
// A V2 card with a night package — tz left blank on purpose: the server must stamp it.
const V2_STRUCTURE = {
version: 2,
tz: "",
gracePeriodEntryMin: 5,
incrementMin: 60,
lostTicketMinor: 2000,
gracePeriodExitMin: 10,
overstay: "reprice",
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null },
windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }],
};
async function editor() {
const { username, password } = await seedUser(db, {
username: "editor",
roleId: "editor",
permissions: ["tariff:read", "tariff:update"],
});
return login(app, username, password);
}
describe("tariff drafts", () => {
it("requires auth", async () => {
const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" });
expect(res.statusCode).toBe(401);
});
it("a tariff:read-only user can list but not create", async () => {
const { username, password } = await seedUser(db, {
username: "viewer",
roleId: "viewer",
permissions: ["tariff:read"],
});
const { cookie, csrf } = await login(app, username, password);
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.statusCode).toBe(200);
expect(list.json().drafts).toEqual([]);
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(403);
});
it("create → list → update → delete roundtrip", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.name).toBe("Winter proposal");
expect(draft.currency).toBe("ALL"); // normalised to upper case
expect(draft.createdBy).toBe("editor");
const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(list.json().drafts).toHaveLength(1);
const update = await app.inject({
method: "PUT",
url: `/api/tariff/drafts/${draft.id}`,
headers,
payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE },
});
expect(update.statusCode).toBe(200);
expect(update.json().name).toBe("Winter v2");
const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers });
expect(del.statusCode).toBe(204);
const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } });
expect(after.json().drafts).toEqual([]);
});
it("rejects an invalid structure with problems (validated like a publish)", async () => {
const { cookie, csrf } = await editor();
const res = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers: { cookie, "x-csrf-token": csrf },
payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } },
});
expect(res.statusCode).toBe(400);
expect(res.json().problems?.length).toBeGreaterThan(0);
});
it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => {
const { cookie, csrf } = await editor();
const headers = { cookie, "x-csrf-token": csrf };
const create = await app.inject({
method: "POST",
url: "/api/tariff/drafts",
headers,
payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE },
});
expect(create.statusCode).toBe(201);
const draft = create.json();
expect(draft.structure.tz).toBe("Europe/Tirane");
// The lab prices the draft by sending its stored structure inline.
const sim = await app.inject({
method: "POST",
url: "/api/tariff/simulate",
headers,
payload: {
enteredAt: "2026-07-03T21:00:00.000+02:00",
asOf: "2026-07-03T23:00:00.000+02:00",
structure: draft.structure,
currency: draft.currency,
},
});
expect(sim.statusCode).toBe(200);
expect(sim.json().pricing.amountMinor).toBe(40000); // one night package
// "Publish this draft" = the normal immutable-version path with the draft's card;
// the draft's name rides along as the version's optional label.
const publish = await app.inject({
method: "POST",
url: "/api/tariff/versions",
headers,
payload: { currency: draft.currency, structure: draft.structure, name: draft.name },
});
expect(publish.statusCode).toBe(201);
const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } });
expect(state.json().active?.name).toBe("Night package");
expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000);
});
});
+99 -8
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db";
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
isTariffV2,
@@ -25,10 +25,19 @@ interface PublishBody {
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). */
@@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
"/api/tariff/versions",
{ preHandler: writeGuard },
async (req, reply) => {
const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody);
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.
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 toStore = stampSiteTz(structure);
const problems = validateTariffStructure(toStore);
if (problems.length) {
@@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const row = {
id,
tariffId,
name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record<string, unknown>,
@@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
},
);
// --- 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();