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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user