Files
parking_solution/apps/server/src/subscription-window.ts
T
julian 294ca85ded 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
2026-06-20 20:37:57 +02:00

130 lines
5.5 KiB
TypeScript

import { desc, eq, siteConfig, subscriptionPlans, tariffVersions, tariffs, type Db } from "@parking/db";
import {
computeFee,
minutesOutsideWindow,
outOfWindowGap,
type PlanTimeframes,
type SubscriptionPlan,
type TariffStructure,
} from "@parking/shared";
// Subscription TIME-WINDOW → TARIFF BRIDGE. A plan may restrict WHEN a subscriber may be
// parked (e.g. weekday 20:00→08:00, weekend all-day). A scan OUTSIDE the window is NOT
// refused — the out-of-window minutes are charged at the normal TRANSIENT tariff:
// - early ENTRY: arrival → window-open is owed (deferred; collected at exit).
// - late EXIT: window-close → departure is owed (exit is GATED until paid).
// Only plans WITH timeframes trigger any charge; a 24/7 plan never does. The gap math is
// pure + tz-aware (outOfWindowGap in @parking/shared); pricing reuses computeFee (the same
// engine transient stays use). See wiki/entities/subscription.md ("tariff bridge").
const DEFAULT_TZ = "Europe/Tirane";
/** A computed out-of-window charge: the gap, what it costs, and the tariff version used
* (recorded so it reprices identically — like every transient payment). */
export interface WindowCharge {
readonly amountMinor: number;
readonly gapStart: string;
readonly gapEnd: string;
readonly minutes: number;
readonly currency: string;
readonly tariffVersionId: string;
}
/** The plan VERSION that priced a subscription's sale (by planVersionId), or null. The
* timeframes are read from THIS version so a later plan edit can't retroactively change
* an existing subscriber's window rules. */
export function planVersionById(db: Db, planVersionId: string | null): SubscriptionPlan | null {
if (!planVersionId) return null;
const row = db.select().from(subscriptionPlans).where(eq(subscriptionPlans.id, planVersionId)).get();
return row ? (row as unknown as SubscriptionPlan) : null;
}
/** The site IANA timezone (falls back to the project default). */
export function siteTz(db: Db): string {
const cfg = db.select().from(siteConfig).where(eq(siteConfig.id, 1)).get();
return cfg?.timezone && cfg.timezone.length > 0 ? cfg.timezone : DEFAULT_TZ;
}
/** The active site tariff version in force at `at` (latest effectiveFrom ≤ at), or null. */
function tariffVersionAt(db: Db, at: string) {
const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get();
if (!tariff) return null;
const versions = db
.select()
.from(tariffVersions)
.where(eq(tariffVersions.tariffId, tariff.id))
.orderBy(desc(tariffVersions.effectiveFrom))
.all();
return versions.find((v) => v.effectiveFrom <= at) ?? null;
}
/**
* Compute the out-of-window charge for a subscriber scan at `atISO`, or null when there
* is nothing to charge (no plan timeframes, in-window, weekend all-day, within grace, or
* no tariff configured). `edge` = "entry" (early) or "exit" (late). The gap is priced as
* a fresh transient stay of that duration (computeFee over [gapStart, gapEnd]).
*/
export function windowCharge(
db: Db,
planVersionId: string | null,
atISO: string,
edge: "entry" | "exit",
): WindowCharge | null {
const plan = planVersionById(db, planVersionId);
const timeframes = (plan?.timeframes ?? null) as PlanTimeframes | null;
if (!timeframes) return null; // 24/7 plan (or comp sub) — never a time charge.
const tz = timeframes.tz || siteTz(db);
const gap = outOfWindowGap(timeframes, tz, atISO, edge);
if (!gap) return null; // in-window / all-day / within grace.
const tv = tariffVersionAt(db, gap.start);
if (!tv) return null; // no tariff to price against — can't charge (don't trap).
const structure = tv.structure as unknown as TariffStructure;
const amountMinor = computeFee(gap.start, gap.end, structure);
if (amountMinor <= 0) return null;
return {
amountMinor,
gapStart: gap.start,
gapEnd: gap.end,
minutes: gap.minutes,
currency: tv.currency,
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 };
}