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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user