Merge branch 'dev' into stage
Build & push images / images (push) Successful in 2m50s

This commit is contained in:
2026-07-05 15:24:09 +02:00
31 changed files with 1820 additions and 823 deletions
+4 -1
View File
@@ -63,7 +63,10 @@ export async function shiftRoutes(app: FastifyInstance, shift: ShiftService): Pr
const from = canSeeAll ? q.from?.trim() || undefined : undefined; const from = canSeeAll ? q.from?.trim() || undefined : undefined;
const to = canSeeAll ? q.to?.trim() || undefined : undefined; const to = canSeeAll ? q.to?.trim() || undefined : undefined;
const shifts = shift.listShifts({ operator, from, to }); const shifts = shift.listShifts({ operator, from, to });
return { shifts, scope: canSeeAll ? "all" : "self" }; // Admins also get the distinct operator list (unfiltered) for the filter
// dropdown — operators don't see other names, so it's scope-gated.
if (canSeeAll) return { shifts, scope: "all", operators: shift.listOperators() };
return { shifts, scope: "self" };
}); });
// NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the // NB: drawer cash movements (record/review) moved to routes/drawer.ts (2026-07-01) — the
@@ -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);
});
});
+99 -8
View File
@@ -1,6 +1,6 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { FastifyInstance } from "fastify"; 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 { import {
computeFee, computeFee,
isTariffV2, isTariffV2,
@@ -25,10 +25,19 @@ interface PublishBody {
structure: TariffStructure; structure: TariffStructure;
/** When this version takes effect (ISO-8601). Defaults to now. */ /** When this version takes effect (ISO-8601). Defaults to now. */
effectiveFrom?: string; effectiveFrom?: string;
/** Optional human label (e.g. carried from the lab draft being published). */
name?: string;
} }
const SITE_TARIFF_NAME = "Site tariff"; 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. /** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write.
* Provide a structure source (one of): `tariffVersionId`, inline `structure`, or * Provide a structure source (one of): `tariffVersionId`, inline `structure`, or
* neither (uses the active version). */ * neither (uses the active version). */
@@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
"/api/tariff/versions", "/api/tariff/versions",
{ preHandler: writeGuard }, { preHandler: writeGuard },
async (req, reply) => { 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) { if (!currency || typeof currency !== "string" || currency.length < 3) {
return reply.code(400).send({ error: "currency (ISO 4217) required" }); return reply.code(400).send({ error: "currency (ISO 4217) required" });
} }
// For a windowed (V2) structure, stamp the wall-clock timezone from SITE config // 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 // (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. // validation that requires tz passes. A V1 (bare) structure is left untouched.
let toStore: TariffStructure = structure; const toStore = stampSiteTz(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 problems = validateTariffStructure(toStore); const problems = validateTariffStructure(toStore);
if (problems.length) { if (problems.length) {
@@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const row = { const row = {
id, id,
tariffId, tariffId,
name: typeof name === "string" && name.trim() ? name.trim() : null,
effectiveFrom: effective, effectiveFrom: effective,
currency, currency,
structure: toStore as unknown as Record<string, unknown>, structure: toStore as unknown as Record<string, unknown>,
@@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
}, },
); );
// --- Lab drafts ---------------------------------------------------------------
// The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the
// schema for why mutability is safe here — a draft prices nothing and signs
// nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the
// simulator can always price them and "publish this draft" can never surprise the
// admin with a card that saved fine but won't go live. Publishing a draft is just
// POST /api/tariff/versions with the draft's structure — same guard, same
// validation, same immutability.
app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => {
const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all();
return { drafts };
});
app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => {
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const now = new Date().toISOString();
const row = {
id: randomUUID(),
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
createdBy: req.user?.username ?? null,
createdAt: now,
updatedAt: now,
};
db.insert(tariffDrafts).values(row).run();
return reply.code(201).send(row);
});
app.put<{ Params: { id: string }; Body: DraftBody }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
const parsed = parseDraftBody(req.body);
if ("error" in parsed) return reply.code(400).send(parsed);
const patch = {
name: parsed.name,
currency: parsed.currency,
structure: parsed.structure as unknown as Record<string, unknown>,
updatedAt: new Date().toISOString(),
};
db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run();
return { ...existing, ...patch };
},
);
app.delete<{ Params: { id: string } }>(
"/api/tariff/drafts/:id",
{ preHandler: writeGuard },
async (req, reply) => {
const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get();
if (!existing) return reply.code(404).send({ error: "draft not found" });
db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run();
return reply.code(204).send();
},
);
/** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */
function parseDraftBody(
body: DraftBody | undefined,
): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } {
const b = body ?? ({} as DraftBody);
const name = (b.name ?? "").trim();
if (!name) return { error: "name required" };
const currency = (b.currency ?? "").trim().toUpperCase();
if (currency.length < 3) return { error: "currency (ISO 4217) required" };
const structure = stampSiteTz(b.structure);
const problems = validateTariffStructure(structure);
if (problems.length) return { error: "invalid tariff structure", problems };
return { name, currency, structure };
}
/** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the
* client); a V1 (bare) structure passes through untouched. */
function stampSiteTz(structure: TariffStructure): TariffStructure {
if (structure && isTariffV2(structure)) {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
return { ...structure, tz };
}
return structure;
}
/** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */ /** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */
function tariffVersionIdFor(whenIso: string): string | null { function tariffVersionIdFor(whenIso: string): string | null {
const tariffId = ensureSiteTariff(); const tariffId = ensureSiteTariff();
+7
View File
@@ -234,4 +234,11 @@ describe("close signs a Z-report; listShifts reads it back", () => {
await shift.open("bob"); await shift.close("bob"); await shift.open("bob"); await shift.close("bob");
expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]); expect(shift.listShifts({ operator: "alice" }).map((s) => s.operator)).toEqual(["alice"]);
}); });
it("listOperators: distinct + sorted, includes the OPEN shift's operator", async () => {
await shift.open("bob"); await shift.close("bob");
await shift.open("bob"); await shift.close("bob"); // twice — must stay distinct
await shift.open("alice"); // open, no z-report yet
expect(shift.listOperators()).toEqual(["alice", "bob"]);
});
}); });
+22
View File
@@ -177,6 +177,28 @@ export class ShiftService {
* The open shift (no z_report yet) is intentionally excluded — it's not a * The open shift (no z_report yet) is intentionally excluded — it's not a
* completed accountability period. Use `currentOpenShift()` for the live one. * completed accountability period. Use `currentOpenShift()` for the live one.
*/ */
/**
* Every operator that HAS a shift (closed z_reports + the open one, if any),
* distinct + sorted — feeds the admin filter dropdown so it can only ever ask
* for an operator that exists (the filter is an exact username match).
*/
listOperators(): string[] {
const rows = this.#db
.select()
.from(ledgerEvents)
.where(eq(ledgerEvents.type, "shift_z_report"))
.all();
const names = new Set<string>();
for (const r of rows) {
const op = ((r.payload ?? {}) as { operator?: string }).operator ?? r.identity;
if (op) names.add(op);
}
const open = this.currentOpenShift();
const openOp = open ? (((open.payload ?? {}) as { operator?: string }).operator ?? open.identity) : null;
if (openOp) names.add(openOp);
return [...names].sort((a, b) => a.localeCompare(b));
}
listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] { listShifts(opts: { operator?: string; from?: string; to?: string } = {}): ShiftSummary[] {
const rows = this.#db const rows = this.#db
.select() .select()
+4
View File
@@ -4,6 +4,10 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:," /> <link rel="icon" href="data:," />
<!-- Self-hosted primary face (offline appliance — no webfont CDN). Preload the
two weights on every screen so first paint doesn't flash the fallback. -->
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-400.woff2" as="font" type="font/woff2" crossorigin />
<link rel="preload" href="/fonts/chakra-petch/chakra-petch-latin-600.woff2" as="font" type="font/woff2" crossorigin />
<title>Parking System</title> <title>Parking System</title>
</head> </head>
<body> <body>
@@ -0,0 +1,93 @@
Copyright 2018 The Chakra Petch Project Authors (https://github.com/m4rc1e/Chakra-Petch.git)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+18 -3
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { import {
closeShift, closeShift,
fetchEvents, fetchEvents,
@@ -105,9 +105,17 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined, to: range?.to ? new Date(`${range.to}T23:59:59`).toISOString() : undefined,
}; };
const q = useQuery({ queryKey: ["shifts", applied], queryFn: () => fetchShifts(applied) }); // keepPreviousData: every filter change makes a NEW query key; without it the
// data (and with it `scope`) goes undefined for the fetch round-trip, which
// unmounted the admin filter controls mid-interaction and blanked the list.
const q = useQuery({
queryKey: ["shifts", applied],
queryFn: () => fetchShifts(applied),
placeholderData: keepPreviousData,
});
const isAdmin = q.data?.scope === "all"; const isAdmin = q.data?.scope === "all";
const closed = q.data?.shifts ?? []; const closed = q.data?.shifts ?? [];
const operators = q.data?.operators ?? [];
// The current/open shift sits at the TOP of the list (when present + visible to me). // The current/open shift sits at the TOP of the list (when present + visible to me).
const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed; const list: (ShiftSummary & { open?: boolean })[] = current && (isMine || isAdmin) ? [current, ...closed] : closed;
@@ -169,7 +177,14 @@ export function ShiftsHistory({ user, canManage = false }: { user: SessionUser |
{isAdmin && ( {isAdmin && (
<div className="field"> <div className="field">
<span className="label">{t("shifts.operator")}</span> <span className="label">{t("shifts.operator")}</span>
<input className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)} placeholder={t("shifts.allOperators")} /> {/* A select over operators that HAVE shifts — the server filter is an
exact username match, so free text could only miss. */}
<select className="input w-44" value={operator} onChange={(e) => setOperator(e.target.value)}>
<option value="">{t("shifts.allOperators")}</option>
{operators.map((op) => (
<option key={op} value={op}>{op}</option>
))}
</select>
</div> </div>
)} )}
</div> </div>
+6 -1
View File
@@ -14,6 +14,7 @@ import {
type SubscriptionPlan, type SubscriptionPlan,
} from "./api.js"; } from "./api.js";
import { Modal } from "./ui/Modal.js"; import { Modal } from "./ui/Modal.js";
import { currencyOptions } from "./lib/currencies.js";
// Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the // Admin-only subscription PLAN catalog. Plans are admin-composed, versioned config the
// operator sells from (so the operator never types a price). Editing a plan PUBLISHES A // operator sells from (so the operator never types a price). Editing a plan PUBLISHES A
@@ -348,7 +349,11 @@ export function SubscriptionPlansManager() {
<label className="label">{t("plans.pricePer")}</label> <label className="label">{t("plans.pricePer")}</label>
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" /> <input className="input w-28" value={form.priceMajor} inputMode="decimal" onChange={(e) => setForm((f) => f && { ...f, priceMajor: e.target.value })} placeholder="e.g. 800" />
<input className="input w-16" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })} /> <select className="input w-auto" value={form.currency} onChange={(e) => setForm((f) => f && { ...f, currency: e.target.value })}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span> <span className="text-[0.75rem] text-term-muted">/ {t(PERIOD_KEY[form.period])}</span>
</span> </span>
</div> </div>
+25 -555
View File
@@ -1,267 +1,23 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js";
ApiError, import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js";
fetchTariff,
isTariffV2,
publishTariffVersion,
type TariffBlock,
type TariffCard,
type TariffStep,
type TariffStructure,
type TariffState,
} from "./api.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 // 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 // sessions reprice correctly. The form machinery is shared with the Tariff Lab's
// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md. // draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the
// lab (a draft only becomes real through this same publish path). See
// Editable form mirror of TariffStructure, but money in major-unit strings. // wiki/concepts/tariff.md.
// 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<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
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),
};
}
export function TariffComposer() { export function TariffComposer() {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null); const [state, setState] = useState<TariffState | null>(null);
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(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 [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); 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 })); .catch((e) => setMsg({ kind: "err", text: (e as Error).message }));
}, []); }, []);
function set<K extends keyof FormState>(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<BlockForm>) {
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<StepForm>) {
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<TierForm>) {
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() { async function publish() {
setSaving(true); setSaving(true);
setMsg(null); setMsg(null);
try { 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(); const fresh = await fetchTariff();
setState(fresh); setState(fresh);
setVersionName("");
setMsg({ kind: "ok", text: t("tariff.publishedOk") }); setMsg({ kind: "ok", text: t("tariff.publishedOk") });
} catch (e) { } catch (e) {
const text = const text =
@@ -359,6 +63,7 @@ export function TariffComposer() {
</p> </p>
) : ( ) : (
<p className="mb-4 text-[0.75rem] text-term-muted"> <p className="mb-4 text-[0.75rem] text-term-muted">
{state.active.name ? `${state.active.name} — ` : ""}
{t("tariff.activeSince", { {t("tariff.activeSince", {
date: new Date(state.active.effectiveFrom).toLocaleString(), date: new Date(state.active.effectiveFrom).toLocaleString(),
count: state.versions.length, count: state.versions.length,
@@ -366,114 +71,15 @@ export function TariffComposer() {
</p> </p>
)} )}
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2"> <TariffEditorForm form={form} onChange={setForm} />
<label className="label">{t("tariff.currency")}</label>
<input className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)} maxLength={3} />
<label className="label">{t("tariff.freeEntryGrace")}</label>
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label className="label">{t("tariff.billingIncrement")}</label>
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label className="label">{t("tariff.lostTicketFee")}</label>
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label className="label">{t("tariff.exitGrace")}</label>
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never <div className="mt-6 flex flex-wrap items-center gap-3">
wants tiers just edits this and publishes a bare V1 structure. */} <input
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3> className="input w-64"
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p> value={versionName}
<div className="card card-body"> onChange={(e) => setVersionName(e.target.value)}
<PricingEditor placeholder={t("tariff.versionNamePh")}
t={t}
pricing={form.base}
allowStepped
onMode={(mode) => 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}
/> />
</div>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{/* 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 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
{t("tariff.steppedTiersConflict")}
</p>
)}
{form.tiers.map((tr, i) => (
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
<input
className="input w-40"
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
/>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.tierPriority")}</label>
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
<label className="label">{t("tariff.tierCategory")}</label>
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("tariff.tierHours")}</label>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label className="label">{t("tariff.tierDates")}</label>
<span className="inline-flex items-center gap-2">
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span className="text-term-muted">–</span>
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div className="mt-3 border-t border-term-border pt-3">
<PricingEditor
t={t}
pricing={tr.pricing}
onMode={(mode) => 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)}
/>
</div>
</fieldset>
))}
<button type="button" className="btn btn-sm" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}> <button type="button" className="btn btn-primary btn-lg" onClick={publish} disabled={saving}>
{saving ? t("tariff.publishing") : t("tariff.publishNewVersion")} {saving ? t("tariff.publishing") : t("tariff.publishNewVersion")}
</button> </button>
@@ -484,139 +90,3 @@ export function TariffComposer() {
</section> </section>
); );
} }
// 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<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
onStep?: (i: number, patch: Partial<StepForm>) => void;
onAddStep?: () => void;
onRemoveStep?: (i: number) => void;
}) {
const { t, pricing: p } = props;
return (
<div>
<div className="mb-3 flex gap-4 text-[0.75rem]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
{t("tariff.modeStepped")}
</label>
)}
</div>
{p.mode === "stepped" ? (
<>
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
<th />
</tr>
</thead>
<tbody>
{p.steps.map((s, i) => (
<tr key={i}>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
</td>
<td className="px-2">
{p.steps.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
{t("tariff.addStep")}
</button>
</div>
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
</div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
<th />
</tr>
</thead>
<tbody>
{p.blocks.map((b, i) => {
const isTail = i === p.blocks.length - 1;
return (
<tr key={i}>
<td className="px-2 py-1">
{isTail ? (
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
) : (
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td className="px-2 py-1">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
</td>
<td className="px-2">
{!isTail && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-3 flex items-center gap-4">
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
{t("tariff.addBlock")}
</button>
<span className="inline-flex items-center gap-2">
<span className="label">{t("tariff.dailyCap")}</span>
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
</span>
</div>
</>
)}
</div>
);
}
+609
View File
@@ -0,0 +1,609 @@
import { useTranslation } from "react-i18next";
import { currencyOptions } from "./lib/currencies.js";
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: "ALL",
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<TariffCard, "flatMinor" | "blocks" | "steps" | "packageMinor" | "dailyCapMinor"> {
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<K extends keyof FormState>(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<BlockForm>) {
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<StepForm>) {
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<TierForm>) {
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 (
<div>
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.currency")}</label>
<select className="input w-24" value={form.currency} onChange={(e) => set("currency", e.target.value)}>
{currencyOptions(form.currency).map((c) => (
<option key={c} value={c}>{c}</option>
))}
</select>
<label className="label">{t("tariff.freeEntryGrace")}</label>
<input className="input w-32" value={form.gracePeriodEntryMin} onChange={(e) => set("gracePeriodEntryMin", e.target.value)} />
<label className="label">{t("tariff.billingIncrement")}</label>
<input className="input w-32" value={form.incrementMin} onChange={(e) => set("incrementMin", e.target.value)} />
<label className="label">{t("tariff.lostTicketFee")}</label>
<input className="input w-32" value={form.lostTicket} onChange={(e) => set("lostTicket", e.target.value)} />
<label className="label">{t("tariff.exitGrace")}</label>
<input className="input w-32" value={form.gracePeriodExitMin} onChange={(e) => set("gracePeriodExitMin", e.target.value)} />
</div>
{/* The DEFAULT card — always-active rate. Front-and-centre; a site that never
wants tiers just edits this and publishes a bare V1 structure. */}
<h3 className="mt-6 mb-0.5 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.defaultCard")}</h3>
<p className="hint mb-2">{t("tariff.defaultCardHint")}</p>
<div className="card card-body">
<PricingEditor
t={t}
pricing={form.base}
allowStepped
onMode={(mode) => 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}
/>
</div>
{/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */}
<details className="mt-6" open={form.tiers.length > 0}>
<summary className="cursor-pointer text-h6 font-semibold uppercase tracking-wider text-term-text">{t("tariff.tiersAdvanced")}</summary>
<p className="hint mt-1.5 mb-2">{t("tariff.tiersHint")}</p>
{/* 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 && (
<p className="mb-3 rounded-term border border-term-red/50 bg-term-red/10 px-3 py-2 text-[0.75rem] text-term-red">
{t("tariff.steppedTiersConflict")}
</p>
)}
{form.tiers.map((tr, i) => (
<fieldset key={i} className="card mb-3 p-4">
<legend className="flex items-center gap-2 px-1">
<input
className="input w-40"
value={tr.name}
onChange={(e) => setTier(i, { name: e.target.value })}
placeholder={t("tariff.tierName")}
/>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeTier(i)}>
{t("tariff.remove")}
</button>
</legend>
<div className="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<label className="label">{t("tariff.tierPriority")}</label>
<input className="input w-20" value={tr.priority} onChange={(e) => setTier(i, { priority: e.target.value })} />
<label className="label">{t("tariff.tierCategory")}</label>
<input className="input w-40" value={tr.category} onChange={(e) => setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} />
<label className="label">{t("tariff.tierDays")}</label>
<span className="flex flex-wrap gap-2">
{[1, 2, 3, 4, 5, 6, 0].map((d) => (
<label key={d} className="inline-flex items-center gap-1 text-[0.75rem] text-term-text">
<input type="checkbox" className="accent-term-amber" checked={tr.dow.includes(d)} onChange={() => toggleDow(i, d)} />
{t(`tariff.dow${d}`)}
</label>
))}
</span>
<label className="label">{t("tariff.tierHours")}</label>
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={tr.fromHour} onChange={(e) => setTier(i, { fromHour: e.target.value })} placeholder="22:00" />
<span className="text-term-muted">–</span>
<input className="input w-20" value={tr.toHour} onChange={(e) => setTier(i, { toHour: e.target.value })} placeholder="06:00" />
{tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && (
<span className="text-[0.6875rem] text-term-muted">{t("tariff.tierOvernight")}</span>
)}
</span>
<label className="label">{t("tariff.tierDates")}</label>
<span className="inline-flex items-center gap-2">
<input type="date" className="input w-40" value={tr.dateFrom} onChange={(e) => setTier(i, { dateFrom: e.target.value })} />
<span className="text-term-muted">–</span>
<input type="date" className="input w-40" value={tr.dateTo} onChange={(e) => setTier(i, { dateTo: e.target.value })} />
</span>
</div>
<div className="mt-3 border-t border-term-border pt-3">
<PricingEditor
t={t}
pricing={tr.pricing}
allowPackage
onMode={(mode) => 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)}
/>
</div>
</fieldset>
))}
<button type="button" className="btn btn-sm" onClick={addTier}>
{t("tariff.addTier")}
</button>
</details>
</div>
);
}
// 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<BlockForm>) => void;
onAddBlock: () => void;
onRemoveBlock: (i: number) => void;
onStep?: (i: number, patch: Partial<StepForm>) => void;
onAddStep?: () => void;
onRemoveStep?: (i: number) => void;
}) {
const { t, pricing: p } = props;
return (
<div>
<div className="mb-3 flex flex-wrap gap-4 text-[0.75rem]">
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "ladder"} onChange={() => props.onMode("ladder")} />
{t("tariff.modeLadder")}
</label>
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")}
</label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
{t("tariff.modeStepped")}
</label>
)}
{props.allowPackage && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "package"} onChange={() => props.onMode("package")} />
{t("tariff.modePackage")}
</label>
)}
</div>
{p.mode === "package" ? (
<div>
<p className="hint mb-2">{t("tariff.packageHint")}</p>
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.packageTotal")}</span>
<input className="input w-28" value={p.packageTotal} onChange={(e) => props.onPackage?.(e.target.value)} />
</div>
</div>
) : p.mode === "stepped" ? (
<>
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
<th />
</tr>
</thead>
<tbody>
{p.steps.map((s, i) => (
<tr key={i}>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
</td>
<td className="px-2">
{p.steps.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
{t("tariff.addStep")}
</button>
</div>
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
</div>
) : (
<>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.bandDuration")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.pricePerIncrement")}</th>
<th />
</tr>
</thead>
<tbody>
{p.blocks.map((b, i) => {
const isTail = i === p.blocks.length - 1;
return (
<tr key={i}>
<td className="px-2 py-1">
{isTail ? (
<span className="italic text-term-muted">{t("tariff.thereafter")}</span>
) : (
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={b.hours} onChange={(e) => props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[0.6875rem] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
)}
</td>
<td className="px-2 py-1">
<input className="input w-28" value={b.price} onChange={(e) => props.onBlock(i, { price: e.target.value })} />
</td>
<td className="px-2">
{!isTail && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveBlock(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
<div className="mt-3 flex items-center gap-4">
<button type="button" className="btn btn-sm" onClick={props.onAddBlock}>
{t("tariff.addBlock")}
</button>
<span className="inline-flex items-center gap-2">
<span className="label">{t("tariff.dailyCap")}</span>
<input className="input w-28" value={p.dailyCap} onChange={(e) => props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} />
</span>
</div>
</>
)}
</div>
);
}
+329 -165
View File
@@ -1,21 +1,30 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { import {
ApiError,
createTariffDraft,
deleteTariffDraft,
fetchTariff, fetchTariff,
loadSimSession, fetchTariffDrafts,
publishTariffVersion,
simulateTariff, simulateTariff,
updateTariffDraft,
type SimulateResult, type SimulateResult,
type SimPayment, type TariffDraft,
type TariffState, type TariffState,
} from "./api.js"; } 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"; import { formatMoney, formatDuration } from "./lib/format.js";
// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight // The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY // live in their own mutable table (tariff_drafts), so experimenting never churns the
// published tariff version, with no real ledger writes. Build a hypothetical session // immutable published versions or risks a half-baked card going live: the admin
// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any // composes a draft in the modal (the same form the composer page uses), simulates
// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and // hypothetical stays against it (entry + exit, nothing else), and only when satisfied
// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md. // 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.
/** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */ /** <input type="datetime-local"> wants "YYYY-MM-DDTHH:mm" in LOCAL time. */
function toLocalInput(iso: string): string { function toLocalInput(iso: string): string {
@@ -33,50 +42,76 @@ function nowLocal(): string {
return toLocalInput(new Date().toISOString()); 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() { export function TariffLab() {
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<TariffState | null>(null); const [state, setState] = useState<TariffState | null>(null);
const [drafts, setDrafts] = useState<TariffDraft[]>([]);
const [selected, setSelected] = useState<Selection>({ kind: "active" });
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
// Inputs (datetime-local strings, local wall-clock). // The hypothetical stay: entry + exit, nothing else.
const [entered, setEntered] = useState<string>(() => { const [entered, setEntered] = useState<string>(() => {
const d = new Date(); const d = new Date();
d.setHours(d.getHours() - 3); // default: a 3h-ago entry d.setHours(d.getHours() - 3); // default: a 3h-ago entry
return toLocalInput(d.toISOString()); return toLocalInput(d.toISOString());
}); });
const [asOf, setAsOf] = useState<string>(nowLocal); const [exit, setExit] = useState<string>(nowLocal);
const [category, setCategory] = useState("");
const [versionId, setVersionId] = useState<string>(""); // "" = active
// Optional single hypothetical payment (the latest grants the walk-back grace).
const [paid, setPaid] = useState(false);
const [paidAt, setPaidAt] = useState<string>(nowLocal);
const [graceMin, setGraceMin] = useState<string>("5");
// Load-a-real-ticket.
const [ticket, setTicket] = useState("");
const [loadMsg, setLoadMsg] = useState<string | null>(null);
const [result, setResult] = useState<SimulateResult | null>(null); const [result, setResult] = useState<SimulateResult | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// The draft-composer modal.
const [edit, setEdit] = useState<DraftEdit | null>(null);
const [saving, setSaving] = useState(false);
const [editErr, setEditErr] = useState<string | null>(null);
async function refresh() {
const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]);
setState(s);
setDrafts(d.drafts);
return d.drafts;
}
useEffect(() => { useEffect(() => {
fetchTariff() refresh().catch((e) => setErr((e as Error).message));
.then(setState)
.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() { async function run() {
setErr(null); setErr(null);
setBusy(true); setBusy(true);
try { try {
const payments: SimPayment[] = paid
? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }]
: [];
const r = await simulateTariff({ const r = await simulateTariff({
enteredAt: fromLocalInput(entered), enteredAt: fromLocalInput(entered),
asOf: fromLocalInput(asOf), asOf: fromLocalInput(exit),
payments, // A draft carries its own structure+currency; a historical version is
category: category.trim() || undefined, // referenced by id; otherwise the ACTIVE version.
tariffVersionId: versionId || undefined, ...(selectedDraft
? { structure: selectedDraft.structure, currency: selectedDraft.currency }
: selectedVersion
? { tariffVersionId: selectedVersion.id }
: {}),
}); });
setResult(r); setResult(r);
} catch (e) { } catch (e) {
@@ -87,162 +122,291 @@ export function TariffLab() {
} }
} }
async function loadTicket() { // --- draft actions ---
setLoadMsg(null); 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); setErr(null);
try { try {
const s = await loadSimSession(ticket.trim()); await deleteTariffDraft(d.id);
setEntered(toLocalInput(s.enteredAt)); await refresh();
setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal()); select({ kind: "active" });
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 }));
} catch (e) { } catch (e) {
setErr((e as Error).message); 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 ( return (
<section className="px-4 py-6"> <section className="px-4 py-6">
<h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2> <h2 className="mb-1 text-h4 font-semibold text-term-text">{t("lab.title")}</h2>
<p className="hint mb-4">{t("lab.intro")}</p> <p className="hint mb-4">{t("lab.intro")}</p>
{/* Load a real ticket */} <div className="flex flex-col gap-4 lg:flex-row">
<div className="card card-body mb-4 flex flex-wrap items-end gap-2"> {/* Main: the hypothetical stay + result, priced against the selection. */}
<div className="flex flex-col gap-1"> <div className="min-w-0 flex-1">
<label className="label">{t("lab.loadTicket")}</label> {/* What we're pricing against + draft actions. */}
<input <div className="mb-3 flex flex-wrap items-center gap-2">
className="input w-56" <span className="rounded bg-term-panel-2 px-2 py-1 text-[0.75rem] text-term-cyan">
value={ticket} {selectedDraft
onChange={(e) => setTicket(e.target.value)} ? selectedDraft.name
placeholder={t("lab.loadTicketPh")} : selectedVersion
/> ? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
</div> : t("lab.activeTariff")}
<button type="button" className="btn btn-sm" onClick={loadTicket} disabled={!ticket.trim()}> </span>
{t("lab.load")} {selectedDraft && (
</button> <>
{loadMsg && <span className="text-[0.75rem] text-term-green">{loadMsg}</span>} <button type="button" className="btn btn-sm" onClick={() => editDraft(selectedDraft)}>
</div> {t("lab.edit")}
</button>
<button type="button" className="btn btn-sm" onClick={() => publishDraft(selectedDraft)}>
{t("lab.publish")}
</button>
<button type="button" className="btn btn-danger btn-sm" onClick={() => removeDraft(selectedDraft)}>
{t("lab.delete")}
</button>
</>
)}
{notice && <span className="text-[0.75rem] text-term-green">{notice}</span>}
</div>
{/* Hypothetical session inputs */} <div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2">
<div className="card card-body grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2"> <label className="label">{t("lab.entered")}</label>
<label className="label">{t("lab.tariffVersion")}</label> <input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} />
<select className="input w-full max-w-md" value={versionId} onChange={(e) => setVersionId(e.target.value)}>
<option value="">{t("lab.activeVersion")}</option>
{state?.versions.map((v) => (
<option key={v.id} value={v.id}>
{new Date(v.effectiveFrom).toLocaleString()} · {v.currency} · {v.id.slice(0, 8)}
</option>
))}
</select>
<label className="label">{t("lab.entered")}</label> <label className="label">{t("lab.exit")}</label>
<input type="datetime-local" className="input w-64" value={entered} onChange={(e) => setEntered(e.target.value)} /> <span className="flex items-center gap-2">
<input type="datetime-local" className="input w-64" value={exit} onChange={(e) => setExit(e.target.value)} />
<button type="button" className="btn btn-sm" onClick={() => setExit(nowLocal())}>
{t("lab.now")}
</button>
</span>
</div>
<label className="label">{t("lab.asOf")}</label> <div className="mt-4 flex items-center gap-3">
<span className="flex items-center gap-2"> <button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
<input type="datetime-local" className="input w-64" value={asOf} onChange={(e) => setAsOf(e.target.value)} /> {busy ? t("lab.pricing") : t("lab.price")}
<button type="button" className="btn btn-sm" onClick={() => setAsOf(nowLocal())}> </button>
{t("lab.now")} {err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</button> </div>
</span>
<label className="label">{t("lab.category")}</label> {result && (
<input <div className="mt-6 grid gap-4 md:grid-cols-2">
className="input w-40" {/* Outcome */}
value={category} <div className="card card-body">
onChange={(e) => setCategory(e.target.value)} <h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
placeholder={t("lab.categoryPh")} <dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
/> <dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(exit))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
<label className="label">{t("lab.payment")}</label> {/* Duration curve from entry — see where the cap flattens / windows shift. */}
<span className="flex flex-wrap items-center gap-2"> <div className="card card-body">
<label className="inline-flex items-center gap-1 text-[0.75rem] text-term-text"> <h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<input type="checkbox" className="accent-term-amber" checked={paid} onChange={(e) => setPaid(e.target.checked)} /> <p className="hint mb-2">{t("lab.curveHint")}</p>
{t("lab.paid")} <table className="w-full text-[0.75rem] tabular-nums">
</label> <tbody>
{paid && ( {result.curve.map((c) => (
<> <tr key={c.minutes} className="border-b border-term-border/40">
<input <td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
type="datetime-local" <td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
className="input w-64" </tr>
value={paidAt} ))}
onChange={(e) => setPaidAt(e.target.value)} </tbody>
/> </table>
<span className="text-term-muted">{t("lab.graceMin")}</span> </div>
<input className="input w-20" value={graceMin} onChange={(e) => setGraceMin(e.target.value)} /> </div>
</>
)} )}
</span>
</div>
<div className="mt-4 flex items-center gap-3">
<button type="button" className="btn btn-primary btn-lg" onClick={run} disabled={busy}>
{busy ? t("lab.pricing") : t("lab.price")}
</button>
{err && <span className="text-[0.75rem] text-term-red">{err}</span>}
</div>
{result && (
<div className="mt-6 grid gap-4 md:grid-cols-2">
{/* Outcome */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.outcome")}</h3>
<dl className="grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-[0.8125rem]">
<dt className="text-term-muted">{t("lab.amountDue")}</dt>
<dd className="text-2xl font-bold text-term-cyan">{formatMoney(result.pricing.amountMinor, currency)}</dd>
<dt className="text-term-muted">{t("lab.billedPeriod")}</dt>
<dd className="text-term-text">
{formatDuration(result.pricing.periodStart, fromLocalInput(asOf))}
{result.pricing.overstay && (
<span className="ml-2 rounded bg-term-red/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-red">
{t("lab.overstay")}
</span>
)}
{result.pricing.withinGrace && (
<span className="ml-2 rounded bg-term-green/15 px-1.5 py-0.5 text-[0.625rem] uppercase text-term-green">
{t("lab.settled")}
</span>
)}
</dd>
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
{result.pricing.graceExpiresAt && (
<>
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
</>
)}
</dl>
</div>
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
<div className="card card-body">
<h3 className="mb-2 text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.curve")}</h3>
<p className="hint mb-2">{t("lab.curveHint")}</p>
<table className="w-full text-[0.75rem] tabular-nums">
<tbody>
{result.curve.map((c) => (
<tr key={c.minutes} className="border-b border-term-border/40">
<td className="py-0.5 text-term-muted">{labelMin(c.minutes)}</td>
<td className="py-0.5 text-right text-term-text">{formatMoney(c.amountMinor, currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
)}
{/* Sidebar: lab drafts + the full published history; click any to price
against it. */}
<aside className="w-full shrink-0 lg:w-72">
<div className="mb-2 flex items-center justify-between">
<h3 className="text-h6 font-semibold uppercase tracking-wider text-term-text">{t("lab.drafts")}</h3>
<button type="button" className="btn btn-sm" onClick={newDraft}>
{t("lab.newDraft")}
</button>
</div>
<ul className="flex flex-col gap-1">
{drafts.map((d) => (
<li key={d.id}>
<button
type="button"
onClick={() => select({ kind: "draft", id: d.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "draft" && selected.id === d.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">{d.name}</span>
<span className="block text-[0.6875rem] text-term-muted">
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
</span>
</button>
</li>
))}
{drafts.length === 0 && <li className="hint px-1 py-2">{t("lab.noDrafts")}</li>}
</ul>
{/* Published versions: the active card first, then the immutable history
(older versions still price past sessions — see wiki/concepts/tariff.md). */}
<h3 className="mb-2 mt-5 text-h6 font-semibold uppercase tracking-wider text-term-text">
{t("lab.published")}
</h3>
<ul className="flex flex-col gap-1">
<li>
<button
type="button"
onClick={() => select({ kind: "active" })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "active"
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{t("lab.activeTariff")}
{state?.active?.name ? ` — ${state.active.name}` : ""}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
</span>
</button>
</li>
{state?.versions
.filter((v) => v.id !== state.active?.id)
.map((v) => (
<li key={v.id}>
<button
type="button"
onClick={() => select({ kind: "version", id: v.id })}
className={`w-full rounded-term border px-3 py-2 text-left text-[0.8125rem] ${
selected.kind === "version" && selected.id === v.id
? "border-term-amber bg-term-amber/10 text-term-text"
: "border-term-border text-term-muted hover:text-term-text"
}`}
>
<span className="block font-semibold">
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
</span>
<span className="block text-[0.6875rem] text-term-muted">
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
{v.currency}
</span>
</button>
</li>
))}
</ul>
</aside>
</div>
{/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */}
<Modal
open={edit != null}
onClose={() => setEdit(null)}
title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")}
width="max-w-3xl"
>
{edit && (
<div>
<div className="mb-4 flex items-center gap-2">
<label className="label">{t("lab.draftName")}</label>
<input
className="input w-72"
value={edit.name}
onChange={(e) => setEdit((d) => (d ? { ...d, name: e.target.value } : d))}
placeholder={t("lab.draftNamePh")}
/>
</div>
<TariffEditorForm
form={edit.form}
onChange={(update) => setEdit((d) => (d ? { ...d, form: update(d.form) } : d))}
/>
<div className="mt-6 flex items-center gap-3">
<button type="button" className="btn btn-primary" onClick={saveDraft} disabled={saving || !edit.name.trim()}>
{saving ? t("lab.savingDraft") : t("lab.saveDraft")}
</button>
{editErr && <span className="text-[0.75rem] text-term-red">{editErr}</span>}
</div>
</div>
)}
</Modal>
</section> </section>
); );
} }
+41 -10
View File
@@ -669,10 +669,14 @@ export interface TariffCard {
priority: number; priority: number;
category?: string; category?: string;
window?: TariffWindow; window?: TariffWindow;
/** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */
flatMinor?: number; flatMinor?: number;
blocks?: TariffBlock[]; blocks?: TariffBlock[];
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */ /** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
steps?: TariffStep[]; 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; dailyCapMinor?: number | null;
} }
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including /** 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 { export interface TariffVersion {
id: string; id: string;
tariffId: string; tariffId: string;
/** Optional human label, stamped at publish (e.g. carried from a lab draft). */
name?: string | null;
effectiveFrom: string; effectiveFrom: string;
currency: string; currency: string;
structure: TariffStructure; structure: TariffStructure;
@@ -723,6 +729,7 @@ export function publishTariffVersion(body: {
currency: string; currency: string;
structure: TariffStructure; structure: TariffStructure;
effectiveFrom?: string; effectiveFrom?: string;
name?: string;
}): Promise<TariffVersion> { }): Promise<TariffVersion> {
return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) });
} }
@@ -761,18 +768,40 @@ export function simulateTariff(body: SimulateBody): Promise<SimulateResult> {
return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) }); return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) });
} }
export interface SimSessionLoad { // --- Tariff Lab drafts ------------------------------------------------------
identity: string; // Mutable experimental rate cards — the lab composes + simulates these, and
enteredAt: string; // publishing one goes through the normal immutable-version path above.
exitedAt: string | null;
payments: SimPayment[]; export interface TariffDraft {
category: string | null; id: string;
tariffVersionId: string | null; name: string;
currency: string;
structure: TariffStructure;
createdBy: string | null;
createdAt: string;
updatedAt: string;
} }
/** Prefill the lab from a real ledger session. */ export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> {
export function loadSimSession(identity: string): Promise<SimSessionLoad> { return apiFetch("/api/tariff/drafts");
return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`); }
export interface TariffDraftBody {
name: string;
currency: string;
structure: TariffStructure;
}
export function createTariffDraft(body: TariffDraftBody): Promise<TariffDraft> {
return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) });
}
export function updateTariffDraft(id: string, body: TariffDraftBody): Promise<TariffDraft> {
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) });
}
export function deleteTariffDraft(id: string): Promise<void> {
return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" });
} }
// --- Subscriptions -------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
@@ -1122,6 +1151,8 @@ export interface ShiftSummary extends ShiftSourceSplit {
export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{ export function fetchShifts(params: { operator?: string; from?: string; to?: string } = {}): Promise<{
shifts: ShiftSummary[]; shifts: ShiftSummary[];
scope: "all" | "self"; scope: "all" | "self";
/** Admin scope only: every operator that has a shift — feeds the filter dropdown. */
operators?: string[];
}> { }> {
const qs = new URLSearchParams(); const qs = new URLSearchParams();
if (params.operator) qs.set("operator", params.operator); if (params.operator) qs.set("operator", params.operator);
+45 -10
View File
@@ -13,9 +13,42 @@
exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the exposed as utilities (night-*, ink-*, paper-*, flag/amber/green/blue, the
spacing/type/shadow scales) for new work. spacing/type/shadow scales) for new work.
Offline appliance: NO webfont @import (no network at runtime). Goldplay (the Offline appliance: NO webfont @import (no network at runtime). The primary
TRM display face) is not self-hosted yet — display/heading text falls back to face is Chakra Petch, SELF-HOSTED from public/fonts/chakra-petch (SIL OFL,
a clean sans stack; wire local Goldplay @font-face here if it's wanted. */ license alongside the files) — latin subset only (covers en + sq ë/ç), the
weights the UI actually uses (400/600/700 + 400 italic). Not a true
monospace: it stays FIRST in --font-mono for the look, with the real mono
stack behind it as fallback; .num/.tabular still request tabular figures. */
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-400.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 600;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-600.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-700.woff2") format("woff2");
}
@font-face {
font-family: "Chakra Petch";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/chakra-petch/chakra-petch-latin-400-italic.woff2") format("woff2");
}
@theme { @theme {
/* ============================================================ /* ============================================================
TERMINAL ACCENTS — aligned onto TRM's exact values. TERMINAL ACCENTS — aligned onto TRM's exact values.
@@ -92,13 +125,15 @@
--color-viz-8: #5a5a53; --color-viz-8: #5a5a53;
/* ---------- TYPE — families ---------- */ /* ---------- TYPE — families ---------- */
/* Mono is the booth's primary face (data-dense, tabular). Display/UI fall /* Chakra Petch (self-hosted, see @font-face above) is the booth's primary
back to a clean sans (Goldplay not self-hosted — see header note). */ face everywhere — it leads every stack so headings, body, and the
--font-mono: "JetBrains Mono", "IBM Plex Mono", ui-monospace, "SFMono-Regular", `font-mono` chrome all render with it; the stacks behind it are the
"Menlo", "Consolas", monospace; pre-2026-07-05 fallbacks for glyphs outside the latin subset. */
--font-display: "Goldplay", "Helvetica Neue", Arial, sans-serif; --font-mono: "Chakra Petch", "JetBrains Mono", "IBM Plex Mono", ui-monospace,
--font-ui: "Goldplay", "Helvetica Neue", Arial, sans-serif; "SFMono-Regular", "Menlo", "Consolas", monospace;
--font-body: "Inter", "Helvetica Neue", Arial, sans-serif; --font-display: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
--font-ui: "Chakra Petch", "Helvetica Neue", Arial, sans-serif;
--font-body: "Chakra Petch", "Inter", "Helvetica Neue", Arial, sans-serif;
/* ---------- TYPE — scale (TRM, optimised for data density) ---------- */ /* ---------- TYPE — scale (TRM, optimised for data density) ---------- */
--text-overline: 11px; --text-overline: 11px;
+11
View File
@@ -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];
}
+26 -16
View File
@@ -319,25 +319,30 @@ export const en: Catalog = {
bandDuration: "Band duration", bandDuration: "Band duration",
hoursUnit: "hours", hoursUnit: "hours",
egHours: "e.g. 2", egHours: "e.g. 2",
pricePerIncrement: "Price / increment", pricePerIncrement: "Price / increment (per hour)",
thereafter: "thereafter (open-ended)", thereafter: "thereafter (open-ended)",
remove: "Remove", remove: "Remove",
addBlock: "+ Add block", addBlock: "+ Add block",
publishNewVersion: "Publish new version", publishNewVersion: "Publish new version",
publishing: "Publishing…", publishing: "Publishing…",
versionNamePh: "Version name (optional), e.g. Summer 2026",
publishedOk: "New tariff version published — it's now the active rate.", publishedOk: "New tariff version published — it's now the active rate.",
defaultCard: "Base rate (always active)", defaultCard: "Base rate (always active)",
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.", defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder", modeLadder: "Hourly ladder",
modeFlat: "Flat price", modeFlat: "Flat price / hour",
modeStepped: "By duration (up-to)", 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: 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.", "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", stepUpTo: "Up to",
stepTotal: "Total price", stepTotal: "Total price",
addStep: "+ Add row", addStep: "+ Add row",
steppedTiersConflict: 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", 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.", 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", tierName: "Name",
@@ -513,21 +518,26 @@ export const en: Catalog = {
lab: { lab: {
title: "Tariff Lab", title: "Tariff Lab",
intro: 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.", "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.",
loadTicket: "Load from a real ticket", drafts: "Lab tariffs",
loadTicketPh: "Ticket number / identity", newDraft: "New draft",
load: "Load", activeTariff: "Active tariff",
loaded: "Loaded session {{id}}", published: "Published versions",
tariffVersion: "Tariff version", noDrafts: "No lab tariffs yet — create a draft to experiment.",
activeVersion: "Active version (current)", 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", entered: "Entered",
asOf: "As of (now/exit)", exit: "Exit",
now: "Now", now: "Now",
category: "Category",
categoryPh: "e.g. bus (blank = car)",
payment: "Payment",
paid: "paid",
graceMin: "grace (min)",
price: "Compute price", price: "Compute price",
pricing: "Pricing…", pricing: "Pricing…",
outcome: "Outcome", outcome: "Outcome",
+26 -16
View File
@@ -322,25 +322,30 @@ export const sq = {
bandDuration: "Kohëzgjatja e brezit", bandDuration: "Kohëzgjatja e brezit",
hoursUnit: "orë", hoursUnit: "orë",
egHours: "p.sh. 2", egHours: "p.sh. 2",
pricePerIncrement: "Çmimi / interval", pricePerIncrement: "Çmimi / interval (orë)",
thereafter: "më pas (i hapur)", thereafter: "më pas (i hapur)",
remove: "Hiq", remove: "Hiq",
addBlock: "+ Shto bllok", addBlock: "+ Shto bllok",
publishNewVersion: "Publiko version të ri", publishNewVersion: "Publiko version të ri",
publishing: "Duke publikuar…", 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.", publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.",
defaultCard: "Tarifa bazë (gjithmonë 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.", 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", modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks", modeFlat: "Çmim fiks / orë",
modeStepped: "Sipas kohëzgjatjes (deri-në)", 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: 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.", "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ë", stepUpTo: "Deri në",
stepTotal: "Çmimi total", stepTotal: "Çmimi total",
addStep: "+ Shto rresht", addStep: "+ Shto rresht",
steppedTiersConflict: 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", 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ë.", 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", tierName: "Emri",
@@ -525,21 +530,26 @@ export const sq = {
lab: { lab: {
title: "Lab Tarife", title: "Lab Tarife",
intro: 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.", "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.",
loadTicket: "Ngarko nga një biletë reale", drafts: "Tarifa laboratori",
loadTicketPh: "Numri i biletës / identiteti", newDraft: "Draft i ri",
load: "Ngarko", activeTariff: "Tarifa aktive",
loaded: "U ngarkua sesioni {{id}}", published: "Versione të publikuara",
tariffVersion: "Versioni i tarifës", noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.",
activeVersion: "Versioni aktiv (i tanishëm)", 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", entered: "Hyrja",
asOf: "Deri më (tani/dalja)", exit: "Dalja",
now: "Tani", now: "Tani",
category: "Kategoria",
categoryPh: "p.sh. bus (bosh = makinë)",
payment: "Pagesa",
paid: "u pagua",
graceMin: "afati (min)",
price: "Llogarit çmimin", price: "Llogarit çmimin",
pricing: "Duke llogaritur…", pricing: "Duke llogaritur…",
outcome: "Rezultati", outcome: "Rezultati",
+36 -14
View File
@@ -117,9 +117,10 @@ function SetupLayout() {
} }
/** Subscriptions layout — a standalone top-level section (its own header nav entry), /** 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 * with tabs for the subscriber catalog and the plan catalog. Each tab is a gated
* tab is a gated child route; an operator with only subscription:read sees just the * child route; an operator with only subscription:read sees just the first tab.
* 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() { function SubscriptionsLayout() {
const { user } = rootRoute.useRouteContext(); const { user } = rootRoute.useRouteContext();
const { t } = useTranslation(); const { t } = useTranslation();
@@ -129,7 +130,21 @@ function SubscriptionsLayout() {
<nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border"> <nav className="mb-4 flex flex-wrap items-center gap-1 border-b border-term-border">
{show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />} {show("subscription:read") && <SetupTab to="/subscriptions" label={t("nav.subscriptions")} exact />}
{show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />} {show("subscription:plan") && <SetupTab to="/subscriptions/plans" label={t("nav.plans")} />}
{show("tariff:read") && <SetupTab to="/subscriptions/tariff-lab" label={t("nav.tariffLab")} />} </nav>
<Outlet />
</div>
);
}
/** 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 (
<div>
<nav className="mb-2 flex flex-wrap items-center gap-1 border-b border-term-border">
<SetupTab to="/setup/tariff" label={t("nav.tariff")} exact />
<SetupTab to="/setup/tariff/lab" label={t("nav.tariffLab")} />
</nav> </nav>
<Outlet /> <Outlet />
</div> </div>
@@ -519,7 +534,10 @@ const legacyRedirects = (
["/shift", "/shifts"], ["/shift", "/shifts"],
["/setup/subscriptions", "/subscriptions"], ["/setup/subscriptions", "/subscriptions"],
["/setup/plans", "/subscriptions/plans"], ["/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/shifts", "/shifts"],
["/setup/reports", "/reports"], ["/setup/reports", "/reports"],
] as const ] as const
@@ -633,8 +651,20 @@ const tariffRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
path: "tariff", path: "tariff",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context), beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: TariffLayout,
});
const tariffComposerRoute = createRoute({
getParentRoute: () => tariffRoute,
path: "/",
component: () => <TariffComposer />, component: () => <TariffComposer />,
}); });
// 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: () => <TariffLab />,
});
// --- /subscriptions — a standalone top-level section with its own tabs. The catalog // --- /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. --- // (index), the plan catalog, and the tariff lab live here, not under /setup. ---
@@ -651,7 +681,6 @@ const subscriptionsIndexRoute = createRoute({
beforeLoad: ({ context }) => { beforeLoad: ({ context }) => {
if (can(context.user, "subscription:read")) return; if (can(context.user, "subscription:read")) return;
if (can(context.user, "subscription:plan")) throw redirect({ to: "/subscriptions/plans" }); 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" }); throw redirect({ to: "/booth" });
}, },
component: function SubscriptionsRoute() { component: function SubscriptionsRoute() {
@@ -665,12 +694,6 @@ const subscriptionPlansRoute = createRoute({
beforeLoad: ({ context }) => requirePerm("subscription:plan")(context), beforeLoad: ({ context }) => requirePerm("subscription:plan")(context),
component: () => <SubscriptionPlansManager />, component: () => <SubscriptionPlansManager />,
}); });
const tariffLabRoute = createRoute({
getParentRoute: () => subscriptionsRoute,
path: "tariff-lab",
beforeLoad: ({ context }) => requirePerm("tariff:read")(context),
component: () => <TariffLab />,
});
const siteRoute = createRoute({ const siteRoute = createRoute({
getParentRoute: () => setupRoute, getParentRoute: () => setupRoute,
path: "site", path: "site",
@@ -751,11 +774,10 @@ const routeTree = rootRoute.addChildren([
subscriptionsRoute.addChildren([ subscriptionsRoute.addChildren([
subscriptionsIndexRoute, subscriptionsIndexRoute,
subscriptionPlansRoute, subscriptionPlansRoute,
tariffLabRoute,
]), ]),
setupRoute.addChildren([ setupRoute.addChildren([
setupDevicesRoute, setupDevicesRoute,
tariffRoute, tariffRoute.addChildren([tariffComposerRoute, tariffLabRoute]),
siteRoute, siteRoute,
usersRoute, usersRoute,
rolesRoute, rolesRoute,
@@ -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
);
@@ -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;
+14
View File
@@ -148,6 +148,20 @@
"when": 1781886300000, "when": 1781886300000,
"tag": "0020_entry_presence_bypass", "tag": "0020_entry_presence_bypass",
"breakpoints": true "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
} }
] ]
} }
+28
View File
@@ -316,6 +316,10 @@ export const tariffs = sqliteTable("tariffs", {
export const tariffVersions = sqliteTable("tariff_versions", { export const tariffVersions = sqliteTable("tariff_versions", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
tariffId: text("tariff_id").notNull(), 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). // The version is in force from this instant (latest with effectiveFrom ≤ entry wins).
effectiveFrom: text("effective_from").notNull(), effectiveFrom: text("effective_from").notNull(),
// ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float. // 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)`), .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<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Subscriptions -------------------------------------------------------- // --- Subscriptions --------------------------------------------------------
// A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL / // 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 // 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 SiteConfigRow = typeof siteConfig.$inferSelect;
export type TariffRow = typeof tariffs.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect;
export type TariffVersionRow = typeof tariffVersions.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect;
export type TariffDraftRow = typeof tariffDrafts.$inferSelect;
export type SubscriptionRow = typeof subscriptions.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect;
export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect; export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect;
export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect;
+38 -9
View File
@@ -606,8 +606,9 @@ export interface TariffWindow {
readonly toHour?: string; readonly toHour?: string;
} }
/** A V2 pricing card: a flat rate OR a stepped block ladder (with its own cap). /** A V2 pricing card: a per-increment flat rate, a block ladder, a stepped table
* `flatMinor` and `blocks` are mutually exclusive. The defaultCard has no window. */ * (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 { export interface TariffCard {
/** Human label (also the final, deterministic precedence tiebreak). */ /** Human label (also the final, deterministic precedence tiebreak). */
readonly name: string; readonly name: string;
@@ -617,13 +618,21 @@ export interface TariffCard {
readonly category?: string; readonly category?: string;
/** Wall-clock activation window. Absent only on the defaultCard (always active). */ /** Wall-clock activation window. Absent only on the defaultCard (always active). */
readonly window?: TariffWindow; 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; 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[]; readonly blocks?: readonly TariffBlock[];
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/ /** STEPPED ("up-to") total-by-duration table (mutually exclusive with the other
* `blocks`). The top tier's total is this card's per-day price. */ * pricing fields; defaultCard only). The top tier's total is the per-day price. */
readonly steps?: readonly TariffStep[]; 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 /** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
* a mixed day (see computeFeeV2). null = no cap. */ * a mixed day (see computeFeeV2). null = no cap. */
readonly dailyCapMinor?: number | null; readonly dailyCapMinor?: number | null;
@@ -856,18 +865,30 @@ function computeFeeV2(
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!); if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
let total = 0; 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) { for (let segStart = 0; segStart < minutes; segStart += DAY) {
const segEnd = Math.min(segStart + DAY, minutes); const segEnd = Math.min(segStart + DAY, minutes);
let segFee = 0; let segFee = 0;
for (let within = segStart; within < segEnd; within += inc) { for (let within = segStart; within < segEnd; within += inc) {
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz); const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
const card = selectCard(cards, wall); 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; segFee += card.flatMinor;
} else { } else {
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule). // Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
segFee += rateAt(card.blocks ?? [], within - segStart); segFee += rateAt(card.blocks ?? [], within - segStart);
} }
prevWinner = card;
} }
if (dayCap != null) segFee = Math.min(segFee, dayCap); if (dayCap != null) segFee = Math.min(segFee, dayCap);
total += segFee; total += segFee;
@@ -983,9 +1004,17 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
const hasFlat = c.flatMinor != null; const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null; const hasBlocks = c.blocks != null;
const hasStepTable = c.steps != 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) { 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) { } else if (hasFlat) {
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs); nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`); if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
+55 -1
View File
@@ -223,7 +223,7 @@ describe("validate V2", () => {
}); });
it("rejects a card with both flat and blocks", () => { it("rejects a card with both flat and blocks", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } }); 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", () => { it("rejects defaultCard with a window", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } }); 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); 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);
});
});
+17 -2
View File
@@ -2,7 +2,7 @@
type: concept type: concept
tags: [parking, domain, business, pricing, design] tags: [parking, domain, business, pricing, design]
sources: [parksql2017-legacy-schema] sources: [parksql2017-legacy-schema]
updated: 2026-06-18 updated: 2026-07-05
status: settled 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 `DEFAULT_VEHICLE_CATEGORY` in `@parking/shared`). Per-relay capture (a "bus lane") is the future
seam, mirroring per-relay direction. seam, mirroring per-relay direction.
- **Flat rate** is a first-class card body (`flatMinor`, mutually exclusive with `blocks`). A flat V1 - **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 - **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, 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 optional date range, optional hour range with an overnight hint; category; priority; flat/ladder
+32 -12
View File
@@ -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 - 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). 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 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: 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 - **Drafts (`tariff_drafts` table, 2026-07-05).** The lab's rate cards live in their own **mutable**
on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical 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 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 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` SEE where the daily cap flattens or a window shifts); the lab passes a draft's stored `structure`
prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are inline. `GET /api/tariff/simulate/session/:identity` (prefill from a real ledger session) still
**read-only — no ledger writes.** exists API-side but the UI no longer uses it. All **read-only — no ledger writes.**
- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any - **UI** (`apps/web/src/TariffLab.tsx`, Setup → Tariff → "Tariff Lab" tab): a **sidebar lists every
historical), set entry / "as of" times, an optional payment (with its grace), and a category; or lab draft AND the full published history** (active card first, then older immutable versions) —
"Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/ click any to price against it (drafts send their structure inline; published versions go by
settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real `tariffVersionId`). Published versions carry an **optional name** (`tariff_versions.name`,
overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay). 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 ## The pay-on-foot consequence
+38
View File
@@ -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 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 (≈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. 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).