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:
+134
-12
@@ -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<TariffCard, "flatMinor" | "blocks" | "dailyCapMinor"> {
|
||||
// Build a tariff card's pricing body (flat XOR ladder XOR stepped) from a PricingForm.
|
||||
function pricingToCardBody(p: PricingForm): Pick<TariffCard, "flatMinor" | "blocks" | "steps" | "dailyCapMinor"> {
|
||||
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<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 ---
|
||||
function setTier(i: number, patch: Partial<TierForm>) {
|
||||
@@ -320,12 +387,16 @@ export function TariffComposer() {
|
||||
<PricingEditor
|
||||
t={t}
|
||||
pricing={form.base}
|
||||
allowStepped
|
||||
onMode={(mode) => 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}
|
||||
/>
|
||||
</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: {
|
||||
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<BlockForm>) => void;
|
||||
onAddBlock: () => void;
|
||||
onRemoveBlock: (i: number) => void;
|
||||
onStep?: (i: number, patch: Partial<StepForm>) => void;
|
||||
onAddStep?: () => void;
|
||||
onRemoveStep?: (i: number) => void;
|
||||
}) {
|
||||
const { t, pricing: p } = props;
|
||||
return (
|
||||
@@ -430,9 +506,55 @@ function PricingEditor(props: {
|
||||
<input type="radio" className="accent-term-amber" checked={p.mode === "flat"} onChange={() => props.onMode("flat")} />
|
||||
{t("tariff.modeFlat")}
|
||||
</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>
|
||||
|
||||
{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">
|
||||
<span className="label">{t("tariff.pricePerIncrement")}</span>
|
||||
<input className="input w-28" value={p.flat} onChange={(e) => props.onFlat(e.target.value)} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user