import { beforeEach, describe, expect, it } from "vitest"; import { sessions, siteConfig, subscriptions, type Db } from "@parking/db"; import { createTestDb } from "@parking/db/testing"; import { randomUUID } from "node:crypto"; import { makeLog } from "./test-helpers.js"; import { reportSummary } from "./reports.js"; import type { EventLog } from "./event-log.js"; // Reports aggregation — LEDGER-FIRST. These pin that the numbers an admin sees are // summed straight from the signed ledger (entry/exit counts + payment money, split the // same way the shift Z-report splits it), bucketed in the SITE TIMEZONE, with duration // stats from the closed-sessions cache and subscription counts as of the range end. let db: Db; let log: EventLog; beforeEach(() => { ({ db } = createTestDb()); log = makeLog(db); // Fix the site timezone so bucket labels are deterministic regardless of the test host. db.insert(siteConfig).values({ id: 1, timezone: "Europe/Tirane" }).run(); }); /** ISO at a UTC instant, for deterministic bucket assertions. */ function at(iso: string): string { return new Date(iso).toISOString(); } async function entry(occurredAt: string): Promise { await log.append({ type: "vehicle_entry", direction: "entry", identity: randomUUID(), occurredAt }); } async function exit(occurredAt: string): Promise { await log.append({ type: "vehicle_exit", direction: "exit", identity: randomUUID(), occurredAt }); } async function payment( occurredAt: string, amountMinor: number, opts: { tender?: "cash" | "card"; subscriptionSale?: boolean; subscriptionWindowCharge?: boolean } = {}, ): Promise { await log.append({ type: "payment", occurredAt, payload: { amountMinor, currency: "ALL", tender: opts.tender ?? "cash", ...(opts.subscriptionSale ? { subscriptionSale: true } : {}), ...(opts.subscriptionWindowCharge ? { subscriptionWindowCharge: true } : {}), }, }); } const RANGE = { from: at("2026-06-01T00:00:00Z"), to: at("2026-06-30T23:59:59Z") }; describe("reportSummary — ledger-first totals", () => { it("counts entries and exits from the signed ledger", async () => { await entry(at("2026-06-10T08:00:00Z")); await entry(at("2026-06-10T09:00:00Z")); await exit(at("2026-06-10T18:00:00Z")); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.totals.entries).toBe(2); expect(r.totals.exits).toBe(1); }); it("excludes events outside [from, to)", async () => { await entry(at("2026-05-31T23:00:00Z")); // before await entry(at("2026-06-15T10:00:00Z")); // inside const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.totals.entries).toBe(1); }); it("sums payment money and splits cash vs card", async () => { await payment(at("2026-06-12T10:00:00Z"), 20000, { tender: "cash" }); await payment(at("2026-06-12T11:00:00Z"), 5000, { tender: "card" }); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.totals.payments).toBe(2); expect(r.totals.revenueMinor).toBe(25000); expect(r.totals.cashMinor).toBe(20000); expect(r.totals.cardMinor).toBe(5000); }); it("splits revenue into ticket / subscription-sale / out-of-window, mirroring the Z-report", async () => { await payment(at("2026-06-12T10:00:00Z"), 10000); // transient ticket await payment(at("2026-06-12T10:05:00Z"), 30000, { subscriptionSale: true }); await payment(at("2026-06-12T10:06:00Z"), 1500, { subscriptionWindowCharge: true }); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.totals.ticketMinor).toBe(10000); expect(r.totals.subscriptionSalesMinor).toBe(30000); expect(r.totals.subscriptionWindowMinor).toBe(1500); // The three add up to the gross revenue. expect(r.totals.revenueMinor).toBe(41500); }); it("picks up the currency from a payment in range", async () => { await payment(at("2026-06-12T10:00:00Z"), 10000); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.currency).toBe("ALL"); }); }); describe("reportSummary — time bucketing (site timezone)", () => { it("buckets by local day; a 23:30 UTC event lands on the NEXT local day in Tirane (UTC+2/3)", async () => { // 2026-06-15T23:30Z is 2026-06-16 01:30 local (summer, UTC+2) → the 16th bucket. await entry(at("2026-06-15T23:30:00Z")); const r = reportSummary(db, { ...RANGE, bucket: "day" }); const point = r.series.find((p) => p.entries > 0); expect(point?.bucket).toBe("2026-06-16"); }); it("series points are sorted and carry per-bucket entries/exits/revenue", async () => { await entry(at("2026-06-10T08:00:00Z")); await payment(at("2026-06-10T09:00:00Z"), 7000); await entry(at("2026-06-12T08:00:00Z")); const r = reportSummary(db, { ...RANGE, bucket: "day" }); const labels = r.series.map((p) => p.bucket); expect(labels).toEqual([...labels].sort()); const d10 = r.series.find((p) => p.bucket === "2026-06-10"); expect(d10?.entries).toBe(1); expect(d10?.revenueMinor).toBe(7000); }); it("entriesByHour is a 24-slot local-hour histogram", async () => { // 06:00Z = 08:00 local (summer) → hour slot 8. await entry(at("2026-06-10T06:00:00Z")); await entry(at("2026-06-11T06:00:00Z")); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.entriesByHour).toHaveLength(24); expect(r.entriesByHour[8]).toBe(2); expect(r.entriesByHour.reduce((a, b) => a + b, 0)).toBe(2); }); }); describe("reportSummary — duration (sessions cache) + subscriptions", () => { it("computes parked-minute stats from closed sessions whose exit fell in range", async () => { // 60-min and 120-min stays → avg 90, median 90. db.insert(sessions).values({ id: "s1", identity: "t1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed", }).run(); db.insert(sessions).values({ id: "s2", identity: "t2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T10:00:00Z"), state: "closed", }).run(); // An OPEN session (no exit) must not count. db.insert(sessions).values({ id: "s3", identity: "t3", enteredAt: at("2026-06-10T08:00:00Z"), state: "open" }).run(); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.totals.closedSessions).toBe(2); expect(r.totals.totalParkedMinutes).toBe(180); expect(r.totals.avgParkedMinutes).toBe(90); expect(r.totals.medianParkedMinutes).toBe(90); }); it("counts subscriptions by status and currently-valid coverage as of `to`", async () => { const base = { holderName: "x", period: "month" as const, createdAt: at("2026-06-01T00:00:00Z") }; // active + valid window covering `to`, quantity 2. db.insert(subscriptions).values({ id: "a", status: "active", quantity: 2, validFrom: at("2026-06-01T00:00:00Z"), validTo: at("2026-07-01T00:00:00Z"), ...base, }).run(); // active but EXPIRED before `to` → not currently valid. db.insert(subscriptions).values({ id: "b", status: "active", quantity: 1, validFrom: at("2026-05-01T00:00:00Z"), validTo: at("2026-06-05T00:00:00Z"), ...base, }).run(); // suspended. db.insert(subscriptions).values({ id: "c", status: "suspended", quantity: 1, ...base }).run(); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.subscriptions.active).toBe(2); expect(r.subscriptions.suspended).toBe(1); expect(r.subscriptions.revoked).toBe(0); expect(r.subscriptions.currentlyValid).toBe(1); expect(r.subscriptions.coveredCars).toBe(2); }); }); describe("reportSummary — occupancy, heatmap, stay histogram, look-closer counters (2026-07-05)", () => { it("folds prior ledger into occupancyStart and walks occupancyEnd through the series", async () => { // Before the range: 3 entries, 1 exit → 2 cars inside when June opens. await entry(at("2026-05-20T08:00:00Z")); await entry(at("2026-05-20T09:00:00Z")); await entry(at("2026-05-21T10:00:00Z")); await exit(at("2026-05-21T12:00:00Z")); // In range: +2 on the 10th, −1 on the 11th. await entry(at("2026-06-10T08:00:00Z")); await entry(at("2026-06-10T09:00:00Z")); await exit(at("2026-06-11T09:00:00Z")); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.occupancyStart).toBe(2); expect(r.series.map((p) => [p.bucket, p.occupancyEnd])).toEqual([ ["2026-06-10", 4], ["2026-06-11", 3], ]); }); it("a voided pre-range entry does not inflate occupancyStart", async () => { const id = randomUUID(); await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-05-20T08:00:00Z") }); await log.append({ type: "void", identity: id, occurredAt: at("2026-05-20T08:05:00Z"), payload: { reason: "misprint" } }); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.occupancyStart).toBe(0); }); it("entriesByDowHour lands on the local weekday/hour (row 0 = Monday)", async () => { // 2026-06-10 is a WEDNESDAY; 08:00Z = 10:00 in Tirane (UTC+2 in June). await entry(at("2026-06-10T08:00:00Z")); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.entriesByDowHour[2]![10]).toBe(1); // Wed row, 10h column expect(r.entriesByDowHour.flat().reduce((a, b) => a + b, 0)).toBe(1); }); it("stay histogram buckets closed sessions; series carries the cash/card split", async () => { db.insert(sessions).values({ id: "h1", identity: "h1", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T08:20:00Z"), state: "closed" }).run(); // 20m → ≤30 db.insert(sessions).values({ id: "h2", identity: "h2", enteredAt: at("2026-06-10T08:00:00Z"), exitedAt: at("2026-06-10T09:30:00Z"), state: "closed" }).run(); // 90m → ≤120 db.insert(sessions).values({ id: "h3", identity: "h3", enteredAt: at("2026-06-08T08:00:00Z"), exitedAt: at("2026-06-10T09:00:00Z"), state: "closed" }).run(); // 2 days → >24h tail await payment(at("2026-06-10T09:00:00Z"), 500, { tender: "cash" }); await payment(at("2026-06-10T09:30:00Z"), 700, { tender: "card" }); const r = reportSummary(db, { ...RANGE, bucket: "day" }); const counts = Object.fromEntries(r.stayHistogram.map((b) => [String(b.uptoMin), b.count])); expect(counts["30"]).toBe(1); expect(counts["120"]).toBe(1); expect(counts["null"]).toBe(1); const day = r.series.find((p) => p.bucket === "2026-06-10")!; expect(day.cashMinor).toBe(500); expect(day.cardMinor).toBe(700); }); it("counts voids and anomalies in range (the look-closer counters)", async () => { const id = randomUUID(); await log.append({ type: "vehicle_entry", direction: "entry", identity: id, occurredAt: at("2026-06-10T08:00:00Z") }); await log.append({ type: "void", identity: id, occurredAt: at("2026-06-10T08:05:00Z"), payload: { reason: "misprint" } }); await log.append({ type: "anomaly", identity: "X", occurredAt: at("2026-06-10T09:00:00Z"), payload: { reason: "test" } }); const r = reportSummary(db, { ...RANGE, bucket: "day" }); expect(r.totals.voids).toBe(1); expect(r.totals.anomalies).toBe(1); expect(r.totals.entries).toBe(0); // the voided entry stays excluded }); });