294ca85ded
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
130 lines
5.5 KiB
TypeScript
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 };
|
|
}
|