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:
@@ -336,7 +336,12 @@ export interface TariffStructureV1 {
|
||||
readonly incrementMin: number;
|
||||
/** Consumed in order as duration accrues; last block may be open-ended. */
|
||||
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;
|
||||
/** Flat charge when there's no entry id (admin may override at the moment). */
|
||||
readonly lostTicketMinor: number;
|
||||
@@ -352,6 +357,18 @@ export interface TariffBlock {
|
||||
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
|
||||
* part is unconstrained. Evaluated in the version's frozen tz. */
|
||||
export interface TariffWindow {
|
||||
@@ -377,10 +394,13 @@ export interface TariffCard {
|
||||
readonly category?: string;
|
||||
/** Wall-clock activation window. Absent only on the defaultCard (always active). */
|
||||
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;
|
||||
/** 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[];
|
||||
/** 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
|
||||
* a mixed day (see computeFeeV2). null = no cap. */
|
||||
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
|
||||
* VERBATIM so bare/legacy structures (incl. the live production version) price
|
||||
* 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 {
|
||||
const ms = Date.parse(asOf) - Date.parse(enteredAt);
|
||||
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 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;
|
||||
let total = 0;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
@@ -572,6 +624,14 @@ function computeFeeV2(
|
||||
const dayCap = tariff.defaultCard.dailyCapMinor ?? null;
|
||||
|
||||
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;
|
||||
for (let segStart = 0; segStart < minutes; segStart += DAY) {
|
||||
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[] {
|
||||
const errs: string[] = [];
|
||||
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) {
|
||||
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"');
|
||||
validateBlocks(t.blocks, "blocks", errs);
|
||||
// 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);
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
@@ -671,11 +759,19 @@ function validateCard(c: Partial<TariffCard> | undefined, label: string, isDefau
|
||||
|
||||
const hasFlat = c.flatMinor != null;
|
||||
const hasBlocks = c.blocks != null;
|
||||
if (hasFlat === hasBlocks) {
|
||||
errs.push(`${label} must set exactly one of flatMinor or blocks`);
|
||||
const hasStepTable = c.steps != null;
|
||||
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) {
|
||||
nonNegInt(c.flatMinor, `${label}.flatMinor`, errs);
|
||||
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 {
|
||||
validateBlocks(c.blocks, `${label}.blocks`, errs);
|
||||
if (c.dailyCapMinor != null) nonNegInt(c.dailyCapMinor, `${label}.dailyCapMinor`, errs);
|
||||
|
||||
@@ -223,7 +223,7 @@ describe("validate V2", () => {
|
||||
});
|
||||
it("rejects a card with both flat and blocks", () => {
|
||||
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", () => {
|
||||
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
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// (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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user