From cc507f490fed271900c26cc3a34f09fcd85201d5 Mon Sep 17 00:00:00 2001 From: Julian Cuni Date: Sat, 20 Jun 2026 12:33:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(tariff):=20stepped=20("up-to")=20pricing?= =?UTF-8?q?=20mode=20=E2=80=94=20total-by-duration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/src/TariffComposer.tsx | 146 ++++++++++++++++++++++++++--- apps/web/src/api.ts | 11 +++ apps/web/src/lib/i18n/en.ts | 6 ++ apps/web/src/lib/i18n/sq.ts | 10 +- packages/shared/src/index.ts | 112 ++++++++++++++++++++-- packages/shared/src/tariff.test.ts | 73 ++++++++++++++- wiki/concepts/tariff.md | 38 ++++++++ wiki/log.md | 18 ++++ 8 files changed, 391 insertions(+), 23 deletions(-) diff --git a/apps/web/src/TariffComposer.tsx b/apps/web/src/TariffComposer.tsx index 162a32e..99656ad 100644 --- a/apps/web/src/TariffComposer.tsx +++ b/apps/web/src/TariffComposer.tsx @@ -7,6 +7,7 @@ import { publishTariffVersion, type TariffBlock, type TariffCard, + type TariffStep, type TariffStructure, type TariffState, } from "./api.js"; @@ -26,11 +27,19 @@ interface BlockForm { hours: string; // duration of THIS band, in hours (ignored for the last block) 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 { - mode: "ladder" | "flat"; + mode: "ladder" | "flat" | "stepped"; flat: string; // major units (used when mode==="flat") blocks: BlockForm[]; // hours-based ladder (used when mode==="ladder") + steps: StepForm[]; // up-to tiers (used when mode==="stepped") dailyCap: string; // "" = no cap (ladder only) } // 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 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 { - 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 { - 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 { @@ -92,16 +123,30 @@ function blocksToForm(blocks: TariffBlock[]): BlockForm[] { }); } -// A stored card (V2) or bare-V1 body → the form's PricingForm (flat or ladder). -function pricingFromCard(c: { flatMinor?: number; blocks?: TariffBlock[]; dailyCapMinor?: number | null }): PricingForm { +// A stored stepped table's `uptoMin` (minutes) → the per-tier hours the form edits. +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) { - 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 { mode: "ladder", flat: "0.00", dailyCap: c.dailyCapMinor == null ? "" : toMajor(c.dailyCapMinor), blocks: blocksToForm(c.blocks ?? []), + steps: emptySteps(), }; } @@ -138,9 +183,17 @@ function formFromActive(s: TariffState): FormState { return { ...common, base: pricingFromCard(st), tiers: [] }; } -// Build a tariff card's pricing body (flat XOR ladder) from a PricingForm. -function pricingToCardBody(p: PricingForm): Pick { +// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm. +function pricingToCardBody(p: PricingForm): Pick { 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. const last = p.blocks.length - 1; 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 // tiers gets exactly today's shape; the server leaves it untouched). 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") { // 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 }; @@ -244,6 +301,16 @@ export function TariffComposer() { 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) })); } + // --- stepped (up-to) editing (base card only) --- + function setStep(i: number, patch: Partial) { + 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 --- function setTier(i: number, patch: Partial) { @@ -320,12 +387,16 @@ export function TariffComposer() { updatePricing("base", (p) => ({ ...p, mode }))} onFlat={(flat) => updatePricing("base", (p) => ({ ...p, flat }))} onCap={(dailyCap) => updatePricing("base", (p) => ({ ...p, dailyCap }))} onBlock={(i, patch) => setBlock("base", i, patch)} onAddBlock={() => addBlock("base")} onRemoveBlock={(i) => removeBlock("base", i)} + onStep={setStep} + onAddStep={addStep} + onRemoveStep={removeStep} /> @@ -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: { t: (k: string) => string; pricing: PricingForm; - onMode: (m: "ladder" | "flat") => void; + allowStepped?: boolean; + onMode: (m: "ladder" | "flat" | "stepped") => void; onFlat: (v: string) => void; onCap: (v: string) => void; onBlock: (i: number, patch: Partial) => void; onAddBlock: () => void; onRemoveBlock: (i: number) => void; + onStep?: (i: number, patch: Partial) => void; + onAddStep?: () => void; + onRemoveStep?: (i: number) => void; }) { const { t, pricing: p } = props; return ( @@ -430,9 +506,55 @@ function PricingEditor(props: { props.onMode("flat")} /> {t("tariff.modeFlat")} + {props.allowStepped && ( + + )} - {p.mode === "flat" ? ( + {p.mode === "stepped" ? ( + <> +

{t("tariff.steppedHint")}

+ + + + + + + + + {p.steps.map((s, i) => ( + + + + + + ))} + +
{t("tariff.stepUpTo")}{t("tariff.stepTotal")} +
+ + props.onStep?.(i, { hours: e.target.value })} placeholder={t("tariff.egHours")} /> + {t("tariff.hoursUnit")} + + + props.onStep?.(i, { total: e.target.value })} /> + + {p.steps.length > 1 && ( + + )} +
+
+ +
+ + ) : p.mode === "flat" ? (
{t("tariff.pricePerIncrement")} props.onFlat(e.target.value)} /> diff --git a/apps/web/src/api.ts b/apps/web/src/api.ts index 51753b5..baa5dc7 100644 --- a/apps/web/src/api.ts +++ b/apps/web/src/api.ts @@ -359,6 +359,8 @@ export interface TariffStructureV1 { gracePeriodEntryMin: number; incrementMin: number; blocks: TariffBlock[]; + /** STEPPED ("up-to") total-by-duration table; when non-empty it replaces `blocks`. */ + steps?: TariffStep[]; dailyCapMinor: number | null; lostTicketMinor: number; gracePeriodExitMin: number; @@ -378,8 +380,17 @@ export interface TariffCard { window?: TariffWindow; flatMinor?: number; blocks?: TariffBlock[]; + /** STEPPED ("up-to") table (defaultCard only); mutually exclusive with flat/blocks. */ + steps?: TariffStep[]; 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 { version: 2; tz: string; diff --git a/apps/web/src/lib/i18n/en.ts b/apps/web/src/lib/i18n/en.ts index d9adc39..c165b89 100644 --- a/apps/web/src/lib/i18n/en.ts +++ b/apps/web/src/lib/i18n/en.ts @@ -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", diff --git a/apps/web/src/lib/i18n/sq.ts b/apps/web/src/lib/i18n/sq.ts index dc83f8c..80566a8 100644 --- a/apps/web/src/lib/i18n/sq.ts +++ b/apps/web/src/lib/i18n/sq.ts @@ -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", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0404d94..c6125f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -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, 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): string[] { const errs: string[] = []; nonNegInt(t.gracePeriodEntryMin, "gracePeriodEntryMin", errs); @@ -651,9 +731,17 @@ function validateTariffV1(t: Partial): 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 | 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); diff --git a/packages/shared/src/tariff.test.ts b/packages/shared/src/tariff.test.ts index d4f66ab..388bcbb 100644 --- a/packages/shared/src/tariff.test.ts +++ b/packages/shared/src/tariff.test.ts @@ -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 = { + "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); + }); +}); diff --git a/wiki/concepts/tariff.md b/wiki/concepts/tariff.md index a74c93b..b60853a 100644 --- a/wiki/concepts/tariff.md +++ b/wiki/concepts/tariff.md @@ -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 diff --git a/wiki/log.md b/wiki/log.md index ce2903c..540868b 100644 --- a/wiki/log.md +++ b/wiki/log.md @@ -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]].