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:
@@ -1,6 +1,7 @@
|
||||
import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
|
||||
import {
|
||||
computeFee,
|
||||
minutesOutsideWindow,
|
||||
outOfWindowGap,
|
||||
type PlanTimeframes,
|
||||
type SubscriptionPlan,
|
||||
@@ -92,3 +93,37 @@ export function windowCharge(
|
||||
tariffVersionId: tv.id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The TOTAL out-of-window charge a subscriber owes for an OPEN occurrence, computed over
|
||||
* the whole stay `[enteredAt, nowISO)` in ONE shot (not entry-gap + exit-gap, which
|
||||
* double-counts and lets the exit gap reach back to a previous day's close). Sums the
|
||||
* minutes parked outside the plan's allowed window and prices them as a single transient
|
||||
* stay of that duration — so the tariff's increments + daily cap apply correctly. Returns
|
||||
* null when the plan has no timeframes / nothing is owed / no tariff to price against.
|
||||
*/
|
||||
export function windowOwedBetween(
|
||||
db: Db,
|
||||
planVersionId: string | null,
|
||||
enteredAtISO: string,
|
||||
nowISO: string,
|
||||
): { amountMinor: number; minutes: number; currency: string; tariffVersionId: string } | null {
|
||||
const plan = planVersionById(db, planVersionId);
|
||||
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
|
||||
if (!timeframes) return null;
|
||||
|
||||
const tz = timeframes.tz || siteTz(db);
|
||||
const minutes = minutesOutsideWindow(timeframes, tz, enteredAtISO, nowISO);
|
||||
if (minutes <= 0) return null;
|
||||
|
||||
// Price the out-of-window duration as a transient stay (entry→entry+minutes), against
|
||||
// the tariff in force at entry — reproducible, and the daily cap applies.
|
||||
const tv = tariffVersionAt(db, enteredAtISO);
|
||||
if (!tv) return null;
|
||||
const structure = tv.structure as unknown as TariffStructure;
|
||||
const end = new Date(Date.parse(enteredAtISO) + minutes * 60_000).toISOString();
|
||||
const amountMinor = computeFee(enteredAtISO, end, structure);
|
||||
if (amountMinor <= 0) return null;
|
||||
|
||||
return { amountMinor, minutes, currency: tv.currency, tariffVersionId: tv.id };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user