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:
@@ -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