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:
@@ -63,6 +63,44 @@ code. All amounts are **integer minor units** in the tariff's currency.
|
||||
> the owner must compose + publish one before the lot can charge (until then: free, or gated —
|
||||
> operator policy, see Open).
|
||||
|
||||
### Three pricing modes (per card / per V1 structure)
|
||||
|
||||
A card's body is **one of three mutually-exclusive shapes** — `flatMinor`, `blocks`, or `steps`:
|
||||
|
||||
1. **Hourly ladder (`blocks`)** — the model above: a **marginal per-increment** rate that the engine
|
||||
*sums* across increments. "Each next hour costs X." Daily-cap and multi-day reset apply.
|
||||
2. **Flat (`flatMinor`)** — one rate per increment (a one-block ladder).
|
||||
3. **Stepped / "up-to" (`steps`)** — *added 2026-06-20.* A **total-by-duration** table the owner
|
||||
enters verbatim — the opposite of marginal: each row is the **cumulative TOTAL** for a stay within
|
||||
that tier. Needed because owners think in totals, and many real cards (flat-day, airport) are
|
||||
stated this way and **cannot** be expressed as a marginal ladder.
|
||||
|
||||
```jsonc
|
||||
"steps": [ // each row: total price for a stay UP TO uptoMin (inclusive)
|
||||
{ "uptoMin": 60, "totalMinor": 200 }, // 0–1h → 200
|
||||
{ "uptoMin": 180, "totalMinor": 500 }, // 0–3h → 500
|
||||
{ "uptoMin": 360, "totalMinor": 800 }, // 0–6h → 800
|
||||
{ "uptoMin": 540, "totalMinor": 900 }, // 0–9h → 900
|
||||
{ "uptoMin": 720, "totalMinor": 1000 } // 0–12h → 1000
|
||||
]
|
||||
```
|
||||
|
||||
**Stepped semantics** (decided with the user, 2026-06-20):
|
||||
- The **smallest tier whose `uptoMin ≥ duration`** wins; the boundary is **inclusive** (`≤`) — a stay
|
||||
of exactly 3h00m costs the 3h tier (500), not the next.
|
||||
- Beyond the **largest threshold**, that tier's total is the **per-day price** (a daily-cap repeat):
|
||||
a 13h stay within one rolling day = 1000 (the top total is the day's ceiling), and a 25h stay =
|
||||
1000 (day 1) + the stepped ladder for the remaining 1h on day 2 = 1200.
|
||||
- A `steps` table **replaces** the `blocks` ladder and **forbids `dailyCapMinor`** (the top tier IS
|
||||
the per-day cap). In V2 it is allowed **only on the `defaultCard`** — a whole-stay total can't be
|
||||
sliced per-increment by a windowed card, so windowed/stepped don't compose.
|
||||
- Validation: ≥1 row, strictly-ascending positive `uptoMin`, non-negative integer totals (totals
|
||||
need not be monotonic — an owner *may* price a longer stay cheaper).
|
||||
|
||||
The owner authors this in the composer ("By duration (up-to)" mode) as an *up-to N hours / total*
|
||||
table; the [[#tariff-lab-simulator-as-built-2026-06-20|Tariff Lab]] previews the curve. Verified
|
||||
end-to-end: the matrix above publishes and prices exactly (30m→200, 3h→500, 6h→800, 12h→1000, 2d→2000).
|
||||
|
||||
**Lost ticket** is not just the flat `lostTicketMinor`: the admin may **override with an arbitrary
|
||||
amount** at the moment (operator judgement — establish entry time from [[opencv-anpr-service|plate]]
|
||||
capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
|
||||
|
||||
+18
@@ -1022,3 +1022,21 @@ incl. the ticket-1245791632490 overstay-not-zero regression (40 tests pass). Ver
|
||||
live via the real UI: a 3h stay → ALL 3,000, curve shows the daily cap flattening at
|
||||
6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
|
||||
Updated [[tariff]] + [[booth-exit-flow]].
|
||||
|
||||
## [2026-06-20] feat | Stepped ("up-to") tariff mode — total-by-duration pricing
|
||||
|
||||
The owner needed a total-by-duration matrix (0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900,
|
||||
0-12h=1000) the marginal hourly ladder CANNOT express (ladder sums per-increment
|
||||
rates; this is cumulative totals at thresholds). Added STEPPED pricing as a third
|
||||
mode alongside ladder + flat. New TariffStep{uptoMin,totalMinor} + steps[] on V1
|
||||
structures and V2 cards (mutually exclusive w/ blocks/flatMinor). Engine steppedFee():
|
||||
smallest tier with uptoMin>=duration wins (INCLUSIVE <=), top tier repeats as per-day
|
||||
cap; wired into computeFeeV1 + computeFeeV2 (V2 defaultCard only — a whole-stay total
|
||||
can't be sliced by a windowed card). Validation: ascending uptoMin, non-neg totals, no
|
||||
dailyCap-with-steps, steps-only-on-default. priceSession/quote/booth/Lab all price it
|
||||
via the shared core (no extra wiring). Composer UI: "By duration (up-to)" radio +
|
||||
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 + overstay
|
||||
+ validation (53 pass). VERIFIED end-to-end via the real UI: authored the matrix in the
|
||||
composer, published, Tariff Lab priced it exactly (3h->500, 6h->800, 12h->1000,
|
||||
2d->2000). Build+lint green. Updated [[tariff]].
|
||||
|
||||
Reference in New Issue
Block a user