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:
@@ -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