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:
2026-06-20 12:05:30 +02:00
parent a4712774ab
commit 3d02134711
11 changed files with 669 additions and 17 deletions
+44
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
computeFee,
priceSession,
validateTariffStructure,
type TariffStructureV1,
type TariffStructureV2,
@@ -255,3 +256,46 @@ describe("validate V2", () => {
expect(errs).toEqual([]);
});
});
// ---------------------------------------------------------------------------
// (h) priceSession — the grace/overstay wrapper shared by the booth + Tariff Lab.
// liveV1: 5-min entry grace, 60-min increment, blocks 20000(1h)/10000(to 3h),
// daily cap 100000, exit grace 5 min.
// ---------------------------------------------------------------------------
describe("priceSession grace + overstay", () => {
const paidAt = (min: number) => at(min);
it("unpaid → bills entry→asOf (running total)", () => {
const r = priceSession(entered, at(120), liveV1, []);
expect(r.overstay).toBe(false);
expect(r.withinGrace).toBe(false);
expect(r.periodStart).toBe(entered);
expect(r.amountMinor).toBe(30000); // 2h: 20000 + 10000
});
it("paid and still within walk-back grace → settled (owes 0)", () => {
// Paid at 120 min with a 5-min grace; asOf 123 min is inside the window.
const r = priceSession(entered, at(123), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.withinGrace).toBe(true);
expect(r.overstay).toBe(false);
expect(r.amountMinor).toBe(0);
});
it("paid but grace expired → overstay priced as a NEW period from grace-expiry", () => {
// Paid at 120 min, grace 5 → expires at 125 min. asOf 245 min ⇒ a 2h new period.
const r = priceSession(entered, at(245), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.withinGrace).toBe(false);
expect(r.periodStart).toBe(at(125));
// The new period is its own ladder from 0: 2h ⇒ 20000 + 10000 = 30000.
expect(r.amountMinor).toBe(30000);
});
it("overstay does NOT collapse to 0 under a daily cap (regression: ticket 1245791632490)", () => {
// A ~2-day overstay: with 'full stay minus paid' the cap made this 0. The
// new-period model re-accrues — strictly positive.
const r = priceSession(entered, at(120 + 5 + 2880), liveV1, [{ paidAt: paidAt(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.amountMinor).toBe(200000); // 2 capped days
});
});