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