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
+39 -10
View File
@@ -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<TariffVersion> {
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) });
}
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<SimSessionLoad> {
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<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 --------------------------------------------------------