feat(tariff): lab explains the sum — fee breakdown from the engine walk
"ALL 740 / 3h 2m" gave no derivation. explainFee in @parking/shared runs the EXACT computeFee walk with an optional trace collector — one code path, so Σ line items ≡ the amount by construction (golden V1 regression byte-identical; instrumentation changes no fee). Items: contiguous same-price increment runs (time window · N × unit · tier-card name), window-package occurrences, stepped day totals (top-tier repeat flagged), daily-cap clamps as NEGATIVE adjustments, entry grace. /api/tariff/simulate returns `breakdown` (null when settled); the lab's Outcome panel renders the lined table with a rounding note (raw min → billed min at the increment — answers "why does 3h 2m bill as 4h") and a total row. Works against active/historical versions and drafts alike, so a night-package draft can be verified line by line before publish. Largely delivers the wiki's open "composer price preview" item. 4 new engine tests pin the sum invariant + item shapes (97 shared green). Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -3,6 +3,7 @@ import type { FastifyInstance } from "fastify";
|
|||||||
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
import { and, desc, eq, isNull, ledgerEvents, siteConfig, tariffDrafts, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
isTariffV2,
|
isTariffV2,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
const payments = Array.isArray(b.payments) ? b.payments : [];
|
const payments = Array.isArray(b.payments) ? b.payments : [];
|
||||||
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
const pricing = priceSession(b.enteredAt, b.asOf, structure, payments, b.category);
|
||||||
|
|
||||||
|
// HOW the amount is produced — the same engine walk with a trace collector
|
||||||
|
// (Σ lines ≡ amountMinor by construction). Null when settled (nothing billed).
|
||||||
|
const breakdown = pricing.withinGrace
|
||||||
|
? null
|
||||||
|
: explainFee(pricing.periodStart, b.asOf, structure, b.category);
|
||||||
|
|
||||||
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
// A duration curve from entry: handy to SEE where the cap flattens / windows shift.
|
||||||
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
|
||||||
const enteredMs = Date.parse(b.enteredAt);
|
const enteredMs = Date.parse(b.enteredAt);
|
||||||
@@ -196,7 +203,7 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
|
|||||||
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
amountMinor: computeFee(b.enteredAt, new Date(enteredMs + min * 60_000).toISOString(), structure!, b.category),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return { currency, pricing, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
return { currency, pricing, breakdown, curve, gracePeriodExitMin: structure.gracePeriodExitMin };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
// Prefill the lab from a REAL session: fold its ledger into entry + payments so the
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ import {
|
|||||||
} from "./api.js";
|
} from "./api.js";
|
||||||
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
import { TariffEditorForm, emptyForm, formFromActive, formFromVersion, toStructure, type FormState } from "./TariffEditorForm.js";
|
||||||
import { Modal } from "./ui/Modal.js";
|
import { Modal } from "./ui/Modal.js";
|
||||||
import { formatMoney, formatDuration } from "./lib/format.js";
|
import { formatClock, formatDateTime, formatMoney, formatDuration } from "./lib/format.js";
|
||||||
|
import type { FeeBreakdown } from "@parking/shared";
|
||||||
|
import type { TFunction } from "i18next";
|
||||||
|
|
||||||
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
// The TARIFF LAB — a sandbox for composing + pricing EXPERIMENTAL rate cards. Drafts
|
||||||
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
// live in their own mutable table (tariff_drafts), so experimenting never churns the
|
||||||
@@ -202,7 +204,7 @@ export function TariffLab() {
|
|||||||
{selectedDraft
|
{selectedDraft
|
||||||
? selectedDraft.name
|
? selectedDraft.name
|
||||||
: selectedVersion
|
: selectedVersion
|
||||||
? selectedVersion.name ?? new Date(selectedVersion.effectiveFrom).toLocaleString()
|
? selectedVersion.name ?? formatDateTime(selectedVersion.effectiveFrom, t)
|
||||||
: t("lab.activeTariff")}
|
: t("lab.activeTariff")}
|
||||||
</span>
|
</span>
|
||||||
{selectedDraft && (
|
{selectedDraft && (
|
||||||
@@ -264,14 +266,19 @@ export function TariffLab() {
|
|||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
<dt className="text-term-muted">{t("lab.periodStart")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.periodStart).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.periodStart, t)}</dd>
|
||||||
{result.pricing.graceExpiresAt && (
|
{result.pricing.graceExpiresAt && (
|
||||||
<>
|
<>
|
||||||
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
<dt className="text-term-muted">{t("lab.graceExpires")}</dt>
|
||||||
<dd className="text-term-text">{new Date(result.pricing.graceExpiresAt).toLocaleString()}</dd>
|
<dd className="text-term-text">{formatDateTime(result.pricing.graceExpiresAt, t)}</dd>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
|
{/* HOW the sum is produced — line items from the SAME engine walk
|
||||||
|
(their sum is the amount by construction). */}
|
||||||
|
{result.breakdown && (
|
||||||
|
<BreakdownTable b={result.breakdown} periodStart={result.pricing.periodStart} currency={currency} t={t} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
{/* Duration curve from entry — see where the cap flattens / windows shift. */}
|
||||||
@@ -316,7 +323,7 @@ export function TariffLab() {
|
|||||||
>
|
>
|
||||||
<span className="block font-semibold">{d.name}</span>
|
<span className="block font-semibold">{d.name}</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{d.currency} · {new Date(d.updatedAt).toLocaleString()}
|
{d.currency} · {formatDateTime(d.updatedAt, t)}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -345,7 +352,7 @@ export function TariffLab() {
|
|||||||
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
{state?.active?.name ? ` — ${state.active.name}` : ""}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{state?.active ? new Date(state.active.effectiveFrom).toLocaleString() : t("tariff.noRateCard")}
|
{state?.active ? formatDateTime(state.active.effectiveFrom, t) : t("tariff.noRateCard")}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -363,10 +370,10 @@ export function TariffLab() {
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="block font-semibold">
|
<span className="block font-semibold">
|
||||||
{v.name ?? new Date(v.effectiveFrom).toLocaleString()}
|
{v.name ?? formatDateTime(v.effectiveFrom, t)}
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-[0.6875rem] text-term-muted">
|
<span className="block text-[0.6875rem] text-term-muted">
|
||||||
{v.name ? `${new Date(v.effectiveFrom).toLocaleString()} · ` : ""}
|
{v.name ? `${formatDateTime(v.effectiveFrom, t)} · ` : ""}
|
||||||
{v.currency}
|
{v.currency}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -416,3 +423,82 @@ function labelMin(min: number): string {
|
|||||||
if (min < 1440) return `${min / 60}h`;
|
if (min < 1440) return `${min / 60}h`;
|
||||||
return `${min / 1440}d`;
|
return `${min / 1440}d`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The fee's line items — every row states its time window / rule and its amount, so
|
||||||
|
* the operator can retrace the exact sum (caps show as negative adjustments). */
|
||||||
|
function BreakdownTable({
|
||||||
|
b,
|
||||||
|
periodStart,
|
||||||
|
currency,
|
||||||
|
t,
|
||||||
|
}: {
|
||||||
|
b: FeeBreakdown;
|
||||||
|
periodStart: string;
|
||||||
|
currency: string;
|
||||||
|
t: TFunction;
|
||||||
|
}) {
|
||||||
|
const startMs = Date.parse(periodStart);
|
||||||
|
const multiDay = b.billedMinutes > 1440;
|
||||||
|
const at = (min: number) => {
|
||||||
|
const iso = new Date(startMs + min * 60_000).toISOString();
|
||||||
|
return multiDay ? formatDateTime(iso, t) : formatClock(iso);
|
||||||
|
};
|
||||||
|
const money = (m: number) => formatMoney(m, currency);
|
||||||
|
const hours = (min: number) => (min % 60 === 0 ? `${min / 60}` : (min / 60).toFixed(1));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mt-3 border-t border-term-border pt-2">
|
||||||
|
<div className="mb-1 text-[0.6875rem] uppercase tracking-wider text-term-muted">{t("lab.bd.title")}</div>
|
||||||
|
{b.billedMinutes > 0 && (
|
||||||
|
<p className="hint mb-1.5">
|
||||||
|
{t("lab.bd.rounding", { raw: b.rawMinutes, billed: b.billedMinutes, inc: b.incrementMin })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-[0.75rem] tabular-nums">
|
||||||
|
<tbody>
|
||||||
|
{b.items.map((it, i) => {
|
||||||
|
let label: string;
|
||||||
|
let amount: number;
|
||||||
|
let cls = "text-term-text";
|
||||||
|
switch (it.kind) {
|
||||||
|
case "grace":
|
||||||
|
label = t("lab.bd.grace", { min: it.minutes });
|
||||||
|
amount = 0;
|
||||||
|
cls = "text-term-green";
|
||||||
|
break;
|
||||||
|
case "band":
|
||||||
|
label = `${at(it.fromMin)}–${at(it.toMin)} · ${it.increments} × ${money(it.unitMinor)}${it.card ? ` · ${it.card}` : ""}`;
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "package":
|
||||||
|
label = `${at(it.fromMin)} · ${it.card} — ${t("lab.bd.package")}`;
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "step":
|
||||||
|
label = it.repeated
|
||||||
|
? t("lab.bd.stepRepeated", { day: it.day })
|
||||||
|
: t("lab.bd.step", { day: it.day, hours: hours(it.uptoMin) });
|
||||||
|
amount = it.amountMinor;
|
||||||
|
break;
|
||||||
|
case "cap":
|
||||||
|
label = t("lab.bd.cap", { day: it.day, cap: money(it.capMinor) });
|
||||||
|
amount = it.amountMinor;
|
||||||
|
cls = "text-term-red";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<tr key={i} className="border-b border-term-border/40">
|
||||||
|
<td className="py-0.5 pr-2 text-term-muted">{label}</td>
|
||||||
|
<td className={`whitespace-nowrap py-0.5 text-right ${cls}`}>{money(amount)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<tr>
|
||||||
|
<td className="py-1 pr-2 font-semibold text-term-text">{t("lab.bd.total")}</td>
|
||||||
|
<td className="whitespace-nowrap py-1 text-right font-semibold text-term-cyan">{money(b.totalMinor)}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -762,6 +762,9 @@ export interface SimSessionPricing {
|
|||||||
export interface SimulateResult {
|
export interface SimulateResult {
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
pricing: SimSessionPricing;
|
pricing: SimSessionPricing;
|
||||||
|
/** Line items explaining pricing.amountMinor (same engine walk, Σ ≡ amount);
|
||||||
|
* null when the session is settled (within walk-back grace). */
|
||||||
|
breakdown: import("@parking/shared").FeeBreakdown | null;
|
||||||
curve: { minutes: number; amountMinor: number }[];
|
curve: { minutes: number; amountMinor: number }[];
|
||||||
gracePeriodExitMin: number;
|
gracePeriodExitMin: number;
|
||||||
}
|
}
|
||||||
|
|||||||
+170
-12
@@ -764,6 +764,115 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
return Array.isArray(s.steps) && s.steps.length > 0;
|
return Array.isArray(s.steps) && s.steps.length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Fee breakdown (explainability) -------------------------------------------
|
||||||
|
// One line item per priced "reason": a run of same-priced increments, a window
|
||||||
|
// package occurrence, a stepped day total, a daily-cap clamp, or the entry grace.
|
||||||
|
// Produced by the SAME walk computeFee runs (an optional trace collector inside
|
||||||
|
// computeFeeV1/V2), so Σ item amounts ≡ the fee by construction — the breakdown can
|
||||||
|
// never tell a different story than the bill. Built for the Tariff Lab's "how is
|
||||||
|
// this sum produced" view (2026-07-06). Minutes are offsets from the priced
|
||||||
|
// period's start.
|
||||||
|
|
||||||
|
export type FeeBreakdownItem =
|
||||||
|
/** The whole stay fit inside the free entry-grace window (fee 0). */
|
||||||
|
| { readonly kind: "grace"; readonly minutes: number }
|
||||||
|
/** A contiguous run of increments billed at one unit price by one card.
|
||||||
|
* `card` is the windowed card's name, or null for the base/default rate. */
|
||||||
|
| {
|
||||||
|
readonly kind: "band";
|
||||||
|
readonly card: string | null;
|
||||||
|
readonly fromMin: number;
|
||||||
|
readonly toMin: number;
|
||||||
|
readonly increments: number;
|
||||||
|
readonly unitMinor: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
}
|
||||||
|
/** One window-package occurrence (charged once per contiguous run the card wins). */
|
||||||
|
| { readonly kind: "package"; readonly card: string; readonly fromMin: number; readonly amountMinor: number }
|
||||||
|
/** A stepped ("up-to") day total: day N used `dayMinutes`, priced by the tier at
|
||||||
|
* `uptoMin` (`repeated` = past the top tier, so the top total repeats as a cap). */
|
||||||
|
| {
|
||||||
|
readonly kind: "step";
|
||||||
|
readonly day: number;
|
||||||
|
readonly dayMinutes: number;
|
||||||
|
readonly uptoMin: number;
|
||||||
|
readonly amountMinor: number;
|
||||||
|
readonly repeated: boolean;
|
||||||
|
}
|
||||||
|
/** The daily cap clamped day N: amountMinor is the (negative) adjustment. */
|
||||||
|
| { readonly kind: "cap"; readonly day: number; readonly capMinor: number; readonly amountMinor: number };
|
||||||
|
|
||||||
|
export interface FeeBreakdown {
|
||||||
|
/** Actual stay length in whole minutes (before increment rounding). */
|
||||||
|
readonly rawMinutes: number;
|
||||||
|
/** Minutes billed after rounding UP to the increment (0 within grace). */
|
||||||
|
readonly billedMinutes: number;
|
||||||
|
readonly incrementMin: number;
|
||||||
|
readonly items: FeeBreakdownItem[];
|
||||||
|
/** Σ item amounts — always equals computeFee for the same arguments. */
|
||||||
|
readonly totalMinor: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explain a fee: run the exact computeFee walk with a trace collector and return
|
||||||
|
* the line items plus the total. Same arguments as computeFee; the total returned
|
||||||
|
* here IS computeFee's answer (one code path, not a parallel calculation).
|
||||||
|
*/
|
||||||
|
export function explainFee(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructure,
|
||||||
|
category?: string,
|
||||||
|
): FeeBreakdown {
|
||||||
|
const items: FeeBreakdownItem[] = [];
|
||||||
|
const totalMinor = isTariffV2(tariff)
|
||||||
|
? computeFeeV2(enteredAt, asOf, tariff, category, items)
|
||||||
|
: computeFeeV1(enteredAt, asOf, tariff, items);
|
||||||
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
|
const rawMinutes = Number.isFinite(ms) && ms > 0 ? Math.round(ms / 60_000) : 0;
|
||||||
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
|
const inGrace = items.length === 1 && items[0]!.kind === "grace";
|
||||||
|
const billedMinutes =
|
||||||
|
inGrace || rawMinutes === 0 || ms / 60_000 <= tariff.gracePeriodEntryMin
|
||||||
|
? 0
|
||||||
|
: Math.ceil(ms / 60_000 / inc) * inc;
|
||||||
|
return { rawMinutes, billedMinutes, incrementMin: inc, items, totalMinor };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Band-merging helper for the trace: accumulate consecutive increments that share
|
||||||
|
* a (card, unit price) and flush them as one `band` item. */
|
||||||
|
class BandTracer {
|
||||||
|
#card: string | null = null;
|
||||||
|
#unit = 0;
|
||||||
|
#from = 0;
|
||||||
|
#count = 0;
|
||||||
|
constructor(private readonly items: FeeBreakdownItem[], private readonly inc: number) {}
|
||||||
|
add(card: string | null, unitMinor: number, atMin: number): void {
|
||||||
|
if (this.#count > 0 && this.#card === card && this.#unit === unitMinor) {
|
||||||
|
this.#count++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.flush();
|
||||||
|
this.#card = card;
|
||||||
|
this.#unit = unitMinor;
|
||||||
|
this.#from = atMin;
|
||||||
|
this.#count = 1;
|
||||||
|
}
|
||||||
|
flush(): void {
|
||||||
|
if (this.#count === 0) return;
|
||||||
|
this.items.push({
|
||||||
|
kind: "band",
|
||||||
|
card: this.#card,
|
||||||
|
fromMin: this.#from,
|
||||||
|
toMin: this.#from + this.#count * this.inc,
|
||||||
|
increments: this.#count,
|
||||||
|
unitMinor: this.#unit,
|
||||||
|
amountMinor: this.#count * this.#unit,
|
||||||
|
});
|
||||||
|
this.#count = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
* Total fee for ELAPSED minutes under a STEPPED tariff, per the rolling-24h-day rule.
|
||||||
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
* Pure + integer. The smallest tier whose `uptoMin ≥` the day's minutes wins (≤ /
|
||||||
@@ -771,7 +880,7 @@ export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
|
|||||||
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
* FULL day (a daily-cap repeat) and price the remainder on the next day's ladder.
|
||||||
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
* `steps` need not be sorted; we sort defensively. See wiki/concepts/tariff.md.
|
||||||
*/
|
*/
|
||||||
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
function steppedFee(minutes: number, steps: readonly TariffStep[], trace?: FeeBreakdownItem[]): number {
|
||||||
if (minutes <= 0 || steps.length === 0) return 0;
|
if (minutes <= 0 || steps.length === 0) return 0;
|
||||||
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
|
||||||
const top = sorted[sorted.length - 1]!;
|
const top = sorted[sorted.length - 1]!;
|
||||||
@@ -780,8 +889,17 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
for (let dayStart = 0; dayStart < minutes; dayStart += DAY) {
|
||||||
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
const dayMin = Math.min(DAY, minutes - dayStart); // minutes within this rolling day
|
||||||
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
// Beyond the largest tier → the whole day is the top total (per-day cap repeat).
|
||||||
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
|
const found = sorted.find((s) => dayMin <= s.uptoMin);
|
||||||
|
const tier = found ?? top;
|
||||||
total += tier.totalMinor;
|
total += tier.totalMinor;
|
||||||
|
trace?.push({
|
||||||
|
kind: "step",
|
||||||
|
day: dayStart / DAY + 1,
|
||||||
|
dayMinutes: dayMin,
|
||||||
|
uptoMin: tier.uptoMin,
|
||||||
|
amountMinor: tier.totalMinor,
|
||||||
|
repeated: found == null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
@@ -791,30 +909,50 @@ function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
|
|||||||
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
* identically. Do not "unify" this into the V2 path: a rounding divergence would
|
||||||
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
* corrupt repricing of already-signed sessions. A `steps` table (when present)
|
||||||
* REPLACES the ladder via {@link steppedFee}. */
|
* REPLACES the ladder via {@link steppedFee}. */
|
||||||
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number {
|
function computeFeeV1(
|
||||||
|
enteredAt: string,
|
||||||
|
asOf: string,
|
||||||
|
tariff: TariffStructureV1,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
|
): number {
|
||||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
// Grace uses the RAW duration (a 10-min stay is free even if the increment is
|
||||||
// 60 min — otherwise rounding-up would defeat the grace window).
|
// 60 min — otherwise rounding-up would defeat the grace window).
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0;
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP to the increment
|
||||||
|
|
||||||
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
// STEPPED pricing: a total-by-duration table replaces the marginal ladder.
|
||||||
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
|
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!, trace);
|
||||||
|
|
||||||
const DAY = 24 * 60;
|
const DAY = 24 * 60;
|
||||||
let total = 0;
|
let total = 0;
|
||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
// The block ladder RESETS each rolling-24h day: `within` is minutes elapsed
|
||||||
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
// WITHIN this day, so day 2 starts at the first block again (decision 2026-06-15).
|
||||||
for (let within = 0; segStart + within < segEnd; within += inc) {
|
for (let within = 0; segStart + within < segEnd; within += inc) {
|
||||||
segFee += rateAt(tariff.blocks, within);
|
const unit = rateAt(tariff.blocks, within);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(null, unit, segStart + within);
|
||||||
|
}
|
||||||
|
bands?.flush();
|
||||||
|
if (tariff.dailyCapMinor != null && segFee > tariff.dailyCapMinor) {
|
||||||
|
trace?.push({
|
||||||
|
kind: "cap",
|
||||||
|
day: segStart / DAY + 1,
|
||||||
|
capMinor: tariff.dailyCapMinor,
|
||||||
|
amountMinor: tariff.dailyCapMinor - segFee,
|
||||||
|
});
|
||||||
|
segFee = tariff.dailyCapMinor;
|
||||||
}
|
}
|
||||||
if (tariff.dailyCapMinor != null) segFee = Math.min(segFee, tariff.dailyCapMinor);
|
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
@@ -837,12 +975,16 @@ function computeFeeV2(
|
|||||||
asOf: string,
|
asOf: string,
|
||||||
tariff: TariffStructureV2,
|
tariff: TariffStructureV2,
|
||||||
category?: string,
|
category?: string,
|
||||||
|
trace?: FeeBreakdownItem[],
|
||||||
): number {
|
): number {
|
||||||
const enteredMs = Date.parse(enteredAt);
|
const enteredMs = Date.parse(enteredAt);
|
||||||
const ms = Date.parse(asOf) - enteredMs;
|
const ms = Date.parse(asOf) - enteredMs;
|
||||||
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
if (!Number.isFinite(ms) || ms <= 0) return 0;
|
||||||
const rawMinutes = ms / 60_000;
|
const rawMinutes = ms / 60_000;
|
||||||
if (rawMinutes <= tariff.gracePeriodEntryMin) return 0; // grace on RAW duration (V1 rule)
|
if (rawMinutes <= tariff.gracePeriodEntryMin) {
|
||||||
|
trace?.push({ kind: "grace", minutes: Math.round(rawMinutes) });
|
||||||
|
return 0; // grace on RAW duration (V1 rule)
|
||||||
|
}
|
||||||
const inc = Math.max(1, tariff.incrementMin);
|
const inc = Math.max(1, tariff.incrementMin);
|
||||||
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
const minutes = Math.ceil(rawMinutes / inc) * inc; // round UP (V1 rule)
|
||||||
|
|
||||||
@@ -862,7 +1004,11 @@ function computeFeeV2(
|
|||||||
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
// defaultCard is stepped we price the WHOLE stay by the stepped day rule and ignore
|
||||||
// windowed cards (they have nothing to override at the increment level). This is the
|
// windowed cards (they have nothing to override at the increment level). This is the
|
||||||
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
// only sound place for steps in V2. See wiki/concepts/tariff.md.
|
||||||
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
|
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!, trace);
|
||||||
|
|
||||||
|
// Trace labels: the defaultCard reads as the base rate (null), a windowed card by
|
||||||
|
// its name.
|
||||||
|
const traceName = (card: TariffCard): string | null => (card === tariff.defaultCard ? null : card.name);
|
||||||
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
// WINDOW-PACKAGE tracking (2026-07-05): a `packageMinor` card charges ONE total per
|
||||||
@@ -876,21 +1022,33 @@ function computeFeeV2(
|
|||||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||||
const segEnd = Math.min(segStart + DAY, minutes);
|
const segEnd = Math.min(segStart + DAY, minutes);
|
||||||
let segFee = 0;
|
let segFee = 0;
|
||||||
|
const bands = trace ? new BandTracer(trace, inc) : null;
|
||||||
for (let within = segStart; within < segEnd; within += inc) {
|
for (let within = segStart; within < segEnd; within += inc) {
|
||||||
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
const wall = localBreakdown(enteredMs + within * 60_000, tariff.tz);
|
||||||
const card = selectCard(cards, wall);
|
const card = selectCard(cards, wall);
|
||||||
if (card.packageMinor != null) {
|
if (card.packageMinor != null) {
|
||||||
// First increment of a new occurrence pays the package; the rest ride free.
|
// First increment of a new occurrence pays the package; the rest ride free.
|
||||||
if (prevWinner !== card) segFee += card.packageMinor;
|
if (prevWinner !== card) {
|
||||||
|
segFee += card.packageMinor;
|
||||||
|
bands?.flush();
|
||||||
|
trace?.push({ kind: "package", card: card.name, fromMin: within, amountMinor: card.packageMinor });
|
||||||
|
}
|
||||||
} else if (card.flatMinor != null) {
|
} else if (card.flatMinor != null) {
|
||||||
segFee += card.flatMinor;
|
segFee += card.flatMinor;
|
||||||
|
bands?.add(traceName(card), card.flatMinor, within);
|
||||||
} else {
|
} else {
|
||||||
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
// Ladder position = minutes into THIS rolling-24h day (resets each day, V1 rule).
|
||||||
segFee += rateAt(card.blocks ?? [], within - segStart);
|
const unit = rateAt(card.blocks ?? [], within - segStart);
|
||||||
|
segFee += unit;
|
||||||
|
bands?.add(traceName(card), unit, within);
|
||||||
}
|
}
|
||||||
prevWinner = card;
|
prevWinner = card;
|
||||||
}
|
}
|
||||||
if (dayCap != null) segFee = Math.min(segFee, dayCap);
|
bands?.flush();
|
||||||
|
if (dayCap != null && segFee > dayCap) {
|
||||||
|
trace?.push({ kind: "cap", day: segStart / DAY + 1, capMinor: dayCap, amountMinor: dayCap - segFee });
|
||||||
|
segFee = dayCap;
|
||||||
|
}
|
||||||
total += segFee;
|
total += segFee;
|
||||||
}
|
}
|
||||||
return total;
|
return total;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
computeFee,
|
computeFee,
|
||||||
|
explainFee,
|
||||||
priceSession,
|
priceSession,
|
||||||
validateTariffStructure,
|
validateTariffStructure,
|
||||||
|
type FeeBreakdownItem,
|
||||||
|
type TariffStructure,
|
||||||
type TariffStructureV1,
|
type TariffStructureV1,
|
||||||
type TariffStructureV2,
|
type TariffStructureV2,
|
||||||
type TariffCard,
|
type TariffCard,
|
||||||
@@ -437,3 +440,95 @@ describe("V2 window package (whole-window total)", () => {
|
|||||||
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
expect(validateTariffStructure(capped).some((e) => /dailyCapMinor does not apply to a window package/.test(e))).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("explainFee — the breakdown IS the fee (2026-07-06)", () => {
|
||||||
|
const v1: TariffStructure = {
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
blocks: [
|
||||||
|
{ uptoMin: 120, priceMinorPerIncrement: 200 },
|
||||||
|
{ uptoMin: null, priceMinorPerIncrement: 100 },
|
||||||
|
],
|
||||||
|
dailyCapMinor: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
const sum = (b: ReturnType<typeof explainFee>) => b.items.reduce((a, i) => a + ("amountMinor" in i ? i.amountMinor : 0), 0);
|
||||||
|
|
||||||
|
it("V1 ladder: bands merge per rate, the cap shows as a negative line, sum == fee", () => {
|
||||||
|
const from = "2026-07-06T08:00:00.000Z";
|
||||||
|
const to = "2026-07-06T15:02:00.000Z"; // 7h2m → 8 increments: 2×200 + 6×100 = 1000 → cap 500
|
||||||
|
const b = explainFee(from, to, v1);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v1));
|
||||||
|
expect(b.totalMinor).toBe(500);
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items.map((i) => i.kind)).toEqual(["band", "band", "cap"]);
|
||||||
|
const [first, second, cap] = b.items as [
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "band" }>,
|
||||||
|
Extract<FeeBreakdownItem, { kind: "cap" }>,
|
||||||
|
];
|
||||||
|
expect([first.increments, first.unitMinor, first.amountMinor]).toEqual([2, 200, 400]);
|
||||||
|
expect([second.increments, second.unitMinor, second.amountMinor]).toEqual([6, 100, 600]);
|
||||||
|
expect(cap.amountMinor).toBe(-500);
|
||||||
|
expect(b.billedMinutes).toBe(480);
|
||||||
|
expect(b.rawMinutes).toBe(422);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("grace: one zero line, billed 0", () => {
|
||||||
|
const b = explainFee("2026-07-06T08:00:00.000Z", "2026-07-06T08:04:00.000Z", v1);
|
||||||
|
expect(b.items).toEqual([{ kind: "grace", minutes: 4 }]);
|
||||||
|
expect(b.totalMinor).toBe(0);
|
||||||
|
expect(b.billedMinutes).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stepped: one line per rolling day, top tier repeats flagged", () => {
|
||||||
|
const stepped: TariffStructure = {
|
||||||
|
...v1,
|
||||||
|
blocks: [],
|
||||||
|
dailyCapMinor: null,
|
||||||
|
steps: [
|
||||||
|
{ uptoMin: 180, totalMinor: 500 },
|
||||||
|
{ uptoMin: 1440, totalMinor: 1000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const from = "2026-07-04T08:00:00.000Z";
|
||||||
|
const to = "2026-07-05T10:00:00.000Z"; // 26h → day1 top(1000) + day2 ≤180 (500)
|
||||||
|
const b = explainFee(from, to, stepped);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, stepped));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "step", day: 1, dayMinutes: 1440, uptoMin: 1440, amountMinor: 1000, repeated: false },
|
||||||
|
{ kind: "step", day: 2, dayMinutes: 120, uptoMin: 180, amountMinor: 500, repeated: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("V2 night package + base ladder: package is one line, bands name the card, sum == fee", () => {
|
||||||
|
const v2: TariffStructure = {
|
||||||
|
version: 2,
|
||||||
|
tz: "Europe/Tirane",
|
||||||
|
gracePeriodEntryMin: 5,
|
||||||
|
incrementMin: 60,
|
||||||
|
lostTicketMinor: 2000,
|
||||||
|
gracePeriodExitMin: 10,
|
||||||
|
overstay: "reprice",
|
||||||
|
defaultCard: { name: "default", priority: 0, blocks: [{ uptoMin: null, priceMinorPerIncrement: 10000 }], dailyCapMinor: null },
|
||||||
|
windowedCards: [
|
||||||
|
{ name: "night", priority: 10, window: { fromHour: "20:00", toHour: "07:00" }, packageMinor: 40000 },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// 18:00 → 22:30 local (16:00Z→20:30Z in July, UTC+2): 2 base hours + the night package.
|
||||||
|
const from = "2026-07-06T16:00:00.000Z";
|
||||||
|
const to = "2026-07-06T20:30:00.000Z";
|
||||||
|
const b = explainFee(from, to, v2);
|
||||||
|
expect(b.totalMinor).toBe(computeFee(from, to, v2));
|
||||||
|
expect(sum(b)).toBe(b.totalMinor);
|
||||||
|
expect(b.totalMinor).toBe(2 * 10000 + 40000);
|
||||||
|
expect(b.items).toEqual([
|
||||||
|
{ kind: "band", card: null, fromMin: 0, toMin: 120, increments: 2, unitMinor: 10000, amountMinor: 20000 },
|
||||||
|
{ kind: "package", card: "night", fromMin: 120, amountMinor: 40000 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user