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
@@ -0,0 +1,14 @@
-- Tariff-lab drafts (2026-07-05). A mutable scratchpad for the lab: the admin composes
-- experimental rate cards here, simulates them against hypothetical stays, and only
-- PUBLISHES (normal immutable tariff_versions path) when satisfied. Deliberately mutable —
-- a draft prices nothing and signs nothing; experimenting through real publishes would
-- churn permanent versions and risk a wrong card going live. See wiki/concepts/tariff.md.
CREATE TABLE `tariff_drafts` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`currency` text NOT NULL,
`structure` text NOT NULL,
`created_by` text,
`created_at` text DEFAULT (current_timestamp) NOT NULL,
`updated_at` text DEFAULT (current_timestamp) NOT NULL
);
@@ -0,0 +1,6 @@
-- Optional name on published tariff versions (2026-07-05). The lab's draft workflow gave
-- rate cards human names; published versions were only tellable apart by effective date +
-- UUID prefix. The name is stamped at publish (carried from the lab draft, or typed in the
-- composer) and is immutable like the rest of the row. Nullable — old versions and unnamed
-- publishes are fine.
ALTER TABLE `tariff_versions` ADD `name` text;
+14
View File
@@ -148,6 +148,20 @@
"when": 1781886300000,
"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
}
]
}
+28
View File
@@ -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<Record<string, unknown>>(),
createdBy: text("created_by"),
createdAt: text("created_at")
.notNull()
.default(sql`(current_timestamp)`),
updatedAt: text("updated_at")
.notNull()
.default(sql`(current_timestamp)`),
});
// --- Subscriptions --------------------------------------------------------
// 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;