feat(tariff): stepped ("up-to") pricing mode — total-by-duration

Owners often state rates as a total-by-duration matrix (0-1h=200, 0-3h=500,
0-6h=800, 0-9h=900, 0-12h=1000) that the marginal hourly ladder can't express
(the ladder sums per-increment rates; this is cumulative totals at thresholds).
Add STEPPED as a third pricing mode alongside the ladder and flat.

- @parking/shared: TariffStep {uptoMin, totalMinor} + a `steps[]` field on V1
  structures and V2 cards (mutually exclusive with blocks/flatMinor). steppedFee():
  smallest tier with uptoMin >= duration wins (INCLUSIVE boundary), the top tier
  repeats as a per-day cap; wired into computeFeeV1 + computeFeeV2 (V2 default card
  only — a whole-stay total can't be sliced per-increment by a windowed card).
  Validation: ascending uptoMin, non-negative totals, no daily-cap-with-steps,
  steps-only-on-default. priceSession/quote/booth/Lab price it via the shared core.
- Composer UI: a "By duration (up-to)" mode with an up-to/total table (base card
  only). i18n modeStepped/steppedHint/stepUpTo/stepTotal/addStep (sq+en).
- 8 new unit tests incl. the exact owner matrix, multi-day repeat, overstay, and
  validation (53 pass). Verified end-to-end via the UI: authored + published the
  matrix, Tariff Lab prices it exactly (3h->500, 6h->800, 12h->1000, 2d->2000).

Wiki: tariff (three pricing modes + stepped semantics), log.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 12:33:27 +02:00
parent 3d02134711
commit cc507f490f
8 changed files with 391 additions and 23 deletions
+6
View File
@@ -237,6 +237,12 @@ export const en: Catalog = {
defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder",
modeFlat: "Flat price",
modeStepped: "By duration (up-to)",
steppedHint:
"Set the TOTAL price for a stay up to a given time (e.g. up to 3h = 500). The first row whose limit ≥ the duration wins (the limit is inclusive). The last row's total repeats as a per-day price for longer stays.",
stepUpTo: "Up to",
stepTotal: "Total price",
addStep: "+ Add row",
tiersAdvanced: "Advanced: time & seasonal tiers",
tiersHint: "Optional. Add tiers that apply only at certain hours/days/dates or for a category (e.g. happy hour, night rate, weekend, bus). With no tiers, just the base rate is published.",
tierName: "Name",
+8 -2
View File
@@ -52,7 +52,7 @@ export const sq = {
users: "Përdoruesit",
roles: "Rolet",
shifts: "Turnet",
logs: "Regjistrat",
logs: "Loget",
},
status: {
live: "LIVE",
@@ -240,6 +240,12 @@ export const sq = {
defaultCardHint: "Çmimi bazë i zbatuar kur asnjë nivel kohor/sezonal nuk vlen. Kjo e vetme është mjaftueshëm për shumicën e parkimeve.",
modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks",
modeStepped: "Sipas kohëzgjatjes (deri-në)",
steppedHint:
"Vendos çmimin TOTAL për një qëndrim deri në një kohë të caktuar (p.sh. deri 3 orë = 500). Fiton rreshti i parë me kufi ≥ kohëzgjatjes (kufiri përfshihet). Totali i rreshtit të fundit përsëritet si çmim ditor për qëndrime më të gjata.",
stepUpTo: "Deri në",
stepTotal: "Çmimi total",
addStep: "+ Shto rresht",
tiersAdvanced: "Të avancuara: nivele kohore & sezonale",
tiersHint: "Opsionale. Shto nivele tarifore që vlejnë vetëm në orë/ditë/data ose kategori të caktuara (p.sh. orë e lirë, tarifë nate, fundjavë, autobus). Pa nivele, publikohet vetëm tarifa bazë.",
tierName: "Emri",
@@ -566,7 +572,7 @@ export const sq = {
loadFailed: "Ngarkimi i turneve dështoi.",
},
logs: {
title: "Regjistrat e sistemit",
title: "Loget e sistemit",
refresh: "Rifresko",
level: "Niveli",
source: "Burimi",