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
+134 -12
View File
@@ -7,6 +7,7 @@ import {
publishTariffVersion, publishTariffVersion,
type TariffBlock, type TariffBlock,
type TariffCard, type TariffCard,
type TariffStep,
type TariffStructure, type TariffStructure,
type TariffState, type TariffState,
} from "./api.js"; } from "./api.js";
@@ -26,11 +27,19 @@ interface BlockForm {
hours: string; // duration of THIS band, in hours (ignored for the last block) hours: string; // duration of THIS band, in hours (ignored for the last block)
price: string; // major units, e.g. "2.00" price: string; // major units, e.g. "2.00"
} }
// A pricing body the form edits: either a flat rate or a block ladder. // One STEPPED ("up-to") row: "a stay up to N hours costs TOTAL". The owner enters the
// matrix verbatim (totals, not marginal rates). See wiki/concepts/tariff.md.
interface StepForm {
hours: string; // inclusive upper bound of this tier, in hours (e.g. "3")
total: string; // TOTAL major units for a stay within this tier (e.g. "5.00")
}
// A pricing body the form edits: a flat rate, a marginal block ladder, or a stepped
// (up-to) total-by-duration table.
interface PricingForm { interface PricingForm {
mode: "ladder" | "flat"; mode: "ladder" | "flat" | "stepped";
flat: string; // major units (used when mode==="flat") flat: string; // major units (used when mode==="flat")
blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder")
steps: StepForm[]; // up-to tiers (used when mode==="stepped")
dailyCap: string; // "" = no cap (ladder only) dailyCap: string; // "" = no cap (ladder only)
} }
// An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained. // An optional time/category TIER (a V2 windowed card). Absent windows = unconstrained.
@@ -60,11 +69,33 @@ interface FormState {
const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100); const toMinor = (major: string): number => Math.round(parseFloat(major || "0") * 100);
const toMajor = (minor: number): string => (minor / 100).toFixed(2); const toMajor = (minor: number): string => (minor / 100).toFixed(2);
function emptySteps(): StepForm[] {
return [
{ hours: "1", total: "2.00" },
{ hours: "3", total: "5.00" },
];
}
function emptyLadder(): PricingForm { function emptyLadder(): PricingForm {
return { mode: "ladder", flat: "0.00", dailyCap: "", blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }] }; return {
mode: "ladder",
flat: "0.00",
dailyCap: "",
blocks: [{ hours: "1", price: "2.00" }, { hours: "", price: "1.00" }],
steps: emptySteps(),
};
} }
function emptyTier(): TierForm { function emptyTier(): TierForm {
return { name: "", priority: "10", category: "", dow: [], fromHour: "", toHour: "", dateFrom: "", dateTo: "", pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] } }; return {
name: "",
priority: "10",
category: "",
dow: [],
fromHour: "",
toHour: "",
dateFrom: "",
dateTo: "",
pricing: { ...emptyLadder(), blocks: [{ hours: "", price: "1.00" }] },
};
} }
function emptyForm(): FormState { function emptyForm(): FormState {
@@ -92,16 +123,30 @@ function blocksToForm(blocks: TariffBlock[]): BlockForm[] {
}); });
} }
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder). // A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits.
function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm { function stepsToForm(steps: TariffStep[]): StepForm[] {
return steps.map((s) => ({ hours: String(s.uptoMin / 60), total: toMajor(s.totalMinor) }));
}
// A stored card (V2) or bare-V1 body → the form's PricingForm (flat, ladder, or stepped).
function pricingFromCard(c: {
flatMinor?: number;
blocks?: TariffBlock[];
steps?: TariffStep[];
dailyCapMinor?: number | null;
}): PricingForm {
if (c.steps != null && c.steps.length > 0) {
return { mode: "stepped", flat: "0.00", dailyCap: "", blocks: emptyLadder().blocks, steps: stepsToForm(c.steps) };
}
if (c.flatMinor != null) { if (c.flatMinor != null) {
return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks }; return { mode: "flat", flat: toMajor(c.flatMinor), dailyCap: "", blocks: emptyLadder().blocks, steps: emptySteps() };
} }
return { return {
mode: "ladder", mode: "ladder",
flat: "0.00", flat: "0.00",
dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor),
blocks: blocksToForm(c.blocks ?? []), blocks: blocksToForm(c.blocks ?? []),
steps: emptySteps(),
}; };
} }
@@ -138,9 +183,17 @@ function formFromActive(s: TariffState): FormState {
return { ...common, base: pricingFromCard(st), tiers: [] }; return { ...common, base: pricingFromCard(st), tiers: [] };
} }
// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm. // Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> { function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
if (p.mode === "flat") return { flatMinor: toMinor(p.flat) }; if (p.mode === "flat") return { flatMinor: toMinor(p.flat) };
if (p.mode === "stepped") {
// Each row's `hours` IS the inclusive threshold (the matrix "up to N hours").
const steps: TariffStep[] = p.steps.map((s) => ({
uptoMin: Math.round(Number(s.hours || "0") * 60),
totalMinor: toMinor(s.total),
}));
return { steps };
}
// Accumulate each band's hours into cumulative uptoMin (min); last band open-ended. // Accumulate each band's hours into cumulative uptoMin (min); last band open-ended.
const last = p.blocks.length - 1; const last = p.blocks.length - 1;
let cum = 0; let cum = 0;
@@ -184,6 +237,10 @@ function toStructure(f: FormState): TariffStructure {
// NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants // NO tiers ⇒ publish a BARE V1 structure (back-compat: a site that never wants
// tiers gets exactly today's shape; the server leaves it untouched). // tiers gets exactly today's shape; the server leaves it untouched).
if (f.tiers.length === 0) { if (f.tiers.length === 0) {
if (f.base.mode === "stepped") {
// A stepped V1: the up-to table replaces the ladder (blocks empty, no cap).
return { ...common, blocks: [], steps: baseBody.steps ?? [], dailyCapMinor: null };
}
if (f.base.mode === "flat") { if (f.base.mode === "flat") {
// A flat V1: a single open-ended block at the flat rate (V1 has no flat field). // A flat V1: a single open-ended block at the flat rate (V1 has no flat field).
return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null }; return { ...common, blocks: [{ uptoMin: null, priceMinorPerIncrement: toMinor(f.base.flat) }], dailyCapMinor: null };
@@ -244,6 +301,16 @@ export function TariffComposer() {
function removeBlock(target: "base" | number, i: number) { function removeBlock(target: "base" | number, i: number) {
updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) })); updatePricing(target, (p) => (i === p.blocks.length - 1 || p.blocks.length <= 1 ? p : { ...p, blocks: p.blocks.filter((_, j) => j !== i) }));
} }
// --- stepped (up-to) editing (base card only) ---
function setStep(i: number, patch: Partial<StepForm>) {
updatePricing("base", (p) => ({ ...p, steps: p.steps.map((s, j) => (j === i ? { ...s, ...patch } : s)) }));
}
function addStep() {
updatePricing("base", (p) => ({ ...p, steps: [...p.steps, { hours: "", total: "0.00" }] }));
}
function removeStep(i: number) {
updatePricing("base", (p) => (p.steps.length <= 1 ? p : { ...p, steps: p.steps.filter((_, j) => j !== i) }));
}
// --- tier editing --- // --- tier editing ---
function setTier(i: number, patch: Partial<TierForm>) { function setTier(i: number, patch: Partial<TierForm>) {
@@ -320,12 +387,16 @@ export function TariffComposer() {
<PricingEditor <PricingEditor
t={t} t={t}
pricing={form.base} pricing={form.base}
allowStepped
onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))} onMode={(mode) => updatePricing("base", (p) => ({ ...p, mode }))}
onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))}
onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))}
onBlock={(i, patch) => setBlock("base", i, patch)} onBlock={(i, patch) => setBlock("base", i, patch)}
onAddBlock={() => addBlock("base")} onAddBlock={() => addBlock("base")}
onRemoveBlock={(i) => removeBlock("base", i)} onRemoveBlock={(i) => removeBlock("base", i)}
onStep={setStep}
onAddStep={addStep}
onRemoveStep={removeStep}
/> />
</div> </div>
@@ -407,16 +478,21 @@ export function TariffComposer() {
); );
} }
// A reusable flat/ladder pricing-body editor — used by the default card and each tier. // A reusable pricing-body editor — flat / marginal ladder / stepped (up-to). The
// stepped mode is offered only where `allowStepped` (the default card, not tiers).
function PricingEditor(props: { function PricingEditor(props: {
t: (k: string) => string; t: (k: string) => string;
pricing: PricingForm; pricing: PricingForm;
onMode: (m: "ladder" | "flat") => void; allowStepped?: boolean;
onMode: (m: "ladder" | "flat" | "stepped") => void;
onFlat: (v: string) => void; onFlat: (v: string) => void;
onCap: (v: string) => void; onCap: (v: string) => void;
onBlock: (i: number, patch: Partial<BlockForm>) => void; onBlock: (i: number, patch: Partial<BlockForm>) => void;
onAddBlock: () => void; onAddBlock: () => void;
onRemoveBlock: (i: number) => void; onRemoveBlock: (i: number) => void;
onStep?: (i: number, patch: Partial<StepForm>) => void;
onAddStep?: () => void;
onRemoveStep?: (i: number) => void;
}) { }) {
const { t, pricing: p } = props; const { t, pricing: p } = props;
return ( return (
@@ -430,9 +506,55 @@ function PricingEditor(props: {
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} /> <input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
{t("tariff.modeFlat")} {t("tariff.modeFlat")}
</label> </label>
{props.allowStepped && (
<label className="inline-flex items-center gap-1.5 text-term-text">
<input type="radio" className="accent-term-amber" checked={p.mode === "stepped"} onChange={() => props.onMode("stepped")} />
{t("tariff.modeStepped")}
</label>
)}
</div> </div>
{p.mode === "flat" ? ( {p.mode === "stepped" ? (
<>
<p className="hint mb-2">{t("tariff.steppedHint")}</p>
<table className="w-full border-collapse">
<thead>
<tr className="text-left">
<th className="label px-2 pb-1 font-normal">{t("tariff.stepUpTo")}</th>
<th className="label px-2 pb-1 font-normal">{t("tariff.stepTotal")}</th>
<th />
</tr>
</thead>
<tbody>
{p.steps.map((s, i) => (
<tr key={i}>
<td className="px-2 py-1">
<span className="inline-flex items-center gap-2">
<input className="input w-20" value={s.hours} onChange={(e) => props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} />
<span className="text-[11px] text-term-muted">{t("tariff.hoursUnit")}</span>
</span>
</td>
<td className="px-2 py-1">
<input className="input w-28" value={s.total} onChange={(e) => props.onStep?.(i, { total: e.target.value })} />
</td>
<td className="px-2">
{p.steps.length > 1 && (
<button type="button" className="btn btn-ghost btn-sm" onClick={() => props.onRemoveStep?.(i)}>
{t("tariff.remove")}
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
<div className="mt-3">
<button type="button" className="btn btn-sm" onClick={props.onAddStep}>
{t("tariff.addStep")}
</button>
</div>
</>
) : p.mode === "flat" ? (
<div className="inline-flex items-center gap-2"> <div className="inline-flex items-center gap-2">
<span className="label">{t("tariff.pricePerIncrement")}</span> <span className="label">{t("tariff.pricePerIncrement")}</span>
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} /> <input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
+11
View File
@@ -359,6 +359,8 @@ export interface TariffStructureV1 {
gracePeriodEntryMin: number; gracePeriodEntryMin: number;
incrementMin: number; incrementMin: number;
blocks: TariffBlock[]; blocks: TariffBlock[];
/** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */
steps?: TariffStep[];
dailyCapMinor: number | null; dailyCapMinor: number | null;
lostTicketMinor: number; lostTicketMinor: number;
gracePeriodExitMin: number; gracePeriodExitMin: number;
@@ -378,8 +380,17 @@ export interface TariffCard {
window?: TariffWindow; window?: TariffWindow;
flatMinor?: number; flatMinor?: number;
blocks?: TariffBlock[]; blocks?: TariffBlock[];
/** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */
steps?: TariffStep[];
dailyCapMinor?: number | null; dailyCapMinor?: number | null;
} }
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay up to and including
* `uptoMin` minutes (cumulative, not marginal). Mirrors @parking/shared TariffStep. */
export interface TariffStep {
uptoMin: number;
totalMinor: number;
}
export interface TariffStructureV2 { export interface TariffStructureV2 {
version: 2; version: 2;
tz: string; tz: string;
+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.", defaultCardHint: "The base rate applied when no time/seasonal tier matches. This alone is enough for most car parks.",
modeLadder: "Hourly ladder", modeLadder: "Hourly ladder",
modeFlat: "Flat price", 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", 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.", 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", tierName: "Name",
+8 -2
View File
@@ -52,7 +52,7 @@ export const sq = {
users: "Përdoruesit", users: "Përdoruesit",
roles: "Rolet", roles: "Rolet",
shifts: "Turnet", shifts: "Turnet",
logs: "Regjistrat", logs: "Loget",
}, },
status: { status: {
live: "LIVE", 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.", 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", modeLadder: "Shkallë orësh",
modeFlat: "Çmim fiks", 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", 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ë.", 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", tierName: "Emri",
@@ -566,7 +572,7 @@ export const sq = {
loadFailed: "Ngarkimi i turneve dështoi.", loadFailed: "Ngarkimi i turneve dështoi.",
}, },
logs: { logs: {
title: "Regjistrat e sistemit", title: "Loget e sistemit",
refresh: "Rifresko", refresh: "Rifresko",
level: "Niveli", level: "Niveli",
source: "Burimi", source: "Burimi",
+103 -7
View File
@@ -336,7 +336,12 @@ export interface TariffStructureV1 {
readonly incrementMin: number; readonly incrementMin: number;
/** Consumed in order as duration accrues; last block may be open-ended. */ /** Consumed in order as duration accrues; last block may be open-ended. */
readonly blocks: readonly TariffBlock[]; readonly blocks: readonly TariffBlock[];
/** Cap per rolling 24h (null = no cap). */ /** STEPPED ("up-to") pricing — a total-by-duration table. When present (non-empty) it
* REPLACES `blocks`: the day's fee is the smallest tier whose `uptoMin ≥ elapsed`, and
* the top tier's total becomes the per-day price beyond it. Mutually exclusive with the
* marginal `blocks` ladder. Absent/empty ⇒ the ladder is used (back-compat). */
readonly steps?: readonly TariffStep[];
/** Cap per rolling 24h (null = no cap). Ignored for `steps` (the top tier IS the cap). */
readonly dailyCapMinor: number | null; readonly dailyCapMinor: number | null;
/** Flat charge when there's no entry id (admin may override at the moment). */ /** Flat charge when there's no entry id (admin may override at the moment). */
readonly lostTicketMinor: number; readonly lostTicketMinor: number;
@@ -352,6 +357,18 @@ export interface TariffBlock {
readonly priceMinorPerIncrement: number; readonly priceMinorPerIncrement: number;
} }
/** One row of a STEPPED ("up-to") tariff: a TOTAL price for a stay UP TO AND INCLUDING
* `uptoMin` minutes. Unlike a {@link TariffBlock} (a marginal per-increment rate), this
* is the cumulative total — the owner enters the price table directly (e.g. "0–3h →
* 500"). The smallest `uptoMin ≥ duration` wins; the largest row's total acts as the
* per-day price for stays beyond it (daily-cap repeat). See wiki/concepts/tariff.md. */
export interface TariffStep {
/** Inclusive upper bound of this tier in minutes (e.g. 180 = "up to 3 hours"). */
readonly uptoMin: number;
/** TOTAL charge for a stay within this tier (minor units), not a marginal rate. */
readonly totalMinor: number;
}
/** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent /** A wall-clock activation window for a V2 card. All parts are AND-ed; an absent
* part is unconstrained. Evaluated in the version's frozen tz. */ * part is unconstrained. Evaluated in the version's frozen tz. */
export interface TariffWindow { export interface TariffWindow {
@@ -377,10 +394,13 @@ export interface TariffCard {
readonly category?: string; readonly category?: string;
/** Wall-clock activation window. Absent only on the defaultCard (always active). */ /** Wall-clock activation window. Absent only on the defaultCard (always active). */
readonly window?: TariffWindow; readonly window?: TariffWindow;
/** Flat price per billing increment (mutually exclusive with `blocks`). */ /** Flat price per billing increment (mutually exclusive with `blocks`/`steps`). */
readonly flatMinor?: number; readonly flatMinor?: number;
/** Stepped ladder (mutually exclusive with `flatMinor`); last block open-ended. */ /** Marginal block ladder (mutually exclusive with `flatMinor`/`steps`); last open-ended. */
readonly blocks?: readonly TariffBlock[]; readonly blocks?: readonly TariffBlock[];
/** STEPPED ("up-to") total-by-duration table (mutually exclusive with `flatMinor`/
* `blocks`). The top tier's total is this card's per-day price. */
readonly steps?: readonly TariffStep[];
/** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs /** Cap per rolling 24h for THIS card's ladder. Only the defaultCard's cap governs
* a mixed day (see computeFeeV2). null = no cap. */ * a mixed day (see computeFeeV2). null = no cap. */
readonly dailyCapMinor?: number | null; readonly dailyCapMinor?: number | null;
@@ -506,10 +526,39 @@ export function priceSession(
}; };
} }
/** True when a structure/card uses STEPPED ("up-to") pricing (a non-empty `steps`
* table), as opposed to the marginal `blocks` ladder or a flat rate. */
export function hasSteps(s: { steps?: readonly TariffStep[] }): boolean {
return Array.isArray(s.steps) && s.steps.length > 0;
}
/**
* 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 (≤ /
* inclusive boundary). Stays beyond the largest threshold charge that top total per
* 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.
*/
function steppedFee(minutes: number, steps: readonly TariffStep[]): number {
if (minutes <= 0 || steps.length === 0) return 0;
const sorted = [...steps].sort((a, b) => a.uptoMin - b.uptoMin);
const top = sorted[sorted.length - 1]!;
const DAY = 24 * 60;
let total = 0;
for (let dayStart = 0; dayStart < minutes; dayStart += 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).
const tier = sorted.find((s) => dayMin <= s.uptoMin) ?? top;
total += tier.totalMinor;
}
return total;
}
/** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept /** The original (V1) fee algorithm — a single block ladder, no wall-clock. Kept
* VERBATIM so bare/legacy structures (incl. the live production version) price * VERBATIM so bare/legacy structures (incl. the live production version) price
* 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. */ * corrupt repricing of already-signed sessions. A `steps` table (when present)
* REPLACES the ladder via {@link steppedFee}. */
function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): number { function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1): 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;
@@ -520,6 +569,9 @@ function computeFeeV1(enteredAt: string, asOf: string, tariff: TariffStructureV1
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.
if (hasSteps(tariff)) return steppedFee(minutes, tariff.steps!);
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) {
@@ -572,6 +624,14 @@ function computeFeeV2(
const dayCap = tariff.defaultCard.dailyCapMinor ?? null; const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
const DAY = 24 * 60; const DAY = 24 * 60;
// STEPPED default card: a whole-stay "total by duration" model that does NOT compose
// with per-increment windowed cards (a total isn't a per-increment rate). So when the
// 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
// only sound place for steps in V2. See wiki/concepts/tariff.md.
if (hasSteps(tariff.defaultCard)) return steppedFee(minutes, tariff.defaultCard.steps!);
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);
@@ -643,6 +703,26 @@ function validateBlocks(blocks: unknown, prefix: string, errs: string[]): void {
} }
} }
/** Validate a STEPPED ("up-to") table: ≥1 row, strictly-ascending positive `uptoMin`,
* non-negative integer totals. Totals need NOT be monotonic (an owner may price a
* longer stay cheaper if they wish), but each tier must be a clean total. `prefix`
* labels errors (e.g. "steps" or "defaultCard.steps"). */
function validateSteps(steps: unknown, prefix: string, errs: string[]): void {
if (!Array.isArray(steps) || steps.length === 0) {
errs.push(`${prefix} must be a non-empty array`);
return;
}
let prevBound = 0;
steps.forEach((s: Partial<TariffStep>, i: number) => {
nonNegInt(s?.totalMinor, `${prefix}[${i}].totalMinor`, errs);
if (typeof s?.uptoMin !== "number" || !Number.isInteger(s.uptoMin) || s.uptoMin <= prevBound) {
errs.push(`${prefix}[${i}].uptoMin must be an integer greater than the previous tier's bound (${prevBound})`);
} else {
prevBound = s.uptoMin;
}
});
}
function validateTariffV1(t: Partial<TariffStructureV1>): string[] { function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
const errs: string[] = []; const errs: string[] = [];
nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs); nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs);
@@ -651,9 +731,17 @@ function validateTariffV1(t: Partial<TariffStructureV1>): string[] {
if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) { if (typeof t.incrementMin !== "number" || !Number.isInteger(t.incrementMin) || t.incrementMin < 1) {
errs.push("incrementMin must be a positive integer"); errs.push("incrementMin must be a positive integer");
} }
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
if (t.overstay !== "reprice") errs.push('overstay must be "reprice"'); if (t.overstay !== "reprice") errs.push('overstay must be "reprice"');
// STEPPED mode (a non-empty steps table) REPLACES the block ladder: validate steps
// and forbid a daily cap (the top tier IS the per-day price). Otherwise validate the
// ladder. A bare V1 with neither is invalid (validateBlocks reports the empty array).
if (hasSteps(t as { steps?: readonly TariffStep[] })) {
validateSteps(t.steps, "steps", errs);
if (t.dailyCapMinor != null) errs.push("dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)");
} else {
if (t.dailyCapMinor != null) nonNegInt(t.dailyCapMinor, "dailyCapMinor", errs);
validateBlocks(t.blocks, "blocks", errs); validateBlocks(t.blocks, "blocks", errs);
}
return errs; return errs;
} }
@@ -671,11 +759,19 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
const hasFlat = c.flatMinor != null; const hasFlat = c.flatMinor != null;
const hasBlocks = c.blocks != null; const hasBlocks = c.blocks != null;
if (hasFlat === hasBlocks) { const hasStepTable = c.steps != null;
errs.push(`${label} must set exactly one of flatMinor or blocks`); const modes = [hasFlat, hasBlocks, hasStepTable].filter(Boolean).length;
if (modes !== 1) {
errs.push(`${label} must set exactly one of flatMinor, blocks, or steps`);
} else if (hasFlat) { } else if (hasFlat) {
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs); nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`); if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor applies to a block ladder, not a flat rate`);
} else if (hasStepTable) {
// Stepped pricing is only sound on the DEFAULT card (a whole-stay total can't be
// sliced per-increment by a windowed card). Forbid it on a windowed card + the cap.
if (!isDefault) errs.push(`${label}: stepped (steps) pricing is only allowed on the defaultCard`);
validateSteps(c.steps, `${label}.steps`, errs);
if (c.dailyCapMinor != null) errs.push(`${label}: dailyCapMinor does not apply to stepped pricing (the top tier is the per-day price)`);
} else { } else {
validateBlocks(c.blocks, `${label}.blocks`, errs); validateBlocks(c.blocks, `${label}.blocks`, errs);
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs); if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
+72 -1
View File
@@ -223,7 +223,7 @@ describe("validate V2", () => {
}); });
it("rejects a card with both flat and blocks", () => { it("rejects a card with both flat and blocks", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } }); const errs = validateTariffStructure({ ...base, defaultCard: { name: "d", priority: 0, flatMinor: 100, blocks: ladder(100) } });
expect(errs).toContain("defaultCard must set exactly one of flatMinor or blocks"); expect(errs).toContain("defaultCard must set exactly one of flatMinor, blocks, or steps");
}); });
it("rejects defaultCard with a window", () => { it("rejects defaultCard with a window", () => {
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } }); const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
@@ -299,3 +299,74 @@ describe("priceSession grace + overstay", () => {
expect(r.amountMinor).toBe(200000); // 2 capped days expect(r.amountMinor).toBe(200000); // 2 capped days
}); });
}); });
// ---------------------------------------------------------------------------
// (i) STEPPED ("up-to") pricing — the owner's total-by-duration matrix.
// 0-1h=200, 0-3h=500, 0-6h=800, 0-9h=900, 0-12h=1000. Beyond 12h, the top total
// (1000) repeats as a per-day price. Boundary is <= (inclusive).
// ---------------------------------------------------------------------------
const stepped: TariffStructureV1 = {
gracePeriodEntryMin: 5,
incrementMin: 60,
blocks: [], // ignored when steps present
steps: [
{ uptoMin: 60, totalMinor: 200 },
{ uptoMin: 180, totalMinor: 500 },
{ uptoMin: 360, totalMinor: 800 },
{ uptoMin: 540, totalMinor: 900 },
{ uptoMin: 720, totalMinor: 1000 },
],
dailyCapMinor: null,
lostTicketMinor: 100000,
gracePeriodExitMin: 5,
overstay: "reprice",
};
describe("stepped (up-to) pricing — owner matrix", () => {
const cases: Record<string, number> = {
"3": 0, // within entry grace → free
"30": 200, // ≤ 1h
"60": 200, // exactly 1h (inclusive)
"61": 500, // into the 3h tier
"180": 500, // exactly 3h
"181": 800, // into the 6h tier
"360": 800, // exactly 6h
"540": 900, // exactly 9h
"720": 1000, // exactly 12h
};
for (const [min, want] of Object.entries(cases)) {
it(`${min} min → ${want}`, () => {
expect(computeFee(entered, at(Number(min)), stepped)).toBe(want);
});
}
it("beyond the top tier the day's total is the top tier (daily-cap behaviour)", () => {
// 13h is past the 12h top tier but still within ONE rolling day → top total 1000
// (the top tier is that day's ceiling; it does NOT restart a new tier cycle).
expect(computeFee(entered, at(13 * 60), stepped)).toBe(1000);
// exactly 24h = one full day at the top total
expect(computeFee(entered, at(24 * 60), stepped)).toBe(1000);
// 25h = day1 ceiling (1000) + 1h into day2 (200) = 1200
expect(computeFee(entered, at(25 * 60), stepped)).toBe(1200);
// 26h = 1000 + (2h → ≤180min tier = 500) = 1500
expect(computeFee(entered, at(26 * 60), stepped)).toBe(1500);
});
it("priceSession routes overstay through the stepped engine too", () => {
// paid at 120, grace 5 → expires 125; asOf = 125 + 180 (3h new period) → 500
const r = priceSession(entered, at(125 + 180), stepped, [{ paidAt: at(120), graceExitMin: 5 }]);
expect(r.overstay).toBe(true);
expect(r.amountMinor).toBe(500);
});
it("validates: a stepped V1 is valid; non-ascending uptoMin is rejected", () => {
expect(validateTariffStructure(stepped)).toEqual([]);
const bad = { ...stepped, steps: [{ uptoMin: 180, totalMinor: 500 }, { uptoMin: 60, totalMinor: 200 }] };
expect(validateTariffStructure(bad).length).toBeGreaterThan(0);
});
it("rejects a daily cap combined with steps", () => {
const capped = { ...stepped, dailyCapMinor: 100000 };
expect(validateTariffStructure(capped).some((e) => /dailyCap/i.test(e))).toBe(true);
});
});
+38
View File
@@ -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 — > the owner must compose + publish one before the lot can charge (until then: free, or gated —
> operator policy, see Open). > 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 **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]] 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 capture/CCTV and charge real duration, or apply a set penalty). The configured flat fee is the
+18
View File
@@ -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 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. 6h and multi-day stepping; ticket-load returned a real session. Build+lint green.
Updated [[tariff]] + [[booth-exit-flow]]. 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]].