feat(tariff-lab): DB-backed draft tariffs + named published versions

Experimenting used to mean publishing — churning the immutable version
history and risking real tickets pricing against a half-baked card while
the admin iterated. The lab is now a true sandbox:

- tariff_drafts table (migration 0021): MUTABLE by design — the one
  exception to "editing publishes a version"; a draft prices nothing and
  signs nothing. Drafts are validated + tz-stamped on save exactly like a
  publish, so a saved draft always simulates and never fails at publish.
- CRUD under /api/tariff/drafts (list tariff:read, mutations
  tariff:update); publishing a draft goes through the normal immutable
  POST /api/tariff/versions path.
- Lab UI rebuilt: sidebar lists lab drafts AND the full published history
  (click any to price against it); main pane cut to pure entry/exit
  (ticket loader, payment, category inputs dropped); the composer form is
  extracted to TariffEditorForm.tsx and reused in a modal (new drafts
  prefill from the active card); per-draft Publish with confirm.
- tariff_versions.name (migration 0022): optional label stamped at
  publish — carried from the lab draft, or typed in the composer's new
  optional field — so history reads "Winter 2027", not UUID prefixes.
- Includes the composer UI + sq/en labels for the package mode (engine
  landed in d9e6c13) and the "Flat price / hour" relabel.

5 new server integration tests (RBAC, roundtrip, validation, tz-stamp +
simulate + publish w/ name); server suite 288 green.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-07-05 14:31:42 +02:00
parent 52a89bfa56
commit fd9885e9ec
14 changed files with 1457 additions and 782 deletions
+26 -16
View File
@@ -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",