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
+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();