feat(tariff): Tariff Lab — pure session-pricing simulator
Test rates "in time" (overnight windows, daily caps, overstay) in seconds against any tariff version, instead of waiting hours/days. No real ledger writes. - Extract priceSession() into @parking/shared: the grace/overstay wrapper over computeFee (unpaid -> entry..now; within-grace -> settled 0; grace-expired -> overstay, a fresh period from grace-expiry). PayStation.quote() now calls it so the booth and the lab can never diverge. - API (tariffs.ts, tariff:read, read-only): POST /api/tariff/simulate prices a hypothetical session (active/any version/inline structure) and returns the priceSession outcome + a 30m..3d duration curve (see where the daily cap flattens); GET /api/tariff/simulate/session/:identity prefills from a real ledger session. - UI TariffLab.tsx at Setup -> "Tariff Lab": version picker, entry/asOf times, optional payment+grace, category, and load-a-real-ticket. Admin-gated, available on-site (useful to quote a dispute). - 4 new priceSession unit tests incl. the ticket-1245791632490 overstay-not-zero regression (40 pass). i18n lab.* + nav.tariffLab (sq+en). Verified live via the UI. Wiki: tariff (priceSession + Tariff Lab as-built), log. Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
+25
-1
@@ -101,7 +101,12 @@ because the chain + reconciliation depend on the result being reproducible.
|
||||
cap" model made complete — the same engine, no new axis; the only gap was the unstated tail.
|
||||
|
||||
**As-built:** `computeFee(enteredAt, asOf, structure)` in `packages/shared` (pure). Unit-tested
|
||||
across grace, block steps, daily cap, and multi-day reset.
|
||||
across grace, block steps, daily cap, and multi-day reset. A higher-level **`priceSession(enteredAt,
|
||||
asOf, structure, payments[], category?)`** (also pure, shared) wraps `computeFee` with the
|
||||
grace/overstay logic — unpaid → entry→now; paid+within-grace → settled (0); paid+grace-expired →
|
||||
**overstay**, a fresh period from grace-expiry→now (see [[booth-exit-flow]]). The booth's
|
||||
`PayStation.quote()` and the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] both call it, so
|
||||
live pricing and the simulator can never diverge.
|
||||
|
||||
### Composer (as-built 2026-06-15)
|
||||
|
||||
@@ -124,6 +129,25 @@ 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)
|
||||
|
||||
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.
|
||||
|
||||
- **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
|
||||
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).
|
||||
|
||||
## The pay-on-foot consequence
|
||||
|
||||
Because payment is decoupled from exit ([[parking-session]] lifecycle), the tariff has **two
|
||||
|
||||
+19
@@ -1003,3 +1003,22 @@ pay modal: "New period due"/OVERSTAY; handlePayAndExit now charges when canPay (
|
||||
!alreadyPaid — would have skipped the overstay charge). i18n pay.overstay/overstayHint/topUp +
|
||||
booth.badgeOverstay*/fStatusOverstay rewritten in sq+en. Build+lint green. Updated [[booth-exit-flow]]
|
||||
(overstay section + naming history + partial-resolution note on the grace-renewal open question).
|
||||
|
||||
## [2026-06-20] feat | Tariff Lab — pure session-pricing simulator (test rates in time)
|
||||
|
||||
The tariff engine is pure but could only be EXERCISED by waiting (booth reads real
|
||||
wall-clock). Added a simulator. Extracted priceSession(enteredAt, asOf, structure,
|
||||
payments[], category?) into @parking/shared — the grace/overstay wrapper over
|
||||
computeFee (unpaid→entry→now; within-grace→settled 0; grace-expired→overstay new
|
||||
period from grace-expiry). PayStation.quote() now calls it, so booth + lab can't
|
||||
diverge. New routes (tariffs.ts, tariff:read, no ledger writes): POST
|
||||
/api/tariff/simulate (price a hypothetical session vs active/any version/inline
|
||||
structure; returns priceSession outcome + a 30m..3d duration curve) and GET
|
||||
/api/tariff/simulate/session/:identity (prefill from a real ledger session). UI
|
||||
apps/web/src/TariffLab.tsx at Setup→"Tariff Lab": version picker, entry/asOf, optional
|
||||
payment+grace, category, load-a-ticket; shows amount due, billed period, overstay/
|
||||
settled, curve. i18n lab.* + nav.tariffLab (sq+en). 4 new priceSession unit tests
|
||||
incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Verified
|
||||
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
|
||||
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
|
||||
Updated [[tariff]] + [[booth-exit-flow]].
|
||||
|
||||
Reference in New Issue
Block a user