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
+31
View File
@@ -44,6 +44,7 @@ export const en: Catalog = {
setup: "Setup",
devices: "Devices",
tariff: "Tariff",
tariffLab: "Tariff Lab",
subscriptions: "Subscriptions",
site: "Site",
users: "Users",
@@ -330,6 +331,36 @@ export const en: Catalog = {
relayLabel: "Relay {{relay}} ({{direction}})",
noRelaysConfigured: "This controller has no relays configured.",
},
lab: {
title: "Tariff Lab",
intro:
"Test rates in time (day/night windows, daily caps, overstay) in seconds, with no waiting. Pricing uses the same logic as the booth; nothing is written to the ledger.",
loadTicket: "Load from a real ticket",
loadTicketPh: "Ticket number / identity",
load: "Load",
loaded: "Loaded session {{id}}",
tariffVersion: "Tariff version",
activeVersion: "Active version (current)",
entered: "Entered",
asOf: "As of (now/exit)",
now: "Now",
category: "Category",
categoryPh: "e.g. bus (blank = car)",
payment: "Payment",
paid: "paid",
graceMin: "grace (min)",
price: "Compute price",
pricing: "Pricing…",
outcome: "Outcome",
amountDue: "Amount due",
billedPeriod: "Billed period",
overstay: "overstay",
settled: "settled",
periodStart: "Period start",
graceExpires: "Grace expires",
curve: "Duration curve",
curveHint: "Fee from entry at several durations — see where the daily cap flattens or windows shift.",
},
subs: {
title: "Subscriptions",
unnamed: "(unnamed)",
+31
View File
@@ -46,6 +46,7 @@ export const sq = {
setup: "Konfigurimi",
devices: "Pajisjet",
tariff: "Tarifa",
tariffLab: "Lab Tarife",
subscriptions: "Abonimet",
site: "Park",
users: "Përdoruesit",
@@ -341,6 +342,36 @@ export const sq = {
relayLabel: "Rele {{relay}} ({{direction}})",
noRelaysConfigured: "Ky kontrollues nuk ka rele të konfiguruar.",
},
lab: {
title: "Lab Tarife",
intro:
"Testo tarifat në kohë (dritare ditë/natë, kufi ditor, qëndrim tej afatit) në sekonda, pa pritur orë. Çmimi llogaritet me të njëjtën logjikë si kabina; nuk shkruhet asgjë në ledger.",
loadTicket: "Ngarko nga një biletë reale",
loadTicketPh: "Numri i biletës / identiteti",
load: "Ngarko",
loaded: "U ngarkua sesioni {{id}}",
tariffVersion: "Versioni i tarifës",
activeVersion: "Versioni aktiv (i tanishëm)",
entered: "Hyrja",
asOf: "Deri më (tani/dalja)",
now: "Tani",
category: "Kategoria",
categoryPh: "p.sh. bus (bosh = makinë)",
payment: "Pagesa",
paid: "u pagua",
graceMin: "afati (min)",
price: "Llogarit çmimin",
pricing: "Duke llogaritur…",
outcome: "Rezultati",
amountDue: "Shuma për pagesë",
billedPeriod: "Periudha e faturuar",
overstay: "tej afatit",
settled: "i shlyer",
periodStart: "Fillimi i periudhës",
graceExpires: "Afati skadon",
curve: "Kurba sipas kohëzgjatjes",
curveHint: "Tarifa nga hyrja për disa kohëzgjatje — shih ku rrafshohet kufiri ditor ose ndryshojnë dritaret.",
},
subs: {
title: "Abonimet",
unnamed: "(pa emër)",