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 }; }