From d9e6c1383125a46fac3fae36443088d7d01d148f Mon Sep 17 00:00:00 2001
From: Julian Cuni
Date: Sun, 5 Jul 2026 14:31:25 +0200
Subject: [PATCH 1/6] feat(tariff): whole-window package pricing mode
(packageMinor)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A windowed card can now charge ONE total for any presence in its window —
the real night rate ("20:00–07:00 = 400, leave earlier and it's still
400"), which the per-increment flatMinor could not express (park-buzi's
"night 400" card billed 400/HOUR). Engine charges once per contiguous run
of increments the card wins, tracked across rolling-day segments so a
night crossing the 24h boundary charges once; out-of-window increments
price by the base card as usual.
Operator decisions (2026-07-05): per-occurrence repeat (two nights = two
charges), any-touch-pays-full, windowed cards only (a base "price per
day" is a 1-row up-to table). Validator: mutually exclusive with
flat/blocks/steps, no per-card cap, forbidden on the defaultCard.
flatMinor docs clarified as PER INCREMENT. 6 new engine tests.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
---
packages/shared/src/index.ts | 47 ++++++++++++++++++++-----
packages/shared/src/tariff.test.ts | 56 +++++++++++++++++++++++++++++-
wiki/concepts/tariff-time-tiers.md | 19 ++++++++--
3 files changed, 110 insertions(+), 12 deletions(-)
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index e925c4d..a1092bb 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -606,8 +606,9 @@ export interface TariffWindow {
readonly toHour?: string;
}
-/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap).
- * `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */
+/** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table
+ * (defaultCard only), or a whole-window package (windowed cards only). The pricing
+ * fields are mutually exclusive — exactly one. The defaultCard has no window. */
export interface TariffCard {
/** Human label (also the final, deterministic precedence tiebreak). */
readonly name: string;
@@ -617,13 +618,21 @@ export interface TariffCard {
readonly category?: string;
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
readonly window?: TariffWindow;
- /** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */
+ /** Flat price PER BILLING INCREMENT (an hourly flat rate at increment 60) —
+ * mutually exclusive with the other pricing fields. NOT a whole-stay price;
+ * for "one total for the whole window" use `packageMinor`. */
readonly flatMinor?: number;
- /** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
+ /** Marginal block ladder (mutually exclusive with the other pricing fields); last open-ended. */
readonly blocks?: readonly TariffBlock[];
- /** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
- * `blocks`). The top tier's total is this card's per-day price. */
+ /** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
+ * pricing fields; defaultCard only). The top tier's total is the per-day price. */
readonly steps?: readonly TariffStep[];
+ /** WINDOW PACKAGE (windowed cards only, 2026-07-05): ONE total charged per
+ * contiguous occurrence of this card winning increments — e.g. "any presence in
+ * the 20:00–07:00 window = 400, leave earlier and it's still 400". Any touch of
+ * the window pays the full package; a stay spanning two nights pays it twice
+ * (once per occurrence). Mutually exclusive with the other pricing fields. */
+ readonly packageMinor?: number;
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
* a mixed day (see computeFeeV2). null = no cap. */
readonly dailyCapMinor?: number | null;
@@ -856,18 +865,30 @@ function computeFeeV2(
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
let total = 0;
+ // WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
+ // contiguous run of increments it wins (an "occurrence" — e.g. one night), however
+ // little of the window the car actually used. The tracker survives the day-segment
+ // loop so a night run crossing the rolling-24h boundary charges once, not twice;
+ // the charge lands in the segment where the occurrence starts (that day's cap
+ // applies to it). A stay touching the window on two different nights = two
+ // occurrences = two charges.
+ let prevWinner: TariffCard | null = null;
for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0;
for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall);
- if (card.flatMinor != null) {
+ if (card.packageMinor != null) {
+ // First increment of a new occurrence pays the package; the rest ride free.
+ if (prevWinner !== card) segFee += card.packageMinor;
+ } else if (card.flatMinor != null) {
segFee += card.flatMinor;
} else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart);
}
+ prevWinner = card;
}
if (dayCap != null) segFee = Math.min(segFee, dayCap);
total += segFee;
@@ -983,9 +1004,17 @@ function validateCard(c: Partial | undefined, label: string, isDefau
const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null;
const hasStepTable = c.steps != null;
- const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
+ const hasPackage = c.packageMinor != null;
+ const modes = [hasFlat, hasBlocks, hasStepTable, hasPackage].filter(Boolean).length;
if (modes !== 1) {
- errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
+ errs.push(`${label} must set exactly one of flatMinor, blocks, steps, or packageMinor`);
+ } else if (hasPackage) {
+ // A whole-window package needs a window to be an occurrence of — meaningless on
+ // the always-active defaultCard (a base "one price per stay/day" is a 1-row
+ // stepped table there). See wiki/concepts/tariff-time-tiers.md.
+ if (isDefault) errs.push(`${label}: packageMinor (whole-window package) is only allowed on a windowed card`);
+ nonNegInt(c.packageMinor, `${label}.packageMinor`, errs);
+ if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to a window package (the package IS the window's total)`);
} else if (hasFlat) {
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
diff --git a/packages/shared/src/tariff.test.ts b/packages/shared/src/tariff.test.ts
index 6a758fd..3cc7de3 100644
--- a/packages/shared/src/tariff.test.ts
+++ b/packages/shared/src/tariff.test.ts
@@ -223,7 +223,7 @@ describe("validate V2", () => {
});
it("rejects a card with both flat and blocks", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
- expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps");
+ expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, steps, or packageMinor");
});
it("rejects defaultCard with a window", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
@@ -383,3 +383,57 @@ describe("stepped (up-to) pricing — owner matrix", () => {
expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true);
});
});
+
+// ---------------------------------------------------------------------------
+// (j) WINDOW PACKAGE (packageMinor) — "any presence in the window = one total".
+// Charged once per contiguous occurrence of the card winning increments; any touch
+// pays the full package; a run crossing the rolling-24h boundary charges ONCE.
+// Base: open-ended 100/h ladder (minor 10000). Night card: 20:00–07:00 = 40000.
+// tz Europe/Tirane (summer = UTC+2); windows carry no dow so weekday is irrelevant.
+// ---------------------------------------------------------------------------
+describe("V2 window package (whole-window total)", () => {
+ const pkg: TariffStructureV2 = {
+ version: 2,
+ tz: "Europe/Tirane",
+ gracePeriodEntryMin: 0,
+ incrementMin: 60,
+ lostTicketMinor: 0,
+ gracePeriodExitMin: 5,
+ overstay: "reprice",
+ defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
+ windowedCards: [{ name: "night", priority: 10, packageMinor: 40000, window: { fromHour: "20:00", toHour: "07:00" } }],
+ };
+ const fee = (enter: string, exit: string) => computeFee(enter, exit, pkg);
+
+ it("leave early, the package stays: 22:00→23:30 (90 min in-window) = 40000", () => {
+ expect(fee("2026-06-16T22:00:00+02:00", "2026-06-16T23:30:00+02:00")).toBe(40000);
+ });
+
+ it("any touch pays full: 06:00→06:45 (45 min at the window's tail) = 40000", () => {
+ expect(fee("2026-06-16T06:00:00+02:00", "2026-06-16T06:45:00+02:00")).toBe(40000);
+ });
+
+ it("increments inside one occurrence add nothing: a full night 20:00→07:00 = 40000", () => {
+ expect(fee("2026-06-16T20:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(40000);
+ });
+
+ it("mixed 29h stay: two occurrences + day hours; the run over the rolling-day boundary charges ONCE", () => {
+ // Enter Tue 02:00, exit Wed 07:00 (29 increments). Occurrence 1: 02:00–06:00 (the
+ // overnight window's tail) = 40000. Base: 07:00–19:00 = 13 × 10000. Occurrence 2:
+ // Tue 20:00 → Wed 06:00 — CROSSES the rolling-24h boundary (Wed 02:00) but is one
+ // contiguous run → one 40000, not two. Total 40000 + 130000 + 40000 = 210000.
+ expect(fee("2026-06-16T02:00:00+02:00", "2026-06-17T07:00:00+02:00")).toBe(210000);
+ });
+
+ it("validates: package on the defaultCard is rejected", () => {
+ const bad = { ...pkg, defaultCard: { name: "d", priority: 0, packageMinor: 40000 } };
+ expect(validateTariffStructure(bad).some((e) => /only allowed on a windowed card/.test(e))).toBe(true);
+ });
+
+ it("validates: package is exclusive with other pricing fields + the cap", () => {
+ const both = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, flatMinor: 1, window: { dow: [1] } }] };
+ expect(validateTariffStructure(both).some((e) => /exactly one of/.test(e))).toBe(true);
+ const capped = { ...pkg, windowedCards: [{ name: "n", priority: 1, packageMinor: 1, dailyCapMinor: 100, window: { dow: [1] } }] };
+ expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
+ });
+});
diff --git a/wiki/concepts/tariff-time-tiers.md b/wiki/concepts/tariff-time-tiers.md
index a32e2b5..18f267c 100644
--- a/wiki/concepts/tariff-time-tiers.md
+++ b/wiki/concepts/tariff-time-tiers.md
@@ -2,7 +2,7 @@
type: concept
tags: [parking, domain, business, pricing, design]
sources: [parksql2017-legacy-schema]
-updated: 2026-06-18
+updated: 2026-07-05
status: settled
---
@@ -140,7 +140,22 @@ case stays one rate card; tiers are opt-in.
`DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
seam, mirroring per-relay direction.
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1
- is published as a single open-ended block (V1 has no flat field).
+ is published as a single open-ended block (V1 has no flat field). ⚠ `flatMinor` is **per billing
+ increment** (an hourly flat rate at increment 60) — NOT a whole-stay/whole-window price. This was
+ misread in the field (park-buzi published a "night 400" believing it covered the night; it billed
+ 400/h, 2026-07-05) — the UI now labels it "Flat price / hour" and the whole-window need got its own
+ mode:
+- **WINDOW PACKAGE (`packageMinor`, 2026-07-05 — windowed cards only).** "Any presence in this
+ window = ONE total" (the real night rate: 20:00–07:00 = 400, leave earlier and it's still 400).
+ Decisions (operator, 2026-07-05): charged **once per occurrence** (a stay touching two nights pays
+ twice); **any touch pays full** (an 06:30 arrival before the 07:00 close pays the whole package —
+ package pricing's accepted sharp edge); **not offered on the base card** (a base "one price per
+ day" is a 1-row up-to table — no duplicate concept). Engine: one charge per **contiguous run of
+ increments the card wins**, tracked across rolling-day segments so a night crossing the 24h
+ boundary charges once; out-of-window increments price by the base rate as usual; the charge lands
+ in the day segment where the occurrence starts (that day's default-card cap applies). Mutually
+ exclusive with flat/blocks/steps + no per-card cap (the package IS the window's total); validator
+ enforces both and the composer offers the mode only on tier cards.
- **UI** (`TariffComposer.tsx`): default card **front-and-centre** (flat/ladder toggle + cap); tiers
under a collapsed **"Advanced: time & seasonal tiers"** disclosure (window builder — dow checkboxes,
optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
From 52a89bfa569267532af4ab6a88a19d6fd7798ea0 Mon Sep 17 00:00:00 2001
From: Julian Cuni
Date: Sun, 5 Jul 2026 14:31:32 +0200
Subject: [PATCH 2/6] feat(web): move tariff lab under /setup/tariff as a
sub-tab
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The lab lived at /subscriptions/tariff-lab — the wrong neighborhood for a
tool that tests the rate card. /setup/tariff is now a small layout with
two sub-tabs (composer at the index, lab at /setup/tariff/lab) behind the
existing tariff:read gate. Old URLs (/subscriptions/tariff-lab and the
original /setup/tariff-lab) redirect, and the tariff-read-only redirect
branch on /subscriptions is gone with the tab.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
---
apps/web/src/router.tsx | 50 +++++++++++++++++++++++++++++------------
1 file changed, 36 insertions(+), 14 deletions(-)
diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx
index 384d1b0..24728ef 100644
--- a/apps/web/src/router.tsx
+++ b/apps/web/src/router.tsx
@@ -117,9 +117,10 @@ function SetupLayout() {
}
/** Subscriptions layout — a standalone top-level section (its own header nav entry),
- * with tabs for the subscriber catalog, the plan catalog, and the tariff lab. Each
- * tab is a gated child route; an operator with only subscription:read sees just the
- * first tab. */
+ * with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
+ * child route; an operator with only subscription:read sees just the first tab.
+ * (The tariff lab moved to /setup/tariff/lab, 2026-07-05 — it tests the tariff, so
+ * it lives with the tariff.) */
function SubscriptionsLayout() {
const { user } = rootRoute.useRouteContext();
const { t } = useTranslation();
@@ -129,7 +130,21 @@ function SubscriptionsLayout() {
+
+
+ );
+}
+
+/** Tariff layout — the rate-card hub under Setup: the composer (index) and the
+ * pricing LAB as sub-tabs. One tariff:read gate on the parent covers both. */
+function TariffLayout() {
+ const { t } = useTranslation();
+ return (
+
+
@@ -519,7 +534,10 @@ const legacyRedirects = (
["/shift", "/shifts"],
["/setup/subscriptions", "/subscriptions"],
["/setup/plans", "/subscriptions/plans"],
- ["/setup/tariff-lab", "/subscriptions/tariff-lab"],
+ // The tariff lab bounced twice: /setup/tariff-lab → /subscriptions/tariff-lab
+ // (2026-06-21) → /setup/tariff/lab (2026-07-05, back with the tariff it tests).
+ ["/setup/tariff-lab", "/setup/tariff/lab"],
+ ["/subscriptions/tariff-lab", "/setup/tariff/lab"],
["/setup/shifts", "/shifts"],
["/setup/reports", "/reports"],
] as const
@@ -633,8 +651,20 @@ const tariffRoute = createRoute({
getParentRoute: () => setupRoute,
path: "tariff",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
+ component: TariffLayout,
+});
+const tariffComposerRoute = createRoute({
+ getParentRoute: () => tariffRoute,
+ path: "/",
component: () => ,
});
+// The tariff LAB — lives with the tariff it tests (moved from /subscriptions,
+// 2026-07-05). The parent's tariff:read gate covers it.
+const tariffLabRoute = createRoute({
+ getParentRoute: () => tariffRoute,
+ path: "lab",
+ component: () => ,
+});
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog
// (index), the plan catalog, and the tariff lab live here, not under /setup. ---
@@ -651,7 +681,6 @@ const subscriptionsIndexRoute = createRoute({
beforeLoad: ({ context }) => {
if (can(context.user, "subscription:read")) return;
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" });
- if (can(context.user, "tariff:read")) throw redirect({ to: "/subscriptions/tariff-lab" });
throw redirect({ to: "/booth" });
},
component: function SubscriptionsRoute() {
@@ -665,12 +694,6 @@ const subscriptionPlansRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => ,
});
-const tariffLabRoute = createRoute({
- getParentRoute: () => subscriptionsRoute,
- path: "tariff-lab",
- beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
- component: () => ,
-});
const siteRoute = createRoute({
getParentRoute: () => setupRoute,
path: "site",
@@ -751,11 +774,10 @@ const routeTree = rootRoute.addChildren([
subscriptionsRoute.addChildren([
subscriptionsIndexRoute,
subscriptionPlansRoute,
- tariffLabRoute,
]),
setupRoute.addChildren([
setupDevicesRoute,
- tariffRoute,
+ tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
siteRoute,
usersRoute,
rolesRoute,
From fd9885e9ec0e43f9dcf9c654541df3682fb724f1 Mon Sep 17 00:00:00 2001
From: Julian Cuni
Date: Sun, 5 Jul 2026 14:31:42 +0200
Subject: [PATCH 3/6] 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() {
+
- {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
- wants tiers just edits this and publishes a bare V1 structure. */}
-
-
- {/* 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 && (
-
+
+ {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
+ wants tiers just edits this and publishes a bare V1 structure. */}
+
+
+ {/* 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 && (
+
diff --git a/apps/web/src/TariffEditorForm.tsx b/apps/web/src/TariffEditorForm.tsx
index 3407375..50cd04d 100644
--- a/apps/web/src/TariffEditorForm.tsx
+++ b/apps/web/src/TariffEditorForm.tsx
@@ -1,4 +1,5 @@
import { useTranslation } from "react-i18next";
+import { currencyOptions } from "./lib/currencies.js";
import {
isTariffV2,
type TariffBlock,
@@ -101,7 +102,7 @@ function emptyTier(): TierForm {
export function emptyForm(): FormState {
return {
- currency: "EUR",
+ currency: "ALL",
gracePeriodEntryMin: "15",
incrementMin: "60",
lostTicket: "20.00",
@@ -339,7 +340,11 @@ export function TariffEditorForm({
- set("currency", e.target.value)} maxLength={3} />
+
set("gracePeriodEntryMin", e.target.value)} />
diff --git a/apps/web/src/lib/currencies.ts b/apps/web/src/lib/currencies.ts
new file mode 100644
index 0000000..28416e1
--- /dev/null
+++ b/apps/web/src/lib/currencies.ts
@@ -0,0 +1,11 @@
+// The currencies the booth can price in (ISO 4217). Money is always stored as
+// integer minor units + one of these codes; the UI offers a closed select rather
+// than free text so a typo can never publish an unknown currency.
+export const CURRENCIES = ["ALL", "EUR", "USD"] as const;
+
+/** The select options: the known set, plus the current value when it's some
+ * historical code outside it (so an old record still displays + round-trips). */
+export function currencyOptions(current: string): string[] {
+ const cur = current.trim().toUpperCase();
+ return cur && !CURRENCIES.includes(cur as (typeof CURRENCIES)[number]) ? [...CURRENCIES, cur] : [...CURRENCIES];
+}