import { and, asc, desc, eq, gte, lte, ledgerEvents, sessions, subscriptions, tariffVersions, tariffs, type Db, } from "@parking/db"; import { siteTz } from "./subscription-window.js"; // Admin reporting — LEDGER-FIRST aggregation (decision 2026-06-22). The numbers an // admin sees on the Reports page are summed from the SIGNED, hash-chained // ledger_events (vehicle_entry/exit + payment), the same source the shift Z-report // reconciles against — so a chart total always ties out to the drawer. Only the // duration/occupancy view leans on the derived `sessions` cache, where the ledger is // awkward (you'd have to pair every entry with its exit by hand); that's flagged as a // cache, not the financial truth. See wiki/concepts/reports.md, event-streams-split.md. // // All bucketing is in the SITE TIMEZONE (siteConfig.timezone) — a "day" is a local // calendar day, not a UTC one, so a 01:00-local payment lands on the right date and the // peak-hour chart reads in wall-clock. Pure date math on the stored ISO strings; no // floats (money is integer minor units throughout). export type Bucket = "hour" | "day" | "month"; export interface ReportQuery { /** Inclusive lower bound (ISO instant). */ readonly from: string; /** Exclusive upper bound (ISO instant). */ readonly to: string; /** Time grain for the series. Default "day". */ readonly bucket: Bucket; } /** One point in a time series, keyed by its local-time bucket label (e.g. "2026-06-22" * for a day, "2026-06-22 14" for an hour). */ export interface SeriesPoint { readonly bucket: string; readonly entries: number; readonly exits: number; /** Net transient revenue collected in the bucket (minor units), all tenders. */ readonly revenueMinor: number; /** Payment COUNT in the bucket (transactions, not amount). */ readonly payments: number; } export interface ReportTotals { readonly entries: number; readonly exits: number; readonly payments: number; readonly revenueMinor: number; readonly cashMinor: number; readonly cardMinor: number; /** Revenue split by what was sold. ticket = transient parking; subscriptionSales = * new/renewed subscriptions; subscriptionWindow = out-of-window tariff-bridge charges. */ readonly ticketMinor: number; readonly subscriptionSalesMinor: number; readonly subscriptionWindowMinor: number; /** Closed transient sessions in range + their parked-minutes stats (from the cache). */ readonly closedSessions: number; readonly totalParkedMinutes: number; readonly avgParkedMinutes: number; readonly medianParkedMinutes: number; } export interface SubscriptionStats { readonly active: number; readonly suspended: number; readonly revoked: number; /** Active subscriptions whose window covers `to` (the report's "now"). */ readonly currentlyValid: number; /** Cars covered by currently-valid subscriptions (Σ quantity). */ readonly coveredCars: number; } export interface ReportSummary { readonly from: string; readonly to: string; readonly bucket: Bucket; readonly tz: string; readonly currency: string | null; readonly totals: ReportTotals; readonly series: SeriesPoint[]; /** Entries by local hour-of-day (0–23), summed across the range — the peak-hour view. */ readonly entriesByHour: number[]; readonly subscriptions: SubscriptionStats; } /** Local wall-clock parts of an ISO instant in a given IANA tz. Reuses Intl (no dep). */ function localParts(iso: string, tz: string): { y: number; mo: number; d: number; h: number } { const fmt = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", hourCycle: "h23", }); const parts = Object.fromEntries(fmt.formatToParts(new Date(iso)).map((p) => [p.type, p.value])); return { y: Number(parts.year), mo: Number(parts.month), d: Number(parts.day), h: Number(parts.hour), }; } /** Bucket label for an instant at the chosen grain, in local time. Sorts lexically. */ function bucketLabel(iso: string, tz: string, bucket: Bucket): string { const p = localParts(iso, tz); const mo = String(p.mo).padStart(2, "0"); const d = String(p.d).padStart(2, "0"); const h = String(p.h).padStart(2, "0"); if (bucket === "month") return `${p.y}-${mo}`; if (bucket === "hour") return `${p.y}-${mo}-${d} ${h}`; return `${p.y}-${mo}-${d}`; } interface PaymentPayload { amountMinor?: number; currency?: string; tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean; } function median(sorted: number[]): number { if (sorted.length === 0) return 0; const mid = Math.floor(sorted.length / 2); const hi = sorted[mid] ?? 0; if (sorted.length % 2) return hi; const lo = sorted[mid - 1] ?? 0; return Math.round((lo + hi) / 2); } /** * Build the admin report summary for [from, to) at the chosen grain. Entry/exit counts * and money are summed from the signed ledger; duration stats from the closed sessions * in range; subscription counts from the subscriptions table as of `to`. */ export function reportSummary(db: Db, q: ReportQuery): ReportSummary { const tz = siteTz(db); // --- Ledger: entry/exit/payment in range, oldest-first so the series builds in order. const rows = db .select() .from(ledgerEvents) .where(and(gte(ledgerEvents.occurredAt, q.from), lte(ledgerEvents.occurredAt, q.to))) .orderBy(asc(ledgerEvents.index)) .all(); // Currency for display: money everywhere is { minorUnits, currency }; payments carry // the currency they were taken in, so take it from a payment in range (then fall back // to the active tariff version). Reports never mix currencies (single-currency site). let currency: string | null = null; const seriesMap = new Map(); const entriesByHour = new Array(24).fill(0); const totals = { entries: 0, exits: 0, payments: 0, revenueMinor: 0, cashMinor: 0, cardMinor: 0, ticketMinor: 0, subscriptionSalesMinor: 0, subscriptionWindowMinor: 0, }; function point(label: string): SeriesPoint { let p = seriesMap.get(label); if (!p) { p = { bucket: label, entries: 0, exits: 0, revenueMinor: 0, payments: 0 }; seriesMap.set(label, p); } return p; } // Pre-pass: identities cancelled by a `void` in range. A voided entry was a wrongly- // printed ticket (no car entered), so it must NOT inflate the "entries" stat. (The void's // entry is normally in the same window; this skips it when both are in range.) const voided = new Set(); for (const row of rows) if (row.type === "void" && row.identity) voided.add(row.identity); for (const row of rows) { const label = bucketLabel(row.occurredAt, tz, q.bucket); const p = point(label) as { -readonly [K in keyof SeriesPoint]: SeriesPoint[K] }; if (row.type === "vehicle_entry") { if (row.identity && voided.has(row.identity)) continue; // cancelled — not a real entry totals.entries++; p.entries++; const h = localParts(row.occurredAt, tz).h; entriesByHour[h] = (entriesByHour[h] ?? 0) + 1; } else if (row.type === "vehicle_exit") { totals.exits++; p.exits++; } else if (row.type === "payment") { const pl = (row.payload ?? {}) as PaymentPayload; const amt = typeof pl.amountMinor === "number" ? pl.amountMinor : 0; if (!currency && typeof pl.currency === "string") currency = pl.currency; totals.payments++; totals.revenueMinor += amt; p.payments++; p.revenueMinor += amt; if (pl.tender === "card") totals.cardMinor += amt; else totals.cashMinor += amt; // Revenue split mirrors the shift Z-report: subscription sale / window charge / // (the rest is) transient ticket revenue. if (pl.subscriptionSale === true) totals.subscriptionSalesMinor += amt; else if (pl.subscriptionWindowCharge === true) totals.subscriptionWindowMinor += amt; else totals.ticketMinor += amt; } } const series = [...seriesMap.values()].sort((a, b) => a.bucket.localeCompare(b.bucket)); // No payment in range? Fall back to the site tariff's latest version currency, so a // zero-revenue range still labels its money column. if (!currency) { const tariff = db.select().from(tariffs).where(eq(tariffs.scope, "site")).get(); if (tariff) { const tv = db .select() .from(tariffVersions) .where(eq(tariffVersions.tariffId, tariff.id)) .orderBy(desc(tariffVersions.effectiveFrom)) .get(); currency = tv?.currency ?? null; } } // --- Duration: closed transient sessions whose EXIT fell in range (the cache; flagged). const closed = db .select() .from(sessions) .where(and(gte(sessions.exitedAt, q.from), lte(sessions.exitedAt, q.to))) .all(); const durations: number[] = []; for (const s of closed) { if (!s.enteredAt || !s.exitedAt) continue; const mins = Math.max(0, Math.round((Date.parse(s.exitedAt) - Date.parse(s.enteredAt)) / 60000)); durations.push(mins); } durations.sort((a, b) => a - b); const totalParkedMinutes = durations.reduce((a, b) => a + b, 0); // --- Subscriptions: status counts + currently-valid (window covers `to`). const subs = db.select().from(subscriptions).all(); const subStats = { active: 0, suspended: 0, revoked: 0, currentlyValid: 0, coveredCars: 0 }; for (const s of subs) { if (s.status === "active") subStats.active++; else if (s.status === "suspended") subStats.suspended++; else if (s.status === "revoked") subStats.revoked++; const validNow = s.status === "active" && (!s.validFrom || s.validFrom <= q.to) && (!s.validTo || s.validTo >= q.to); if (validNow) { subStats.currentlyValid++; subStats.coveredCars += s.quantity ?? 1; } } return { from: q.from, to: q.to, bucket: q.bucket, tz, currency, totals: { ...totals, closedSessions: durations.length, totalParkedMinutes, avgParkedMinutes: durations.length ? Math.round(totalParkedMinutes / durations.length) : 0, medianParkedMinutes: median(durations), }, series, entriesByHour, subscriptions: subStats, }; }