diff --git a/apps/server/src/routes/tariff-drafts.test.ts b/apps/server/src/routes/tariff-drafts.test.ts new file mode 100644 index 0000000..b55171d --- /dev/null +++ b/apps/server/src/routes/tariff-drafts.test.ts @@ -0,0 +1,177 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createTestDb } from "@parking/db/testing"; +import { type Db } from "@parking/db"; +import type { FastifyInstance } from "fastify"; +import { buildServer } from "../server.js"; +import { seedUser, login } from "../test-helpers.js"; + +// Tariff-lab drafts: the MUTABLE experiment scratchpad next to the immutable +// published versions. The contract under test: drafts are validated + tz-stamped on +// save exactly like a publish (so "publish this draft" can never fail on a card that +// saved fine), mutations need tariff:update, and publishing a draft goes through the +// normal immutable-version path untouched. + +let db: Db; +let close: () => void; +let app: FastifyInstance; + +beforeEach(async () => { + const t = createTestDb(); + db = t.db; + close = t.close; + app = await buildServer({ db }); + await app.ready(); +}); +afterEach(async () => { + await app.close(); + close(); +}); + +const V1_STRUCTURE = { + gracePeriodEntryMin: 5, + incrementMin: 60, + lostTicketMinor: 2000, + gracePeriodExitMin: 10, + overstay: "reprice", + blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], + dailyCapMinor: null, +}; + +// A V2 card with a night package — tz left blank on purpose: the server must stamp it. +const V2_STRUCTURE = { + version: 2, + tz: "", + gracePeriodEntryMin: 5, + incrementMin: 60, + lostTicketMinor: 2000, + gracePeriodExitMin: 10, + overstay: "reprice", + defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 200 }], dailyCapMinor: null }, + windowedCards: [{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 }], +}; + +async function editor() { + const { username, password } = await seedUser(db, { + username: "editor", + roleId: "editor", + permissions: ["tariff:read", "tariff:update"], + }); + return login(app, username, password); +} + +describe("tariff drafts", () => { + it("requires auth", async () => { + const res = await app.inject({ method: "GET", url: "/api/tariff/drafts" }); + expect(res.statusCode).toBe(401); + }); + + it("a tariff:read-only user can list but not create", async () => { + const { username, password } = await seedUser(db, { + username: "viewer", + roleId: "viewer", + permissions: ["tariff:read"], + }); + const { cookie, csrf } = await login(app, username, password); + + const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); + expect(list.statusCode).toBe(200); + expect(list.json().drafts).toEqual([]); + + const create = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers: { cookie, "x-csrf-token": csrf }, + payload: { name: "x", currency: "ALL", structure: V1_STRUCTURE }, + }); + expect(create.statusCode).toBe(403); + }); + + it("create → list → update → delete roundtrip", async () => { + const { cookie, csrf } = await editor(); + const headers = { cookie, "x-csrf-token": csrf }; + + const create = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers, + payload: { name: "Winter proposal", currency: "all", structure: V1_STRUCTURE }, + }); + expect(create.statusCode).toBe(201); + const draft = create.json(); + expect(draft.name).toBe("Winter proposal"); + expect(draft.currency).toBe("ALL"); // normalised to upper case + expect(draft.createdBy).toBe("editor"); + + const list = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); + expect(list.json().drafts).toHaveLength(1); + + const update = await app.inject({ + method: "PUT", + url: `/api/tariff/drafts/${draft.id}`, + headers, + payload: { name: "Winter v2", currency: "ALL", structure: V1_STRUCTURE }, + }); + expect(update.statusCode).toBe(200); + expect(update.json().name).toBe("Winter v2"); + + const del = await app.inject({ method: "DELETE", url: `/api/tariff/drafts/${draft.id}`, headers }); + expect(del.statusCode).toBe(204); + const after = await app.inject({ method: "GET", url: "/api/tariff/drafts", headers: { cookie } }); + expect(after.json().drafts).toEqual([]); + }); + + it("rejects an invalid structure with problems (validated like a publish)", async () => { + const { cookie, csrf } = await editor(); + const res = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers: { cookie, "x-csrf-token": csrf }, + payload: { name: "broken", currency: "ALL", structure: { ...V1_STRUCTURE, blocks: [] } }, + }); + expect(res.statusCode).toBe(400); + expect(res.json().problems?.length).toBeGreaterThan(0); + }); + + it("stamps the site timezone on a V2 draft, and the draft simulates + publishes as-is", async () => { + const { cookie, csrf } = await editor(); + const headers = { cookie, "x-csrf-token": csrf }; + + const create = await app.inject({ + method: "POST", + url: "/api/tariff/drafts", + headers, + payload: { name: "Night package", currency: "ALL", structure: V2_STRUCTURE }, + }); + expect(create.statusCode).toBe(201); + const draft = create.json(); + expect(draft.structure.tz).toBe("Europe/Tirane"); + + // The lab prices the draft by sending its stored structure inline. + const sim = await app.inject({ + method: "POST", + url: "/api/tariff/simulate", + headers, + payload: { + enteredAt: "2026-07-03T21:00:00.000+02:00", + asOf: "2026-07-03T23:00:00.000+02:00", + structure: draft.structure, + currency: draft.currency, + }, + }); + expect(sim.statusCode).toBe(200); + expect(sim.json().pricing.amountMinor).toBe(40000); // one night package + + // "Publish this draft" = the normal immutable-version path with the draft's card; + // the draft's name rides along as the version's optional label. + const publish = await app.inject({ + method: "POST", + url: "/api/tariff/versions", + headers, + payload: { currency: draft.currency, structure: draft.structure, name: draft.name }, + }); + expect(publish.statusCode).toBe(201); + const state = await app.inject({ method: "GET", url: "/api/tariff", headers: { cookie } }); + expect(state.json().active?.name).toBe("Night package"); + expect(state.json().active?.structure?.windowedCards?.[0]?.packageMinor).toBe(40000); + }); +}); diff --git a/apps/server/src/routes/tariffs.ts b/apps/server/src/routes/tariffs.ts index bf789a0..30ac935 100644 --- a/apps/server/src/routes/tariffs.ts +++ b/apps/server/src/routes/tariffs.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { FastifyInstance } from "fastify"; -import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffVersions, tariffs, type Db } from "@parking/db"; +import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db"; import { computeFee, isTariffV2, @@ -25,10 +25,19 @@ interface PublishBody { structure: TariffStructure; /** When this version takes effect (ISO-8601). Defaults to now. */ effectiveFrom?: string; + /** Optional human label (e.g. carried from the lab draft being published). */ + name?: string; } const SITE_TARIFF_NAME = "Site tariff"; +/** Body for saving a lab draft (create + update share the shape). */ +interface DraftBody { + name: string; + currency: string; + structure: TariffStructure; +} + /** Body for POST /api/tariff/simulate — price a hypothetical session, no ledger write. * Provide a structure source (one of): `tariffVersionId`, inline `structure`, or * neither (uses the active version). */ @@ -80,19 +89,14 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise "/api/tariff/versions", { preHandler: writeGuard }, async (req, reply) => { - const { currency, structure, effectiveFrom } = req.body ?? ({} as PublishBody); + const { currency, structure, effectiveFrom, name } = req.body ?? ({} as PublishBody); if (!currency || typeof currency !== "string" || currency.length < 3) { return reply.code(400).send({ error: "currency (ISO 4217) required" }); } // For a windowed (V2) structure, stamp the wall-clock timezone from SITE config // (not the client) BEFORE validating — so the frozen tz is authoritative and the // validation that requires tz passes. A V1 (bare) structure is left untouched. - let toStore: TariffStructure = structure; - if (structure && isTariffV2(structure)) { - const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); - const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ; - toStore = { ...structure, tz }; - } + const toStore = stampSiteTz(structure); const problems = validateTariffStructure(toStore); if (problems.length) { @@ -128,6 +132,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise const row = { id, tariffId, + name: typeof name === "string" && name.trim() ? name.trim() : null, effectiveFrom: effective, currency, structure: toStore as unknown as Record, @@ -230,6 +235,92 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise }, ); + // --- Lab drafts --------------------------------------------------------------- + // The lab's scratchpad: MUTABLE experimental rate cards (see tariff_drafts in the + // schema for why mutability is safe here — a draft prices nothing and signs + // nothing). Saved drafts are validated + tz-stamped exactly like a publish, so the + // simulator can always price them and "publish this draft" can never surprise the + // admin with a card that saved fine but won't go live. Publishing a draft is just + // POST /api/tariff/versions with the draft's structure — same guard, same + // validation, same immutability. + app.get("/api/tariff/drafts", { preHandler: readGuard }, async () => { + const drafts = db.select().from(tariffDrafts).orderBy(desc(tariffDrafts.updatedAt)).all(); + return { drafts }; + }); + + app.post<{ Body: DraftBody }>("/api/tariff/drafts", { preHandler: writeGuard }, async (req, reply) => { + const parsed = parseDraftBody(req.body); + if ("error" in parsed) return reply.code(400).send(parsed); + const now = new Date().toISOString(); + const row = { + id: randomUUID(), + name: parsed.name, + currency: parsed.currency, + structure: parsed.structure as unknown as Record, + createdBy: req.user?.username ?? null, + createdAt: now, + updatedAt: now, + }; + db.insert(tariffDrafts).values(row).run(); + return reply.code(201).send(row); + }); + + app.put<{ Params: { id: string }; Body: DraftBody }>( + "/api/tariff/drafts/:id", + { preHandler: writeGuard }, + async (req, reply) => { + const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get(); + if (!existing) return reply.code(404).send({ error: "draft not found" }); + const parsed = parseDraftBody(req.body); + if ("error" in parsed) return reply.code(400).send(parsed); + const patch = { + name: parsed.name, + currency: parsed.currency, + structure: parsed.structure as unknown as Record, + updatedAt: new Date().toISOString(), + }; + db.update(tariffDrafts).set(patch).where(eq(tariffDrafts.id, existing.id)).run(); + return { ...existing, ...patch }; + }, + ); + + app.delete<{ Params: { id: string } }>( + "/api/tariff/drafts/:id", + { preHandler: writeGuard }, + async (req, reply) => { + const existing = db.select().from(tariffDrafts).where(eq(tariffDrafts.id, req.params.id)).get(); + if (!existing) return reply.code(404).send({ error: "draft not found" }); + db.delete(tariffDrafts).where(eq(tariffDrafts.id, existing.id)).run(); + return reply.code(204).send(); + }, + ); + + /** Validate + normalise a draft save body; tz-stamps V2 structures like a publish. */ + function parseDraftBody( + body: DraftBody | undefined, + ): { name: string; currency: string; structure: TariffStructure } | { error: string; problems?: string[] } { + const b = body ?? ({} as DraftBody); + const name = (b.name ?? "").trim(); + if (!name) return { error: "name required" }; + const currency = (b.currency ?? "").trim().toUpperCase(); + if (currency.length < 3) return { error: "currency (ISO 4217) required" }; + const structure = stampSiteTz(b.structure); + const problems = validateTariffStructure(structure); + if (problems.length) return { error: "invalid tariff structure", problems }; + return { name, currency, structure }; + } + + /** Stamp a V2 structure's frozen wall-clock timezone from SITE config (never the + * client); a V1 (bare) structure passes through untouched. */ + function stampSiteTz(structure: TariffStructure): TariffStructure { + if (structure && isTariffV2(structure)) { + const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get(); + const tz = cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ; + return { ...structure, tz }; + } + return structure; + } + /** The tariff version in force at a given instant (latest effectiveFrom ≤ when). */ function tariffVersionIdFor(whenIso: string): string | null { const tariffId = ensureSiteTariff(); diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx index 2a1004e..7b24dce 100644 --- a/apps/web/src/TariffComposer.tsx +++ b/apps/web/src/TariffComposer.tsx @@ -1,267 +1,23 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { - ApiError, - fetchTariff, - isTariffV2, - publishTariffVersion, - type TariffBlock, - type TariffCard, - type TariffStep, - type TariffStructure, - type TariffState, -} from "./api.js"; +import { ApiError, fetchTariff, publishTariffVersion, type TariffState } from "./api.js"; +import { TariffEditorForm, emptyForm, formFromActive, toStructure, type FormState } from "./TariffEditorForm.js"; -// Tariff composer — the admin builds + edits the rate card at runtime. Publishing +// Tariff composer — the admin edits + publishes the LIVE rate card. Publishing // creates a new IMMUTABLE version (the active card); old versions are kept so past -// sessions reprice correctly. Amounts are entered in major units (e.g. euros) for -// usability and converted to integer minor units on submit. See wiki/concepts/tariff.md. - -// Editable form mirror of TariffStructure, but money in major-unit strings. -// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the -// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes. -// The LAST block is always open-ended ("thereafter"): its hours field is unused -// and it has no bound. On submit, per-block hours accumulate into the engine's -// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null. -interface BlockForm { - hours: string; // duration of THIS band, in hours (ignored for the last block) - price: string; // major units, e.g. "2.00" -} -// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the -// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md. -interface StepForm { - hours: string; // inclusive upper bound of this tier, in hours (e.g. "3") - total: string; // TOTAL major units for a stay within this tier (e.g. "5.00") -} -// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped -// (up-to) total-by-duration table. -interface PricingForm { - mode: "ladder" | "flat" | "stepped"; - flat: string; // major units (used when mode==="flat") - blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") - steps: StepForm[]; // up-to tiers (used when mode==="stepped") - dailyCap: string; // "" = no cap (ladder only) -} -// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained. -interface TierForm { - name: string; - priority: string; - category: string; // "" = applies to all categories - dow: number[]; // selected days 0..6; empty = every day - fromHour: string; // "" = all day - toHour: string; - dateFrom: string; // "" = unbounded - dateTo: string; - pricing: PricingForm; -} -interface FormState { - currency: string; - gracePeriodEntryMin: string; - incrementMin: string; - lostTicket: string; - gracePeriodExitMin: string; - // The default (always-active) card — its own flat/ladder body + daily cap. - base: PricingForm; - // Optional time/category tiers. Empty ⇒ a bare V1 structure is published. - tiers: TierForm[]; -} - -const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); -const toMajor = (minor: number): string => (minor / 100).toFixed(2); - -function emptySteps(): StepForm[] { - return [ - { hours: "1", total: "2.00" }, - { hours: "3", total: "5.00" }, - ]; -} -function emptyLadder(): PricingForm { - return { - mode: "ladder", - flat: "0.00", - dailyCap: "", - blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], - steps: emptySteps(), - }; -} -function emptyTier(): TierForm { - return { - name: "", - priority: "10", - category: "", - dow: [], - fromHour: "", - toHour: "", - dateFrom: "", - dateTo: "", - pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] }, - }; -} - -function emptyForm(): FormState { - return { - currency: "EUR", - gracePeriodEntryMin: "15", - incrementMin: "60", - lostTicket: "20.00", - gracePeriodExitMin: "15", - base: emptyLadder(), - tiers: [], - }; -} - -// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band -// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still -// load (shown as their own band). -function blocksToForm(blocks: TariffBlock[]): BlockForm[] { - let prev = 0; - return blocks.map((b) => { - if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) }; - const hours = (b.uptoMin - prev) / 60; - prev = b.uptoMin; - return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) }; - }); -} - -// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits. -function stepsToForm(steps: TariffStep[]): StepForm[] { - return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) })); -} - -// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped). -function pricingFromCard(c: { - flatMinor?: number; - blocks?: TariffBlock[]; - steps?: TariffStep[]; - dailyCapMinor?: number | null; -}): PricingForm { - if (c.steps != null && c.steps.length > 0) { - return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) }; - } - if (c.flatMinor != null) { - return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() }; - } - return { - mode: "ladder", - flat: "0.00", - dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), - blocks: blocksToForm(c.blocks ?? []), - steps: emptySteps(), - }; -} - -function tierFromCard(c: TariffCard): TierForm { - const w = c.window ?? {}; - return { - name: c.name, - priority: String(c.priority), - category: c.category ?? "", - dow: w.dow ? [...w.dow] : [], - fromHour: w.fromHour ?? "", - toHour: w.toHour ?? "", - dateFrom: w.dateFrom ?? "", - dateTo: w.dateTo ?? "", - pricing: pricingFromCard(c), - }; -} - -function formFromActive(s: TariffState): FormState { - const v = s.active; - if (!v) return emptyForm(); - const st = v.structure; - const common = { - currency: v.currency, - gracePeriodEntryMin: String(st.gracePeriodEntryMin), - incrementMin: String(st.incrementMin), - lostTicket: toMajor(st.lostTicketMinor), - gracePeriodExitMin: String(st.gracePeriodExitMin), - }; - if (isTariffV2(st)) { - return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) }; - } - // V1: the bare ladder becomes the default card body; no tiers. - return { ...common, base: pricingFromCard(st), tiers: [] }; -} - -// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm. -function pricingToCardBody(p: PricingForm): Pick { - if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; - if (p.mode === "stepped") { - // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours"). - const steps: TariffStep[] = p.steps.map((s) => ({ - uptoMin: Math.round(Number(s.hours || "0") * 60), - totalMinor: toMinor(s.total), - })); - return { steps }; - } - // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended. - const last = p.blocks.length - 1; - let cum = 0; - const blocks: TariffBlock[] = p.blocks.map((b, i) => { - if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) }; - cum += Math.round(Number(b.hours || "0") * 60); - return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) }; - }); - return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) }; -} - -function tierToCard(tr: TierForm): TariffCard { - const window: TariffCard["window"] = {}; - if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b); - if (tr.fromHour && tr.toHour) { - window.fromHour = tr.fromHour; - window.toHour = tr.toHour; - } - if (tr.dateFrom) window.dateFrom = tr.dateFrom; - if (tr.dateTo) window.dateTo = tr.dateTo; - const card: TariffCard = { - name: tr.name.trim() || "tier", - priority: Math.round(Number(tr.priority || "0")), - ...pricingToCardBody(tr.pricing), - }; - if (tr.category.trim()) card.category = tr.category.trim(); - if (Object.keys(window).length > 0) card.window = window; - return card; -} - -function toStructure(f: FormState): TariffStructure { - const common = { - gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), - incrementMin: Math.round(Number(f.incrementMin)), - lostTicketMinor: toMinor(f.lostTicket), - gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), - overstay: "reprice" as const, - }; - const baseBody = pricingToCardBody(f.base); - - // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants - // tiers gets exactly today's shape; the server leaves it untouched). - if (f.tiers.length === 0) { - if (f.base.mode === "stepped") { - // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap). - return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null }; - } - if (f.base.mode === "flat") { - // A flat V1: a single open-ended block at the flat rate (V1 has no flat field). - return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null }; - } - return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null }; - } - - // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here). - return { - ...common, - version: 2, - tz: "", - defaultCard: { name: "default", priority: 0, ...baseBody }, - windowedCards: f.tiers.map(tierToCard), - }; -} +// sessions reprice correctly. The form machinery is shared with the Tariff Lab's +// draft modal — see TariffEditorForm.tsx. To experiment without publishing, use the +// lab (a draft only becomes real through this same publish path). See +// wiki/concepts/tariff.md. export function TariffComposer() { const { t } = useTranslation(); const [state, setState] = useState(null); const [form, setForm] = useState(emptyForm); + // Optional label for the version about to be published. Deliberately NOT prefilled + // from the active version — a tweaked card republished under last season's name + // would mislabel the history. + const [versionName, setVersionName] = useState(""); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState<{ kind: "ok" | "err"; text: string } | null>(null); @@ -274,70 +30,18 @@ export function TariffComposer() { .catch((e) => setMsg({ kind: "err", text: (e as Error).message })); }, []); - function set(key: K, value: FormState[K]) { - setForm((f) => ({ ...f, [key]: value })); - } - - // --- pricing-body editing (used by the default card AND each tier) --- - // `update` maps the old PricingForm to a new one; `target` selects which body: - // the base card, or tier index N. - function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) { - setForm((f) => { - if (target === "base") return { ...f, base: update(f.base) }; - return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) }; - }); - } - function setBlock(target: "base" | number, i: number, patch: Partial) { - updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); - } - // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended. - function addBlock(target: "base" | number) { - updatePricing(target, (p) => { - const next = [...p.blocks]; - next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" }); - return { ...p, blocks: next }; - }); - } - function removeBlock(target: "base" | number, i: number) { - updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) })); - } - // --- stepped (up-to) editing (base card only) --- - function setStep(i: number, patch: Partial) { - updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); - } - function addStep() { - updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] })); - } - function removeStep(i: number) { - updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) })); - } - - // --- tier editing --- - function setTier(i: number, patch: Partial) { - setForm((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); - } - function addTier() { - setForm((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); - } - function removeTier(i: number) { - setForm((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); - } - function toggleDow(i: number, d: number) { - setForm((f) => ({ - ...f, - tiers: f.tiers.map((tr, j) => - j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr, - ), - })); - } - async function publish() { setSaving(true); setMsg(null); try { - await publishTariffVersion({ currency: form.currency.trim().toUpperCase(), structure: toStructure(form) }); + await publishTariffVersion({ + currency: form.currency.trim().toUpperCase(), + structure: toStructure(form), + ...(versionName.trim() ? { name: versionName.trim() } : {}), + }); const fresh = await fetchTariff(); setState(fresh); + setVersionName(""); setMsg({ kind: "ok", text: t("tariff.publishedOk") }); } catch (e) { const text = @@ -359,6 +63,7 @@ export function TariffComposer() {

) : (

+ {state.active.name ? `${state.active.name} — ` : ""} {t("tariff.activeSince", { date: new Date(state.active.effectiveFrom).toLocaleString(), count: state.versions.length, @@ -366,114 +71,15 @@ export function TariffComposer() {

)} -
- - set("currency", e.target.value)} maxLength={3} /> - - set("gracePeriodEntryMin", e.target.value)} /> - - set("incrementMin", e.target.value)} /> - - set("lostTicket", e.target.value)} /> - - set("gracePeriodExitMin", e.target.value)} /> -
+ - {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never - wants tiers just edits this and publishes a bare V1 structure. */} -

{t("tariff.defaultCard")}

-

{t("tariff.defaultCardHint")}

-
- updatePricing("base", (p) => ({ ...p, mode }))} - onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} - onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} - onBlock={(i, patch) => setBlock("base", i, patch)} - onAddBlock={() => addBlock("base")} - onRemoveBlock={(i) => removeBlock("base", i)} - onStep={setStep} - onAddStep={addStep} - onRemoveStep={removeStep} +
+ setVersionName(e.target.value)} + placeholder={t("tariff.versionNamePh")} /> -
- - {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */} -
0}> - {t("tariff.tiersAdvanced")} -

{t("tariff.tiersHint")}

- {/* A stepped ("up-to") base rate cannot be combined with time tiers — the - engine would ignore them. Warn up-front; publishing is also blocked server-side. */} - {form.base.mode === "stepped" && form.tiers.length > 0 && ( -

- {t("tariff.steppedTiersConflict")} -

- )} - {form.tiers.map((tr, i) => ( -
- - setTier(i, { name: e.target.value })} - placeholder={t("tariff.tierName")} - /> - - -
- - setTier(i, { priority: e.target.value })} /> - - setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} /> - - - {[1, 2, 3, 4, 5, 6, 0].map((d) => ( - - ))} - - - - setTier(i, { fromHour: e.target.value })} placeholder="22:00" /> - – - setTier(i, { toHour: e.target.value })} placeholder="06:00" /> - {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( - {t("tariff.tierOvernight")} - )} - - - - setTier(i, { dateFrom: e.target.value })} /> - – - setTier(i, { dateTo: e.target.value })} /> - -
-
- updatePricing(i, (p) => ({ ...p, mode }))} - onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} - onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))} - onBlock={(bi, patch) => setBlock(i, bi, patch)} - onAddBlock={() => addBlock(i)} - onRemoveBlock={(bi) => removeBlock(i, bi)} - /> -
-
- ))} - -
- -
@@ -484,139 +90,3 @@ export function TariffComposer() { ); } - -// A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The -// stepped mode is offered only where `allowStepped` (the default card, not tiers). -function PricingEditor(props: { - t: (k: string) => string; - pricing: PricingForm; - allowStepped?: boolean; - onMode: (m: "ladder" | "flat" | "stepped") => void; - onFlat: (v: string) => void; - onCap: (v: string) => void; - onBlock: (i: number, patch: Partial) => void; - onAddBlock: () => void; - onRemoveBlock: (i: number) => void; - onStep?: (i: number, patch: Partial) => void; - onAddStep?: () => void; - onRemoveStep?: (i: number) => void; -}) { - const { t, pricing: p } = props; - return ( -
-
- - - {props.allowStepped && ( - - )} -
- - {p.mode === "stepped" ? ( - <> -

{t("tariff.steppedHint")}

- - - - - - - - - {p.steps.map((s, i) => ( - - - - - - ))} - -
{t("tariff.stepUpTo")}{t("tariff.stepTotal")} -
- - props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> - {t("tariff.hoursUnit")} - - - props.onStep?.(i, { total: e.target.value })} /> - - {p.steps.length > 1 && ( - - )} -
-
- -
- - ) : p.mode === "flat" ? ( -
- {t("tariff.pricePerIncrement")} - props.onFlat(e.target.value)} /> -
- ) : ( - <> - - - - - - - - - {p.blocks.map((b, i) => { - const isTail = i === p.blocks.length - 1; - return ( - - - - - - ); - })} - -
{t("tariff.bandDuration")}{t("tariff.pricePerIncrement")} -
- {isTail ? ( - {t("tariff.thereafter")} - ) : ( - - props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> - {t("tariff.hoursUnit")} - - )} - - props.onBlock(i, { price: e.target.value })} /> - - {!isTail && ( - - )} -
-
- - - {t("tariff.dailyCap")} - props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} /> - -
- - )} -
- ); -} diff --git a/apps/web/src/TariffEditorForm.tsx b/apps/web/src/TariffEditorForm.tsx new file mode 100644 index 0000000..3407375 --- /dev/null +++ b/apps/web/src/TariffEditorForm.tsx @@ -0,0 +1,604 @@ +import { useTranslation } from "react-i18next"; +import { + isTariffV2, + type TariffBlock, + type TariffCard, + type TariffStep, + type TariffStructure, + type TariffState, +} from "./api.js"; + +// The tariff EDITOR FORM — the rate-card composer's form machinery (state shape, +// structure↔form converters, and the editing UI), extracted so two hosts can share +// it: the /setup/tariff page (edits + publishes the live card) and the Tariff Lab's +// draft modal (edits an experimental card). The host owns the FormState and the +// submit action; this module owns everything between. Amounts are entered in major +// units (e.g. euros) and converted to integer minor units on submit. +// See wiki/concepts/tariff.md. + +// Editable form mirror of TariffStructure, but money in major-unit strings. +// Blocks are edited as a DURATION in hours ("this band lasts N hours") — the +// owner thinks "first 2 hours, then next 3 hours", not in cumulative minutes. +// The LAST block is always open-ended ("thereafter"): its hours field is unused +// and it has no bound. On submit, per-block hours accumulate into the engine's +// cumulative `uptoMin` (minutes), and the last block emits uptoMin: null. +export interface BlockForm { + hours: string; // duration of THIS band, in hours (ignored for the last block) + price: string; // major units, e.g. "2.00" +} +// One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the +// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md. +export interface StepForm { + hours: string; // inclusive upper bound of this tier, in hours (e.g. "3") + total: string; // TOTAL major units for a stay within this tier (e.g. "5.00") +} +// A pricing body the form edits: a per-increment flat rate, a marginal block ladder, +// a stepped (up-to) total-by-duration table, or a whole-window package (tiers only). +export interface PricingForm { + mode: "ladder" | "flat" | "stepped" | "package"; + flat: string; // major units PER INCREMENT (used when mode==="flat") + packageTotal: string; // major units for the WHOLE window occurrence (mode==="package") + blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") + steps: StepForm[]; // up-to tiers (used when mode==="stepped") + dailyCap: string; // "" = no cap (ladder only) +} +// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained. +export interface TierForm { + name: string; + priority: string; + category: string; // "" = applies to all categories + dow: number[]; // selected days 0..6; empty = every day + fromHour: string; // "" = all day + toHour: string; + dateFrom: string; // "" = unbounded + dateTo: string; + pricing: PricingForm; +} +export interface FormState { + currency: string; + gracePeriodEntryMin: string; + incrementMin: string; + lostTicket: string; + gracePeriodExitMin: string; + // The default (always-active) card — its own flat/ladder body + daily cap. + base: PricingForm; + // Optional time/category tiers. Empty ⇒ a bare V1 structure is published. + tiers: TierForm[]; +} + +const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); +const toMajor = (minor: number): string => (minor / 100).toFixed(2); + +function emptySteps(): StepForm[] { + return [ + { hours: "1", total: "2.00" }, + { hours: "3", total: "5.00" }, + ]; +} +function emptyLadder(): PricingForm { + return { + mode: "ladder", + flat: "0.00", + packageTotal: "0.00", + dailyCap: "", + blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }], + steps: emptySteps(), + }; +} +function emptyTier(): TierForm { + return { + name: "", + priority: "10", + category: "", + dow: [], + fromHour: "", + toHour: "", + dateFrom: "", + dateTo: "", + pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] }, + }; +} + +export function emptyForm(): FormState { + return { + currency: "EUR", + gracePeriodEntryMin: "15", + incrementMin: "60", + lostTicket: "20.00", + gracePeriodExitMin: "15", + base: emptyLadder(), + tiers: [], + }; +} + +// Convert a stored block ladder's cumulative `uptoMin` (minutes) into the per-band +// hours the form edits. Open-ended last band has no hours. Legacy bounded tails still +// load (shown as their own band). +function blocksToForm(blocks: TariffBlock[]): BlockForm[] { + let prev = 0; + return blocks.map((b) => { + if (b.uptoMin == null) return { hours: "", price: toMajor(b.priceMinorPerIncrement) }; + const hours = (b.uptoMin - prev) / 60; + prev = b.uptoMin; + return { hours: String(hours), price: toMajor(b.priceMinorPerIncrement) }; + }); +} + +// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits. +function stepsToForm(steps: TariffStep[]): StepForm[] { + return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) })); +} + +// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, stepped, +// or window package). +function pricingFromCard(c: { + flatMinor?: number; + blocks?: TariffBlock[]; + steps?: TariffStep[]; + packageMinor?: number; + dailyCapMinor?: number | null; +}): PricingForm { + if (c.steps != null && c.steps.length > 0) { + return { ...emptyLadder(), mode: "stepped", steps: stepsToForm(c.steps) }; + } + if (c.packageMinor != null) { + return { ...emptyLadder(), mode: "package", packageTotal: toMajor(c.packageMinor) }; + } + if (c.flatMinor != null) { + return { ...emptyLadder(), mode: "flat", flat: toMajor(c.flatMinor) }; + } + return { + ...emptyLadder(), + mode: "ladder", + dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), + blocks: blocksToForm(c.blocks ?? []), + }; +} + +function tierFromCard(c: TariffCard): TierForm { + const w = c.window ?? {}; + return { + name: c.name, + priority: String(c.priority), + category: c.category ?? "", + dow: w.dow ? [...w.dow] : [], + fromHour: w.fromHour ?? "", + toHour: w.toHour ?? "", + dateFrom: w.dateFrom ?? "", + dateTo: w.dateTo ?? "", + pricing: pricingFromCard(c), + }; +} + +/** A stored (currency, structure) pair → the editable form. Used to load the active + * version into the composer page and a saved draft into the lab modal. */ +export function formFromVersion(currency: string, st: TariffStructure): FormState { + const common = { + currency, + gracePeriodEntryMin: String(st.gracePeriodEntryMin), + incrementMin: String(st.incrementMin), + lostTicket: toMajor(st.lostTicketMinor), + gracePeriodExitMin: String(st.gracePeriodExitMin), + }; + if (isTariffV2(st)) { + return { ...common, base: pricingFromCard(st.defaultCard), tiers: (st.windowedCards ?? []).map(tierFromCard) }; + } + // V1: the bare ladder becomes the default card body; no tiers. + return { ...common, base: pricingFromCard(st), tiers: [] }; +} + +export function formFromActive(s: TariffState): FormState { + return s.active ? formFromVersion(s.active.currency, s.active.structure) : emptyForm(); +} + +// Build a tariff card's pricing body (flat XOR ladder XOR stepped XOR package) from a PricingForm. +function pricingToCardBody(p: PricingForm): Pick { + if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; + if (p.mode === "package") return { packageMinor: toMinor(p.packageTotal) }; + if (p.mode === "stepped") { + // Each row's `hours` IS the inclusive threshold (the matrix "up to N hours"). + const steps: TariffStep[] = p.steps.map((s) => ({ + uptoMin: Math.round(Number(s.hours || "0") * 60), + totalMinor: toMinor(s.total), + })); + return { steps }; + } + // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended. + const last = p.blocks.length - 1; + let cum = 0; + const blocks: TariffBlock[] = p.blocks.map((b, i) => { + if (i === last) return { uptoMin: null, priceMinorPerIncrement: toMinor(b.price) }; + cum += Math.round(Number(b.hours || "0") * 60); + return { uptoMin: cum, priceMinorPerIncrement: toMinor(b.price) }; + }); + return { blocks, dailyCapMinor: p.dailyCap.trim() === "" ? null : toMinor(p.dailyCap) }; +} + +function tierToCard(tr: TierForm): TariffCard { + const window: TariffCard["window"] = {}; + if (tr.dow.length > 0) window.dow = [...tr.dow].sort((a, b) => a - b); + if (tr.fromHour && tr.toHour) { + window.fromHour = tr.fromHour; + window.toHour = tr.toHour; + } + if (tr.dateFrom) window.dateFrom = tr.dateFrom; + if (tr.dateTo) window.dateTo = tr.dateTo; + const card: TariffCard = { + name: tr.name.trim() || "tier", + priority: Math.round(Number(tr.priority || "0")), + ...pricingToCardBody(tr.pricing), + }; + if (tr.category.trim()) card.category = tr.category.trim(); + if (Object.keys(window).length > 0) card.window = window; + return card; +} + +export function toStructure(f: FormState): TariffStructure { + const common = { + gracePeriodEntryMin: Math.round(Number(f.gracePeriodEntryMin)), + incrementMin: Math.round(Number(f.incrementMin)), + lostTicketMinor: toMinor(f.lostTicket), + gracePeriodExitMin: Math.round(Number(f.gracePeriodExitMin)), + overstay: "reprice" as const, + }; + const baseBody = pricingToCardBody(f.base); + + // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants + // tiers gets exactly today's shape; the server leaves it untouched). + if (f.tiers.length === 0) { + if (f.base.mode === "stepped") { + // A stepped V1: the up-to table replaces the ladder (blocks empty, no cap). + return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null }; + } + if (f.base.mode === "flat") { + // A flat V1: a single open-ended block at the flat rate (V1 has no flat field). + return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null }; + } + return { ...common, blocks: baseBody.blocks ?? [], dailyCapMinor: baseBody.dailyCapMinor ?? null }; + } + + // Tiers present ⇒ V2. tz is stamped server-side from site config (left blank here). + return { + ...common, + version: 2, + tz: "", + defaultCard: { name: "default", priority: 0, ...baseBody }, + windowedCards: f.tiers.map(tierToCard), + }; +} + +/** The full rate-card editing UI (shared settings + default card + tiers). The host + * owns the FormState; every edit flows through `onChange` as a functional update. */ +export function TariffEditorForm({ + form, + onChange, +}: { + form: FormState; + onChange: (update: (f: FormState) => FormState) => void; +}) { + const { t } = useTranslation(); + + function set(key: K, value: FormState[K]) { + onChange((f) => ({ ...f, [key]: value })); + } + + // --- pricing-body editing (used by the default card AND each tier) --- + // `update` maps the old PricingForm to a new one; `target` selects which body: + // the base card, or tier index N. + function updatePricing(target: "base" | number, update: (p: PricingForm) => PricingForm) { + onChange((f) => { + if (target === "base") return { ...f, base: update(f.base) }; + return { ...f, tiers: f.tiers.map((tr, j) => (j === target ? { ...tr, pricing: update(tr.pricing) } : tr)) }; + }); + } + function setBlock(target: "base" | number, i: number, patch: Partial) { + updatePricing(target, (p) => ({ ...p, blocks: p.blocks.map((b, j) => (j === i ? { ...b, ...patch } : b)) })); + } + // Insert a bounded band just BEFORE the open-ended tail, so the last block stays open-ended. + function addBlock(target: "base" | number) { + updatePricing(target, (p) => { + const next = [...p.blocks]; + next.splice(p.blocks.length - 1, 0, { hours: "1", price: "0.00" }); + return { ...p, blocks: next }; + }); + } + function removeBlock(target: "base" | number, i: number) { + updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) })); + } + // --- stepped (up-to) editing (base card only) --- + function setStep(i: number, patch: Partial) { + updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) })); + } + function addStep() { + updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] })); + } + function removeStep(i: number) { + updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) })); + } + + // --- tier editing --- + function setTier(i: number, patch: Partial) { + onChange((f) => ({ ...f, tiers: f.tiers.map((tr, j) => (j === i ? { ...tr, ...patch } : tr)) })); + } + function addTier() { + onChange((f) => ({ ...f, tiers: [...f.tiers, emptyTier()] })); + } + function removeTier(i: number) { + onChange((f) => ({ ...f, tiers: f.tiers.filter((_, j) => j !== i) })); + } + function toggleDow(i: number, d: number) { + onChange((f) => ({ + ...f, + tiers: f.tiers.map((tr, j) => + j === i ? { ...tr, dow: tr.dow.includes(d) ? tr.dow.filter((x) => x !== d) : [...tr.dow, d] } : tr, + ), + })); + } + + return ( +
+
+ + set("currency", e.target.value)} maxLength={3} /> + + set("gracePeriodEntryMin", e.target.value)} /> + + set("incrementMin", e.target.value)} /> + + set("lostTicket", e.target.value)} /> + + set("gracePeriodExitMin", e.target.value)} /> +
+ + {/* The DEFAULT card — always-active rate. Front-and-centre; a site that never + wants tiers just edits this and publishes a bare V1 structure. */} +

{t("tariff.defaultCard")}

+

{t("tariff.defaultCardHint")}

+
+ updatePricing("base", (p) => ({ ...p, mode }))} + onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} + onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} + onBlock={(i, patch) => setBlock("base", i, patch)} + onAddBlock={() => addBlock("base")} + onRemoveBlock={(i) => removeBlock("base", i)} + onStep={setStep} + onAddStep={addStep} + onRemoveStep={removeStep} + /> +
+ + {/* Advanced: time & seasonal/category TIERS (opt-in). Empty ⇒ V1 is published. */} +
0}> + {t("tariff.tiersAdvanced")} +

{t("tariff.tiersHint")}

+ {/* A stepped ("up-to") base rate cannot be combined with time tiers — the + engine would ignore them. Warn up-front; publishing is also blocked server-side. */} + {form.base.mode === "stepped" && form.tiers.length > 0 && ( +

+ {t("tariff.steppedTiersConflict")} +

+ )} + {form.tiers.map((tr, i) => ( +
+ + setTier(i, { name: e.target.value })} + placeholder={t("tariff.tierName")} + /> + + +
+ + setTier(i, { priority: e.target.value })} /> + + setTier(i, { category: e.target.value })} placeholder={t("tariff.tierCategoryPh")} /> + + + {[1, 2, 3, 4, 5, 6, 0].map((d) => ( + + ))} + + + + setTier(i, { fromHour: e.target.value })} placeholder="22:00" /> + – + setTier(i, { toHour: e.target.value })} placeholder="06:00" /> + {tr.fromHour && tr.toHour && tr.toHour <= tr.fromHour && ( + {t("tariff.tierOvernight")} + )} + + + + setTier(i, { dateFrom: e.target.value })} /> + – + setTier(i, { dateTo: e.target.value })} /> + +
+
+ updatePricing(i, (p) => ({ ...p, mode }))} + onFlat={(flat) => updatePricing(i, (p) => ({ ...p, flat }))} + onPackage={(packageTotal) => updatePricing(i, (p) => ({ ...p, packageTotal }))} + onCap={(dailyCap) => updatePricing(i, (p) => ({ ...p, dailyCap }))} + onBlock={(bi, patch) => setBlock(i, bi, patch)} + onAddBlock={() => addBlock(i)} + onRemoveBlock={(bi) => removeBlock(i, bi)} + /> +
+
+ ))} + +
+
+ ); +} + +// A reusable pricing-body editor — flat (per increment) / marginal ladder / stepped +// (up-to) / window package. The stepped mode is offered only where `allowStepped` +// (the default card); the package mode only where `allowPackage` (tier cards — the +// engine needs a window to be an occurrence of). +function PricingEditor(props: { + t: (k: string) => string; + pricing: PricingForm; + allowStepped?: boolean; + allowPackage?: boolean; + onMode: (m: "ladder" | "flat" | "stepped" | "package") => void; + onFlat: (v: string) => void; + onPackage?: (v: string) => void; + onCap: (v: string) => void; + onBlock: (i: number, patch: Partial) => void; + onAddBlock: () => void; + onRemoveBlock: (i: number) => void; + onStep?: (i: number, patch: Partial) => void; + onAddStep?: () => void; + onRemoveStep?: (i: number) => void; +}) { + const { t, pricing: p } = props; + return ( +
+
+ + + {props.allowStepped && ( + + )} + {props.allowPackage && ( + + )} +
+ + {p.mode === "package" ? ( +
+

{t("tariff.packageHint")}

+
+ {t("tariff.packageTotal")} + props.onPackage?.(e.target.value)} /> +
+
+ ) : p.mode === "stepped" ? ( + <> +

{t("tariff.steppedHint")}

+ + + + + + + + + {p.steps.map((s, i) => ( + + + + + + ))} + +
{t("tariff.stepUpTo")}{t("tariff.stepTotal")} +
+ + props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> + {t("tariff.hoursUnit")} + + + props.onStep?.(i, { total: e.target.value })} /> + + {p.steps.length > 1 && ( + + )} +
+
+ +
+ + ) : p.mode === "flat" ? ( +
+ {t("tariff.pricePerIncrement")} + props.onFlat(e.target.value)} /> +
+ ) : ( + <> + + + + + + + + + {p.blocks.map((b, i) => { + const isTail = i === p.blocks.length - 1; + return ( + + + + + + ); + })} + +
{t("tariff.bandDuration")}{t("tariff.pricePerIncrement")} +
+ {isTail ? ( + {t("tariff.thereafter")} + ) : ( + + props.onBlock(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> + {t("tariff.hoursUnit")} + + )} + + props.onBlock(i, { price: e.target.value })} /> + + {!isTail && ( + + )} +
+
+ + + {t("tariff.dailyCap")} + props.onCap(e.target.value)} placeholder={t("tariff.dailyCapPh")} /> + +
+ + )} +
+ ); +} diff --git a/apps/web/src/TariffLab.tsx b/apps/web/src/TariffLab.tsx index 6db953d..9e72c96 100644 --- a/apps/web/src/TariffLab.tsx +++ b/apps/web/src/TariffLab.tsx @@ -1,21 +1,30 @@ import { useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + ApiError, + createTariffDraft, + deleteTariffDraft, fetchTariff, - loadSimSession, + fetchTariffDrafts, + publishTariffVersion, simulateTariff, + updateTariffDraft, type SimulateResult, - type SimPayment, + type TariffDraft, type TariffState, } from "./api.js"; +import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js"; +import { Modal } from "./ui/Modal.js"; import { formatMoney, formatDuration } from "./lib/format.js"; -// The TARIFF LAB — a pure session-pricing simulator. Test rates "in time" (overnight -// windows, daily caps, overstay) in seconds instead of waiting hours, against ANY -// published tariff version, with no real ledger writes. Build a hypothetical session -// (entry, optional payment, "now") OR load a real ticket and re-evaluate it at any -// instant. Prices via the SAME `priceSession` the booth uses (server), so the lab and -// the live booth can never diverge. See wiki/concepts/tariff.md, booth-exit-flow.md. +// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts +// live in their own mutable table (tariff_drafts), so experimenting never churns the +// immutable published versions or risks a half-baked card going live: the admin +// composes a draft in the modal (the same form the composer page uses), simulates +// hypothetical stays against it (entry + exit, nothing else), and only when satisfied +// PUBLISHES it through the normal immutable-version path. Pricing uses the SAME +// `priceSession` the booth uses (server-side), so the lab and the live booth can +// never diverge. No ledger writes. See wiki/concepts/tariff.md. /** wants "YYYY-MM-DDTHH:mm" in LOCAL time. */ function toLocalInput(iso: string): string { @@ -33,50 +42,76 @@ function nowLocal(): string { return toLocalInput(new Date().toISOString()); } +/** What the simulation runs against: the live card, a historical published + * version, or one lab draft. */ +type Selection = { kind: "active" } | { kind: "version"; id: string } | { kind: "draft"; id: string }; + +/** Modal state: a draft being composed (id null = not yet saved). */ +interface DraftEdit { + id: string | null; + name: string; + form: FormState; +} + export function TariffLab() { const { t } = useTranslation(); const [state, setState] = useState(null); + const [drafts, setDrafts] = useState([]); + const [selected, setSelected] = useState({ kind: "active" }); const [err, setErr] = useState(null); + const [notice, setNotice] = useState(null); - // Inputs (datetime-local strings, local wall-clock). + // The hypothetical stay: entry + exit, nothing else. const [entered, setEntered] = useState(() => { const d = new Date(); d.setHours(d.getHours() - 3); // default: a 3h-ago entry return toLocalInput(d.toISOString()); }); - const [asOf, setAsOf] = useState(nowLocal); - const [category, setCategory] = useState(""); - const [versionId, setVersionId] = useState(""); // "" = active - // Optional single hypothetical payment (the latest grants the walk-back grace). - const [paid, setPaid] = useState(false); - const [paidAt, setPaidAt] = useState(nowLocal); - const [graceMin, setGraceMin] = useState("5"); - // Load-a-real-ticket. - const [ticket, setTicket] = useState(""); - const [loadMsg, setLoadMsg] = useState(null); + const [exit, setExit] = useState(nowLocal); const [result, setResult] = useState(null); const [busy, setBusy] = useState(false); + // The draft-composer modal. + const [edit, setEdit] = useState(null); + const [saving, setSaving] = useState(false); + const [editErr, setEditErr] = useState(null); + + async function refresh() { + const [s, d] = await Promise.all([fetchTariff(), fetchTariffDrafts()]); + setState(s); + setDrafts(d.drafts); + return d.drafts; + } useEffect(() => { - fetchTariff() - .then(setState) - .catch((e) => setErr((e as Error).message)); + refresh().catch((e) => setErr((e as Error).message)); }, []); + const selectedDraft = selected.kind === "draft" ? drafts.find((d) => d.id === selected.id) ?? null : null; + const selectedVersion = + selected.kind === "version" ? state?.versions.find((v) => v.id === selected.id) ?? null : null; + + function select(sel: Selection) { + setSelected(sel); + setResult(null); // a stale price against another card would mislead + setErr(null); + setNotice(null); + } + async function run() { setErr(null); setBusy(true); try { - const payments: SimPayment[] = paid - ? [{ paidAt: fromLocalInput(paidAt), graceExitMin: graceMin.trim() === "" ? null : Number(graceMin) }] - : []; const r = await simulateTariff({ enteredAt: fromLocalInput(entered), - asOf: fromLocalInput(asOf), - payments, - category: category.trim() || undefined, - tariffVersionId: versionId || undefined, + asOf: fromLocalInput(exit), + // A draft carries its own structure+currency; a historical version is + // referenced by id; otherwise the ACTIVE version. + ...(selectedDraft + ? { structure: selectedDraft.structure, currency: selectedDraft.currency } + : selectedVersion + ? { tariffVersionId: selectedVersion.id } + : {}), }); setResult(r); } catch (e) { @@ -87,162 +122,291 @@ export function TariffLab() { } } - async function loadTicket() { - setLoadMsg(null); + // --- draft actions --- + function newDraft() { + // Start from the live card when there is one — the admin usually experiments + // with a variation of today's prices, not from a blank slate. + const form = state?.active ? formFromActive(state) : emptyForm(); + setEditErr(null); + setEdit({ id: null, name: "", form }); + } + function editDraft(d: TariffDraft) { + setEditErr(null); + setEdit({ id: d.id, name: d.name, form: formFromVersion(d.currency, d.structure) }); + } + + async function saveDraft() { + if (!edit) return; + setSaving(true); + setEditErr(null); + try { + const body = { + name: edit.name.trim(), + currency: edit.form.currency.trim().toUpperCase(), + structure: toStructure(edit.form), + }; + const saved = edit.id ? await updateTariffDraft(edit.id, body) : await createTariffDraft(body); + await refresh(); + setEdit(null); + select({ kind: "draft", id: saved.id }); + } catch (e) { + const text = + e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message; + setEditErr(text); + } finally { + setSaving(false); + } + } + + async function removeDraft(d: TariffDraft) { + if (!confirm(t("lab.confirmDelete", { name: d.name }))) return; setErr(null); try { - const s = await loadSimSession(ticket.trim()); - setEntered(toLocalInput(s.enteredAt)); - setAsOf(s.exitedAt ? toLocalInput(s.exitedAt) : nowLocal()); - setCategory(s.category ?? ""); - setVersionId(s.tariffVersionId ?? ""); - const last = s.payments.at(-1); - if (last) { - setPaid(true); - setPaidAt(toLocalInput(last.paidAt)); - setGraceMin(last.graceExitMin != null ? String(last.graceExitMin) : ""); - } else { - setPaid(false); - } - setLoadMsg(t("lab.loaded", { id: s.identity })); + await deleteTariffDraft(d.id); + await refresh(); + select({ kind: "active" }); } catch (e) { setErr((e as Error).message); } } - const currency = result?.currency ?? state?.active?.currency ?? "ALL"; + async function publishDraft(d: TariffDraft) { + if (!confirm(t("lab.confirmPublish", { name: d.name }))) return; + setErr(null); + setNotice(null); + try { + // The draft's name rides along onto the immutable version. + await publishTariffVersion({ currency: d.currency, structure: d.structure, name: d.name }); + await refresh(); + setNotice(t("tariff.publishedOk")); + } catch (e) { + const text = + e instanceof ApiError && e.problems?.length ? `${e.message}: ${e.problems.join("; ")}` : (e as Error).message; + setErr(text); + } + } + + const currency = result?.currency ?? selectedDraft?.currency ?? selectedVersion?.currency ?? state?.active?.currency ?? "ALL"; return (

{t("lab.title")}

{t("lab.intro")}

- {/* Load a real ticket */} -
-
- - setTicket(e.target.value)} - placeholder={t("lab.loadTicketPh")} - /> -
- - {loadMsg && {loadMsg}} -
+
+ {/* Main: the hypothetical stay + result, priced against the selection. */} +
+ {/* What we're pricing against + draft actions. */} +
+ + {selectedDraft + ? selectedDraft.name + : selectedVersion + ? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString() + : t("lab.activeTariff")} + + {selectedDraft && ( + <> + + + + + )} + {notice && {notice}} +
- {/* Hypothetical session inputs */} -
- - +
+ + setEntered(e.target.value)} /> - - setEntered(e.target.value)} /> + + + setExit(e.target.value)} /> + + +
- - - setAsOf(e.target.value)} /> - - +
+ + {err && {err}} +
- - setCategory(e.target.value)} - placeholder={t("lab.categoryPh")} - /> + {result && ( +
+ {/* Outcome */} +
+

{t("lab.outcome")}

+
+
{t("lab.amountDue")}
+
{formatMoney(result.pricing.amountMinor, currency)}
+
{t("lab.billedPeriod")}
+
+ {formatDuration(result.pricing.periodStart, fromLocalInput(exit))} + {result.pricing.overstay && ( + + {t("lab.overstay")} + + )} + {result.pricing.withinGrace && ( + + {t("lab.settled")} + + )} +
+
{t("lab.periodStart")}
+
{new Date(result.pricing.periodStart).toLocaleString()}
+ {result.pricing.graceExpiresAt && ( + <> +
{t("lab.graceExpires")}
+
{new Date(result.pricing.graceExpiresAt).toLocaleString()}
+ + )} +
+
- - - - {paid && ( - <> - setPaidAt(e.target.value)} - /> - {t("lab.graceMin")} - setGraceMin(e.target.value)} /> - + {/* Duration curve from entry — see where the cap flattens / windows shift. */} +
+

{t("lab.curve")}

+

{t("lab.curveHint")}

+ + + {result.curve.map((c) => ( + + + + + ))} + +
{labelMin(c.minutes)}{formatMoney(c.amountMinor, currency)}
+
+
)} - -
- -
- - {err && {err}} -
- - {result && ( -
- {/* Outcome */} -
-

{t("lab.outcome")}

-
-
{t("lab.amountDue")}
-
{formatMoney(result.pricing.amountMinor, currency)}
-
{t("lab.billedPeriod")}
-
- {formatDuration(result.pricing.periodStart, fromLocalInput(asOf))} - {result.pricing.overstay && ( - - {t("lab.overstay")} - - )} - {result.pricing.withinGrace && ( - - {t("lab.settled")} - - )} -
-
{t("lab.periodStart")}
-
{new Date(result.pricing.periodStart).toLocaleString()}
- {result.pricing.graceExpiresAt && ( - <> -
{t("lab.graceExpires")}
-
{new Date(result.pricing.graceExpiresAt).toLocaleString()}
- - )} -
-
- - {/* Duration curve from entry — see where the cap flattens / windows shift. */} -
-

{t("lab.curve")}

-

{t("lab.curveHint")}

- - - {result.curve.map((c) => ( - - - - - ))} - -
{labelMin(c.minutes)}{formatMoney(c.amountMinor, currency)}
-
- )} + + {/* Sidebar: lab drafts + the full published history; click any to price + against it. */} + +
+ + {/* The draft composer — the SAME form the /setup/tariff page uses, in a modal. */} + setEdit(null)} + title={edit?.id ? t("lab.editDraftTitle") : t("lab.newDraftTitle")} + width="max-w-3xl" + > + {edit && ( +
+
+ + setEdit((d) => (d ? { ...d, name: e.target.value } : d))} + placeholder={t("lab.draftNamePh")} + /> +
+ setEdit((d) => (d ? { ...d, form: update(d.form) } : d))} + /> +
+ + {editErr && {editErr}} +
+
+ )} +
); } diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 7ed8dd6..5e36d46 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -669,10 +669,14 @@ export interface TariffCard { priority: number; category?: string; window?: TariffWindow; + /** Flat price PER INCREMENT (an hourly flat rate) — not a whole-stay price. */ flatMinor?: number; blocks?: TariffBlock[]; /** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */ steps?: TariffStep[]; + /** WINDOW PACKAGE (windowed cards only): ONE total per contiguous window occurrence + * ("any presence in the window = this price"). Mirrors @parking/shared. */ + packageMinor?: number; dailyCapMinor?: number | null; } /** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including @@ -702,6 +706,8 @@ export function isTariffV2(t: TariffStructure): t is TariffStructureV2 { export interface TariffVersion { id: string; tariffId: string; + /** Optional human label, stamped at publish (e.g. carried from a lab draft). */ + name?: string | null; effectiveFrom: string; currency: string; structure: TariffStructure; @@ -723,6 +729,7 @@ export function publishTariffVersion(body: { currency: string; structure: TariffStructure; effectiveFrom?: string; + name?: string; }): Promise { return apiFetch("/api/tariff/versions", { method: "POST", body: JSON.stringify(body) }); } @@ -761,18 +768,40 @@ export function simulateTariff(body: SimulateBody): Promise { return apiFetch("/api/tariff/simulate", { method: "POST", body: JSON.stringify(body) }); } -export interface SimSessionLoad { - identity: string; - enteredAt: string; - exitedAt: string | null; - payments: SimPayment[]; - category: string | null; - tariffVersionId: string | null; +// --- Tariff Lab drafts ------------------------------------------------------ +// Mutable experimental rate cards — the lab composes + simulates these, and +// publishing one goes through the normal immutable-version path above. + +export interface TariffDraft { + id: string; + name: string; + currency: string; + structure: TariffStructure; + createdBy: string | null; + createdAt: string; + updatedAt: string; } -/** Prefill the lab from a real ledger session. */ -export function loadSimSession(identity: string): Promise { - return apiFetch(`/api/tariff/simulate/session/${encodeURIComponent(identity)}`); +export function fetchTariffDrafts(): Promise<{ drafts: TariffDraft[] }> { + return apiFetch("/api/tariff/drafts"); +} + +export interface TariffDraftBody { + name: string; + currency: string; + structure: TariffStructure; +} + +export function createTariffDraft(body: TariffDraftBody): Promise { + return apiFetch("/api/tariff/drafts", { method: "POST", body: JSON.stringify(body) }); +} + +export function updateTariffDraft(id: string, body: TariffDraftBody): Promise { + return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "PUT", body: JSON.stringify(body) }); +} + +export function deleteTariffDraft(id: string): Promise { + return apiFetch(`/api/tariff/drafts/${encodeURIComponent(id)}`, { method: "DELETE" }); } // --- Subscriptions -------------------------------------------------------- diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index 4c5168a..7d41c68 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -319,25 +319,30 @@ export const en: Catalog = { bandDuration: "Band duration", hoursUnit: "hours", egHours: "e.g. 2", - pricePerIncrement: "Price / increment", + pricePerIncrement: "Price / increment (per hour)", thereafter: "thereafter (open-ended)", remove: "Remove", addBlock: "+ Add block", publishNewVersion: "Publish new version", publishing: "Publishing…", + versionNamePh: "Version name (optional), e.g. Summer 2026", publishedOk: "New tariff version published — it's now the active rate.", defaultCard: "Base rate (always active)", defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.", modeLadder: "Hourly ladder", - modeFlat: "Flat price", + modeFlat: "Flat price / hour", modeStepped: "By duration (up-to)", + modePackage: "Window package (one total)", + packageHint: + "ONE total for any presence inside this tier's window — leaving earlier costs the same. Touching the window on two different nights charges the package twice (once per night). Hours outside the window are priced by the base rate.", + packageTotal: "Package total", steppedHint: "Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.", stepUpTo: "Up to", stepTotal: "Total price", addStep: "+ Add row", steppedTiersConflict: - "⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price'. Publishing is blocked until this is fixed.", + "⚠ Time/seasonal tiers do NOT apply when the base rate is 'By duration (up-to)' — the engine ignores them entirely. Remove the tiers, or switch the base rate to 'Hourly ladder' or 'Flat price / hour'. Publishing is blocked until this is fixed.", tiersAdvanced: "Advanced: time & seasonal tiers", tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.", tierName: "Name", @@ -513,21 +518,26 @@ export const en: Catalog = { lab: { title: "Tariff Lab", intro: - "Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.", - loadTicket: "Load from a real ticket", - loadTicketPh: "Ticket number / identity", - load: "Load", - loaded: "Loaded session {{id}}", - tariffVersion: "Tariff version", - activeVersion: "Active version (current)", + "Compose experimental rate cards and price hypothetical stays against them — nothing goes live until you publish. Pricing uses the same logic as the booth; nothing is written to the ledger.", + drafts: "Lab tariffs", + newDraft: "New draft", + activeTariff: "Active tariff", + published: "Published versions", + noDrafts: "No lab tariffs yet — create a draft to experiment.", + edit: "Edit", + publish: "Publish", + delete: "Delete", + draftName: "Name", + draftNamePh: "e.g. Winter proposal", + saveDraft: "Save draft", + savingDraft: "Saving…", + newDraftTitle: "New lab tariff", + editDraftTitle: "Edit lab tariff", + confirmPublish: 'Publish "{{name}}" as the new live rate card? It takes effect immediately.', + confirmDelete: 'Delete lab tariff "{{name}}"?', entered: "Entered", - asOf: "As of (now/exit)", + exit: "Exit", now: "Now", - category: "Category", - categoryPh: "e.g. bus (blank = car)", - payment: "Payment", - paid: "paid", - graceMin: "grace (min)", price: "Compute price", pricing: "Pricing…", outcome: "Outcome", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index 01239b9..9354c66 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -322,25 +322,30 @@ export const sq = { bandDuration: "Kohëzgjatja e brezit", hoursUnit: "orë", egHours: "p.sh. 2", - pricePerIncrement: "Çmimi / interval", + pricePerIncrement: "Çmimi / interval (orë)", thereafter: "më pas (i hapur)", remove: "Hiq", addBlock: "+ Shto bllok", publishNewVersion: "Publiko version të ri", publishing: "Duke publikuar…", + versionNamePh: "Emri i versionit (opsional), p.sh. Vera 2026", publishedOk: "U publikua versioni i ri i tarifës — tani është tarifa aktive.", defaultCard: "Tarifa bazë (gjithmonë aktive)", defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.", modeLadder: "Shkallë orësh", - modeFlat: "Çmim fiks", + modeFlat: "Çmim fiks / orë", modeStepped: "Sipas kohëzgjatjes (deri-në)", + modePackage: "Paketë dritareje (një total)", + packageHint: + "NJË çmim total për çdo prani brenda dritares së këtij niveli — largimi më herët kushton njësoj. Prekja e dritares në dy net të ndryshme e faturon paketën dy herë (një herë për natë). Orët jashtë dritares vlerësohen me tarifën bazë.", + packageTotal: "Çmimi i paketës", steppedHint: "Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.", stepUpTo: "Deri në", stepTotal: "Çmimi total", addStep: "+ Shto rresht", steppedTiersConflict: - "⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks'. Publikimi bllokohet derisa kjo të rregullohet.", + "⚠ Nivelet kohore/sezonale NUK zbatohen kur tarifa bazë është 'Sipas kohëzgjatjes (deri-në)' — motori i shpërfill plotësisht. Hiqi nivelet, ose ndrysho tarifën bazë në 'Shkallë orësh' a 'Çmim fiks / orë'. Publikimi bllokohet derisa kjo të rregullohet.", tiersAdvanced: "Të avancuara: nivele kohore & sezonale", tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.", tierName: "Emri", @@ -525,21 +530,26 @@ export const sq = { lab: { title: "Lab Tarife", intro: - "Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.", - loadTicket: "Ngarko nga një biletë reale", - loadTicketPh: "Numri i biletës / identiteti", - load: "Ngarko", - loaded: "U ngarkua sesioni {{id}}", - tariffVersion: "Versioni i tarifës", - activeVersion: "Versioni aktiv (i tanishëm)", + "Kompozo tarifa eksperimentale dhe llogarit qëndrime hipotetike kundrejt tyre — asgjë nuk hyn në fuqi pa u publikuar. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.", + drafts: "Tarifa laboratori", + newDraft: "Draft i ri", + activeTariff: "Tarifa aktive", + published: "Versione të publikuara", + noDrafts: "Ende pa tarifa laboratori — krijo një draft për të eksperimentuar.", + edit: "Ndrysho", + publish: "Publiko", + delete: "Fshi", + draftName: "Emri", + draftNamePh: "p.sh. Propozimi i dimrit", + saveDraft: "Ruaj draftin", + savingDraft: "Duke ruajtur…", + newDraftTitle: "Tarifë e re laboratori", + editDraftTitle: "Ndrysho tarifën e laboratorit", + confirmPublish: 'Të publikohet "{{name}}" si karta e re aktive e çmimeve? Hyn në fuqi menjëherë.', + confirmDelete: 'Të fshihet tarifa e laboratorit "{{name}}"?', entered: "Hyrja", - asOf: "Deri më (tani/dalja)", + exit: "Dalja", now: "Tani", - category: "Kategoria", - categoryPh: "p.sh. bus (bosh = makinë)", - payment: "Pagesa", - paid: "u pagua", - graceMin: "afati (min)", price: "Llogarit çmimin", pricing: "Duke llogaritur…", outcome: "Rezultati", diff --git a/packages/db/drizzle/0021_tariff_drafts.sql b/packages/db/drizzle/0021_tariff_drafts.sql new file mode 100644 index 0000000..d25a977 --- /dev/null +++ b/packages/db/drizzle/0021_tariff_drafts.sql @@ -0,0 +1,14 @@ +-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes +-- experimental rate cards here, simulates them against hypothetical stays, and only +-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable — +-- a draft prices nothing and signs nothing; experimenting through real publishes would +-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md. +CREATE TABLE `tariff_drafts` ( + `id` text PRIMARY KEY NOT NULL, + `name` text NOT NULL, + `currency` text NOT NULL, + `structure` text NOT NULL, + `created_by` text, + `created_at` text DEFAULT (current_timestamp) NOT NULL, + `updated_at` text DEFAULT (current_timestamp) NOT NULL +); diff --git a/packages/db/drizzle/0022_tariff_version_name.sql b/packages/db/drizzle/0022_tariff_version_name.sql new file mode 100644 index 0000000..7d1f676 --- /dev/null +++ b/packages/db/drizzle/0022_tariff_version_name.sql @@ -0,0 +1,6 @@ +-- Optional name on published tariff versions (2026-07-05). The lab's draft workflow gave +-- rate cards human names; published versions were only tellable apart by effective date + +-- UUID prefix. The name is stamped at publish (carried from the lab draft, or typed in the +-- composer) and is immutable like the rest of the row. Nullable — old versions and unnamed +-- publishes are fine. +ALTER TABLE `tariff_versions` ADD `name` text; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fad373b..9036507 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -148,6 +148,20 @@ "when": 1781886300000, "tag": "0020_entry_presence_bypass", "breakpoints": true + }, + { + "idx": 21, + "version": "6", + "when": 1781886400000, + "tag": "0021_tariff_drafts", + "breakpoints": true + }, + { + "idx": 22, + "version": "6", + "when": 1781886500000, + "tag": "0022_tariff_version_name", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b964183..bc1921e 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -316,6 +316,10 @@ export const tariffs = sqliteTable("tariffs", { export const tariffVersions = sqliteTable("tariff_versions", { id: text("id").primaryKey(), tariffId: text("tariff_id").notNull(), + // Optional human label ("Winter 2027", carried from the lab draft it was published + // from). Stamped at publish, immutable like the rest of the row — versions are + // told apart in the UI by name, not UUID prefix. + name: text("name"), // The version is in force from this instant (latest with effectiveFrom ≤ entry wins). effectiveFrom: text("effective_from").notNull(), // ISO 4217; selectable. Money everywhere is { minorUnits, currency }, never a float. @@ -329,6 +333,29 @@ export const tariffVersions = sqliteTable("tariff_versions", { .default(sql`(current_timestamp)`), }); +// A LAB DRAFT rate card — the tariff-lab scratchpad. MUTABLE by design (the one +// exception to "editing publishes a version"): a draft prices nothing and signs +// nothing — it exists so the admin can experiment in the lab without churning real +// tariff_versions (each publish is permanent; experimenting through publishes would +// bury the history in noise and risk a wrong card going live). Publishing a draft +// goes through the normal POST /api/tariff/versions path (validated, tz-stamped, +// immutable). See wiki/concepts/tariff.md (Tariff Lab). +export const tariffDrafts = sqliteTable("tariff_drafts", { + id: text("id").primaryKey(), + name: text("name").notNull(), + currency: text("currency").notNull(), + // Same TariffStructure shape as tariff_versions.structure; validated on save so + // the lab can always simulate it. + structure: text("structure", { mode: "json" }).notNull().$type>(), + createdBy: text("created_by"), + createdAt: text("created_at") + .notNull() + .default(sql`(current_timestamp)`), + updatedAt: text("updated_at") + .notNull() + .default(sql`(current_timestamp)`), +}); + // --- Subscriptions -------------------------------------------------------- // A subscriber: a known holder who parks on a recurring plan (e.g. 10,000 ALL / // month) instead of paying per stay. Mutable master data; every USE still produces a @@ -513,6 +540,7 @@ export type SetupStateRow = typeof setupState.$inferSelect; export type SiteConfigRow = typeof siteConfig.$inferSelect; export type TariffRow = typeof tariffs.$inferSelect; export type TariffVersionRow = typeof tariffVersions.$inferSelect; +export type TariffDraftRow = typeof tariffDrafts.$inferSelect; export type SubscriptionRow = typeof subscriptions.$inferSelect; export type SubscriptionPlanRow = typeof subscriptionPlans.$inferSelect; export type SubscriptionCredentialRow = typeof subscriptionCredentials.$inferSelect; diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index d64e59b..00461ae 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -197,24 +197,44 @@ The admin authors the rate card at runtime — no hand-seeding: - Ships **blank** — until a version is published, `GET /api/tariff` returns `active: null` and the pay station returns `409 no active tariff`. Verified end to end (publish → pay station prices). -### Tariff Lab (simulator, as-built 2026-06-20) +### Tariff Lab (simulator, as-built 2026-06-20; drafts redesign 2026-07-05) The tariff engine is a **pure function of time**, but you could previously only *exercise* it by waiting (the only clock the booth reads is the real wall-clock). The **Tariff Lab** closes that gap: -price a session at **any** instant against **any** tariff version in seconds. +compose an **experimental rate card**, price hypothetical stays against it in seconds, and publish +only when satisfied. -- **API** (`apps/server/src/routes/tariffs.ts`, `tariff:read` — admins always have it; available - on-site too, useful to quote a customer dispute): `POST /api/tariff/simulate` prices a hypothetical +- **Drafts (`tariff_drafts` table, 2026-07-05).** The lab's rate cards live in their own **mutable** + table — the one deliberate exception to "editing publishes a version". Rationale (operator ask, + 2026-07-05): experimenting by publishing real versions churns the immutable history with noise AND + risks a wrong card being live while the admin iterates ("we risk taking tickets with a grossly + wrong version"). A draft prices nothing and signs nothing, so mutability is safe; the ONLY way a + draft affects a customer is publication through the normal `POST /api/tariff/versions` path + (validated, tz-stamped, immutable, effectiveFrom-guarded). Drafts are **validated + tz-stamped on + save exactly like a publish**, so a saved draft can always be simulated and "Publish" can never + fail on a card that saved fine. +- **API** (`apps/server/src/routes/tariffs.ts`): `GET/POST/PUT/DELETE /api/tariff/drafts[...]` + (list `tariff:read`; mutations `tariff:update`). `POST /api/tariff/simulate` prices a hypothetical session — body `{enteredAt, asOf, payments[], category?, tariffVersionId? | structure?}` — and returns the full `priceSession` outcome plus a **duration curve** (fee from entry at 30m…3d, so you - SEE where the daily cap flattens or a window shifts). `GET /api/tariff/simulate/session/:identity` - prefills from a **real ledger session** (entry + payments + the version frozen at entry). Both are - **read-only — no ledger writes.** -- **UI** (`apps/web/src/TariffLab.tsx`, Setup → "Tariff Lab"): pick a version (active or any - historical), set entry / "as of" times, an optional payment (with its grace), and a category; or - "Load" a real ticket to re-evaluate it at any moment. Shows amount due, billed period, overstay/ - settled state, and the curve. Prices via the same `priceSession` the booth uses (verified: a real - overstay ticket reads identically in the lab and the booth). See [[booth-exit-flow]] (overstay). + SEE where the daily cap flattens or a window shifts); the lab passes a draft's stored `structure` + inline. `GET /api/tariff/simulate/session/:identity` (prefill from a real ledger session) still + exists API-side but the UI no longer uses it. All **read-only — no ledger writes.** +- **UI** (`apps/web/src/TariffLab.tsx`, Setup → Tariff → "Tariff Lab" tab): a **sidebar lists every + lab draft AND the full published history** (active card first, then older immutable versions) — + click any to price against it (drafts send their structure inline; published versions go by + `tariffVersionId`). Published versions carry an **optional name** (`tariff_versions.name`, + migration 0022, stamped at publish and immutable like the row): publishing a draft carries the + draft's name onto the version, and the composer page grew an optional version-name field — so + history reads "Winter 2027", not UUID prefixes. The main pane is a pure + **entry/exit** pair (the 2026-06-20 ticket-loader, payment, and category inputs were dropped in the + redesign — the lab is for composing rates, not re-evaluating tickets) plus amount due, billed + period, overstay/settled state, and the curve. **"New draft" / "Edit" open the composer form in a + modal** — the *same* form the `/setup/tariff` page uses, extracted to + `apps/web/src/TariffEditorForm.tsx` (new drafts prefill from the active card). Per-draft + **Publish** (confirm prompt) goes through the normal immutable-version path. Prices via the same + `priceSession` the booth uses, so the lab and the live booth can never diverge. + See [[booth-exit-flow]] (overstay). ## The pay-on-foot consequence diff --git a/wiki/log.md b/wiki/log.md index acad2c3..ee1d1fa 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -2282,3 +2282,41 @@ ISO-8601 UTC + level names (pinoDbStream hardened to accept both encodings so th silently break). Rotation: docker json-file caps in docker-compose.prod.yml resized from 10m×3 (≈30 MB!) to ≈2 months by volume (server 20m×30, vision 20m×10, proxy 10m×5; json-file rotates by SIZE — time-based isn't a driver feature). app_logs retention default aligned 30→60 days. + +## [2026-07-05] update | Window-package tariff mode (packageMinor) + honest flat labels + +Tariff-lab verification of a 1,850 ALL bill exposed a field misread: the V2 card "flat price" is +PER INCREMENT (400/h), not per window — park-buzi's "night 400" card billed each night hour 400. +Built the missing concept on [[tariff-time-tiers]]: `packageMinor`, a whole-window package +("any presence in 20:00–07:00 = 400 total"). Operator decisions: per-occurrence repeat (two nights += two charges), any-touch-pays-full, windowed-cards-only (base "price per day" = a 1-row up-to +table). Engine charges once per contiguous run of increments the card wins, tracked across +rolling-day segments (a night crossing the 24h boundary charges once). Validator: exclusive with +flat/blocks/steps, no per-card cap, defaultCard forbidden. Composer offers the mode on tier cards; +flat relabeled "Flat price / hour" (sq+en). Also flagged from the same session: windowed-card +dailyCapMinor is inert by design (only the base card's cap clamps a day) — park-buzi's weekend +card carries a dead 1000 cap. Tests: shared 93 green (6 new), server 283 green. + +## [2026-07-05] update | Tariff Lab redesign: DB-backed drafts, sidebar, composer modal + +The lab previously simulated only against PUBLISHED versions, so experimenting meant publishing — +churning the immutable history and risking real tickets pricing against a half-baked card while +the admin iterated (operator: "we risk taking tickets with a grossly wrong version"). Redesign on +[[tariff]] (Tariff Lab section): new mutable `tariff_drafts` table (migration 0021 — the one +deliberate exception to "editing publishes a version"; a draft prices/signs nothing, only the +normal publish path makes it real), drafts validated + tz-stamped on save exactly like a publish, +CRUD under /api/tariff/drafts (list tariff:read, mutations tariff:update). UI rebuilt: sidebar +lists active card + drafts (click to price against), main pane cut to pure entry/exit (ticket +loader, payment, category inputs dropped), composer form extracted to TariffEditorForm.tsx and +reused in a modal (new drafts prefill from the active card), per-draft Publish with confirm. +Simulation passes the draft's stored structure inline to the existing /api/tariff/simulate. +Tests: server 288 green (5 new: RBAC, roundtrip, validation, tz-stamp + simulate + publish flow). + +## [2026-07-05] update | Published tariff versions get optional names + lab sidebar lists history + +Follow-up to the lab redesign (same session): the sidebar now also lists the PUBLISHED versions +(active first, then the immutable history; click to price against by tariffVersionId), and +`tariff_versions` gained a nullable `name` (migration 0022) — stamped at publish, immutable like +the row. Publishing a lab draft carries the draft's name onto the version; the composer page grew +an optional version-name field (never prefilled — republishing a tweak under last season's name +would mislabel history). Details on [[tariff]] (Tariff Lab section).