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:
@@ -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: [
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
10
backend/src/modules/health-scores/health-scores.module.ts
Normal file
10
backend/src/modules/health-scores/health-scores.module.ts
Normal 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 {}
|
||||
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal file
54
backend/src/modules/health-scores/health-scores.scheduler.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
788
backend/src/modules/health-scores/health-scores.service.ts
Normal file
788
backend/src/modules/health-scores/health-scores.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user