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:
@@ -1142,6 +1142,47 @@ export function outOfWindowGap(
|
||||
return { start, end: atISO, minutes: mins };
|
||||
}
|
||||
|
||||
/**
|
||||
* Total minutes WITHIN the stay span `[fromISO, toISO)` that fall OUTSIDE the plan's
|
||||
* allowed window — the correct charge basis for a subscriber's out-of-window parking
|
||||
* (early entry AND/OR late exit, in one number, bounded by the actual stay). On days the
|
||||
* window doesn't apply (not in `days`) the whole day is allowed (0 outside minutes). The
|
||||
* window edges are widened by `graceMin`. Pure + tz-aware. Returns 0 for an unrestricted
|
||||
* plan / empty span. (Sampled per minute; capped so a pathological span can't spin.)
|
||||
*/
|
||||
export function minutesOutsideWindow(
|
||||
timeframes: PlanTimeframes | null | undefined,
|
||||
tz: string,
|
||||
fromISO: string,
|
||||
toISO: string,
|
||||
): number {
|
||||
if (!timeframes) return 0;
|
||||
if (typeof timeframes.fromMin !== "number" || typeof timeframes.toMin !== "number") return 0;
|
||||
const fromMs = Date.parse(fromISO);
|
||||
const toMs = Date.parse(toISO);
|
||||
if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || toMs <= fromMs) return 0;
|
||||
|
||||
const zone = timeframes.tz || tz;
|
||||
const grace = Math.max(0, timeframes.graceMin ?? 0);
|
||||
const days = timeframes.days && timeframes.days.length > 0 ? new Set(timeframes.days) : null;
|
||||
// Widen the allowed window by grace on both edges (so a few minutes either side is free).
|
||||
const from = (timeframes.fromMin - grace + 1440) % 1440;
|
||||
const to = (timeframes.toMin + grace) % 1440;
|
||||
|
||||
// Iterate minute-by-minute over the stay; count minutes outside the allowed window.
|
||||
const totalMin = Math.ceil((toMs - fromMs) / 60_000);
|
||||
const cap = 60 * 24 * 400; // ~400 days of minutes — a hard safety bound
|
||||
let outside = 0;
|
||||
for (let i = 0; i < totalMin && i < cap; i += 1) {
|
||||
const wall = localBreakdown(fromMs + i * 60_000, zone);
|
||||
// A day the window doesn't apply to ⇒ fully allowed (this minute is free).
|
||||
if (days && !days.has(wall.dow)) continue;
|
||||
const m = wall.hour * 60 + wall.minute;
|
||||
if (!inWindow(m, from, to)) outside += 1;
|
||||
}
|
||||
return outside;
|
||||
}
|
||||
|
||||
/** "HH:MM" → minutes-of-day (0-1439). Invalid → NaN (validation rejects those). */
|
||||
function hourToMin(hhmm: string): number {
|
||||
const m = /^(\d{2}):(\d{2})$/.exec(hhmm);
|
||||
|
||||
Reference in New Issue
Block a user