diff --git a/api/functions/.gitignore b/api/functions/.gitignore index c7fd93c..2e00864 100644 --- a/api/functions/.gitignore +++ b/api/functions/.gitignore @@ -11,4 +11,6 @@ node_modules/ secrets/ .env -logs/ \ No newline at end of file +logs/ + +src/migrations/ \ No newline at end of file diff --git a/api/functions/src/features/chatbot/chatbot.service.ts b/api/functions/src/features/chatbot/chatbot.service.ts index 9c5ccc5..c3c413a 100644 --- a/api/functions/src/features/chatbot/chatbot.service.ts +++ b/api/functions/src/features/chatbot/chatbot.service.ts @@ -10,7 +10,8 @@ const MAX_TOOL_CALL_ROUNDS = 4; const SYSTEM_PROMPT = ` You are the Utilitool billing insight assistant. You help the user understand their own utility usage, accumulation, and billing analytics, using only the data returned by your -tool functions (get_usage_history, get_accumulated_totals, get_billing_cost, detect_spikes). +tool functions (get_usage_history, get_accumulated_totals, get_billing_cost, detect_spikes, +get_billing_reports). @@ -29,9 +30,13 @@ plainly that no data was recorded for that period — do not report a bare "0" a a confirmed reading, since an untracked period and a genuine zero-usage period are not the same thing. -All tools accept propertyNames as an array — pass every name the user mentioned in one call -rather than calling the tool once per name. Use meterGroupName when the user refers to a -meter group rather than (or in addition to) specific properties. +All per-property tools accept propertyNames as an array — pass every name the user +mentioned in one call rather than calling the tool once per name. Use meterGroupName when +the user refers to a meter group rather than (or in addition to) specific properties. + +For portfolio-wide questions — total revenue, collection rate, month-over-month trends, +paid/pending/overdue breakdowns — call get_billing_reports instead of summing per-property +tool calls yourself. diff --git a/api/functions/src/features/chatbot/chatbot.tools.ts b/api/functions/src/features/chatbot/chatbot.tools.ts index 6b540f0..64d2efa 100644 --- a/api/functions/src/features/chatbot/chatbot.tools.ts +++ b/api/functions/src/features/chatbot/chatbot.tools.ts @@ -7,10 +7,12 @@ import {billingRepository} from "../billing/billing.repository"; import {billingCycleRepository} from "../billing-cycle/billing-cycle.repository"; import {calculateTrueReading, resolveVersionsSource} from "../reading/reading.util"; import {billAmount, sumMoney} from "../../utils/money.util"; +import * as reportsService from "../reports/reports.service"; import type {LlmToolDefinition} from "../../lib/llm.lib"; import type {Reading} from "../reading/reading.model"; import type {Property} from "../property/property.model"; import type {BillingCycle} from "../billing-cycle/billing-cycle.model"; +import type {ReportQueryDTO} from "../reports/reports.dto"; const DEFAULT_SPIKE_MULTIPLIER = 5; // same threshold as the reading anomaly guard const MAX_PROPERTIES_SCANNED = 1000; @@ -398,6 +400,67 @@ export async function getBillingCost(args: GetBillingCostArgs) { }; } +export interface GetBillingReportsArgs { + reportType: "summary" | "consumption" | "billing_trends" | "collection_status"; + startDate?: string; + endDate?: string; + meterGroupName?: string; + propertyName?: string; +} + +async function buildReportQuery(args: GetBillingReportsArgs): Promise { + const query: ReportQueryDTO = {}; + if (args.startDate) query.startDate = startOfDayUtc(args.startDate).toISOString(); + if (args.endDate) query.endDate = endOfDayUtc(args.endDate).toISOString(); + if (args.meterGroupName) { + const meterGroup = await resolveMeterGroupByName(args.meterGroupName); + query.meterGroupId = meterGroup.id; + } + if (args.propertyName) { + const [property] = await resolvePropertiesByNames([args.propertyName]); + query.propertyId = property.id; + } + return query; +} + +/** + * Portfolio-level analytics (revenue, collection rate, consumption/billing trends by + * month, payment-status breakdown) — the same data backing the Reports page. `userId` + * is threaded through only to match reportsService's signature; every report is scoped + * by query filters, not by the caller, mirroring how the other chatbot tools already + * work against a single-owner dataset. Property identity is masked the same way as + * every other tool result (Property.room_name -> propertyName, tokenized by + * chatbot.service.ts before reaching the LLM) — raw Firestore property_id is stripped + * from consumption-by-property rows so nothing beyond meter numbers and the masked + * token ever leaves this function. + */ +export async function getBillingReports(args: GetBillingReportsArgs) { + const query = await buildReportQuery(args); + const userId = "chatbot"; // unused by reportsService — every report is scoped by query filters, not caller + + switch (args.reportType) { + case "summary": + return reportsService.getSummary(userId, query); + case "consumption": { + const report = await reportsService.getConsumption(userId, query); + return { + by_month: report.by_month, + by_property: report.by_property.map((p) => ({ + propertyName: p.room_name, + electricity: p.electricity, + water: p.water, + })), + }; + } + case "billing_trends": + return reportsService.getBillingTrends(userId, query); + case "collection_status": + return reportsService.getCollectionStatus(userId, query); + default: + throw new AppError(400, `Unknown reportType: ${args.reportType}`); + } +} + const propertyNamesParam = { type: "array", items: {type: "string"}, @@ -495,6 +558,32 @@ export const chatbotToolDefinitions: LlmToolDefinition[] = [ }, }, }, + { + type: "function", + function: { + name: "get_billing_reports", + description: + "Portfolio-level billing analytics: revenue/collection-rate summary, consumption trends by month or property, billing amounts by month (billed/collected/pending/overdue), or a payment-status breakdown. Optionally scope to a meter group and/or one property; omit both for the whole portfolio.", + parameters: { + type: "object", + properties: { + reportType: { + type: "string", + enum: ["summary", "consumption", "billing_trends", "collection_status"], + description: + "summary = revenue/collection totals. consumption = usage by month and by property. " + + "billing_trends = billed/collected/pending/overdue by month. collection_status = " + + "paid/pending/overdue counts and amounts.", + }, + propertyName: {type: "string", description: "Optional single property name to scope to."}, + meterGroupName: meterGroupNameParam, + startDate: {type: "string", format: "date", description: "Start of range, YYYY-MM-DD"}, + endDate: {type: "string", format: "date", description: "End of range, YYYY-MM-DD"}, + }, + required: ["reportType"], + }, + }, + }, ]; export const chatbotToolHandlers: Record Promise> = { @@ -502,4 +591,5 @@ export const chatbotToolHandlers: Record Promise get_accumulated_totals: getAccumulatedTotals, get_billing_cost: getBillingCost, detect_spikes: detectSpikes, + get_billing_reports: getBillingReports, }; diff --git a/api/functions/src/features/reading/reading.util.ts b/api/functions/src/features/reading/reading.util.ts index 8ff6516..b19ad01 100644 --- a/api/functions/src/features/reading/reading.util.ts +++ b/api/functions/src/features/reading/reading.util.ts @@ -80,22 +80,21 @@ export function getCumulativeOffset( /** * Resolve the version-history map that governs a reading's meter_version. - * Submeter entries on a property track their own versions independently; - * main meters defer to the meter group's version history. + * Property.meter_groups[entry].versions is the source of truth for both main + * meters and submeters (MeterGroup.versions is deprecated — see MeterGroup model). */ export function resolveVersionsSource( meterGroup: MeterGroup | null | undefined, property: Property | null | undefined, meterGroupId: string ): Record | undefined { - let versionsSource = meterGroup?.versions; if (property) { const entry = Object.values(property.meter_groups).find((e) => e?.meter_group_id === meterGroupId); - if (entry && !entry.is_main_meter) { - versionsSource = entry.versions; + if (entry?.versions !== undefined) { + return entry.versions; } } - return versionsSource; + return meterGroup?.versions; } /** @@ -168,9 +167,9 @@ export async function findCurrentMonthReading( /** * Resolve the meter_version that should be stamped on a new reading for the - * given property + meter group. Main-meter properties resolve from the meter - * group's scope (current_version is authoritative there). Submeter properties - * track their own version independently on their MeterGroupEntry. + * given property + meter group. Property.meter_groups[entry].current_version is + * the source of truth for both main meters and submeters (MeterGroup.current_version + * is deprecated — see MeterGroup model). */ export function resolveMeterVersion( property: Property | null | undefined, @@ -181,8 +180,8 @@ export function resolveMeterVersion( Object.values(property.meter_groups).find((e) => e.meter_group_id === meterGroupId) : undefined; - if (entry && !entry.is_main_meter) { - return entry.current_version ?? 1; + if (entry?.current_version !== undefined) { + return entry.current_version; } return meterGroup?.current_version ?? 1; diff --git a/api/functions/src/features/reports/reports.model.ts b/api/functions/src/features/reports/reports.model.ts index f2ab640..6d45fb8 100644 --- a/api/functions/src/features/reports/reports.model.ts +++ b/api/functions/src/features/reports/reports.model.ts @@ -54,6 +54,7 @@ export interface JoinedBilling { billingId: string; amount: number; consumption: number; + cycleStartDate: Date; cycleEndDate: Date; utilityType: string; propertyId: string; diff --git a/api/functions/src/features/reports/reports.service.ts b/api/functions/src/features/reports/reports.service.ts index 052d031..042746b 100644 --- a/api/functions/src/features/reports/reports.service.ts +++ b/api/functions/src/features/reports/reports.service.ts @@ -55,7 +55,10 @@ async function fetchReportContext(query: ReportQueryDTO) { let cycles = cyclesResult.data; // 2. Additional in-memory filter for endDate range to ensure both bounds are respected - if (query.startDate && query.endDate) { + // (runs whenever endDate is supplied, not only alongside startDate — an endDate-only + // query previously only constrained billing_start_date at query time, letting cycles + // that start before endDate but end well after it leak through). + if (query.endDate) { const endDate = new Date(query.endDate); cycles = cycles.filter((c) => c.billing_end_date.toDate() <= endDate); } @@ -185,6 +188,7 @@ async function buildJoinedData(userId: string, query: ReportQueryDTO): Promise { - const {cycles, validBillings, propertyMap, meterGroupMap} = await fetchReportContext(query); - - // Fetch all current readings to determine meter group for each billing - const readingIds = new Set(); - validBillings.forEach((b) => { - if (b.current_reading_id && b.current_reading_id.trim()) { - readingIds.add(b.current_reading_id); - } - }); - - const readings = await readingRepository.getByIds(Array.from(readingIds)); - - const readingMap = new Map(readings.filter((r): r is NonNullable => r !== null).map((r) => [r.id, r])); + // Reuses buildJoinedData so consumption here is computed the same version-aware way + // (calculateTrueReading against Property.meter_groups[entry].versions) as every other + // report — previously this endpoint read the raw, non-version-aware number frozen on + // cycle.billing_ids at cycle-creation time, so a cycle whose meter reset after creation + // (or was backfilled with the old naive number) showed different consumption here than + // on the Summary/Billing-Trends/Collection-Status reports for the same underlying data. + const joinedData = await buildJoinedData(userId, query); - // Group by month const monthMap = new Map>(); - const billingById = new Map(validBillings.map((b) => [b.id, b])); - - for (const cycle of cycles) { - const month = cycle.billing_start_date.toDate().toISOString().slice(0, 7); - - for (const [billingId, consumption] of Object.entries(cycle.billing_ids)) { - const billing = billingById.get(billingId); - if (!billing) continue; - - const property = propertyMap.get(billing.property_id); - if (!property) continue; - - // Get utility type from the reading's meter group (accurate source) - let utilityType = "unknown"; - const reading = readingMap.get(billing.current_reading_id); - if (reading) { - const readingMeterGroup = meterGroupMap.get(reading.meter_group_id); - if (readingMeterGroup) { - utilityType = readingMeterGroup.utility_type; - } - } - - // Apply meter group filter if specified - if (query.meterGroupId && reading?.meter_group_id !== query.meterGroupId) { - continue; - } - - if (!monthMap.has(month)) { - monthMap.set(month, {electricity: 0, water: 0}); - } - - const monthData = monthMap.get(month)!; - if (utilityType === "electricity") { - monthData.electricity += consumption; - } else if (utilityType === "water") { - monthData.water += consumption; - } + for (const j of joinedData) { + const month = j.cycleStartDate.toISOString().slice(0, 7); + if (!monthMap.has(month)) { + monthMap.set(month, {electricity: 0, water: 0}); + } + const monthData = monthMap.get(month)!; + if (j.utilityType === "electricity") { + monthData.electricity += j.consumption; + } else if (j.utilityType === "water") { + monthData.water += j.consumption; } } @@ -288,54 +258,29 @@ export async function getConsumption(userId: string, query: ReportQueryDTO): Pro })) .sort((a, b) => a.period.localeCompare(b.period)); - // Group by property const propertyConsumption = new Map>(); - - for (const cycle of cycles) { - for (const [billingId, consumption] of Object.entries(cycle.billing_ids)) { - const billing = billingById.get(billingId); - if (!billing) continue; - - const property = propertyMap.get(billing.property_id); - if (!property) continue; - - if (query.propertyId && billing.property_id !== query.propertyId) continue; - - // Get utility type from the reading's meter group (accurate source) - let utilityType = "unknown"; - const reading = readingMap.get(billing.current_reading_id); - if (reading) { - const readingMeterGroup = meterGroupMap.get(reading.meter_group_id); - if (readingMeterGroup) { - utilityType = readingMeterGroup.utility_type; - } - } - - // Apply meter group filter if specified - if (query.meterGroupId && reading?.meter_group_id !== query.meterGroupId) { - continue; - } - - if (!propertyConsumption.has(billing.property_id)) { - propertyConsumption.set(billing.property_id, {electricity: 0, water: 0}); - } - - const propData = propertyConsumption.get(billing.property_id)!; - if (utilityType === "electricity") { - propData.electricity += consumption; - } else if (utilityType === "water") { - propData.water += consumption; - } + for (const j of joinedData) { + if (!propertyConsumption.has(j.propertyId)) { + propertyConsumption.set(j.propertyId, {electricity: 0, water: 0}); + } + const propData = propertyConsumption.get(j.propertyId)!; + if (j.utilityType === "electricity") { + propData.electricity += j.consumption; + } else if (j.utilityType === "water") { + propData.water += j.consumption; } } const by_property = Array.from(propertyConsumption.entries()) - .map(([propId, data]) => ({ - property_id: propId, - room_name: propertyMap.get(propId)?.room_name || "Unknown", - electricity: Math.round(data.electricity * 100) / 100, - water: Math.round(data.water * 100) / 100, - })) + .map(([propId, data]) => { + const j = joinedData.find((jb) => jb.propertyId === propId); + return { + property_id: propId, + room_name: j?.roomName || "Unknown", + electricity: Math.round(data.electricity * 100) / 100, + water: Math.round(data.water * 100) / 100, + }; + }) .sort((a, b) => a.room_name.localeCompare(b.room_name)); return {by_month, by_property}; @@ -348,7 +293,11 @@ export async function getBillingTrends(userId: string, query: ReportQueryDTO): P const monthMap = new Map(); for (const billing of joinedData) { - const month = billing.cycleEndDate.toISOString().slice(0, 7); + // Bucketed by cycle start date to match getConsumption's grouping (both use + // billing_start_date) — bucketing by end date instead put the same cycle in a + // different month on this chart vs. the Consumption chart whenever a cycle spans + // a month boundary. + const month = billing.cycleStartDate.toISOString().slice(0, 7); if (!monthMap.has(month)) { monthMap.set(month, {billed: 0, collected: 0, pending: 0, overdue: 0}); diff --git a/api/functions/src/migrations/backfill-property-meter-versions.test.ts b/api/functions/src/migrations/backfill-property-meter-versions.test.ts deleted file mode 100644 index 3ca4787..0000000 --- a/api/functions/src/migrations/backfill-property-meter-versions.test.ts +++ /dev/null @@ -1,164 +0,0 @@ -process.env.GOOGLE_APPLICATION_CREDENTIALS = "secrets/fake-creds.json"; - -const mockUpdate = jest.fn(); -const mockCommit = jest.fn().mockResolvedValue(undefined); -const mockBatch = jest.fn(() => ({update: mockUpdate, commit: mockCommit})); -const mockGet = jest.fn(); -const mockMeterGroupDocGet = jest.fn(); -const mockMeterGroupDoc = jest.fn(() => ({get: mockMeterGroupDocGet})); - -const mockCollection = jest.fn((name: string) => { - if (name === "meter_groups") { - return {doc: mockMeterGroupDoc}; - } - return {get: mockGet}; -}); - -jest.mock("firebase-admin/app", () => ({ - initializeApp: jest.fn(), - cert: jest.fn(), -})); - -jest.mock("firebase-admin/firestore", () => ({ - getFirestore: jest.fn(() => ({ - collection: mockCollection, - batch: mockBatch, - })), -})); - -function docSnapshot(id: string, meterGroups: Record | undefined) { - return { - ref: {id}, - data: () => ({meter_groups: meterGroups}), - }; -} - -function meterGroupDocSnapshot(exists: boolean, data?: Record) { - return {exists, data: () => data}; -} - -describe("backfillPropertyMeterVersions", () => { - beforeEach(() => { - jest.clearAllMocks(); - jest.resetModules(); - delete process.env.EXECUTE; - mockMeterGroupDocGet.mockResolvedValue(meterGroupDocSnapshot(false)); - }); - - it("counts submeter AND main-meter entries needing backfill and writes nothing in dry-run mode", async () => { - mockGet.mockResolvedValue({ - docs: [ - docSnapshot("p1", { - electricity: {meter_group_id: "mg1", is_main_meter: false}, - water: {meter_group_id: "mg2", is_main_meter: true}, - }), - docSnapshot("p2", { - electricity: {meter_group_id: "mg1", is_main_meter: false, current_version: 1, versions: {}}, - }), - docSnapshot("p3", undefined), - ], - }); - mockMeterGroupDocGet.mockResolvedValue(meterGroupDocSnapshot(true, {current_version: 2, versions: {"1": {reset_at: "ts", last_reading: 100}}})); - - const {backfillPropertyMeterVersions} = require("./backfill-property-meter-versions"); - const result = await backfillPropertyMeterVersions(); - - expect(result).toEqual({migrated: 1, skipped: 2}); - expect(mockUpdate).not.toHaveBeenCalled(); - expect(mockCommit).not.toHaveBeenCalled(); - }); - - it("stamps fresh version tracking on submeter entries lacking it when EXECUTE=true", async () => { - process.env.EXECUTE = "true"; - mockGet.mockResolvedValue({ - docs: [ - docSnapshot("p1", { - electricity: {meter_group_id: "mg1", is_main_meter: false}, - water: {meter_group_id: "mg2", is_main_meter: true, current_version: 3, versions: {}}, - }), - ], - }); - - const {backfillPropertyMeterVersions} = require("./backfill-property-meter-versions"); - const result = await backfillPropertyMeterVersions(); - - expect(result).toEqual({migrated: 1, skipped: 0}); - expect(mockUpdate).toHaveBeenCalledTimes(1); - const [, updatePayload] = mockUpdate.mock.calls[0]; - expect(updatePayload.meter_groups.electricity).toEqual({ - meter_group_id: "mg1", - is_main_meter: false, - current_version: 1, - versions: {}, - }); - expect(updatePayload.meter_groups.water).toEqual({meter_group_id: "mg2", is_main_meter: true, current_version: 3, versions: {}}); - expect(mockCommit).toHaveBeenCalledTimes(1); - }); - - it("backfills main-meter entries by copying current_version/versions from their MeterGroup document when EXECUTE=true", async () => { - process.env.EXECUTE = "true"; - mockGet.mockResolvedValue({ - docs: [ - docSnapshot("p1", { - water: {meter_group_id: "mg2", is_main_meter: true}, - }), - ], - }); - mockMeterGroupDocGet.mockResolvedValue( - meterGroupDocSnapshot(true, {current_version: 2, versions: {"1": {reset_at: "ts", last_reading: 634}}}) - ); - - const {backfillPropertyMeterVersions} = require("./backfill-property-meter-versions"); - const result = await backfillPropertyMeterVersions(); - - expect(result).toEqual({migrated: 1, skipped: 0}); - expect(mockUpdate).toHaveBeenCalledTimes(1); - const [, updatePayload] = mockUpdate.mock.calls[0]; - expect(updatePayload.meter_groups.water).toEqual({ - meter_group_id: "mg2", - is_main_meter: true, - current_version: 2, - versions: {"1": {reset_at: "ts", last_reading: 634}}, - }); - }); - - it("falls back to current_version 1 and empty versions for main-meter entries whose MeterGroup is missing", async () => { - process.env.EXECUTE = "true"; - mockGet.mockResolvedValue({ - docs: [ - docSnapshot("p1", { - water: {meter_group_id: "missing-mg", is_main_meter: true}, - }), - ], - }); - mockMeterGroupDocGet.mockResolvedValue(meterGroupDocSnapshot(false)); - - const {backfillPropertyMeterVersions} = require("./backfill-property-meter-versions"); - const result = await backfillPropertyMeterVersions(); - - expect(result).toEqual({migrated: 1, skipped: 0}); - const [, updatePayload] = mockUpdate.mock.calls[0]; - expect(updatePayload.meter_groups.water).toEqual({ - meter_group_id: "missing-mg", - is_main_meter: true, - current_version: 1, - versions: {}, - }); - }); - - it("skips properties whose entries already have current_version set", async () => { - mockGet.mockResolvedValue({ - docs: [ - docSnapshot("p1", { - water: {meter_group_id: "mg2", is_main_meter: true, current_version: 1, versions: {}}, - }), - ], - }); - - const {backfillPropertyMeterVersions} = require("./backfill-property-meter-versions"); - const result = await backfillPropertyMeterVersions(); - - expect(result).toEqual({migrated: 0, skipped: 1}); - expect(mockUpdate).not.toHaveBeenCalled(); - }); -}); diff --git a/api/functions/src/migrations/backfill-property-meter-versions.ts b/api/functions/src/migrations/backfill-property-meter-versions.ts deleted file mode 100644 index d894869..0000000 --- a/api/functions/src/migrations/backfill-property-meter-versions.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * One-time migration: moves ALL per-meter version tracking onto Property.meter_groups, - * completing the transition away from MeterGroup-level version fields. - * - * - Submeter entries (is_main_meter === false) with no current_version: stamped fresh, - * { current_version: 1, versions: {} } (they never had meter-group-level history to inherit). - * - Main-meter entries (is_main_meter === true) with no current_version: backfilled by - * COPYING current_version/versions from their referenced MeterGroup document, so the - * property entry becomes the new source of truth and MeterGroup.current_version/versions - * can eventually be removed without data loss. - * - * Usage (run from api/functions): - * # 1) DRY RUN (default) — counts only, writes nothing: - * APP_ENV=staging npx tsx src/migrations/backfill-property-meter-versions.ts - * - * # 2) EXECUTE the backfill: - * APP_ENV=staging EXECUTE=true npx tsx src/migrations/backfill-property-meter-versions.ts - * - * Safe to re-run — skips entries that already have current_version set. - */ -import {initializeApp, cert} from "firebase-admin/app"; -import {getFirestore} from "firebase-admin/firestore"; -import * as path from "path"; - -const credentialsPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; -if (!credentialsPath) { - throw new Error("GOOGLE_APPLICATION_CREDENTIALS env var is required"); -} - -initializeApp({ - credential: cert(path.resolve(credentialsPath)), -}); - -const db = getFirestore(); -const PROPERTIES_COLLECTION = "properties"; -const METER_GROUPS_COLLECTION = "meter_groups"; - -export async function backfillPropertyMeterVersions(): Promise<{migrated: number; skipped: number}> { - const EXECUTE = process.env.EXECUTE === "true"; - const snapshot = await db.collection(PROPERTIES_COLLECTION).get(); - let migrated = 0; - let skipped = 0; - - // Pre-fetch every meter group referenced by a main-meter entry so we can copy its - // current_version/versions onto the property entry (one read instead of N per property). - const referencedMeterGroupIds = new Set(); - for (const doc of snapshot.docs) { - const meterGroups = doc.data().meter_groups as Record | undefined; - if (!meterGroups) continue; - for (const entry of Object.values(meterGroups)) { - if (entry && typeof entry === "object" && entry.is_main_meter === true && entry.current_version === undefined) { - referencedMeterGroupIds.add(entry.meter_group_id); - } - } - } - - const meterGroupMap = new Map}>(); - await Promise.all( - Array.from(referencedMeterGroupIds).map(async (id) => { - const mgDoc = await db.collection(METER_GROUPS_COLLECTION).doc(id).get(); - if (mgDoc.exists) { - const mgData = mgDoc.data()!; - meterGroupMap.set(id, {current_version: mgData.current_version, versions: mgData.versions}); - } - }) - ); - - let batch = db.batch(); - let opsInBatch = 0; - - for (const doc of snapshot.docs) { - const data = doc.data(); - const meterGroups = data.meter_groups as Record | undefined; - - if (!meterGroups) { - skipped++; - continue; - } - - let needsMigration = false; - const updated: Record = {}; - - for (const [utilityType, entry] of Object.entries(meterGroups)) { - if (entry && typeof entry === "object" && entry.current_version === undefined) { - if (entry.is_main_meter === false) { - updated[utilityType] = {...entry, current_version: 1, versions: {}}; - needsMigration = true; - } else if (entry.is_main_meter === true) { - const source = meterGroupMap.get(entry.meter_group_id); - updated[utilityType] = { - ...entry, - current_version: source?.current_version ?? 1, - versions: source?.versions ?? {}, - }; - needsMigration = true; - } else { - updated[utilityType] = entry; - } - } else { - updated[utilityType] = entry; - } - } - - if (!needsMigration) { - skipped++; - continue; - } - - migrated++; - - if (EXECUTE) { - batch.update(doc.ref, {meter_groups: updated, updated_at: new Date()}); - opsInBatch++; - - if (opsInBatch >= 499) { - await batch.commit(); - batch = db.batch(); - opsInBatch = 0; - } - } - } - - if (EXECUTE && opsInBatch > 0) { - await batch.commit(); - } - - return {migrated, skipped}; -} - -if (require.main === module) { - const execute = process.env.EXECUTE === "true"; - backfillPropertyMeterVersions() - .then(({migrated, skipped}) => { - console.log( - `${execute ? "Migration" : "Dry run"} complete. ${execute ? "Migrated" : "Would migrate"}: ${migrated}, Skipped: ${skipped}` - ); - process.exit(0); - }) - .catch((err) => { - console.error("Migration failed:", err); - process.exit(1); - }); -} diff --git a/api/functions/src/migrations/copy-staging-to-emulator.ts b/api/functions/src/migrations/copy-staging-to-emulator.ts deleted file mode 100644 index a0871d1..0000000 --- a/api/functions/src/migrations/copy-staging-to-emulator.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * One-way, READ-ONLY-on-staging data copy: utilitool-staging Firestore -> the local - * Firestore emulator (project utilitool-test). Lets shadow-replay.ts (and the E2E suite, - * via ui/e2e/global-setup.ts SEED_FROM_STAGING=true) exercise real-shaped data without - * ever writing to staging or touching production. - * - * IMPORTANT — do NOT set FIRESTORE_EMULATOR_HOST in the shell for this script. The Admin - * SDK checks that env var process-wide, not per-app: setting it redirects BOTH the - * "staging" client (meant to read the real project via a real credential) and the - * emulator client to the emulator, silently turning the "staging" reads into queries - * against the emulator's own (usually empty) utilitool-test project — which is exactly - * what produced "copied 0 docs" plus the emulator's "Requested project ID utilitool-staging, - * but the emulator is configured for utilitool-test" warning the first time this ran. - * Instead, this script reads the emulator target from EMULATOR_HOST and applies it via an - * explicit `.settings()` call on ONLY the emulator Firestore instance, leaving the staging - * instance's real-project connection untouched. - * - * Safety: refuses to run unless EMULATOR_HOST is set, and the destination project id is - * hardcoded to "utilitool-test" (not configurable) — this script cannot be pointed at a - * real project as the destination even by mistake. - * - * Usage (run from api/functions, with the Firestore emulator already running — - * e.g. `npm run test:emulator` in another terminal): - * # 1) DRY RUN (default) — counts only, writes nothing: - * EMULATOR_HOST=localhost:8080 npx tsx src/migrations/copy-staging-to-emulator.ts - * - * # 2) EXECUTE the copy into the emulator: - * EMULATOR_HOST=localhost:8080 EXECUTE=true npx tsx src/migrations/copy-staging-to-emulator.ts - * - * Credential key path (override if your filename differs): - * STAGING_CREDENTIALS=secrets/utilitool-staging-firebase-adminsdk-fbsvc-50221e4bd0.json - */ -import {initializeApp, cert, type ServiceAccount} from "firebase-admin/app"; -import {getFirestore} from "firebase-admin/firestore"; -import * as fs from "node:fs"; -import * as path from "node:path"; - -if (process.env.FIRESTORE_EMULATOR_HOST) { - throw new Error( - "FIRESTORE_EMULATOR_HOST is set — refusing to run. That env var redirects BOTH Firestore " + - "clients in this process to the emulator (including the one meant to read real staging " + - "data with a real credential). Unset it and pass EMULATOR_HOST=localhost:8080 instead." - ); -} - -if (!process.env.EMULATOR_HOST) { - throw new Error( - "EMULATOR_HOST is not set. Refusing to run: this script's destination is hardcoded to " + - "the local emulator, and without this env var there's nothing to point the destination " + - "client at. Example: EMULATOR_HOST=localhost:8080" - ); -} - -const STAGING_KEY = process.env.STAGING_CREDENTIALS || - "secrets/utilitool-staging-firebase-adminsdk-fbsvc-50221e4bd0.json"; -const EMULATOR_PROJECT_ID = "utilitool-test"; // matches secrets/.env.test and ui/e2e/global-setup.ts - -const EXECUTE = process.env.EXECUTE === "true"; - -// Business collections to copy. `users` is intentionally excluded — Firebase Auth UIDs -// don't transfer between projects (same rationale as copy-staging-to-prod.ts). -const COLLECTIONS = [ - "meter_groups", - "properties", - "tenants", - "readings", - "billings", - "billing_cycles", - "audits", -]; - -function loadKey(p: string): ServiceAccount { - // eslint-disable-next-line no-irregular-whitespace -- strips a literal BOM character - const raw = fs.readFileSync(path.resolve(p), "utf8").replace(/^/, ""); - return JSON.parse(raw) as ServiceAccount; -} - -const stagingApp = initializeApp({credential: cert(loadKey(STAGING_KEY))}, "staging-readonly"); -const emulatorApp = initializeApp({projectId: EMULATOR_PROJECT_ID}, "emulator-dest"); - -const stagingDb = getFirestore(stagingApp); -const emulatorDb = getFirestore(emulatorApp); -// Explicit, app-scoped emulator routing — NOT via the process-wide FIRESTORE_EMULATOR_HOST -// env var, so the stagingDb connection above is unaffected. -emulatorDb.settings({host: process.env.EMULATOR_HOST, ssl: false}); - -const BATCH_LIMIT = 450; // Firestore hard limit is 500 ops per batch. - -async function copyCollection(name: string): Promise<{copied: number}> { - const snapshot = await stagingDb.collection(name).get(); // READ-ONLY against staging - - if (!EXECUTE) { - return {copied: snapshot.size}; // dry run: just report what WOULD be copied - } - - let batch = emulatorDb.batch(); - let opsInBatch = 0; - let copied = 0; - - for (const doc of snapshot.docs) { - batch.set(emulatorDb.collection(name).doc(doc.id), doc.data()); - opsInBatch++; - copied++; - - if (opsInBatch >= BATCH_LIMIT) { - await batch.commit(); - batch = emulatorDb.batch(); - opsInBatch = 0; - } - } - - if (opsInBatch > 0) { - await batch.commit(); - } - - return {copied}; -} - -async function main(): Promise { - console.log( - `${EXECUTE ? "EXECUTING" : "DRY RUN"} copy: utilitool-staging (read-only) -> local emulator (${EMULATOR_PROJECT_ID})\n` + - `Collections: ${COLLECTIONS.join(", ")} (users excluded)\n` - ); - - let total = 0; - for (const name of COLLECTIONS) { - const {copied} = await copyCollection(name); - total += copied; - console.log(` ${name.padEnd(16)} ${EXECUTE ? "copied" : "would copy"} ${copied} docs`); - } - - console.log(`\n${EXECUTE ? "Done" : "Dry run complete"}. Total: ${total} docs.`); - if (!EXECUTE) { - console.log("Re-run with EXECUTE=true to perform the copy into the emulator."); - } -} - -main().catch((err) => { - console.error("Copy failed:", err); - process.exit(1); -}); diff --git a/api/functions/src/migrations/copy-staging-to-prod.ts b/api/functions/src/migrations/copy-staging-to-prod.ts deleted file mode 100644 index ce0b35f..0000000 --- a/api/functions/src/migrations/copy-staging-to-prod.ts +++ /dev/null @@ -1,114 +0,0 @@ -/** - * One-way data copy: utilitool-staging Firestore -> utilitool-11d7c (production). - * - * Copies the business collections VERBATIM (including soft-deleted docs), preserving - * document IDs so all cross-references stay intact (billings -> reading ids, - * properties -> meter_group ids, etc.). Existing prod docs with the same ID are - * OVERWRITTEN (set). The `users` collection is intentionally NOT copied — Firebase Auth - * UIDs don't transfer between projects, so re-register in prod and the users/{uid} doc - * auto-creates on first GET /auth/me. - * - * Usage (run from api/functions): - * # 1) DRY RUN (default) — counts only, writes nothing: - * npx tsx src/migrations/copy-staging-to-prod.ts - * - * # 2) EXECUTE the copy: - * EXECUTE=true npx tsx src/migrations/copy-staging-to-prod.ts - * - * Credential key paths (override if your filenames differ): - * STAGING_CREDENTIALS=secrets/utilitool-staging-firebase-adminsdk-fbsvc-50221e4bd0.json - * PROD_CREDENTIALS=secrets/utilitool-11d7c-firebase-adminsdk-fbsvc-5f47e8736d.json - */ -import {initializeApp, cert, type ServiceAccount} from "firebase-admin/app"; -import {getFirestore} from "firebase-admin/firestore"; -import * as fs from "node:fs"; -import * as path from "node:path"; - -const STAGING_KEY = process.env.STAGING_CREDENTIALS || - "secrets/utilitool-staging-firebase-adminsdk-fbsvc-50221e4bd0.json"; -const PROD_KEY = process.env.PROD_CREDENTIALS || - "secrets/utilitool-11d7c-firebase-adminsdk-fbsvc-5f47e8736d.json"; - -const EXECUTE = process.env.EXECUTE === "true"; - -// Business collections to copy. `users` is intentionally excluded (see header). -const COLLECTIONS = [ - "meter_groups", - "properties", - "tenants", - "readings", - "billings", - "billing_cycles", - "audits", -]; - -// Read a service-account key, stripping a UTF-8 BOM if present (Windows editors add one, -// which breaks JSON.parse / cert()). -function loadKey(p: string): ServiceAccount { - // eslint-disable-next-line no-irregular-whitespace -- strips a literal BOM character - const raw = fs.readFileSync(path.resolve(p), "utf8").replace(/^/, ""); - return JSON.parse(raw) as ServiceAccount; -} - -const stagingApp = initializeApp({credential: cert(loadKey(STAGING_KEY))}, "staging"); -const prodApp = initializeApp({credential: cert(loadKey(PROD_KEY))}, "prod"); - -const stagingDb = getFirestore(stagingApp); -const prodDb = getFirestore(prodApp); - -const BATCH_LIMIT = 450; // Firestore hard limit is 500 ops per batch. - -async function copyCollection(name: string): Promise<{copied: number}> { - const snapshot = await stagingDb.collection(name).get(); - - if (!EXECUTE) { - return {copied: snapshot.size}; // dry run: just report what WOULD be copied - } - - let batch = prodDb.batch(); - let opsInBatch = 0; - let copied = 0; - - for (const doc of snapshot.docs) { - // set() with the SAME id preserves references and overwrites any existing prod doc. - batch.set(prodDb.collection(name).doc(doc.id), doc.data()); - opsInBatch++; - copied++; - - if (opsInBatch >= BATCH_LIMIT) { - await batch.commit(); - batch = prodDb.batch(); - opsInBatch = 0; - } - } - - if (opsInBatch > 0) { - await batch.commit(); - } - - return {copied}; -} - -async function main(): Promise { - console.log( - `${EXECUTE ? "EXECUTING" : "DRY RUN"} copy: utilitool-staging -> utilitool-11d7c\n` + - `Collections: ${COLLECTIONS.join(", ")} (users excluded)\n` - ); - - let total = 0; - for (const name of COLLECTIONS) { - const {copied} = await copyCollection(name); - total += copied; - console.log(` ${name.padEnd(16)} ${EXECUTE ? "copied" : "would copy"} ${copied} docs`); - } - - console.log(`\n${EXECUTE ? "Done" : "Dry run complete"}. Total: ${total} docs.`); - if (!EXECUTE) { - console.log("Re-run with EXECUTE=true to perform the copy."); - } -} - -main().catch((err) => { - console.error("Copy failed:", err); - process.exit(1); -}); diff --git a/api/functions/src/migrations/migrate-meter-groups.ts b/api/functions/src/migrations/migrate-meter-groups.ts deleted file mode 100644 index 24c9c38..0000000 --- a/api/functions/src/migrations/migrate-meter-groups.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * One-time migration: converts Property.meter_groups values from bare - * meter_group_id strings to { meter_group_id, is_main_meter: false } objects. - * - * Run once against the target environment: - * APP_ENV=staging npx tsx src/migrations/migrate-meter-groups.ts - * - * Safe to re-run — skips documents already in the new format. - */ -import {initializeApp, cert} from "firebase-admin/app"; -import {getFirestore} from "firebase-admin/firestore"; -import * as path from "path"; - -const credentialsPath = process.env.GOOGLE_APPLICATION_CREDENTIALS; -if (!credentialsPath) { - throw new Error("GOOGLE_APPLICATION_CREDENTIALS env var is required"); -} - -initializeApp({ - credential: cert(path.resolve(credentialsPath)), -}); - -const db = getFirestore(); -const PROPERTIES_COLLECTION = "properties"; - -async function migrate(): Promise { - const snapshot = await db.collection(PROPERTIES_COLLECTION).get(); - let migrated = 0; - let skipped = 0; - - let batch = db.batch(); - let opsInBatch = 0; - - for (const doc of snapshot.docs) { - const data = doc.data(); - const meterGroups = data.meter_groups as Record; - - if (!meterGroups) { - skipped++; - continue; - } - - let needsMigration = false; - const updated: Record = {}; - - for (const [utilityType, value] of Object.entries(meterGroups)) { - if (typeof value === "string") { - updated[utilityType] = {meter_group_id: value, is_main_meter: false}; - needsMigration = true; - } else { - // Already in new format — preserve as-is - updated[utilityType] = value; - } - } - - if (!needsMigration) { - skipped++; - continue; - } - - batch.update(doc.ref, {meter_groups: updated, updated_at: new Date()}); - migrated++; - opsInBatch++; - - if (opsInBatch >= 499) { - await batch.commit(); - batch = db.batch(); - opsInBatch = 0; - } - } - - await batch.commit(); - console.log(`Migration complete. Migrated: ${migrated}, Skipped: ${skipped}`); -} - -migrate().catch((err) => { - console.error("Migration failed:", err); - process.exit(1); -}); diff --git a/api/functions/src/migrations/shadow-replay.ts b/api/functions/src/migrations/shadow-replay.ts deleted file mode 100644 index adb3671..0000000 --- a/api/functions/src/migrations/shadow-replay.ts +++ /dev/null @@ -1,136 +0,0 @@ -/** - * Shadow-replay diagnostic: runs the REAL report/validator logic (not a reimplementation) - * against whatever Firestore the app is currently pointed at, and dumps a JSON snapshot. - * - * Intended use (verifying the hardening-roadmap-phase1 changes don't silently change - * business-logic output before they reach production): - * 1. Populate the local emulator with real-shaped data: - * `npx tsx src/migrations/copy-staging-to-emulator.ts` (dry run), then with - * EXECUTE=true, against a running emulator (`npm run test:emulator` in another shell). - * 2. Run this script with the OLD code (before this diff) -> save output A. - * 3. Run this script with the NEW code (current working tree) -> save output B. - * 4. Diff A and B — any unexpected difference in report totals/tenant-cap flags/legacy - * shape counts is a real, silent behavior change worth investigating before merge. - * - * Usage (run from api/functions, against the local emulator — never against staging/prod): - * APP_ENV=test FIRESTORE_EMULATOR_HOST=localhost:8080 npx tsx src/migrations/shadow-replay.ts > /tmp/replay-before.json - * # ...checkout/stash to the other code version... - * APP_ENV=test FIRESTORE_EMULATOR_HOST=localhost:8080 npx tsx src/migrations/shadow-replay.ts > /tmp/replay-after.json - * diff /tmp/replay-before.json /tmp/replay-after.json - * - * Refuses to run unless FIRESTORE_EMULATOR_HOST is set, so it can never be pointed at a - * real project by accident. - */ -if (!process.env.FIRESTORE_EMULATOR_HOST) { - throw new Error( - "FIRESTORE_EMULATOR_HOST is not set. Refusing to run: this script calls production " + - "business logic (reports.service, tenant.validator) and must only ever run against the " + - "local Firestore emulator, never staging or production." - ); -} - -import {firestore} from "../config/firebase.config"; -import {COLLECTIONS} from "../constants/collection.constants"; -import {getSummary, getCollectionStatus, getConsumption} from "../features/reports/reports.service"; - -const SHADOW_USER_ID = "shadow-replay"; // unused by reports.service today, passed for signature only - -async function replayReports() { - const query = {}; // no filters — broadest possible comparison surface - return { - summary: await getSummary(SHADOW_USER_ID, query), - collectionStatus: await getCollectionStatus(SHADOW_USER_ID, query), - consumption: await getConsumption(SHADOW_USER_ID, query), - }; -} - -/** - * Mirrors the item-1 audit question directly: scans every property for legacy - * string-shaped meter_groups entries (pre migrate-meter-groups.ts shape) and counts - * entries still missing current_version (pending backfill-property-meter-versions.ts). - */ -async function scanMeterGroupShapes() { - const snapshot = await firestore.collection(COLLECTIONS.PROPERTIES).get(); - - let propertiesScanned = 0; - let legacyStringEntries = 0; - let objectEntriesMissingCurrentVersion = 0; - const legacyStringEntryPropertyIds: string[] = []; - - for (const doc of snapshot.docs) { - propertiesScanned++; - const meterGroups = doc.data().meter_groups as Record | undefined; - if (!meterGroups) continue; - - for (const entry of Object.values(meterGroups)) { - if (typeof entry === "string") { - legacyStringEntries++; - legacyStringEntryPropertyIds.push(doc.id); - } else if (entry && typeof entry === "object" && (entry as {current_version?: number}).current_version === undefined) { - objectEntriesMissingCurrentVersion++; - } - } - } - - return { - propertiesScanned, - legacyStringEntries, - legacyStringEntryPropertyIds, - objectEntriesMissingCurrentVersion, - }; -} - -/** - * Dry-run tenant-cap check (read-only, does not create/reject anything): for every - * property, compares active (non-deleted) tenant count against tenant_amount. Flags any - * property already at/over cap, which is informational only — the real enforcement is - * the transactional check in tenant.service.ts. - */ -async function scanTenantCaps() { - const propertiesSnap = await firestore.collection(COLLECTIONS.PROPERTIES) - .where("is_deleted", "==", false) - .get(); - - const results: {propertyId: string; tenantAmount: number; activeTenantCount: number; atOrOverCap: boolean}[] = []; - - for (const propDoc of propertiesSnap.docs) { - const property = propDoc.data(); - const tenantsSnap = await firestore.collection(COLLECTIONS.TENANTS) - .where("property_id", "==", propDoc.id) - .where("is_deleted", "==", false) - .get(); - - results.push({ - propertyId: propDoc.id, - tenantAmount: property.tenant_amount, - activeTenantCount: tenantsSnap.size, - atOrOverCap: tenantsSnap.size >= property.tenant_amount, - }); - } - - return { - propertiesChecked: results.length, - atOrOverCap: results.filter((r) => r.atOrOverCap), - all: results, - }; -} - -async function main() { - const output = { - ranAt: new Date().toISOString(), - firestoreEmulatorHost: process.env.FIRESTORE_EMULATOR_HOST, - reports: await replayReports(), - meterGroupShapes: await scanMeterGroupShapes(), - tenantCaps: await scanTenantCaps(), - }; - - // Deterministic key order not required for diffing (JSON.stringify with 2-space - // indent is stable per-run for a fixed data set) — pretty-print to stdout so the - // caller can redirect to a file and `diff` two runs. - console.log(JSON.stringify(output, null, 2)); -} - -main().catch((err) => { - console.error("Shadow replay failed:", err); - process.exit(1); -}); diff --git a/ui/src/routes/(app)/billings/+page.svelte b/ui/src/routes/(app)/billings/+page.svelte index d1a5168..cb0a653 100644 --- a/ui/src/routes/(app)/billings/+page.svelte +++ b/ui/src/routes/(app)/billings/+page.svelte @@ -32,11 +32,7 @@ const crud = createCrudStore(); - let cycles = $state>({ - data: [], - nextCursor: null, - hasMore: false - }); + let cycles = $state([]); let billings: SvelteMap = new SvelteMap(); let allBillings = $state([]); let readings = $state([]); @@ -215,24 +211,43 @@ await loadData(); }); + // Billings/readings/cycles can now number in the hundreds (historical backfill), well + // past a single page — pulling only the first 100 silently dropped older cycles' data + // from billingMap/readingMap (showed as "Unknown" meter group / "N/A" readings), and + // filtering needs the full cycle set anyway. The list endpoints cap `limit` at 100 + // (billing.dto.ts, reading.dto.ts, billing-cycle.dto.ts), so we page through with cursors. + async function fetchAllPages( + fetchPage: (cursor?: string) => Promise> + ): Promise { + const all: T[] = []; + let cursor: string | undefined; + do { + const page = await fetchPage(cursor); + all.push(...page.data); + cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined; + } while (cursor); + return all; + } + async function loadData() { isLoading = true; error = ''; try { - const [cyclesResult, billingsResult, readingsResult, propertiesResult, meterGroupsResult] = + const [cyclesData, billingsData, readingsData, propertiesResult, meterGroupsResult] = await Promise.all([ - getBillingCycles({ limit: 100 }), - getBillings({ limit: 100 }), - getReadings({ limit: 100 }), + fetchAllPages((cursor) => getBillingCycles({ limit: 100, cursor })), + fetchAllPages((cursor) => getBillings({ limit: 100, cursor })), + fetchAllPages((cursor) => getReadings({ limit: 100, cursor })), getProperties({ limit: 100 }), getMeterGroups({ limit: 100 }) ]); - cycles = cyclesResult; - allBillings = billingsResult.data; - readings = readingsResult.data; + cycles = cyclesData; + allBillings = billingsData; + readings = readingsData; properties = propertiesResult.data; meterGroups = meterGroupsResult.data; billings.clear(); + currentPage = 1; } catch (err) { error = err instanceof Error ? err.message : 'Failed to load data'; } finally { @@ -240,25 +255,6 @@ } } - let isLoadingMore = $state(false); - - async function loadMoreCycles() { - if (!cycles.hasMore || !cycles.nextCursor || isLoadingMore) return; - isLoadingMore = true; - try { - const next = await getBillingCycles({ limit: 100, cursor: cycles.nextCursor }); - cycles = { - data: [...cycles.data, ...next.data], - nextCursor: next.nextCursor, - hasMore: next.hasMore - }; - } catch (err) { - error = err instanceof Error ? err.message : 'Failed to load more cycles'; - } finally { - isLoadingMore = false; - } - } - async function toggleCycleExpand(cycleId: string) { if (expandedCycleId === cycleId) { expandedCycleId = null; @@ -271,7 +267,7 @@ return; } - const cycle = cycles.data.find((c) => c.id === cycleId); + const cycle = cycles.find((c) => c.id === cycleId); if (cycle) { const cycleBillings = allBillings.filter((b) => b.id in cycle.billing_ids); billings.set(cycleId, cycleBillings); @@ -439,7 +435,7 @@ if (!cycleFormMeterGroup) return; // Find the last billing cycle for this meter group - const meterGroupCycles = cycles.data + const meterGroupCycles = cycles .filter((c) => { const cycleBillingIds = Object.keys(c.billing_ids); const cycleBillings = allBillings.filter((b) => cycleBillingIds.includes(b.id)); @@ -763,30 +759,12 @@ const meterGroupMap = $derived.by(() => new Map(meterGroups.map((m) => [m.id, m]))); function getCycleUtilityType(cycle: BillingCycle): string { - const cycleBillingIds = Object.keys(cycle.billing_ids); - if (cycleBillingIds.length === 0) return 'electricity'; - - const firstBillingId = cycleBillingIds[0]; - const firstBilling = billingMap.get(firstBillingId); - if (!firstBilling) return 'electricity'; - - const firstReading = readingMap.get(firstBilling.current_reading_id); - if (!firstReading) return 'electricity'; - - const meterGroup = meterGroupMap.get(firstReading.meter_group_id); + const meterGroup = meterGroupMap.get(cycle.meter_group_id); return meterGroup?.utility_type || 'electricity'; } function getCycleMeterGroupId(cycle: BillingCycle): string | null { - const cycleBillingIds = Object.keys(cycle.billing_ids); - if (cycleBillingIds.length === 0) return null; - - const firstBillingId = cycleBillingIds[0]; - const firstBilling = billingMap.get(firstBillingId); - if (!firstBilling) return null; - - const firstReading = readingMap.get(firstBilling.current_reading_id); - return firstReading?.meter_group_id ?? null; + return cycle.meter_group_id ?? null; } function getCyclePaidAmount(cycle: BillingCycle): number { @@ -800,24 +778,82 @@ return sumMoney(amounts); } - const groupedCycles = $derived.by(() => { - const groups = new SvelteMap(); - for (const cycle of cycles.data) { - const meterGroupId = getCycleMeterGroupId(cycle); - const key = meterGroupId || 'unknown'; - if (!groups.has(key)) { - groups.set(key, []); - } - groups.get(key)!.push(cycle); - } - return Array.from(groups.entries()).map(([id, cyclesInGroup]) => ({ - meterGroupId: id === 'unknown' ? null : id, - meterGroupName: - id === 'unknown' - ? 'Unknown' - : meterGroups.find((m) => m.id === id)?.meter_name || 'Unknown', - cycles: cyclesInGroup - })); + function getCycleMeterGroupName(cycle: BillingCycle): string { + return meterGroupMap.get(cycle.meter_group_id)?.meter_name || 'Unknown'; + } + + // A cycle is "paid" only once every billing under it is paid — otherwise "pending". + function getCyclePaymentStatus(cycle: BillingCycle): 'paid' | 'pending' { + const billingIds = Object.keys(cycle.billing_ids); + if (billingIds.length === 0) return 'pending'; + return billingIds.every((id) => billingMap.get(id)?.payment_status === 'paid') + ? 'paid' + : 'pending'; + } + + // Filters — replace the old per-meter-group grouped sections with a single flat, + // filterable, client-paginated table (cycles are fully loaded via fetchAllPages above). + let filterUtilityType = $state<'all' | 'electricity' | 'water'>('all'); + let filterMeterGroupId = $state('all'); + let filterDateField = $state<'billing_start_date' | 'overdue_date'>('billing_start_date'); + let filterDateFrom = $state(''); + let filterDateTo = $state(''); + let filterPaymentStatus = $state<'all' | 'paid' | 'pending'>('all'); + + const filterableMeterGroups = $derived.by(() => + filterUtilityType === 'all' + ? meterGroups + : meterGroups.filter((m) => m.utility_type === filterUtilityType) + ); + + const filteredCycles = $derived.by(() => { + return cycles + .filter((cycle) => { + if (filterUtilityType !== 'all' && getCycleUtilityType(cycle) !== filterUtilityType) { + return false; + } + if (filterMeterGroupId !== 'all' && cycle.meter_group_id !== filterMeterGroupId) { + return false; + } + if (filterPaymentStatus !== 'all' && getCyclePaymentStatus(cycle) !== filterPaymentStatus) { + return false; + } + const dateValue = cycle[filterDateField]; + if (filterDateFrom || filterDateTo) { + if (!dateValue) return false; + const cycleDate = toDate(dateValue); + if (filterDateFrom && cycleDate < new SvelteDate(filterDateFrom)) return false; + if (filterDateTo && cycleDate > new SvelteDate(filterDateTo)) return false; + } + return true; + }) + .sort( + (a, b) => + toDate(b.billing_start_date).getTime() - toDate(a.billing_start_date).getTime() + ); + }); + + const cyclesPageSize = 20; + let currentPage = $state(1); + const totalPages = $derived.by(() => Math.max(1, Math.ceil(filteredCycles.length / cyclesPageSize))); + const pagedCycles = $derived.by(() => { + const start = (currentPage - 1) * cyclesPageSize; + return filteredCycles.slice(start, start + cyclesPageSize); + }); + + function resetToFirstPage() { + currentPage = 1; + } + + $effect(() => { + // Any filter change invalidates the current page position. + void filterUtilityType; + void filterMeterGroupId; + void filterDateField; + void filterDateFrom; + void filterDateTo; + void filterPaymentStatus; + resetToFirstPage(); }); function printReceipts(cyclesToPrint: BillingCycle[]) { @@ -1358,7 +1394,7 @@
{/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {#if filterUtilityType !== 'all' || filterMeterGroupId !== 'all' || filterDateFrom || filterDateTo || filterPaymentStatus !== 'all'} + + {/if} +
+ {#if isLoading}
- {:else if cycles.data.length === 0} + {:else if filteredCycles.length === 0}
{:else} - {#each groupedCycles as group (group.meterGroupId || 'unknown')} -
-

- {group.meterGroupName} - {#if group.meterGroupId} - {@const utilityType = meterGroups.find( - (m) => m.id === group.meterGroupId - )?.utility_type} - {#if utilityType} - {utilityType} + {#each pagedCycles as cycle (cycle.id)} + {@const cycleBillings = billings.get(cycle.id)} + {@const cycleUtilityType = getCycleUtilityType(cycle)} +
+ +
+
+ { + if ((e.target as HTMLInputElement).checked) { + selectedCyclesForPrint = [...selectedCyclesForPrint, cycle.id]; + } else { + selectedCyclesForPrint = selectedCyclesForPrint.filter( + (id) => id !== cycle.id + ); + } + }} + class="h-4 w-4" + /> +

- {#each group.cycles as cycle (cycle.id)} - {@const cycleBillings = billings.get(cycle.id)} -
- -
-
- { - if ((e.target as HTMLInputElement).checked) { - selectedCyclesForPrint = [...selectedCyclesForPrint, cycle.id]; - } else { - selectedCyclesForPrint = selectedCyclesForPrint.filter( - (id) => id !== cycle.id - ); - } - }} - class="h-4 w-4" - /> - +
+ {#if totalPages > 1} +
+ + Page {currentPage} of {totalPages} ({filteredCycles.length} cycles) + +
+ + +
{/if} {/if} diff --git a/ui/src/routes/(app)/dashboard/+page.svelte b/ui/src/routes/(app)/dashboard/+page.svelte index 44da38b..19a101b 100644 --- a/ui/src/routes/(app)/dashboard/+page.svelte +++ b/ui/src/routes/(app)/dashboard/+page.svelte @@ -5,7 +5,7 @@ import { getBillings } from '$lib/api/billings'; import { getBillingCycles } from '$lib/api/billing-cycles'; import { getMeterGroups } from '$lib/api/meter-groups'; - import { formatCurrency, formatDate } from '$lib/utils/format'; + import { formatCurrency, formatDate, getReadingUnit } from '$lib/utils/format'; import { toDate } from '$lib/utils/timestamp'; import { getCyclePaidAmount, getCycleOutstandingAmount } from '$lib/utils/billing-cycle.util'; import { getUtilityTypeBadgeClasses } from '$lib/utils/utility-colors'; @@ -19,47 +19,74 @@ let propertyCount = $state(0); let tenantCount = $state(0); - let totalBilled = $state(0); - let totalCollected = $state(0); - let totalOutstanding = $state(0); - let recentCycles = $state([]); + let allCycles = $state([]); let meterGroups = $state([]); let billingMap = $state>(new Map()); + let recentCyclesRangeMonths = $state<3 | 6 | 12>(3); + + const recentCycles = $derived.by(() => { + const cutoff = new Date(); + cutoff.setMonth(cutoff.getMonth() - recentCyclesRangeMonths); + return allCycles + .filter((cycle) => toDate(cycle.billing_start_date) >= cutoff) + .sort( + (a, b) => toDate(b.billing_start_date).getTime() - toDate(a.billing_start_date).getTime() + ); + }); + + // Stat boxes are scoped to the same selected range as the cycles table below them — + // otherwise "Billed"/"Collected"/"Outstanding" silently summed every cycle ever + // regardless of which range tab was selected, which read as internally inconsistent. + const totalBilled = $derived.by(() => + recentCycles.reduce((sum, cycle) => { + const cycleTotal = Object.values(cycle.billing_ids).reduce( + (s, consumption) => s + consumption * cycle.billing_rate, + 0 + ); + return sum + cycleTotal; + }, 0) + ); + const totalCollected = $derived.by(() => + recentCycles.reduce((sum, cycle) => sum + getCyclePaidAmount(cycle, billingMap), 0) + ); + const totalOutstanding = $derived.by(() => + recentCycles.reduce((sum, cycle) => sum + getCycleOutstandingAmount(cycle, billingMap), 0) + ); + + // Cycles/billings can number in the hundreds (historical backfill) — a single capped + // page would silently under-count older cycles/billings, so page through with cursors + // (mirrors the same fix applied to the Billings page). + async function fetchAllPages( + fetchPage: (cursor?: string) => Promise<{ data: T[]; hasMore: boolean; nextCursor: string | null }> + ): Promise { + const all: T[] = []; + let cursor: string | undefined; + do { + const page = await fetchPage(cursor); + all.push(...page.data); + cursor = page.hasMore ? (page.nextCursor ?? undefined) : undefined; + } while (cursor); + return all; + } onMount(async () => { try { - const [ - propertiesResult, - tenantsResult, - billingCyclesResult, - billingsResult, - meterGroupsResult - ] = await Promise.all([ - getProperties({ limit: 100 }), - getTenants({ limit: 100 }), - getBillingCycles({ limit: 50 }), - getBillings({ limit: 100 }), - getMeterGroups({ limit: 100 }) - ]); + const [propertiesResult, tenantsResult, cyclesData, allBillings, meterGroupsResult] = + await Promise.all([ + getProperties({ limit: 100 }), + getTenants({ limit: 100 }), + fetchAllPages((cursor) => getBillingCycles({ limit: 100, cursor })), + fetchAllPages((cursor) => getBillings({ limit: 100, cursor })), + getMeterGroups({ limit: 100 }) + ]); propertyCount = propertiesResult.data.length; tenantCount = tenantsResult.data.length; - recentCycles = billingCyclesResult.data; + allCycles = cyclesData; meterGroups = meterGroupsResult.data; // Build billing map for lookup - const allBillings: Billing[] = billingsResult.data; billingMap = new Map(allBillings.map((b) => [b.id, b])); - - // Calculate global totals and per-cycle amounts - recentCycles.forEach((cycle) => { - const cycleTotal = Object.values(cycle.billing_ids).reduce((sum, consumption) => { - return sum + consumption * cycle.billing_rate; - }, 0); - totalBilled += cycleTotal; - totalCollected += getCyclePaidAmount(cycle, billingMap); - totalOutstanding += getCycleOutstandingAmount(cycle, billingMap); - }); } catch (err) { error = err instanceof Error ? err.message : 'Failed to load dashboard data'; } finally { @@ -91,12 +118,12 @@ {:else}
-

This Month Billed

+

{recentCyclesRangeMonths} Month(s) Billed

{formatCurrency(totalBilled)}

-

From recent cycles

+

{recentCycles.length} cycle(s) in range

-

Collected

+

Collected (same range)

{formatCurrency(totalCollected)}

{totalBilled > 0 ? Math.round((totalCollected / totalBilled) * 100) : 0}% @@ -106,7 +133,7 @@ class="rounded-lg border border-gray-200 bg-white p-6" style="background-color: var(--color-status-unpaid-bg)" > -

Outstanding

+

Outstanding (same range)

{formatCurrency(totalOutstanding)}

Pending payments

@@ -119,7 +146,23 @@ {/if}
-

Recent Billing Cycles

+
+

Recent Billing Cycles

+
+ {#each [3, 6, 12] as months (months)} + + {/each} +
+
{#if isLoading} {:else if recentCycles.length === 0} @@ -159,7 +202,10 @@ {/if}
- {cycle.billing_consumption.toLocaleString()} + {cycle.billing_consumption.toLocaleString()} + {getReadingUnit(meterGroup?.utility_type || 'electricity')} {formatCurrency(getCyclePaidAmount(cycle, billingMap))}