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:
@@ -1,5 +1,5 @@
|
||||
import { desc, eq, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import { computeFee, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import { priceSession, type TariffStructure, type Tender } from "@parking/shared";
|
||||
import type { FastifyBaseLogger } from "fastify";
|
||||
import type { EventLog } from "./event-log.js";
|
||||
|
||||
@@ -128,15 +128,6 @@ export class PayStation {
|
||||
const entry = this.#openEntry(identity);
|
||||
if (!entry) throw new NoOpenSessionError(identity);
|
||||
|
||||
// If the latest payment's walk-back grace has expired, this is an overstay: anchor
|
||||
// the new billing period at grace-expiry (paidAt + graceExitMin). Otherwise price
|
||||
// from entry (first payment, or a still-within-grace re-quote of the same stay).
|
||||
const last = this.#lastPayment(identity);
|
||||
const graceExpiryMs =
|
||||
last && last.graceExitMin != null ? Date.parse(last.paidAt) + last.graceExitMin * 60_000 : null;
|
||||
const overstay = graceExpiryMs != null && Date.now() > graceExpiryMs;
|
||||
const periodStart = overstay ? new Date(graceExpiryMs!).toISOString() : entry.occurredAt;
|
||||
|
||||
// The tariff in force is keyed to ENTRY (the version frozen for this session), even
|
||||
// for an overstay period — the customer keeps the rate card they entered under.
|
||||
const tv = this.#tariffVersionFor(entry.occurredAt);
|
||||
@@ -147,13 +138,24 @@ export class PayStation {
|
||||
// both read it from there, so a V2 category tariff yields the same amount at the
|
||||
// booth and at exit. Absent (legacy/V1) ⇒ undefined ⇒ category-agnostic pricing.
|
||||
const category = (entry.payload as { category?: string } | null)?.category;
|
||||
const amountMinor = computeFee(periodStart, new Date().toISOString(), structure, category);
|
||||
|
||||
// Pure pricing shared with the Tariff Lab (priceSession). Only the latest payment
|
||||
// matters for grace/overstay; pass it through. Overstay → fresh period from
|
||||
// grace-expiry; within-grace → settled; unpaid → entry→now running total.
|
||||
const last = this.#lastPayment(identity);
|
||||
const p = priceSession(
|
||||
entry.occurredAt,
|
||||
new Date().toISOString(),
|
||||
structure,
|
||||
last ? [last] : [],
|
||||
category,
|
||||
);
|
||||
return {
|
||||
identity,
|
||||
enteredAt: entry.occurredAt,
|
||||
periodStart,
|
||||
amountMinor,
|
||||
overstay,
|
||||
periodStart: p.periodStart,
|
||||
amountMinor: p.amountMinor,
|
||||
overstay: p.overstay,
|
||||
currency: tv.currency,
|
||||
tariffVersionId: tv.id,
|
||||
graceExitMin: structure.gracePeriodExitMin,
|
||||
|
||||
Reference in New Issue
Block a user