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
+64
View File
@@ -442,6 +442,70 @@ export function computeFee(
: computeFeeV1(enteredAt, asOf, tariff);
}
/** A signed payment as far as session pricing cares: when it happened and the
* walk-back grace it granted. (The booth folds these from the ledger; the lab
* supplies a hypothetical one.) */
export interface SessionPayment {
readonly paidAt: string; // ISO-8601
readonly graceExitMin: number | null;
}
/** The full pricing outcome for a session at a moment in time — what the booth's
* `quote()` and the exit flow compute, made PURE so it can be tested or previewed
* without a real ledger. See wiki/concepts/booth-exit-flow.md (overstay pricing). */
export interface SessionPricing {
/** The window actually billed now: entry→asOf normally, or grace-expiry→asOf for an
* overstay (a paid session whose walk-back grace lapsed — a new period began). */
readonly periodStart: string;
/** Fee for [periodStart, asOf]. */
readonly amountMinor: number;
/** True when the latest payment's grace has lapsed (overstay = new period). */
readonly overstay: boolean;
/** True when paid AND still inside the walk-back window (a settled, exitable stay). */
readonly withinGrace: boolean;
/** ISO time the walk-back grace expires (lastPaid + graceExitMin), if paid. */
readonly graceExpiresAt: string | null;
}
/**
* Price a session PURELY from its times + tariff structure — the single source of
* truth shared by the live booth (`PayStation.quote`) and the Tariff Lab simulator,
* so the two can never diverge.
*
* - Not yet paid → bill entry→asOf (the running total).
* - Paid, still within walk-back grace → settled (amount 0; the car may exit).
* - Paid, grace lapsed → OVERSTAY: bill a fresh period from grace-expiry→asOf with its
* own daily-cap ladder (NOT "full stay minus paid", which a daily cap collapses to 0).
*
* `payments` is the session's payment history (only the LATEST matters for grace);
* pass [] for an unpaid session. The tariff version is the one frozen at entry — the
* customer keeps their rate card even across an overstay. See booth-exit-flow.md.
*/
export function priceSession(
enteredAt: string,
asOf: string,
tariff: TariffStructure,
payments: readonly SessionPayment[] = [],
category?: string,
): SessionPricing {
const last = payments.length ? payments[payments.length - 1] : null;
const graceExpiryMs =
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
const asOfMs = Date.parse(asOf);
const overstay = graceExpiryMs != null && asOfMs > graceExpiryMs;
const withinGrace = graceExpiryMs != null && asOfMs <= graceExpiryMs;
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : enteredAt;
// A settled (paid + within grace) session owes nothing more; otherwise bill the period.
const amountMinor = withinGrace ? 0 : computeFee(periodStart, asOf, tariff, category);
return {
periodStart,
amountMinor,
overstay,
withinGrace,
graceExpiresAt: graceExpiryMs != null ? new Date(graceExpiryMs).toISOString() : null,
};
}
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
* VERBATIM so bare/legacy structures (incl. the live production version) price
* identically. Do not "unify" this into the V2 path: a rounding divergence would