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:
2026-07-06 15:41:40 +02:00
parent ab968eb25e
commit 7649b897c4
5 changed files with 370 additions and 21 deletions
+8 -1
View File
@@ -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 {
computeFee,
explainFee,
isTariffV2,
priceSession,
validateTariffStructure,
@@ -188,6 +189,12 @@ export async function tariffRoutes(app: FastifyInstance, db: Db): Promise<void>
const payments = Array.isArray(b.payments) ? b.payments : [];
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.
const SAMPLES_MIN = [30, 60, 120, 180, 360, 720, 1440, 2880, 4320];
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),
}));
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