fix(subs): out-of-window charge was a phantom 12h span (4,100 ALL bug)

The tariff-bridge owed amount summed TWO charges — the early-entry gap +
a "late-exit" gap — and the exit gap (outOfWindowGap edge:"exit") always
measured back to the PREVIOUS window close, even for a subscriber still BEFORE
their window. So a car that entered ~30 min early showed ~12h owed (4,100 ALL)
the moment it was looked up, instead of ~100 ALL.

Replace the two-gap sum with a single correct primitive,
minutesOutsideWindow(timeframes, tz, from, to): the minutes within the actual
stay [entry, now] that fall outside the allowed window (covering early entry AND
late exit, bounded by the stay, weekend/off-days free). windowOwedBetween prices
those minutes once as a transient stay (so increments + daily cap apply) against
the tariff in force at entry. Both the exit gate (subscription-flow) and the
booth quote (pay-station) now use this one source of truth — they can't disagree.

Verified on the live occurrence: was 4,100 ALL, now 100 ALL (9 min outside →
one increment). 87 shared tests (6 new regression cases incl. the phantom span).
Build+lint 12/12.

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-20 20:37:57 +02:00
parent eafbc3ddbb
commit 294ca85ded
5 changed files with 126 additions and 34 deletions
@@ -1,5 +1,5 @@
import { describe, it, expect } from "vitest";
import { outOfWindowGap, type PlanTimeframes } from "./index.js";
import { minutesOutsideWindow, outOfWindowGap, type PlanTimeframes } from "./index.js";
// The "tariff bridge" gap for a subscriber scan outside their allowed window. UTC tz
// keeps the wall-clock arithmetic obvious in the tests. See wiki/entities/subscription.md.
@@ -86,3 +86,28 @@ describe("outOfWindowGap — unrestricted", () => {
expect(outOfWindowGap(null, "UTC", monday("09:00"), "entry")).toBeNull();
});
});
describe("minutesOutsideWindow — the charge basis (regression: no phantom span)", () => {
// entered 30 min before the 20:00 window; STILL inside, only a few minutes elapsed.
it("early entry, barely elapsed → only the elapsed pre-window minutes (NOT back to a prior close)", () => {
// 19:30 entry, now 19:33 → 3 minutes outside (the bug charged ~12h here).
expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("19:33"))).toBe(3);
});
it("early entry until the window opens = the full pre-window gap, then 0 inside", () => {
// 19:30 → 22:00: 30 min outside (19:30→20:00), the rest in-window.
expect(minutesOutsideWindow(night, "UTC", monday("19:30"), monday("22:00"))).toBe(30);
});
it("in-window the whole stay → 0", () => {
expect(minutesOutsideWindow(night, "UTC", monday("22:00"), monday("23:30"))).toBe(90 - 90); // fully inside
});
it("late exit after close adds the post-close minutes", () => {
// enter 22:00 (in window), exit 08:30 → 30 min outside (08:00→08:30).
expect(minutesOutsideWindow(night, "UTC", monday("22:00"), `2026-06-23T08:30:00.000Z`)).toBe(30);
});
it("a day the window doesn't apply contributes 0 (weekend free)", () => {
expect(minutesOutsideWindow(night, "UTC", saturday("09:00"), saturday("18:00"))).toBe(0);
});
it("unrestricted plan → 0", () => {
expect(minutesOutsideWindow(null, "UTC", monday("09:00"), monday("23:00"))).toBe(0);
});
});