Phase 8: AI-driven operating and reserve fund health scores

Add daily AI health score calculation (0-100) for both operating and
reserve funds. Scores include trajectory tracking, factor analysis,
recommendations, and data readiness checks. Dashboard displays
graphical RingProgress gauges with color-coded scores, trend
indicators, and expandable detail popovers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 09:56:56 -05:00
parent bfcbe086f2
commit 2ca277b6e6
10 changed files with 1244 additions and 2 deletions

View File

@@ -14,6 +14,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",
@@ -1592,6 +1593,19 @@
"@nestjs/core": "^10.0.0"
}
},
"node_modules/@nestjs/schedule": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-6.1.1.tgz",
"integrity": "sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==",
"license": "MIT",
"dependencies": {
"cron": "4.4.0"
},
"peerDependencies": {
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0"
}
},
"node_modules/@nestjs/schematics": {
"version": "10.2.3",
"resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.2.3.tgz",
@@ -2027,6 +2041,12 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.7.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.1.tgz",
"integrity": "sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==",
"license": "MIT"
},
"node_modules/@types/multer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
@@ -3432,6 +3452,23 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/cron": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/cron/-/cron-4.4.0.tgz",
"integrity": "sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==",
"license": "MIT",
"dependencies": {
"@types/luxon": "~3.7.0",
"luxon": "~3.7.0"
},
"engines": {
"node": ">=18.x"
},
"funding": {
"type": "ko-fi",
"url": "https://ko-fi.com/intcreator"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -5916,6 +5953,15 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.8",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.8.tgz",

View File

@@ -23,6 +23,7 @@
"@nestjs/jwt": "^10.2.0",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-express": "^10.4.15",
"@nestjs/schedule": "^6.1.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/typeorm": "^10.0.2",
"bcryptjs": "^3.0.3",

View File

@@ -26,6 +26,8 @@ import { ProjectsModule } from './modules/projects/projects.module';
import { MonthlyActualsModule } from './modules/monthly-actuals/monthly-actuals.module';
import { AttachmentsModule } from './modules/attachments/attachments.module';
import { InvestmentPlanningModule } from './modules/investment-planning/investment-planning.module';
import { HealthScoresModule } from './modules/health-scores/health-scores.module';
import { ScheduleModule } from '@nestjs/schedule';
@Module({
imports: [
@@ -64,6 +66,8 @@ import { InvestmentPlanningModule } from './modules/investment-planning/investme
MonthlyActualsModule,
AttachmentsModule,
InvestmentPlanningModule,
HealthScoresModule,
ScheduleModule.forRoot(),
],
controllers: [AppController],
providers: [

View File

@@ -328,6 +328,25 @@ export class TenantSchemaService {
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
// Health Scores (AI-derived operating / reserve fund health)
`CREATE TABLE "${s}".health_scores (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
score_type VARCHAR(20) NOT NULL CHECK (score_type IN ('operating', 'reserve')),
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 100),
previous_score INTEGER,
trajectory VARCHAR(20) CHECK (trajectory IN ('improving', 'stable', 'declining')),
label VARCHAR(30),
summary TEXT,
factors JSONB,
recommendations JSONB,
missing_data JSONB,
status VARCHAR(20) NOT NULL DEFAULT 'complete' CHECK (status IN ('complete', 'pending', 'error')),
response_time_ms INTEGER,
calculated_at TIMESTAMPTZ DEFAULT NOW(),
created_at TIMESTAMPTZ DEFAULT NOW()
)`,
`CREATE INDEX "idx_${s}_hs_type_calc" ON "${s}".health_scores(score_type, calculated_at DESC)`,
// Attachments (file storage for receipts/invoices)
`CREATE TABLE "${s}".attachments (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Post, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiOperation } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AllowViewer } from '../../common/decorators/allow-viewer.decorator';
import { HealthScoresService } from './health-scores.service';
@ApiTags('health-scores')
@Controller('health-scores')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
export class HealthScoresController {
constructor(private service: HealthScoresService) {}
@Get('latest')
@ApiOperation({ summary: 'Get latest operating and reserve health scores' })
getLatest(@Req() req: any) {
const schema = req.user?.orgSchema;
return this.service.getLatestScores(schema);
}
@Post('calculate')
@ApiOperation({ summary: 'Trigger health score recalculation for current tenant' })
@AllowViewer()
async calculate(@Req() req: any) {
const schema = req.user?.orgSchema;
const [operating, reserve] = await Promise.all([
this.service.calculateScore(schema, 'operating'),
this.service.calculateScore(schema, 'reserve'),
]);
return { operating, reserve };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { HealthScoresController } from './health-scores.controller';
import { HealthScoresService } from './health-scores.service';
import { HealthScoresScheduler } from './health-scores.scheduler';
@Module({
controllers: [HealthScoresController],
providers: [HealthScoresService, HealthScoresScheduler],
})
export class HealthScoresModule {}

View File

@@ -0,0 +1,54 @@
import { Injectable, Logger } from '@nestjs/common';
import { Cron, CronExpression } from '@nestjs/schedule';
import { DataSource } from 'typeorm';
import { HealthScoresService } from './health-scores.service';
@Injectable()
export class HealthScoresScheduler {
private readonly logger = new Logger(HealthScoresScheduler.name);
constructor(
private dataSource: DataSource,
private healthScoresService: HealthScoresService,
) {}
/**
* Run daily at 2:00 AM — calculate health scores for all active tenants.
* Uses DataSource directly to list tenants (no HTTP request context needed).
*/
@Cron('0 2 * * *')
async calculateAllTenantScores() {
this.logger.log('Starting daily health score calculation for all tenants...');
const startTime = Date.now();
try {
const orgs = await this.dataSource.query(
`SELECT id, name, schema_name FROM shared.organizations WHERE status = 'active'`,
);
this.logger.log(`Found ${orgs.length} active tenants`);
let successCount = 0;
let errorCount = 0;
for (const org of orgs) {
try {
await this.healthScoresService.calculateScore(org.schema_name, 'operating');
await this.healthScoresService.calculateScore(org.schema_name, 'reserve');
successCount++;
this.logger.log(`Health scores calculated for ${org.name} (${org.schema_name})`);
} catch (err: any) {
errorCount++;
this.logger.error(`Failed to calculate health scores for ${org.name}: ${err.message}`);
}
}
const elapsed = Date.now() - startTime;
this.logger.log(
`Daily health scores complete: ${successCount} success, ${errorCount} errors (${elapsed}ms)`,
);
} catch (err: any) {
this.logger.error(`Health score scheduler failed: ${err.message}`);
}
}
}

View File

@@ -0,0 +1,788 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DataSource } from 'typeorm';
export interface HealthScore {
id: string;
score_type: string;
score: number;
previous_score: number | null;
trajectory: string | null;
label: string;
summary: string;
factors: any;
recommendations: any;
missing_data: any;
status: string;
response_time_ms: number | null;
calculated_at: string;
}
interface AIHealthResponse {
score: number;
label: string;
summary: string;
factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>;
recommendations: Array<{ priority: 'high' | 'medium' | 'low'; text: string }>;
}
@Injectable()
export class HealthScoresService {
private readonly logger = new Logger(HealthScoresService.name);
private debugEnabled: boolean;
constructor(
private configService: ConfigService,
private dataSource: DataSource,
) {
this.debugEnabled = this.configService.get<string>('AI_DEBUG') === 'true';
}
private debug(label: string, data: any) {
if (!this.debugEnabled) return;
const text = typeof data === 'string' ? data : JSON.stringify(data, null, 2);
const truncated = text.length > 5000 ? text.slice(0, 5000) + `\n... [truncated]` : text;
this.logger.log(`[AI_DEBUG] ${label}:\n${truncated}`);
}
// ── Public API ──
async getLatestScores(schema: string): Promise<{ operating: HealthScore | null; reserve: HealthScore | null }> {
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schema}"`);
const operating = await qr.query(
`SELECT * FROM health_scores WHERE score_type = 'operating' ORDER BY calculated_at DESC LIMIT 1`,
);
const reserve = await qr.query(
`SELECT * FROM health_scores WHERE score_type = 'reserve' ORDER BY calculated_at DESC LIMIT 1`,
);
return {
operating: operating[0] || null,
reserve: reserve[0] || null,
};
} finally {
await qr.release();
}
}
async calculateScore(schema: string, scoreType: 'operating' | 'reserve'): Promise<HealthScore> {
const startTime = Date.now();
const qr = this.dataSource.createQueryRunner();
try {
await qr.connect();
await qr.query(`SET search_path TO "${schema}"`);
// Check data readiness
const missingData = await this.checkDataReadiness(qr, scoreType);
if (missingData.length > 0) {
return this.savePendingScore(qr, scoreType, missingData);
}
// Gather financial data
const data = scoreType === 'operating'
? await this.gatherOperatingData(qr)
: await this.gatherReserveData(qr);
// Build prompt and call AI
const messages = scoreType === 'operating'
? this.buildOperatingPrompt(data)
: this.buildReservePrompt(data);
const aiResponse = await this.callAI(messages);
const elapsed = Date.now() - startTime;
// Get previous score for trajectory
const prevRows = await qr.query(
`SELECT score FROM health_scores WHERE score_type = $1 ORDER BY calculated_at DESC LIMIT 1`,
[scoreType],
);
const previousScore = prevRows[0]?.score ?? null;
let trajectory: string | null = null;
if (previousScore !== null) {
const diff = aiResponse.score - previousScore;
if (diff >= 3) trajectory = 'improving';
else if (diff <= -3) trajectory = 'declining';
else trajectory = 'stable';
}
// Save and return
const rows = await qr.query(
`INSERT INTO health_scores
(score_type, score, previous_score, trajectory, label, summary, factors, recommendations, status, response_time_ms)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'complete', $9)
RETURNING *`,
[
scoreType,
aiResponse.score,
previousScore,
trajectory,
aiResponse.label,
aiResponse.summary,
JSON.stringify(aiResponse.factors),
JSON.stringify(aiResponse.recommendations),
elapsed,
],
);
this.logger.log(`Health score calculated: ${schema}.${scoreType} = ${aiResponse.score} (${elapsed}ms)`);
return rows[0];
} catch (err: any) {
this.logger.error(`Health score calculation failed for ${schema}.${scoreType}: ${err.message}`);
// Save error status
try {
const rows = await qr.query(
`INSERT INTO health_scores
(score_type, score, status, summary, response_time_ms)
VALUES ($1, 0, 'error', $2, $3)
RETURNING *`,
[scoreType, `Calculation error: ${err.message}`, Date.now() - startTime],
);
return rows[0];
} catch {
throw err;
}
} finally {
await qr.release();
}
}
// ── Data Readiness Checks ──
private async checkDataReadiness(qr: any, scoreType: string): Promise<string[]> {
const missing: string[] = [];
if (scoreType === 'operating') {
// Must have at least one operating asset account
const opAccounts = await qr.query(
`SELECT COUNT(*) as cnt FROM accounts WHERE fund_type = 'operating' AND account_type = 'asset' AND is_active = true`,
);
if (parseInt(opAccounts[0].cnt) === 0) {
missing.push('No operating fund accounts found. Create at least one operating asset account.');
}
// Must have an annual budget for the current year
const year = new Date().getFullYear();
const budgets = await qr.query(
`SELECT COUNT(*) as cnt FROM budgets WHERE fiscal_year = $1`,
[year],
);
if (parseInt(budgets[0].cnt) === 0) {
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
}
} else {
// Reserve: must have at least one reserve asset account
const resAccounts = await qr.query(
`SELECT COUNT(*) as cnt FROM accounts WHERE fund_type = 'reserve' AND account_type = 'asset' AND is_active = true`,
);
if (parseInt(resAccounts[0].cnt) === 0) {
missing.push('No reserve fund accounts found. Create at least one reserve asset account.');
}
// Must have a budget
const year = new Date().getFullYear();
const budgets = await qr.query(
`SELECT COUNT(*) as cnt FROM budgets WHERE fiscal_year = $1`,
[year],
);
if (parseInt(budgets[0].cnt) === 0) {
missing.push(`No budget found for ${year}. Upload or create an annual budget.`);
}
// Should have capital projects (warn but don't block)
const projects = await qr.query(
`SELECT COUNT(*) as cnt FROM projects WHERE is_active = true`,
);
if (parseInt(projects[0].cnt) === 0) {
missing.push('No capital projects found. Add planned capital projects for a more accurate reserve health assessment.');
}
}
return missing;
}
private async savePendingScore(qr: any, scoreType: string, missingData: string[]): Promise<HealthScore> {
const rows = await qr.query(
`INSERT INTO health_scores
(score_type, score, status, summary, missing_data)
VALUES ($1, 0, 'pending', $2, $3)
RETURNING *`,
[
scoreType,
'Unable to calculate health score — required data is missing.',
JSON.stringify(missingData),
],
);
return rows[0];
}
// ── Data Gathering ──
private async gatherOperatingData(qr: any) {
const year = new Date().getFullYear();
const [accounts, budgets, assessments, cashFlow, recentTransactions] = await Promise.all([
// Operating accounts with balances
qr.query(`
SELECT a.name, a.account_number, a.account_type, a.fund_type,
CASE
WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
WHERE a.is_active = true AND a.fund_type = 'operating'
GROUP BY a.id, a.name, a.account_number, a.account_type, a.fund_type
ORDER BY a.account_number
`),
// Budget for current year (operating fund)
qr.query(
`SELECT a.name, a.account_number, a.account_type,
(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
b.jul + b.aug + b.sep + b.oct + b.nov + b.dec_amt) as annual_total,
b.jan, b.feb, b.mar, b.apr, b.may, b.jun,
b.jul, b.aug, b.sep, b.oct, b.nov, b.dec_amt
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND b.fund_type = 'operating'
ORDER BY a.account_type, a.account_number`,
[year],
),
// Assessment groups
qr.query(`
SELECT ag.name, ag.frequency, ag.regular_assessment,
(SELECT COUNT(*) FROM units u WHERE u.assessment_group_id = ag.id AND u.status = 'active') as unit_count
FROM assessment_groups ag WHERE ag.is_active = true
`),
// YTD income and expenses
qr.query(`
SELECT a.account_type,
CASE
WHEN a.account_type = 'income'
THEN COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
ELSE COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
END as ytd_amount
FROM accounts a
JOIN journal_entry_lines jel ON jel.account_id = a.id
JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
AND je.entry_date >= $1
WHERE a.is_active = true AND a.fund_type = 'operating'
AND a.account_type IN ('income', 'expense')
GROUP BY a.account_type
`, [`${year}-01-01`]),
// Delinquent receivables (invoices past due)
qr.query(`
SELECT COUNT(*) as count, COALESCE(SUM(amount_due - amount_paid), 0) as total_overdue
FROM invoices
WHERE status IN ('sent', 'overdue') AND due_date < CURRENT_DATE
`),
]);
// Calculate month-by-month budget actuals progress
const currentMonth = new Date().getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
let budgetedIncomeYTD = 0;
let budgetedExpenseYTD = 0;
for (const b of budgets) {
for (let m = 0; m <= currentMonth; m++) {
const amt = parseFloat(b[monthNames[m]]) || 0;
if (b.account_type === 'income') budgetedIncomeYTD += amt;
else if (b.account_type === 'expense') budgetedExpenseYTD += amt;
}
}
const operatingCash = accounts
.filter((a: any) => a.account_type === 'asset')
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
const budgetedIncomeAnnual = budgets
.filter((b: any) => b.account_type === 'income')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
const budgetedExpenseAnnual = budgets
.filter((b: any) => b.account_type === 'expense')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
const ytdIncome = parseFloat(cashFlow.find((c: any) => c.account_type === 'income')?.ytd_amount || '0');
const ytdExpense = parseFloat(cashFlow.find((c: any) => c.account_type === 'expense')?.ytd_amount || '0');
const monthlyAssessmentIncome = assessments.reduce((s: number, ag: any) => {
const regular = parseFloat(ag.regular_assessment) || 0;
const units = parseInt(ag.unit_count) || 0;
return s + (regular * units);
}, 0);
return {
operatingCash,
accounts,
budgets,
assessments,
budgetedIncomeAnnual,
budgetedExpenseAnnual,
budgetedIncomeYTD,
budgetedExpenseYTD,
ytdIncome,
ytdExpense,
monthlyAssessmentIncome,
delinquentCount: parseInt(recentTransactions[0]?.count || '0'),
delinquentAmount: parseFloat(recentTransactions[0]?.total_overdue || '0'),
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
year,
currentMonth: currentMonth + 1,
};
}
private async gatherReserveData(qr: any) {
const year = new Date().getFullYear();
const [accounts, investments, reserveComponents, projects, budgets] = await Promise.all([
// Reserve accounts with balances
qr.query(`
SELECT a.name, a.account_number, a.account_type, a.fund_type,
CASE
WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
FROM accounts a
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id
AND je.is_posted = true AND je.is_void = false
WHERE a.is_active = true AND a.fund_type = 'reserve'
GROUP BY a.id, a.name, a.account_number, a.account_type, a.fund_type
ORDER BY a.account_number
`),
// Investment accounts (reserve fund)
qr.query(`
SELECT name, institution, investment_type, fund_type,
principal, interest_rate, maturity_date, current_value
FROM investment_accounts
WHERE is_active = true AND fund_type = 'reserve'
ORDER BY maturity_date NULLS LAST
`),
// Reserve components
qr.query(`
SELECT name, category, useful_life_years, remaining_life_years,
replacement_cost, current_fund_balance, annual_contribution, condition_rating
FROM reserve_components
ORDER BY remaining_life_years ASC NULLS LAST
`),
// Capital projects
qr.query(`
SELECT name, estimated_cost, target_year, target_month, fund_source,
status, priority, current_fund_balance, funded_percentage
FROM projects
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
ORDER BY target_year, target_month NULLS LAST
`),
// Reserve budget
qr.query(
`SELECT a.name, a.account_number, a.account_type,
(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
b.jul + b.aug + b.sep + b.oct + b.nov + b.dec_amt) as annual_total
FROM budgets b
JOIN accounts a ON a.id = b.account_id
WHERE b.fiscal_year = $1 AND b.fund_type = 'reserve'
ORDER BY a.account_type, a.account_number`,
[year],
),
]);
const reserveCash = accounts
.filter((a: any) => a.account_type === 'asset')
.reduce((s: number, a: any) => s + parseFloat(a.balance || '0'), 0);
const totalInvestments = investments
.reduce((s: number, i: any) => s + parseFloat(i.current_value || i.principal || '0'), 0);
const totalReserveFund = reserveCash + totalInvestments;
const totalReplacementCost = reserveComponents
.reduce((s: number, c: any) => s + parseFloat(c.replacement_cost || '0'), 0);
const totalComponentFunded = reserveComponents
.reduce((s: number, c: any) => s + parseFloat(c.current_fund_balance || '0'), 0);
const percentFunded = totalReplacementCost > 0 ? (totalReserveFund / totalReplacementCost) * 100 : 0;
const totalProjectCost = projects
.reduce((s: number, p: any) => s + parseFloat(p.estimated_cost || '0'), 0);
const annualReserveContribution = budgets
.filter((b: any) => b.account_type === 'income')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
const annualReserveExpenses = budgets
.filter((b: any) => b.account_type === 'expense')
.reduce((s: number, b: any) => s + parseFloat(b.annual_total || '0'), 0);
// Components needing replacement within 5 years
const urgentComponents = reserveComponents.filter(
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
);
return {
reserveCash,
totalInvestments,
totalReserveFund,
accounts,
investments,
reserveComponents,
projects,
budgets,
totalReplacementCost,
totalComponentFunded,
percentFunded,
totalProjectCost,
annualReserveContribution,
annualReserveExpenses,
urgentComponents,
year,
};
}
// ── AI Prompt Construction ──
private buildOperatingPrompt(data: any): Array<{ role: string; content: string }> {
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are an HOA financial health analyst. You evaluate the operating fund health of homeowners associations on a scale of 0-100.
SCORING GUIDELINES:
- 90-100 (Excellent): Strong cash reserves (6+ months expenses), income exceeding expenses, low delinquency, well under budget
- 75-89 (Good): Healthy cash position (3-6 months expenses), income roughly matching expenses, manageable delinquency
- 60-74 (Fair): Adequate but thin cash cushion (2-3 months), budget variances, moderate delinquency
- 40-59 (Needs Attention): Low cash reserves (<2 months expenses), spending over budget, rising delinquency
- 20-39 (At Risk): Critically low cash, significant budget overruns, high delinquency threatening operations
- 0-19 (Critical): Negative cash flow, unable to meet obligations, emergency situation
KEY FACTORS TO EVALUATE:
1. Cash reserves relative to monthly operating expenses (months of runway)
2. Budget performance: YTD actual income vs budgeted, YTD actual expenses vs budgeted
3. Assessment collection rate and delinquency
4. Income-to-expense ratio
5. Emergency buffer adequacy
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences):
{
"score": 82,
"label": "Good",
"summary": "1-2 sentence plain-English summary of operating fund health",
"factors": [
{ "name": "Cash Reserves", "impact": "positive", "detail": "6.2 months of expenses in operating cash" },
{ "name": "Budget Performance", "impact": "neutral", "detail": "YTD spending is 3% over budget" }
],
"recommendations": [
{ "priority": "medium", "text": "Consider increasing delinquency follow-up to reduce $4,200 in overdue assessments" }
]
}
Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar amounts.`;
const accountLines = data.accounts
.map((a: any) => `- ${a.name} (${a.account_number}) [${a.account_type}]: $${parseFloat(a.balance || '0').toFixed(2)}`)
.join('\n');
const budgetLines = data.budgets
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
.join('\n') || 'No budget line items.';
const assessmentLines = data.assessments
.map((a: any) => `- ${a.name}: $${parseFloat(a.regular_assessment || '0').toFixed(2)}/unit × ${a.unit_count} units (${a.frequency})`)
.join('\n') || 'No assessment groups.';
const userPrompt = `Evaluate this HOA's operating fund health.
TODAY: ${today}
FISCAL YEAR: ${data.year}
CURRENT MONTH: ${data.currentMonth} of 12
=== OPERATING FUND ACCOUNTS ===
${accountLines}
Total Operating Cash: $${data.operatingCash.toFixed(2)}
=== BUDGET (${data.year}) ===
${budgetLines}
Budgeted Annual Income: $${data.budgetedIncomeAnnual.toFixed(2)}
Budgeted Annual Expenses: $${data.budgetedExpenseAnnual.toFixed(2)}
Monthly Expense Run Rate: $${(data.budgetedExpenseAnnual / 12).toFixed(2)}
=== BUDGET VS ACTUAL (YTD through month ${data.currentMonth}) ===
Budgeted Income YTD: $${data.budgetedIncomeYTD.toFixed(2)}
Actual Income YTD: $${data.ytdIncome.toFixed(2)}
Income Variance: $${(data.ytdIncome - data.budgetedIncomeYTD).toFixed(2)} (${data.budgetedIncomeYTD > 0 ? ((data.ytdIncome / data.budgetedIncomeYTD) * 100).toFixed(1) : 0}% of budget)
Budgeted Expenses YTD: $${data.budgetedExpenseYTD.toFixed(2)}
Actual Expenses YTD: $${data.ytdExpense.toFixed(2)}
Expense Variance: $${(data.ytdExpense - data.budgetedExpenseYTD).toFixed(2)} (${data.budgetedExpenseYTD > 0 ? ((data.ytdExpense / data.budgetedExpenseYTD) * 100).toFixed(1) : 0}% of budget)
=== CASH RUNWAY ===
Months of Operating Expenses Covered: ${data.monthsOfExpenses.toFixed(1)} months
=== ASSESSMENT INCOME ===
${assessmentLines}
Monthly Assessment Income: $${data.monthlyAssessmentIncome.toFixed(2)}
=== DELINQUENCY ===
Overdue Invoices: ${data.delinquentCount}
Total Overdue Amount: $${data.delinquentAmount.toFixed(2)}
Delinquency Rate: ${data.monthlyAssessmentIncome > 0 ? ((data.delinquentAmount / (data.monthlyAssessmentIncome * 3)) * 100).toFixed(1) : 0}% of quarterly income`;
return [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
];
}
private buildReservePrompt(data: any): Array<{ role: string; content: string }> {
const today = new Date().toISOString().split('T')[0];
const systemPrompt = `You are an HOA reserve fund analyst. You evaluate reserve fund health on a scale of 0-100, assessing whether the HOA is adequately prepared for future capital expenditures.
SCORING GUIDELINES:
- 90-100 (Excellent): 70%+ funded ratio, strong annual contributions, well-documented components, investments earning returns, no urgent unfunded projects
- 75-89 (Good): 50-70% funded, adequate contributions, most components tracked, some investments
- 60-74 (Fair): 30-50% funded, contributions below ideal, some components untracked, limited investments
- 40-59 (Needs Attention): 20-30% funded, inadequate contributions, multiple urgent unfunded components
- 20-39 (At Risk): <20% funded, no meaningful contributions, urgent replacements needed with no funding
- 0-19 (Critical): Nearly depleted reserves, imminent major expenditures, no funding plan
KEY FACTORS TO EVALUATE:
1. Percent funded (total reserve assets vs total replacement costs)
2. Annual contribution adequacy (is annual contribution enough to keep pace with aging components?)
3. Component urgency (components due within 5 years and their funding status)
4. Capital project readiness (are planned projects adequately funded?)
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
6. Diversity of reserve components (is the full building covered?)
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences):
{
"score": 65,
"label": "Fair",
"summary": "1-2 sentence plain-English summary of reserve fund health",
"factors": [
{ "name": "Funded Ratio", "impact": "negative", "detail": "Only 42% funded against $1.2M in replacement costs" },
{ "name": "Annual Contributions", "impact": "positive", "detail": "$48,000/year being set aside for reserves" }
],
"recommendations": [
{ "priority": "high", "text": "Roof replacement due in 2 years needs $180,000 — only $45,000 currently set aside" }
]
}
Provide 3-5 factors and 1-3 actionable recommendations. Be specific with dollar amounts and timelines.`;
const accountLines = data.accounts
.map((a: any) => `- ${a.name} (${a.account_number}) [${a.account_type}]: $${parseFloat(a.balance || '0').toFixed(2)}`)
.join('\n');
const investmentLines = data.investments.length === 0
? 'No reserve investments.'
: data.investments.map((i: any) =>
`- ${i.name} | ${i.investment_type} @ ${i.institution} | $${parseFloat(i.current_value || i.principal || '0').toFixed(2)} | Rate: ${parseFloat(i.interest_rate || '0').toFixed(2)}% | Maturity: ${i.maturity_date ? new Date(i.maturity_date).toLocaleDateString() : 'N/A'}`,
).join('\n');
const componentLines = data.reserveComponents.length === 0
? 'No reserve components tracked.'
: data.reserveComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const pct = cost > 0 ? ((funded / cost) * 100).toFixed(0) : '0';
return `- ${c.name} [${c.category}] | Life: ${c.useful_life_years}yr, Remaining: ${c.remaining_life_years}yr | Cost: $${cost.toFixed(0)} | Funded: $${funded.toFixed(0)} (${pct}%) | Condition: ${c.condition_rating}/10 | Annual Contribution: $${parseFloat(c.annual_contribution || '0').toFixed(0)}`;
}).join('\n');
const projectLines = data.projects.length === 0
? 'No capital projects planned.'
: data.projects.map((p: any) =>
`- ${p.name} | Cost: $${parseFloat(p.estimated_cost || '0').toFixed(0)} | Target: ${p.target_year || '?'}/${p.target_month || '?'} | Status: ${p.status} | Funded: ${parseFloat(p.funded_percentage || '0').toFixed(0)}% | Source: ${p.fund_source}`,
).join('\n');
const budgetLines = data.budgets
.map((b: any) => `- ${b.name} (${b.account_number}) [${b.account_type}]: $${parseFloat(b.annual_total || '0').toFixed(2)}/yr`)
.join('\n') || 'No reserve budget line items.';
const urgentLines = data.urgentComponents.length === 0
? 'None — no components due within 5 years.'
: data.urgentComponents.map((c: any) => {
const cost = parseFloat(c.replacement_cost || '0');
const funded = parseFloat(c.current_fund_balance || '0');
const gap = cost - funded;
return `- ${c.name}: ${c.remaining_life_years} years remaining, $${gap.toFixed(0)} funding gap`;
}).join('\n');
const userPrompt = `Evaluate this HOA's reserve fund health.
TODAY: ${today}
FISCAL YEAR: ${data.year}
=== RESERVE FUND OVERVIEW ===
Reserve Cash (bank accounts): $${data.reserveCash.toFixed(2)}
Reserve Investments: $${data.totalInvestments.toFixed(2)}
Total Reserve Fund: $${data.totalReserveFund.toFixed(2)}
Total Replacement Cost (all components): $${data.totalReplacementCost.toFixed(2)}
Percent Funded: ${data.percentFunded.toFixed(1)}%
Annual Reserve Contribution (budgeted income): $${data.annualReserveContribution.toFixed(2)}
Annual Reserve Expenses (budgeted): $${data.annualReserveExpenses.toFixed(2)}
Net Annual Reserve Growth: $${(data.annualReserveContribution - data.annualReserveExpenses).toFixed(2)}
=== RESERVE ACCOUNTS ===
${accountLines}
=== RESERVE INVESTMENTS ===
${investmentLines}
=== RESERVE COMPONENTS (ordered by urgency) ===
${componentLines}
=== COMPONENTS DUE WITHIN 5 YEARS (URGENT) ===
${urgentLines}
=== CAPITAL PROJECTS ===
${projectLines}
Total Planned Project Cost: $${data.totalProjectCost.toFixed(2)}
=== RESERVE BUDGET (${data.year}) ===
${budgetLines}`;
return [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt },
];
}
// ── AI API Call (mirrors investment-planning pattern) ──
private async callAI(messages: Array<{ role: string; content: string }>): Promise<AIHealthResponse> {
const apiUrl = this.configService.get<string>('AI_API_URL') || 'https://integrate.api.nvidia.com/v1';
const apiKey = this.configService.get<string>('AI_API_KEY');
const model = this.configService.get<string>('AI_MODEL') || 'qwen/qwen3.5-397b-a17b';
if (!apiKey) {
this.logger.error('AI_API_KEY not configured');
return {
score: 50,
label: 'Unknown',
summary: 'AI health scoring is not available. Configure AI_API_KEY in environment.',
factors: [],
recommendations: [{ priority: 'high', text: 'Configure AI_API_KEY to enable health scoring.' }],
};
}
const requestBody = {
model,
messages,
temperature: 0.3,
max_tokens: 2048,
};
const bodyString = JSON.stringify(requestBody);
this.debug('health_prompt_system', messages[0]?.content);
this.debug('health_prompt_user', messages[1]?.content);
try {
const { URL } = await import('url');
const https = await import('https');
const aiResult = await new Promise<any>((resolve, reject) => {
const url = new URL(`${apiUrl}/chat/completions`);
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(bodyString, 'utf-8'),
},
timeout: 120000,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body: data });
});
});
req.on('error', (err) => reject(err));
req.on('timeout', () => {
req.destroy();
reject(new Error('Request timed out after 120s'));
});
req.write(bodyString);
req.end();
});
if (aiResult.status >= 400) {
throw new Error(`AI API returned ${aiResult.status}: ${aiResult.body}`);
}
const data = JSON.parse(aiResult.body);
const content = data.choices?.[0]?.message?.content || null;
if (!content) {
throw new Error('AI model returned empty content');
}
// Parse JSON — handle markdown fences and thinking blocks
let cleaned = content.trim();
if (cleaned.startsWith('```')) {
cleaned = cleaned.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '');
}
cleaned = cleaned.replace(/<think>[\s\S]*?<\/think>\s*/g, '').trim();
const parsed = JSON.parse(cleaned) as AIHealthResponse;
// Validate
if (typeof parsed.score !== 'number' || parsed.score < 0 || parsed.score > 100) {
throw new Error(`Invalid score: ${parsed.score}`);
}
// Ensure label matches score
if (!parsed.label) {
if (parsed.score >= 90) parsed.label = 'Excellent';
else if (parsed.score >= 75) parsed.label = 'Good';
else if (parsed.score >= 60) parsed.label = 'Fair';
else if (parsed.score >= 40) parsed.label = 'Needs Attention';
else if (parsed.score >= 20) parsed.label = 'At Risk';
else parsed.label = 'Critical';
}
this.debug('health_response', parsed);
return parsed;
} catch (error: any) {
this.logger.error(`Health score AI call failed: ${error.message}`);
if (error instanceof SyntaxError) {
return {
score: 50,
label: 'Unknown',
summary: 'AI returned an invalid response format. Please try again.',
factors: [],
recommendations: [],
};
}
throw error;
}
}
}