Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion api/functions/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ node_modules/

secrets/
.env
logs/
logs/

src/migrations/
13 changes: 9 additions & 4 deletions api/functions/src/features/chatbot/chatbot.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ const MAX_TOOL_CALL_ROUNDS = 4;
const SYSTEM_PROMPT = `<role>
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).
</role>

<task>
Expand All @@ -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.
</task>

<constraints>
Expand Down
90 changes: 90 additions & 0 deletions api/functions/src/features/chatbot/chatbot.tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,12 @@
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;
Expand Down Expand Up @@ -398,6 +400,67 @@
};
}

export interface GetBillingReportsArgs {
reportType: "summary" | "consumption" | "billing_trends" | "collection_status";
startDate?: string;
endDate?: string;
meterGroupName?: string;
propertyName?: string;
}

async function buildReportQuery(args: GetBillingReportsArgs): Promise<ReportQueryDTO> {
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":

Check failure on line 442 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 2 spaces but found 4
return reportsService.getSummary(userId, query);

Check failure on line 443 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 4 spaces but found 6
case "consumption": {

Check failure on line 444 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 2 spaces but found 4
const report = await reportsService.getConsumption(userId, query);

Check failure on line 445 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 4 spaces but found 6
return {

Check failure on line 446 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 4 spaces but found 6
by_month: report.by_month,

Check failure on line 447 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 6 spaces but found 8
by_property: report.by_property.map((p) => ({

Check failure on line 448 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 6 spaces but found 8
propertyName: p.room_name,

Check failure on line 449 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 8 spaces but found 10
electricity: p.electricity,

Check failure on line 450 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 8 spaces but found 10
water: p.water,

Check failure on line 451 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Expected indentation of 8 spaces but found 10
})),
};
}
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"},
Expand Down Expand Up @@ -495,11 +558,38 @@
},
},
},
{
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<string, (args: any) => Promise<unknown>> = {

Check warning on line 589 in api/functions/src/features/chatbot/chatbot.tools.ts

View workflow job for this annotation

GitHub Actions / CI & Deploy

Unexpected any. Specify a different type
get_usage_history: getUsageHistory,
get_accumulated_totals: getAccumulatedTotals,
get_billing_cost: getBillingCost,
detect_spikes: detectSpikes,
get_billing_reports: getBillingReports,
};
21 changes: 10 additions & 11 deletions api/functions/src/features/reading/reading.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, MeterGroupVersionEntry> | 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;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
1 change: 1 addition & 0 deletions api/functions/src/features/reports/reports.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface JoinedBilling {
billingId: string;
amount: number;
consumption: number;
cycleStartDate: Date;
cycleEndDate: Date;
utilityType: string;
propertyId: string;
Expand Down
141 changes: 45 additions & 96 deletions api/functions/src/features/reports/reports.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -185,6 +188,7 @@ async function buildJoinedData(userId: string, query: ReportQueryDTO): Promise<J
billingId: billing.id,
amount: amount || 0,
consumption,
cycleStartDate: cycle.billing_start_date.toDate(),
cycleEndDate: cycle.billing_end_date.toDate(),
utilityType,
propertyId: billing.property_id,
Expand Down Expand Up @@ -224,59 +228,25 @@ export async function getSummary(userId: string, query: ReportQueryDTO): Promise
}

export async function getConsumption(userId: string, query: ReportQueryDTO): Promise<ConsumptionReport> {
const {cycles, validBillings, propertyMap, meterGroupMap} = await fetchReportContext(query);

// Fetch all current readings to determine meter group for each billing
const readingIds = new Set<string>();
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<typeof r> => 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<string, Record<string, number>>();
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;
}
}

Expand All @@ -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<string, Record<string, number>>();

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};
Expand All @@ -348,7 +293,11 @@ export async function getBillingTrends(userId: string, query: ReportQueryDTO): P
const monthMap = new Map<string, { billed: number; collected: number; pending: number; overdue: number }>();

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});
Expand Down
Loading
Loading