fix(tariff): reject stepped base combined with time/seasonal tiers
A stepped ("up-to") default card prices the whole stay as one total, so the V2
engine short-circuits to steppedFee and NEVER consults windowed cards — any
time/seasonal tiers would silently never fire. Found live: an active tariff had a
stepped base plus weekday-night + weekend tiers, and every 3h stay priced 600 ALL
regardless of hour/day because the tiers were dead.
- validateTariffV2 now rejects a stepped defaultCard combined with windowedCards,
with an actionable message (switch the base to ladder/flat, or remove the tiers).
- Composer shows an inline red warning the moment base mode is stepped and tiers
exist; publishing is blocked server-side regardless.
- ApiError now carries the server's problems[], so the publish error surfaces the
SPECIFIC reason instead of a generic "invalid tariff structure".
- 2 new validation tests (55 pass).
Wiki: tariff, log.
Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
@@ -833,6 +833,17 @@ function validateTariffV2(t: Partial<TariffStructureV2>): string[] {
|
||||
cards.forEach((c, i) => validateCard(c, `windowedCards[${i}]`, false, errs));
|
||||
}
|
||||
|
||||
// A STEPPED base (an "up-to" total-by-duration table) prices the WHOLE stay as one
|
||||
// number — it cannot be sliced per-increment, so windowed (time/seasonal) tiers have
|
||||
// nothing to override and the engine ignores them entirely. Forbid the combination
|
||||
// rather than let an operator publish tiers that silently never fire. (Switch the base
|
||||
// to an hourly ladder / flat rate to use tiers, or remove the tiers.)
|
||||
if (t.defaultCard != null && hasSteps(t.defaultCard) && cards.length > 0) {
|
||||
errs.push(
|
||||
"time/seasonal tiers do not apply to an up-to-duration (stepped) base rate — remove the tiers, or switch the base rate to an hourly ladder or flat price",
|
||||
);
|
||||
}
|
||||
|
||||
// Precedence determinism: reject two cards (same category bucket) that tie on
|
||||
// (specificity, priority) with overlapping windows — the operator must break the
|
||||
// tie with priority rather than relying silently on the name tiebreak.
|
||||
|
||||
@@ -229,6 +229,19 @@ describe("validate V2", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: { ...okDefault, window: { dow: [1] } } });
|
||||
expect(errs).toContain("defaultCard must not have a window (it is the always-active fallback)");
|
||||
});
|
||||
it("rejects a STEPPED base card combined with windowed tiers (they would be ignored)", () => {
|
||||
const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] };
|
||||
const errs = validateTariffStructure({
|
||||
...base,
|
||||
defaultCard: steppedDefault,
|
||||
windowedCards: [{ name: "night", priority: 1, window: { dow: [1] }, blocks: ladder(5000) }],
|
||||
});
|
||||
expect(errs.some((e) => /up-to-duration \(stepped\) base/.test(e))).toBe(true);
|
||||
});
|
||||
it("accepts a STEPPED base card with NO tiers", () => {
|
||||
const steppedDefault: TariffCard = { name: "d", priority: 0, steps: [{ uptoMin: 60, totalMinor: 200 }] };
|
||||
expect(validateTariffStructure({ ...base, defaultCard: steppedDefault })).toEqual([]);
|
||||
});
|
||||
it("rejects a bad hour format", () => {
|
||||
const errs = validateTariffStructure({ ...base, defaultCard: okDefault, windowedCards: [{ name: "w", priority: 1, window: { fromHour: "25:00", toHour: "26:00" }, blocks: ladder(5000) }] });
|
||||
expect(errs.some((e) => e.includes("fromHour"))).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user