From fd9885e9ec0e43f9dcf9c654541df3682fb724f1 Mon Sep 17 00:00:00 2001
From: Julian Cuni
Date: Sun, 5 Jul 2026 14:31:42 +0200
Subject: [PATCH] feat(tariff-lab): DB-backed draft tariffs + named published
versions
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
apps/server/src/routes/tariff-drafts.test.ts | 177 +++++
apps/server/src/routes/tariffs.ts | 107 +++-
apps/web/src/TariffComposer.tsx | 580 +----------------
apps/web/src/TariffEditorForm.tsx | 604 ++++++++++++++++++
apps/web/src/TariffLab.tsx | 494 +++++++++-----
apps/web/src/api.ts | 49 +-
apps/web/src/lib/i18n/en.ts | 42 +-
apps/web/src/lib/i18n/sq.ts | 42 +-
packages/db/drizzle/0021_tariff_drafts.sql | 14 +
.../db/drizzle/0022_tariff_version_name.sql | 6 +
packages/db/drizzle/meta/_journal.json | 14 +
packages/db/src/schema.ts | 28 +
wiki/concepts/tariff.md | 44 +-
wiki/log.md | 38 ++
14 files changed, 1457 insertions(+), 782 deletions(-)
create mode 100644 apps/server/src/routes/tariff-drafts.test.ts
create mode 100644 apps/web/src/TariffEditorForm.tsx
create mode 100644 packages/db/drizzle/0021_tariff_drafts.sql
create mode 100644 packages/db/drizzle/0022_tariff_version_name.sql
diff --git a/apps/server/src/routes/tariff-drafts.test.ts b/apps/server/src/routes/tariff-drafts.test.ts
new file mode 100644
index 0000000..b55171d
--- /dev/null
+++ b/apps/server/src/routes/tariff-drafts.test.ts
@@ -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);
+ });
+});
diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts
index bf789a0..30ac935 100644
--- a/apps/server/src/routes/tariffs.ts
+++ b/apps/server/src/routes/tariffs.ts
@@ -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
"/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
const row = {
id,
tariffId,
+ name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective,
currency,
structure: toStore as unknown as Record,
@@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise
},
);
+ // --- 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,
+ 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,
+ 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();
diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx
index 2a1004e..7b24dce 100644
--- a/apps/web/src/TariffComposer.tsx
+++ b/apps/web/src/TariffComposer.tsx
@@ -1,267 +1,23 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
-import {
- ApiError,
- fetchTariff,
- isTariffV2,
- publishTariffVersion,
- type TariffBlock,
- type TariffCard,
- type TariffStep,
- type TariffStructure,
- type TariffState,
-} from "./api.js";
+import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
+import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
-// Tariff composer — the admin builds + edits the rate card at runtime. Publishing
+// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing
// creates a new IMMUTABLE version (the active card); old versions are kept so past
-// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for
-// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md.
-
-// Editable form mirror of TariffStructure, but money in major-unit strings.
-// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
-// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
-// The LAST block is always open-ended ("thereafter"): its hours field is unused
-// and it has no bound. On submit, per-block hours accumulate into the engine's
-// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
-interface BlockForm {
- hours: string; // duration of THIS band, in hours (ignored for the last block)
- price: string; // major units, e.g. "2.00"
-}
-// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
-// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
-interface StepForm {
- hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
- total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
-}
-// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
-// (up-to) total-by-duration table.
-interface PricingForm {
- mode: "ladder" | "flat" | "stepped";
- flat: string; // major units (used when mode==="flat")
- blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
- steps: StepForm[]; // up-to tiers (used when mode==="stepped")
- dailyCap: string; // "" = no cap (ladder only)
-}
-// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
-interface TierForm {
- name: string;
- priority: string;
- category: string; // "" = applies to all categories
- dow: number[]; // selected days 0..6; empty = every day
- fromHour: string; // "" = all day
- toHour: string;
- dateFrom: string; // "" = unbounded
- dateTo: string;
- pricing: PricingForm;
-}
-interface FormState {
- currency: string;
- gracePeriodEntryMin: string;
- incrementMin: string;
- lostTicket: string;
- gracePeriodExitMin: string;
- // The default (always-active) card — its own flat/ladder body + daily cap.
- base: PricingForm;
- // Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
- tiers: TierForm[];
-}
-
-const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
-const toMajor = (minor: number): string => (minor / 100).toFixed(2);
-
-function emptySteps(): StepForm[] {
- return [
- { hours: "1", total: "2.00" },
- { hours: "3", total: "5.00" },
- ];
-}
-function emptyLadder(): PricingForm {
- return {
- mode: "ladder",
- flat: "0.00",
- dailyCap: "",
- blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
- steps: emptySteps(),
- };
-}
-function emptyTier(): TierForm {
- return {
- name: "",
- priority: "10",
- category: "",
- dow: [],
- fromHour: "",
- toHour: "",
- dateFrom: "",
- dateTo: "",
- pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
- };
-}
-
-function emptyForm(): FormState {
- return {
- currency: "EUR",
- gracePeriodEntryMin: "15",
- incrementMin: "60",
- lostTicket: "20.00",
- gracePeriodExitMin: "15",
- base: emptyLadder(),
- tiers: [],
- };
-}
-
-// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
-// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
-// load (shown as their own band).
-function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
- let prev = 0;
- return blocks.map((b) => {
- if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
- const hours = (b.uptoMin - prev) / 60;
- prev = b.uptoMin;
- return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
- });
-}
-
-// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
-function stepsToForm(steps: TariffStep[]): StepForm[] {
- return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
-}
-
-// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
-function pricingFromCard(c: {
- flatMinor?: number;
- blocks?: TariffBlock[];
- steps?: TariffStep[];
- dailyCapMinor?: number | null;
-}): PricingForm {
- if (c.steps != null && c.steps.length > 0) {
- return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
- }
- if (c.flatMinor != null) {
- return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
- }
- return {
- mode: "ladder",
- flat: "0.00",
- dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
- blocks: blocksToForm(c.blocks ?? []),
- steps: emptySteps(),
- };
-}
-
-function tierFromCard(c: TariffCard): TierForm {
- const w = c.window ?? {};
- return {
- name: c.name,
- priority: String(c.priority),
- category: c.category ?? "",
- dow: w.dow ? [...w.dow] : [],
- fromHour: w.fromHour ?? "",
- toHour: w.toHour ?? "",
- dateFrom: w.dateFrom ?? "",
- dateTo: w.dateTo ?? "",
- pricing: pricingFromCard(c),
- };
-}
-
-function formFromActive(s: TariffState): FormState {
- const v = s.active;
- if (!v) return emptyForm();
- const st = v.structure;
- const common = {
- currency: v.currency,
- gracePeriodEntryMin: String(st.gracePeriodEntryMin),
- incrementMin: String(st.incrementMin),
- lostTicket: toMajor(st.lostTicketMinor),
- gracePeriodExitMin: String(st.gracePeriodExitMin),
- };
- if (isTariffV2(st)) {
- return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
- }
- // V1: the bare ladder becomes the default card body; no tiers.
- return { ...common, base: pricingFromCard(st), tiers: [] };
-}
-
-// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
-function pricingToCardBody(p: PricingForm): Pick {
- if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
- if (p.mode === "stepped") {
- // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
- const steps: TariffStep[] = p.steps.map((s) => ({
- uptoMin: Math.round(Number(s.hours || "0") * 60),
- totalMinor: toMinor(s.total),
- }));
- return { steps };
- }
- // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
- const last = p.blocks.length - 1;
- let cum = 0;
- const blocks: TariffBlock[] = p.blocks.map((b, i) => {
- if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
- cum += Math.round(Number(b.hours || "0") * 60);
- return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
- });
- return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
-}
-
-function tierToCard(tr: TierForm): TariffCard {
- const window: TariffCard["window"] = {};
- if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
- if (tr.fromHour && tr.toHour) {
- window.fromHour = tr.fromHour;
- window.toHour = tr.toHour;
- }
- if (tr.dateFrom) window.dateFrom = tr.dateFrom;
- if (tr.dateTo) window.dateTo = tr.dateTo;
- const card: TariffCard = {
- name: tr.name.trim() || "tier",
- priority: Math.round(Number(tr.priority || "0")),
- ...pricingToCardBody(tr.pricing),
- };
- if (tr.category.trim()) card.category = tr.category.trim();
- if (Object.keys(window).length > 0) card.window = window;
- return card;
-}
-
-function toStructure(f: FormState): TariffStructure {
- const common = {
- gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
- incrementMin: Math.round(Number(f.incrementMin)),
- lostTicketMinor: toMinor(f.lostTicket),
- gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
- overstay: "reprice" as const,
- };
- const baseBody = pricingToCardBody(f.base);
-
- // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
- // tiers gets exactly today's shape; the server leaves it untouched).
- if (f.tiers.length === 0) {
- if (f.base.mode === "stepped") {
- // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
- return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
- }
- if (f.base.mode === "flat") {
- // A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
- return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
- }
- return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
- }
-
- // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
- return {
- ...common,
- version: 2,
- tz: "",
- defaultCard: { name: "default", priority: 0, ...baseBody },
- windowedCards: f.tiers.map(tierToCard),
- };
-}
+// sessions reprice correctly. The form machinery is shared with the Tariff Lab's
+// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
+// lab (a draft only becomes real through this same publish path). See
+// wiki/concepts/tariff.md.
export function TariffComposer() {
const { t } = useTranslation();
const [state, setState] = useState(null);
const [form, setForm] = useState(emptyForm);
+ // Optional label for the version about to be published. Deliberately NOT prefilled
+ // from the active version — a tweaked card republished under last season's name
+ // would mislabel the history.
+ const [versionName, setVersionName] = useState("");
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null);
@@ -274,70 +30,18 @@ export function TariffComposer() {
.catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}, []);
- function set(key: K, value: FormState[K]) {
- setForm((f) => ({ ...f, [key]: value }));
- }
-
- // --- pricing-body editing (used by the default card AND each tier) ---
- // `update` maps the old PricingForm to a new one; `target` selects which body:
- // the base card, or tier index N.
- function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
- setForm((f) => {
- if (target === "base") return { ...f, base: update(f.base) };
- return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
- });
- }
- function setBlock(target: "base" | number, i: number, patch: Partial) {
- updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
- }
- // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
- function addBlock(target: "base" | number) {
- updatePricing(target, (p) => {
- const next = [...p.blocks];
- next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
- return { ...p, blocks: next };
- });
- }
- function removeBlock(target: "base" | number, i: number) {
- updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
- }
- // --- stepped (up-to) editing (base card only) ---
- function setStep(i: number, patch: Partial) {
- updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
- }
- function addStep() {
- updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
- }
- function removeStep(i: number) {
- updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
- }
-
- // --- tier editing ---
- function setTier(i: number, patch: Partial) {
- setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
- }
- function addTier() {
- setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
- }
- function removeTier(i: number) {
- setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
- }
- function toggleDow(i: number, d: number) {
- setForm((f) => ({
- ...f,
- tiers: f.tiers.map((tr, j) =>
- j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
- ),
- }));
- }
-
async function publish() {
setSaving(true);
setMsg(null);
try {
- await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) });
+ await publishTariffVersion({
+ currency: form.currency.trim().toUpperCase(),
+ structure: toStructure(form),
+ ...(versionName.trim() ? { name: versionName.trim() } : {}),
+ });
const fresh = await fetchTariff();
setState(fresh);
+ setVersionName("");
setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) {
const text =
@@ -359,6 +63,7 @@ export function TariffComposer() {
) : (
+ {state.active.name ? `${state.active.name} — ` : ""}
{t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length,
@@ -366,114 +71,15 @@ export function TariffComposer() {
)}
-
- {t("tariff.currency")}
- set("currency", e.target.value)} maxLength={3} />
- {t("tariff.freeEntryGrace")}
- set("gracePeriodEntryMin", e.target.value)} />
- {t("tariff.billingIncrement")}
- set("incrementMin", e.target.value)} />
- {t("tariff.lostTicketFee")}
- set("lostTicket", e.target.value)} />
- {t("tariff.exitGrace")}
- set("gracePeriodExitMin", e.target.value)} />
-
+
- {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
- wants tiers just edits this and publishes a bare V1 structure. */}
- {t("tariff.defaultCard")}
- {t("tariff.defaultCardHint")}
-
-
updatePricing("base", (p) => ({ ...p, mode }))}
- onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
- onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
- onBlock={(i, patch) => setBlock("base", i, patch)}
- onAddBlock={() => addBlock("base")}
- onRemoveBlock={(i) => removeBlock("base", i)}
- onStep={setStep}
- onAddStep={addStep}
- onRemoveStep={removeStep}
+
+ setVersionName(e.target.value)}
+ placeholder={t("tariff.versionNamePh")}
/>
-
-
- {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
- 0}>
- {t("tariff.tiersAdvanced")}
- {t("tariff.tiersHint")}
- {/* A stepped ("up-to") base rate cannot be combined with time tiers — the
- engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
- {form.base.mode === "stepped" && form.tiers.length > 0 && (
-
- {t("tariff.steppedTiersConflict")}
-
- )}
- {form.tiers.map((tr, i) => (
-
-
- setTier(i, { name: e.target.value })}
- placeholder={t("tariff.tierName")}
- />
- removeTier(i)}>
- {t("tariff.remove")}
-
-
-
- {t("tariff.tierPriority")}
- setTier(i, { priority: e.target.value })} />
- {t("tariff.tierCategory")}
- setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
- {t("tariff.tierDays")}
-
- {[1, 2, 3, 4, 5, 6, 0].map((d) => (
-
- toggleDow(i, d)} />
- {t(`tariff.dow${d}`)}
-
- ))}
-
- {t("tariff.tierHours")}
-
- setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
- –
- setTier(i, { toHour: e.target.value })} placeholder="06:00" />
- {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
- {t("tariff.tierOvernight")}
- )}
-
- {t("tariff.tierDates")}
-
- setTier(i, { dateFrom: e.target.value })} />
- –
- setTier(i, { dateTo: e.target.value })} />
-
-
-
-
updatePricing(i, (p) => ({ ...p, mode }))}
- onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
- onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
- onBlock={(bi, patch) => setBlock(i, bi, patch)}
- onAddBlock={() => addBlock(i)}
- onRemoveBlock={(bi) => removeBlock(i, bi)}
- />
-
-
- ))}
-
- {t("tariff.addTier")}
-
-
-
-
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
@@ -484,139 +90,3 @@ export function TariffComposer() {
);
}
-
-// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
-// stepped mode is offered only where `allowStepped` (the default card, not tiers).
-function PricingEditor(props: {
- t: (k: string) => string;
- pricing: PricingForm;
- allowStepped?: boolean;
- onMode: (m: "ladder" | "flat" | "stepped") => void;
- onFlat: (v: string) => void;
- onCap: (v: string) => void;
- onBlock: (i: number, patch: Partial
) => void;
- onAddBlock: () => void;
- onRemoveBlock: (i: number) => void;
- onStep?: (i: number, patch: Partial) => void;
- onAddStep?: () => void;
- onRemoveStep?: (i: number) => void;
-}) {
- const { t, pricing: p } = props;
- return (
-
- );
-}
diff --git a/apps/web/src/TariffEditorForm.tsx b/apps/web/src/TariffEditorForm.tsx
new file mode 100644
index 0000000..3407375
--- /dev/null
+++ b/apps/web/src/TariffEditorForm.tsx
@@ -0,0 +1,604 @@
+import { useTranslation } from "react-i18next";
+import {
+ isTariffV2,
+ type TariffBlock,
+ type TariffCard,
+ type TariffStep,
+ type TariffStructure,
+ type TariffState,
+} from "./api.js";
+
+// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape,
+// structure↔form converters, and the editing UI), extracted so two hosts can share
+// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's
+// draft modal (edits an experimental card). The host owns the FormState and the
+// submit action; this module owns everything between. Amounts are entered in major
+// units (e.g. euros) and converted to integer minor units on submit.
+// See wiki/concepts/tariff.md.
+
+// Editable form mirror of TariffStructure, but money in major-unit strings.
+// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the
+// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes.
+// The LAST block is always open-ended ("thereafter"): its hours field is unused
+// and it has no bound. On submit, per-block hours accumulate into the engine's
+// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null.
+export interface BlockForm {
+ hours: string; // duration of THIS band, in hours (ignored for the last block)
+ price: string; // major units, e.g. "2.00"
+}
+// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
+// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
+export interface StepForm {
+ hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
+ total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
+}
+// A pricing body the form edits: a per-increment flat rate, a marginal block ladder,
+// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only).
+export interface PricingForm {
+ mode: "ladder" | "flat" | "stepped" | "package";
+ flat: string; // major units PER INCREMENT (used when mode==="flat")
+ packageTotal: string; // major units for the WHOLE window occurrence (mode==="package")
+ blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
+ steps: StepForm[]; // up-to tiers (used when mode==="stepped")
+ dailyCap: string; // "" = no cap (ladder only)
+}
+// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
+export interface TierForm {
+ name: string;
+ priority: string;
+ category: string; // "" = applies to all categories
+ dow: number[]; // selected days 0..6; empty = every day
+ fromHour: string; // "" = all day
+ toHour: string;
+ dateFrom: string; // "" = unbounded
+ dateTo: string;
+ pricing: PricingForm;
+}
+export interface FormState {
+ currency: string;
+ gracePeriodEntryMin: string;
+ incrementMin: string;
+ lostTicket: string;
+ gracePeriodExitMin: string;
+ // The default (always-active) card — its own flat/ladder body + daily cap.
+ base: PricingForm;
+ // Optional time/category tiers. Empty ⇒ a bare V1 structure is published.
+ tiers: TierForm[];
+}
+
+const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
+const toMajor = (minor: number): string => (minor / 100).toFixed(2);
+
+function emptySteps(): StepForm[] {
+ return [
+ { hours: "1", total: "2.00" },
+ { hours: "3", total: "5.00" },
+ ];
+}
+function emptyLadder(): PricingForm {
+ return {
+ mode: "ladder",
+ flat: "0.00",
+ packageTotal: "0.00",
+ dailyCap: "",
+ blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
+ steps: emptySteps(),
+ };
+}
+function emptyTier(): TierForm {
+ return {
+ name: "",
+ priority: "10",
+ category: "",
+ dow: [],
+ fromHour: "",
+ toHour: "",
+ dateFrom: "",
+ dateTo: "",
+ pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
+ };
+}
+
+export function emptyForm(): FormState {
+ return {
+ currency: "EUR",
+ gracePeriodEntryMin: "15",
+ incrementMin: "60",
+ lostTicket: "20.00",
+ gracePeriodExitMin: "15",
+ base: emptyLadder(),
+ tiers: [],
+ };
+}
+
+// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band
+// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still
+// load (shown as their own band).
+function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
+ let prev = 0;
+ return blocks.map((b) => {
+ if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) };
+ const hours = (b.uptoMin - prev) / 60;
+ prev = b.uptoMin;
+ return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) };
+ });
+}
+
+// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
+function stepsToForm(steps: TariffStep[]): StepForm[] {
+ return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
+}
+
+// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped,
+// or window package).
+function pricingFromCard(c: {
+ flatMinor?: number;
+ blocks?: TariffBlock[];
+ steps?: TariffStep[];
+ packageMinor?: number;
+ dailyCapMinor?: number | null;
+}): PricingForm {
+ if (c.steps != null && c.steps.length > 0) {
+ return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) };
+ }
+ if (c.packageMinor != null) {
+ return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) };
+ }
+ if (c.flatMinor != null) {
+ return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) };
+ }
+ return {
+ ...emptyLadder(),
+ mode: "ladder",
+ dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
+ blocks: blocksToForm(c.blocks ?? []),
+ };
+}
+
+function tierFromCard(c: TariffCard): TierForm {
+ const w = c.window ?? {};
+ return {
+ name: c.name,
+ priority: String(c.priority),
+ category: c.category ?? "",
+ dow: w.dow ? [...w.dow] : [],
+ fromHour: w.fromHour ?? "",
+ toHour: w.toHour ?? "",
+ dateFrom: w.dateFrom ?? "",
+ dateTo: w.dateTo ?? "",
+ pricing: pricingFromCard(c),
+ };
+}
+
+/** A stored (currency, structure) pair → the editable form. Used to load the active
+ * version into the composer page and a saved draft into the lab modal. */
+export function formFromVersion(currency: string, st: TariffStructure): FormState {
+ const common = {
+ currency,
+ gracePeriodEntryMin: String(st.gracePeriodEntryMin),
+ incrementMin: String(st.incrementMin),
+ lostTicket: toMajor(st.lostTicketMinor),
+ gracePeriodExitMin: String(st.gracePeriodExitMin),
+ };
+ if (isTariffV2(st)) {
+ return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) };
+ }
+ // V1: the bare ladder becomes the default card body; no tiers.
+ return { ...common, base: pricingFromCard(st), tiers: [] };
+}
+
+export function formFromActive(s: TariffState): FormState {
+ return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm();
+}
+
+// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm.
+function pricingToCardBody(p: PricingForm): Pick {
+ if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
+ if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) };
+ if (p.mode === "stepped") {
+ // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
+ const steps: TariffStep[] = p.steps.map((s) => ({
+ uptoMin: Math.round(Number(s.hours || "0") * 60),
+ totalMinor: toMinor(s.total),
+ }));
+ return { steps };
+ }
+ // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
+ const last = p.blocks.length - 1;
+ let cum = 0;
+ const blocks: TariffBlock[] = p.blocks.map((b, i) => {
+ if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) };
+ cum += Math.round(Number(b.hours || "0") * 60);
+ return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) };
+ });
+ return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) };
+}
+
+function tierToCard(tr: TierForm): TariffCard {
+ const window: TariffCard["window"] = {};
+ if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b);
+ if (tr.fromHour && tr.toHour) {
+ window.fromHour = tr.fromHour;
+ window.toHour = tr.toHour;
+ }
+ if (tr.dateFrom) window.dateFrom = tr.dateFrom;
+ if (tr.dateTo) window.dateTo = tr.dateTo;
+ const card: TariffCard = {
+ name: tr.name.trim() || "tier",
+ priority: Math.round(Number(tr.priority || "0")),
+ ...pricingToCardBody(tr.pricing),
+ };
+ if (tr.category.trim()) card.category = tr.category.trim();
+ if (Object.keys(window).length > 0) card.window = window;
+ return card;
+}
+
+export function toStructure(f: FormState): TariffStructure {
+ const common = {
+ gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)),
+ incrementMin: Math.round(Number(f.incrementMin)),
+ lostTicketMinor: toMinor(f.lostTicket),
+ gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)),
+ overstay: "reprice" as const,
+ };
+ const baseBody = pricingToCardBody(f.base);
+
+ // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
+ // tiers gets exactly today's shape; the server leaves it untouched).
+ if (f.tiers.length === 0) {
+ if (f.base.mode === "stepped") {
+ // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
+ return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
+ }
+ if (f.base.mode === "flat") {
+ // A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
+ return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
+ }
+ return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null };
+ }
+
+ // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here).
+ return {
+ ...common,
+ version: 2,
+ tz: "",
+ defaultCard: { name: "default", priority: 0, ...baseBody },
+ windowedCards: f.tiers.map(tierToCard),
+ };
+}
+
+/** The full rate-card editing UI (shared settings + default card + tiers). The host
+ * owns the FormState; every edit flows through `onChange` as a functional update. */
+export function TariffEditorForm({
+ form,
+ onChange,
+}: {
+ form: FormState;
+ onChange: (update: (f: FormState) => FormState) => void;
+}) {
+ const { t } = useTranslation();
+
+ function set(key: K, value: FormState[K]) {
+ onChange((f) => ({ ...f, [key]: value }));
+ }
+
+ // --- pricing-body editing (used by the default card AND each tier) ---
+ // `update` maps the old PricingForm to a new one; `target` selects which body:
+ // the base card, or tier index N.
+ function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) {
+ onChange((f) => {
+ if (target === "base") return { ...f, base: update(f.base) };
+ return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) };
+ });
+ }
+ function setBlock(target: "base" | number, i: number, patch: Partial) {
+ updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) }));
+ }
+ // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended.
+ function addBlock(target: "base" | number) {
+ updatePricing(target, (p) => {
+ const next = [...p.blocks];
+ next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" });
+ return { ...p, blocks: next };
+ });
+ }
+ function removeBlock(target: "base" | number, i: number) {
+ updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
+ }
+ // --- stepped (up-to) editing (base card only) ---
+ function setStep(i: number, patch: Partial) {
+ updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
+ }
+ function addStep() {
+ updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
+ }
+ function removeStep(i: number) {
+ updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
+ }
+
+ // --- tier editing ---
+ function setTier(i: number, patch: Partial) {
+ onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) }));
+ }
+ function addTier() {
+ onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] }));
+ }
+ function removeTier(i: number) {
+ onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) }));
+ }
+ function toggleDow(i: number, d: number) {
+ onChange((f) => ({
+ ...f,
+ tiers: f.tiers.map((tr, j) =>
+ j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr,
+ ),
+ }));
+ }
+
+ return (
+
+
+ {t("tariff.currency")}
+ set("currency", e.target.value)} maxLength={3} />
+ {t("tariff.freeEntryGrace")}
+ set("gracePeriodEntryMin", e.target.value)} />
+ {t("tariff.billingIncrement")}
+ set("incrementMin", e.target.value)} />
+ {t("tariff.lostTicketFee")}
+ set("lostTicket", e.target.value)} />
+ {t("tariff.exitGrace")}
+ set("gracePeriodExitMin", e.target.value)} />
+
+
+ {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
+ wants tiers just edits this and publishes a bare V1 structure. */}
+
{t("tariff.defaultCard")}
+
{t("tariff.defaultCardHint")}
+
+
updatePricing("base", (p) => ({ ...p, mode }))}
+ onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
+ onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
+ onBlock={(i, patch) => setBlock("base", i, patch)}
+ onAddBlock={() => addBlock("base")}
+ onRemoveBlock={(i) => removeBlock("base", i)}
+ onStep={setStep}
+ onAddStep={addStep}
+ onRemoveStep={removeStep}
+ />
+
+
+ {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
+
0}>
+ {t("tariff.tiersAdvanced")}
+ {t("tariff.tiersHint")}
+ {/* A stepped ("up-to") base rate cannot be combined with time tiers — the
+ engine would ignore them. Warn up-front; publishing is also blocked server-side. */}
+ {form.base.mode === "stepped" && form.tiers.length > 0 && (
+
+ {t("tariff.steppedTiersConflict")}
+
+ )}
+ {form.tiers.map((tr, i) => (
+
+
+ setTier(i, { name: e.target.value })}
+ placeholder={t("tariff.tierName")}
+ />
+ removeTier(i)}>
+ {t("tariff.remove")}
+
+
+
+ {t("tariff.tierPriority")}
+ setTier(i, { priority: e.target.value })} />
+ {t("tariff.tierCategory")}
+ setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
+ {t("tariff.tierDays")}
+
+ {[1, 2, 3, 4, 5, 6, 0].map((d) => (
+
+ toggleDow(i, d)} />
+ {t(`tariff.dow${d}`)}
+
+ ))}
+
+ {t("tariff.tierHours")}
+
+ setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
+ –
+ setTier(i, { toHour: e.target.value })} placeholder="06:00" />
+ {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
+ {t("tariff.tierOvernight")}
+ )}
+
+ {t("tariff.tierDates")}
+
+ setTier(i, { dateFrom: e.target.value })} />
+ –
+ setTier(i, { dateTo: e.target.value })} />
+
+
+
+
updatePricing(i, (p) => ({ ...p, mode }))}
+ onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))}
+ onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))}
+ onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))}
+ onBlock={(bi, patch) => setBlock(i, bi, patch)}
+ onAddBlock={() => addBlock(i)}
+ onRemoveBlock={(bi) => removeBlock(i, bi)}
+ />
+
+
+ ))}
+
+ {t("tariff.addTier")}
+
+
+
+ );
+}
+
+// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped
+// (up-to) / window package. The stepped mode is offered only where `allowStepped`
+// (the default card); the package mode only where `allowPackage` (tier cards — the
+// engine needs a window to be an occurrence of).
+function PricingEditor(props: {
+ t: (k: string) => string;
+ pricing: PricingForm;
+ allowStepped?: boolean;
+ allowPackage?: boolean;
+ onMode: (m: "ladder" | "flat" | "stepped" | "package") => void;
+ onFlat: (v: string) => void;
+ onPackage?: (v: string) => void;
+ onCap: (v: string) => void;
+ onBlock: (i: number, patch: Partial) => void;
+ onAddBlock: () => void;
+ onRemoveBlock: (i: number) => void;
+ onStep?: (i: number, patch: Partial) => void;
+ onAddStep?: () => void;
+ onRemoveStep?: (i: number) => void;
+}) {
+ const { t, pricing: p } = props;
+ return (
+
+ );
+}
diff --git a/apps/web/src/TariffLab.tsx b/apps/web/src/TariffLab.tsx
index 6db953d..9e72c96 100644
--- a/apps/web/src/TariffLab.tsx
+++ b/apps/web/src/TariffLab.tsx
@@ -1,21 +1,30 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
+ ApiError,
+ createTariffDraft,
+ deleteTariffDraft,
fetchTariff,
- loadSimSession,
+ fetchTariffDrafts,
+ publishTariffVersion,
simulateTariff,
+ updateTariffDraft,
type SimulateResult,
- type SimPayment,
+ type TariffDraft,
type TariffState,
} from "./api.js";
+import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
+import { Modal } from "./ui/Modal.js";
import { formatMoney, formatDuration } from "./lib/format.js";
-// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight
-// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY
-// published tariff version, with no real ledger writes. Build a hypothetical session
-// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any
-// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and
-// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md.
+// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
+// live in their own mutable table (tariff_drafts), so experimenting never churns the
+// immutable published versions or risks a half-baked card going live: the admin
+// composes a draft in the modal (the same form the composer page uses), simulates
+// hypothetical stays against it (entry + exit, nothing else), and only when satisfied
+// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME
+// `priceSession` the booth uses (server-side), so the lab and the live booth can
+// never diverge. No ledger writes. See wiki/concepts/tariff.md.
/** wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
function toLocalInput(iso: string): string {
@@ -33,50 +42,76 @@ function nowLocal(): string {
return toLocalInput(new Date().toISOString());
}
+/** What the simulation runs against: the live card, a historical published
+ * version, or one lab draft. */
+type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string };
+
+/** Modal state: a draft being composed (id null = not yet saved). */
+interface DraftEdit {
+ id: string | null;
+ name: string;
+ form: FormState;
+}
+
export function TariffLab() {
const { t } = useTranslation();
const [state, setState] = useState(null);
+ const [drafts, setDrafts] = useState([]);
+ const [selected, setSelected] = useState({ kind: "active" });
const [err, setErr] = useState(null);
+ const [notice, setNotice] = useState(null);
- // Inputs (datetime-local strings, local wall-clock).
+ // The hypothetical stay: entry + exit, nothing else.
const [entered, setEntered] = useState(() => {
const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString());
});
- const [asOf, setAsOf] = useState(nowLocal);
- const [category, setCategory] = useState("");
- const [versionId, setVersionId] = useState(""); // "" = active
- // Optional single hypothetical payment (the latest grants the walk-back grace).
- const [paid, setPaid] = useState(false);
- const [paidAt, setPaidAt] = useState(nowLocal);
- const [graceMin, setGraceMin] = useState("5");
- // Load-a-real-ticket.
- const [ticket, setTicket] = useState("");
- const [loadMsg, setLoadMsg] = useState(null);
+ const [exit, setExit] = useState(nowLocal);
const [result, setResult] = useState(null);
const [busy, setBusy] = useState(false);
+ // The draft-composer modal.
+ const [edit, setEdit] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [editErr, setEditErr] = useState(null);
+
+ async function refresh() {
+ const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
+ setState(s);
+ setDrafts(d.drafts);
+ return d.drafts;
+ }
useEffect(() => {
- fetchTariff()
- .then(setState)
- .catch((e) => setErr((e as Error).message));
+ refresh().catch((e) => setErr((e as Error).message));
}, []);
+ const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null;
+ const selectedVersion =
+ selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null;
+
+ function select(sel: Selection) {
+ setSelected(sel);
+ setResult(null); // a stale price against another card would mislead
+ setErr(null);
+ setNotice(null);
+ }
+
async function run() {
setErr(null);
setBusy(true);
try {
- const payments: SimPayment[] = paid
- ? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
- : [];
const r = await simulateTariff({
enteredAt: fromLocalInput(entered),
- asOf: fromLocalInput(asOf),
- payments,
- category: category.trim() || undefined,
- tariffVersionId: versionId || undefined,
+ asOf: fromLocalInput(exit),
+ // A draft carries its own structure+currency; a historical version is
+ // referenced by id; otherwise the ACTIVE version.
+ ...(selectedDraft
+ ? { structure: selectedDraft.structure, currency: selectedDraft.currency }
+ : selectedVersion
+ ? { tariffVersionId: selectedVersion.id }
+ : {}),
});
setResult(r);
} catch (e) {
@@ -87,162 +122,291 @@ export function TariffLab() {
}
}
- async function loadTicket() {
- setLoadMsg(null);
+ // --- draft actions ---
+ function newDraft() {
+ // Start from the live card when there is one — the admin usually experiments
+ // with a variation of today's prices, not from a blank slate.
+ const form = state?.active ? formFromActive(state) : emptyForm();
+ setEditErr(null);
+ setEdit({ id: null, name: "", form });
+ }
+ function editDraft(d: TariffDraft) {
+ setEditErr(null);
+ setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) });
+ }
+
+ async function saveDraft() {
+ if (!edit) return;
+ setSaving(true);
+ setEditErr(null);
+ try {
+ const body = {
+ name: edit.name.trim(),
+ currency: edit.form.currency.trim().toUpperCase(),
+ structure: toStructure(edit.form),
+ };
+ const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body);
+ await refresh();
+ setEdit(null);
+ select({ kind: "draft", id: saved.id });
+ } catch (e) {
+ const text =
+ e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
+ setEditErr(text);
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function removeDraft(d: TariffDraft) {
+ if (!confirm(t("lab.confirmDelete", { name: d.name }))) return;
setErr(null);
try {
- const s = await loadSimSession(ticket.trim());
- setEntered(toLocalInput(s.enteredAt));
- setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal());
- setCategory(s.category ?? "");
- setVersionId(s.tariffVersionId ?? "");
- const last = s.payments.at(-1);
- if (last) {
- setPaid(true);
- setPaidAt(toLocalInput(last.paidAt));
- setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : "");
- } else {
- setPaid(false);
- }
- setLoadMsg(t("lab.loaded", { id: s.identity }));
+ await deleteTariffDraft(d.id);
+ await refresh();
+ select({ kind: "active" });
} catch (e) {
setErr((e as Error).message);
}
}
- const currency = result?.currency ?? state?.active?.currency ?? "ALL";
+ async function publishDraft(d: TariffDraft) {
+ if (!confirm(t("lab.confirmPublish", { name: d.name }))) return;
+ setErr(null);
+ setNotice(null);
+ try {
+ // The draft's name rides along onto the immutable version.
+ await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name });
+ await refresh();
+ setNotice(t("tariff.publishedOk"));
+ } catch (e) {
+ const text =
+ e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message;
+ setErr(text);
+ }
+ }
+
+ const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL";
return (
{t("lab.title")}
{t("lab.intro")}
- {/* Load a real ticket */}
-
-
- {t("lab.loadTicket")}
- setTicket(e.target.value)}
- placeholder={t("lab.loadTicketPh")}
- />
-
-
- {t("lab.load")}
-
- {loadMsg &&
{loadMsg} }
-
+
+ {/* Main: the hypothetical stay + result, priced against the selection. */}
+
+ {/* What we're pricing against + draft actions. */}
+
+
+ {selectedDraft
+ ? selectedDraft.name
+ : selectedVersion
+ ? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
+ : t("lab.activeTariff")}
+
+ {selectedDraft && (
+ <>
+ editDraft(selectedDraft)}>
+ {t("lab.edit")}
+
+ publishDraft(selectedDraft)}>
+ {t("lab.publish")}
+
+ removeDraft(selectedDraft)}>
+ {t("lab.delete")}
+
+ >
+ )}
+ {notice && {notice} }
+
- {/* Hypothetical session inputs */}
-
-
-
-
- {busy ? t("lab.pricing") : t("lab.price")}
-
- {err && {err} }
-
-
- {result && (
-
- {/* Outcome */}
-
-
{t("lab.outcome")}
-
- {t("lab.amountDue")}
- {formatMoney(result.pricing.amountMinor, currency)}
- {t("lab.billedPeriod")}
-
- {formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
- {result.pricing.overstay && (
-
- {t("lab.overstay")}
-
- )}
- {result.pricing.withinGrace && (
-
- {t("lab.settled")}
-
- )}
-
- {t("lab.periodStart")}
- {new Date(result.pricing.periodStart).toLocaleString()}
- {result.pricing.graceExpiresAt && (
- <>
- {t("lab.graceExpires")}
- {new Date(result.pricing.graceExpiresAt).toLocaleString()}
- >
- )}
-
-
-
- {/* Duration curve from entry — see where the cap flattens / windows shift. */}
-
-
{t("lab.curve")}
-
{t("lab.curveHint")}
-
-
- {result.curve.map((c) => (
-
- {labelMin(c.minutes)}
- {formatMoney(c.amountMinor, currency)}
-
- ))}
-
-
-
- )}
+
+ {/* Sidebar: lab drafts + the full published history; click any to price
+ against it. */}
+
+
+
+ {/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
+
setEdit(null)}
+ title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
+ width="max-w-3xl"
+ >
+ {edit && (
+
+
+ {t("lab.draftName")}
+ setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
+ placeholder={t("lab.draftNamePh")}
+ />
+
+
setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
+ />
+
+
+ {saving ? t("lab.savingDraft") : t("lab.saveDraft")}
+
+ {editErr && {editErr} }
+
+
+ )}
+
);
}
diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts
index 7ed8dd6..5e36d46 100644
--- a/apps/web/src/api.ts
+++ b/apps/web/src/api.ts
@@ -669,10 +669,14 @@ export interface TariffCard {
priority: number;
category?: string;
window?: TariffWindow;
+ /** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
flatMinor?: number;
blocks?: TariffBlock[];
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
steps?: TariffStep[];
+ /** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence
+ * ("any presence in the window = this price"). Mirrors @parking/shared. */
+ packageMinor?: number;
dailyCapMinor?: number | null;
}
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
@@ -702,6 +706,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 {
export interface TariffVersion {
id: string;
tariffId: string;
+ /** Optional human label, stamped at publish (e.g. carried from a lab draft). */
+ name?: string | null;
effectiveFrom: string;
currency: string;
structure: TariffStructure;
@@ -723,6 +729,7 @@ export function publishTariffVersion(body: {
currency: string;
structure: TariffStructure;
effectiveFrom?: string;
+ name?: string;
}): Promise
{
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
}
@@ -761,18 +768,40 @@ export function simulateTariff(body: SimulateBody): Promise {
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
}
-export interface SimSessionLoad {
- identity: string;
- enteredAt: string;
- exitedAt: string | null;
- payments: SimPayment[];
- category: string | null;
- tariffVersionId: string | null;
+// --- Tariff Lab drafts ------------------------------------------------------
+// Mutable experimental rate cards — the lab composes + simulates these, and
+// publishing one goes through the normal immutable-version path above.
+
+export interface TariffDraft {
+ id: string;
+ name: string;
+ currency: string;
+ structure: TariffStructure;
+ createdBy: string | null;
+ createdAt: string;
+ updatedAt: string;
}
-/** Prefill the lab from a real ledger session. */
-export function loadSimSession(identity: string): Promise {
- return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`);
+export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
+ return apiFetch("/api/tariff/drafts");
+}
+
+export interface TariffDraftBody {
+ name: string;
+ currency: string;
+ structure: TariffStructure;
+}
+
+export function createTariffDraft(body: TariffDraftBody): Promise {
+ return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
+}
+
+export function updateTariffDraft(id: string, body: TariffDraftBody): Promise {
+ return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
+}
+
+export function deleteTariffDraft(id: string): Promise {
+ return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
}
// --- Subscriptions --------------------------------------------------------
diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts
index 4c5168a..7d41c68 100644
--- a/apps/web/src/lib/i18n/en.ts
+++ b/apps/web/src/lib/i18n/en.ts
@@ -319,25 +319,30 @@ export const en: Catalog = {
bandDuration: "Band duration",
hoursUnit: "hours",
egHours: "e.g. 2",
- pricePerIncrement: "Price / increment",
+ pricePerIncrement: "Price / increment (per hour)",
thereafter: "thereafter (open-ended)",
remove: "Remove",
addBlock: "+ Add block",
publishNewVersion: "Publish new version",
publishing: "Publishing…",
+ versionNamePh: "Version name (optional), e.g. Summer 2026",
publishedOk: "New tariff version published — it's now the active rate.",
defaultCard: "Base rate (always active)",
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
- modeFlat: "Flat price",
+ modeFlat: "Flat price / hour",
modeStepped: "By duration (up-to)",
+ modePackage: "Window package (one total)",
+ packageHint:
+ "ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.",
+ packageTotal: "Package total",
steppedHint:
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
stepUpTo: "Up to",
stepTotal: "Total price",
addStep: "+ Add row",
steppedTiersConflict:
- "⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.",
+ "⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price / hour'. Publishing is blocked until this is fixed.",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
@@ -513,21 +518,26 @@ export const en: Catalog = {
lab: {
title: "Tariff Lab",
intro:
- "Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
- loadTicket: "Load from a real ticket",
- loadTicketPh: "Ticket number / identity",
- load: "Load",
- loaded: "Loaded session {{id}}",
- tariffVersion: "Tariff version",
- activeVersion: "Active version (current)",
+ "Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.",
+ drafts: "Lab tariffs",
+ newDraft: "New draft",
+ activeTariff: "Active tariff",
+ published: "Published versions",
+ noDrafts: "No lab tariffs yet — create a draft to experiment.",
+ edit: "Edit",
+ publish: "Publish",
+ delete: "Delete",
+ draftName: "Name",
+ draftNamePh: "e.g. Winter proposal",
+ saveDraft: "Save draft",
+ savingDraft: "Saving…",
+ newDraftTitle: "New lab tariff",
+ editDraftTitle: "Edit lab tariff",
+ confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.',
+ confirmDelete: 'Delete lab tariff "{{name}}"?',
entered: "Entered",
- asOf: "As of (now/exit)",
+ exit: "Exit",
now: "Now",
- category: "Category",
- categoryPh: "e.g. bus (blank = car)",
- payment: "Payment",
- paid: "paid",
- graceMin: "grace (min)",
price: "Compute price",
pricing: "Pricing…",
outcome: "Outcome",
diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts
index 01239b9..9354c66 100644
--- a/apps/web/src/lib/i18n/sq.ts
+++ b/apps/web/src/lib/i18n/sq.ts
@@ -322,25 +322,30 @@ export const sq = {
bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë",
egHours: "p.sh. 2",
- pricePerIncrement: "Çmimi / interval",
+ pricePerIncrement: "Çmimi / interval (orë)",
thereafter: "më pas (i hapur)",
remove: "Hiq",
addBlock: "+ Shto bllok",
publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…",
+ versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026",
publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
defaultCard: "Tarifa bazë (gjithmonë aktive)",
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
modeLadder: "Shkallë orësh",
- modeFlat: "Çmim fiks",
+ modeFlat: "Çmim fiks / orë",
modeStepped: "Sipas kohëzgjatjes (deri-në)",
+ modePackage: "Paketë dritareje (një total)",
+ packageHint:
+ "NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.",
+ packageTotal: "Çmimi i paketës",
steppedHint:
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
stepUpTo: "Deri në",
stepTotal: "Çmimi total",
addStep: "+ Shto rresht",
steppedTiersConflict:
- "⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.",
+ "⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks / orë'. Publikimi bllokohet derisa kjo të rregullohet.",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
@@ -525,21 +530,26 @@ export const sq = {
lab: {
title: "Lab Tarife",
intro:
- "Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
- loadTicket: "Ngarko nga një biletë reale",
- loadTicketPh: "Numri i biletës / identiteti",
- load: "Ngarko",
- loaded: "U ngarkua sesioni {{id}}",
- tariffVersion: "Versioni i tarifës",
- activeVersion: "Versioni aktiv (i tanishëm)",
+ "Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
+ drafts: "Tarifa laboratori",
+ newDraft: "Draft i ri",
+ activeTariff: "Tarifa aktive",
+ published: "Versione të publikuara",
+ noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
+ edit: "Ndrysho",
+ publish: "Publiko",
+ delete: "Fshi",
+ draftName: "Emri",
+ draftNamePh: "p.sh. Propozimi i dimrit",
+ saveDraft: "Ruaj draftin",
+ savingDraft: "Duke ruajtur…",
+ newDraftTitle: "Tarifë e re laboratori",
+ editDraftTitle: "Ndrysho tarifën e laboratorit",
+ confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.',
+ confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?',
entered: "Hyrja",
- asOf: "Deri më (tani/dalja)",
+ exit: "Dalja",
now: "Tani",
- category: "Kategoria",
- categoryPh: "p.sh. bus (bosh = makinë)",
- payment: "Pagesa",
- paid: "u pagua",
- graceMin: "afati (min)",
price: "Llogarit çmimin",
pricing: "Duke llogaritur…",
outcome: "Rezultati",
diff --git a/packages/db/drizzle/0021_tariff_drafts.sql b/packages/db/drizzle/0021_tariff_drafts.sql
new file mode 100644
index 0000000..d25a977
--- /dev/null
+++ b/packages/db/drizzle/0021_tariff_drafts.sql
@@ -0,0 +1,14 @@
+-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes
+-- experimental rate cards here, simulates them against hypothetical stays, and only
+-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable —
+-- a draft prices nothing and signs nothing; experimenting through real publishes would
+-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md.
+CREATE TABLE `tariff_drafts` (
+ `id` text PRIMARY KEY NOT NULL,
+ `name` text NOT NULL,
+ `currency` text NOT NULL,
+ `structure` text NOT NULL,
+ `created_by` text,
+ `created_at` text DEFAULT (current_timestamp) NOT NULL,
+ `updated_at` text DEFAULT (current_timestamp) NOT NULL
+);
diff --git a/packages/db/drizzle/0022_tariff_version_name.sql b/packages/db/drizzle/0022_tariff_version_name.sql
new file mode 100644
index 0000000..7d1f676
--- /dev/null
+++ b/packages/db/drizzle/0022_tariff_version_name.sql
@@ -0,0 +1,6 @@
+-- Optional name on published tariff versions (2026-07-05). The lab's draft workflow gave
+-- rate cards human names; published versions were only tellable apart by effective date +
+-- UUID prefix. The name is stamped at publish (carried from the lab draft, or typed in the
+-- composer) and is immutable like the rest of the row. Nullable — old versions and unnamed
+-- publishes are fine.
+ALTER TABLE `tariff_versions` ADD `name` text;
diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json
index fad373b..9036507 100644
--- a/packages/db/drizzle/meta/_journal.json
+++ b/packages/db/drizzle/meta/_journal.json
@@ -148,6 +148,20 @@
"when": 1781886300000,
"tag": "0020_entry_presence_bypass",
"breakpoints": true
+ },
+ {
+ "idx": 21,
+ "version": "6",
+ "when": 1781886400000,
+ "tag": "0021_tariff_drafts",
+ "breakpoints": true
+ },
+ {
+ "idx": 22,
+ "version": "6",
+ "when": 1781886500000,
+ "tag": "0022_tariff_version_name",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts
index b964183..bc1921e 100644
--- a/packages/db/src/schema.ts
+++ b/packages/db/src/schema.ts
@@ -316,6 +316,10 @@ export const tariffs = sqliteTable("tariffs", {
export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(),
+ // Optional human label ("Winter 2027", carried from the lab draft it was published
+ // from). Stamped at publish, immutable like the rest of the row — versions are
+ // told apart in the UI by name, not UUID prefix.
+ name: text("name"),
// The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float.
@@ -329,6 +333,29 @@ export const tariffVersions = sqliteTable("tariff_versions", {
.default(sql`(current_timestamp)`),
});
+// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one
+// exception to "editing publishes a version"): a draft prices nothing and signs
+// nothing — it exists so the admin can experiment in the lab without churning real
+// tariff_versions (each publish is permanent; experimenting through publishes would
+// bury the history in noise and risk a wrong card going live). Publishing a draft
+// goes through the normal POST /api/tariff/versions path (validated, tz-stamped,
+// immutable). See wiki/concepts/tariff.md (Tariff Lab).
+export const tariffDrafts = sqliteTable("tariff_drafts", {
+ id: text("id").primaryKey(),
+ name: text("name").notNull(),
+ currency: text("currency").notNull(),
+ // Same TariffStructure shape as tariff_versions.structure; validated on save so
+ // the lab can always simulate it.
+ structure: text("structure", { mode: "json" }).notNull().$type>(),
+ createdBy: text("created_by"),
+ createdAt: text("created_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+ updatedAt: text("updated_at")
+ .notNull()
+ .default(sql`(current_timestamp)`),
+});
+
// --- Subscriptions --------------------------------------------------------
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL /
// month) instead of paying per stay. Mutable master data; every USE still produces a
@@ -513,6 +540,7 @@ export type SetupStateRow = typeof setupState.$inferSelect;
export type SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect;
+export type TariffDraftRow = typeof tariffDrafts.$inferSelect;
export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md
index d64e59b..00461ae 100644
--- a/wiki/concepts/tariff.md
+++ b/wiki/concepts/tariff.md
@@ -197,24 +197,44 @@ The admin authors the rate card at runtime — no hand-seeding:
- Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the
pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices).
-### Tariff Lab (simulator, as-built 2026-06-20)
+### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05)
The tariff engine is a **pure function of time**, but you could previously only *exercise* it by
waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap:
-price a session at **any** instant against **any** tariff version in seconds.
+compose an **experimental rate card**, price hypothetical stays against it in seconds, and publish
+only when satisfied.
-- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available
- on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical
+- **Drafts (`tariff_drafts` table, 2026-07-05).** The lab's rate cards live in their own **mutable**
+ table — the one deliberate exception to "editing publishes a version". Rationale (operator ask,
+ 2026-07-05): experimenting by publishing real versions churns the immutable history with noise AND
+ risks a wrong card being live while the admin iterates ("we risk taking tickets with a grossly
+ wrong version"). A draft prices nothing and signs nothing, so mutability is safe; the ONLY way a
+ draft affects a customer is publication through the normal `POST /api/tariff/versions` path
+ (validated, tz-stamped, immutable, effectiveFrom-guarded). Drafts are **validated + tz-stamped on
+ save exactly like a publish**, so a saved draft can always be simulated and "Publish" can never
+ fail on a card that saved fine.
+- **API** (`apps/server/src/routes/tariffs.ts`): `GET/POST/PUT/DELETE /api/tariff/drafts[...]`
+ (list `tariff:read`; mutations `tariff:update`). `POST /api/tariff/simulate` prices a hypothetical
session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and
returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you
- SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity`
- prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are
- **read-only — no ledger writes.**
-- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any
- historical), set entry / "as of" times, an optional payment (with its grace), and a category; or
- "Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/
- settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real
- overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay).
+ SEE where the daily cap flattens or a window shifts); the lab passes a draft's stored `structure`
+ inline. `GET /api/tariff/simulate/session/:identity` (prefill from a real ledger session) still
+ exists API-side but the UI no longer uses it. All **read-only — no ledger writes.**
+- **UI** (`apps/web/src/TariffLab.tsx`, Setup → Tariff → "Tariff Lab" tab): a **sidebar lists every
+ lab draft AND the full published history** (active card first, then older immutable versions) —
+ click any to price against it (drafts send their structure inline; published versions go by
+ `tariffVersionId`). Published versions carry an **optional name** (`tariff_versions.name`,
+ migration 0022, stamped at publish and immutable like the row): publishing a draft carries the
+ draft's name onto the version, and the composer page grew an optional version-name field — so
+ history reads "Winter 2027", not UUID prefixes. The main pane is a pure
+ **entry/exit** pair (the 2026-06-20 ticket-loader, payment, and category inputs were dropped in the
+ redesign — the lab is for composing rates, not re-evaluating tickets) plus amount due, billed
+ period, overstay/settled state, and the curve. **"New draft" / "Edit" open the composer form in a
+ modal** — the *same* form the `/setup/tariff` page uses, extracted to
+ `apps/web/src/TariffEditorForm.tsx` (new drafts prefill from the active card). Per-draft
+ **Publish** (confirm prompt) goes through the normal immutable-version path. Prices via the same
+ `priceSession` the booth uses, so the lab and the live booth can never diverge.
+ See [[booth-exit-flow]] (overstay).
## The pay-on-foot consequence
diff --git a/wiki/log.md b/wiki/log.md
index acad2c3..ee1d1fa 100644
--- a/wiki/log.md
+++ b/wiki/log.md
@@ -2282,3 +2282,41 @@ ISO-8601 UTC + level names (pinoDbStream hardened to accept both encodings so th
silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3
(≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by
SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days.
+
+## [2026-07-05] update | Window-package tariff mode (packageMinor) + honest flat labels
+
+Tariff-lab verification of a 1,850 ALL bill exposed a field misread: the V2 card "flat price" is
+PER INCREMENT (400/h), not per window — park-buzi's "night 400" card billed each night hour 400.
+Built the missing concept on [[tariff-time-tiers]]: `packageMinor`, a whole-window package
+("any presence in 20:00–07:00 = 400 total"). Operator decisions: per-occurrence repeat (two nights
+= two charges), any-touch-pays-full, windowed-cards-only (base "price per day" = a 1-row up-to
+table). Engine charges once per contiguous run of increments the card wins, tracked across
+rolling-day segments (a night crossing the 24h boundary charges once). Validator: exclusive with
+flat/blocks/steps, no per-card cap, defaultCard forbidden. Composer offers the mode on tier cards;
+flat relabeled "Flat price / hour" (sq+en). Also flagged from the same session: windowed-card
+dailyCapMinor is inert by design (only the base card's cap clamps a day) — park-buzi's weekend
+card carries a dead 1000 cap. Tests: shared 93 green (6 new), server 283 green.
+
+## [2026-07-05] update | Tariff Lab redesign: DB-backed drafts, sidebar, composer modal
+
+The lab previously simulated only against PUBLISHED versions, so experimenting meant publishing —
+churning the immutable history and risking real tickets pricing against a half-baked card while
+the admin iterated (operator: "we risk taking tickets with a grossly wrong version"). Redesign on
+[[tariff]] (Tariff Lab section): new mutable `tariff_drafts` table (migration 0021 — the one
+deliberate exception to "editing publishes a version"; a draft prices/signs nothing, only the
+normal publish path makes it real), drafts validated + tz-stamped on save exactly like a publish,
+CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update). UI rebuilt: sidebar
+lists active card + drafts (click to price against), main pane cut to pure entry/exit (ticket
+loader, payment, category inputs dropped), composer form extracted to TariffEditorForm.tsx and
+reused in a modal (new drafts prefill from the active card), per-draft Publish with confirm.
+Simulation passes the draft's stored structure inline to the existing /api/tariff/simulate.
+Tests: server 288 green (5 new: RBAC, roundtrip, validation, tz-stamp + simulate + publish flow).
+
+## [2026-07-05] update | Published tariff versions get optional names + lab sidebar lists history
+
+Follow-up to the lab redesign (same session): the sidebar now also lists the PUBLISHED versions
+(active first, then the immutable history; click to price against by tariffVersionId), and
+`tariff_versions` gained a nullable `name` (migration 0022) — stamped at publish, immutable like
+the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew
+an optional version-name field (never prefilled — republishing a tweak under last season's name
+would mislabel history). Details on [[tariff]] (Tariff Lab section).