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:
+39
-10
@@ -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 --------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user