diff --git a/backend/package-lock.json b/backend/package-lock.json index 9f554ac..bf509a8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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", diff --git a/backend/package.json b/backend/package.json index b87eeda..c8cab4a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index db7ca5b..dae6fa5 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -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: [ diff --git a/backend/src/database/tenant-schema.service.ts b/backend/src/database/tenant-schema.service.ts index 2534f82..d5e8d41 100644 --- a/backend/src/database/tenant-schema.service.ts +++ b/backend/src/database/tenant-schema.service.ts @@ -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(), diff --git a/backend/src/modules/health-scores/health-scores.controller.ts b/backend/src/modules/health-scores/health-scores.controller.ts new file mode 100644 index 0000000..7efad05 --- /dev/null +++ b/backend/src/modules/health-scores/health-scores.controller.ts @@ -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 }; + } +} diff --git a/backend/src/modules/health-scores/health-scores.module.ts b/backend/src/modules/health-scores/health-scores.module.ts new file mode 100644 index 0000000..1053ba9 --- /dev/null +++ b/backend/src/modules/health-scores/health-scores.module.ts @@ -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 {} diff --git a/backend/src/modules/health-scores/health-scores.scheduler.ts b/backend/src/modules/health-scores/health-scores.scheduler.ts new file mode 100644 index 0000000..b497fb3 --- /dev/null +++ b/backend/src/modules/health-scores/health-scores.scheduler.ts @@ -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}`); + } + } +} diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts new file mode 100644 index 0000000..6ecbec9 --- /dev/null +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -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('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 { + 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 { + 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 { + 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 { + const apiUrl = this.configService.get('AI_API_URL') || 'https://integrate.api.nvidia.com/v1'; + const apiKey = this.configService.get('AI_API_KEY'); + const model = this.configService.get('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((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(/[\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; + } + } +} diff --git a/db/migrations/010-health-scores.sql b/db/migrations/010-health-scores.sql new file mode 100644 index 0000000..2b4d528 --- /dev/null +++ b/db/migrations/010-health-scores.sql @@ -0,0 +1,34 @@ +-- Migration: Add health_scores table to all tenant schemas +-- This table stores AI-derived operating and reserve fund health scores + +DO $$ +DECLARE + tenant RECORD; +BEGIN + FOR tenant IN + SELECT schema_name FROM shared.organizations WHERE status = 'active' + LOOP + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.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() + )', tenant.schema_name + ); + EXECUTE format( + 'CREATE INDEX IF NOT EXISTS idx_%s_hs_type_calc ON %I.health_scores(score_type, calculated_at DESC)', + replace(tenant.schema_name, '.', '_'), tenant.schema_name + ); + END LOOP; +END $$; diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 0d9860d..068e680 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -1,6 +1,7 @@ import { Title, Text, SimpleGrid, Card, Group, ThemeIcon, Stack, Table, - Badge, Loader, Center, Divider, + Badge, Loader, Center, Divider, RingProgress, Tooltip, Button, + Popover, List, } from '@mantine/core'; import { IconCash, @@ -8,11 +9,215 @@ import { IconShieldCheck, IconAlertTriangle, IconBuildingBank, + IconTrendingUp, + IconTrendingDown, + IconMinus, + IconHeartbeat, + IconRefresh, + IconInfoCircle, } from '@tabler/icons-react'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useAuthStore } from '../../stores/authStore'; import api from '../../services/api'; +interface HealthScore { + id: string; + score_type: string; + score: number; + previous_score: number | null; + trajectory: string | null; + label: string; + summary: string; + factors: Array<{ name: string; impact: 'positive' | 'neutral' | 'negative'; detail: string }>; + recommendations: Array<{ priority: string; text: string }>; + missing_data: string[] | null; + status: string; + response_time_ms: number | null; + calculated_at: string; +} + +interface HealthScoresData { + operating: HealthScore | null; + reserve: HealthScore | null; +} + +function getScoreColor(score: number): string { + if (score >= 75) return 'green'; + if (score >= 60) return 'yellow'; + if (score >= 40) return 'orange'; + return 'red'; +} + +function TrajectoryIcon({ trajectory }: { trajectory: string | null }) { + if (trajectory === 'improving') return ; + if (trajectory === 'declining') return ; + if (trajectory === 'stable') return ; + return null; +} + +function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; title: string; icon: React.ReactNode }) { + if (!score) { + return ( + + + {title} Health + {icon} + +
+ No health score yet +
+
+ ); + } + + if (score.status === 'pending') { + const missingItems = Array.isArray(score.missing_data) ? score.missing_data : + (typeof score.missing_data === 'string' ? JSON.parse(score.missing_data) : []); + return ( + + + {title} Health + {icon} + +
+ + Pending + Missing data: + {missingItems.map((item: string, i: number) => ( + {item} + ))} + +
+
+ ); + } + + if (score.status === 'error') { + return ( + + + {title} Health + {icon} + +
+ Error calculating score +
+
+ ); + } + + const color = getScoreColor(score.score); + const factors = Array.isArray(score.factors) ? score.factors : + (typeof score.factors === 'string' ? JSON.parse(score.factors) : []); + const recommendations = Array.isArray(score.recommendations) ? score.recommendations : + (typeof score.recommendations === 'string' ? JSON.parse(score.recommendations) : []); + + return ( + + + {title} Health + {icon} + + + + {score.score} + /100 + + } + /> + + + {score.label} + {score.trajectory && ( + + + + {score.trajectory} + + + )} + {score.previous_score !== null && ( + (prev: {score.previous_score}) + )} + + {score.summary} + + {factors.slice(0, 3).map((f: any, i: number) => ( + + + {f.name} + + + ))} + {(factors.length > 3 || recommendations.length > 0) && ( + + + + Details + + + + + {factors.length > 0 && ( + <> + Factors + {factors.map((f: any, i: number) => ( + + + {f.name} + + {f.detail} + + ))} + + )} + {recommendations.length > 0 && ( + <> + + Recommendations + + {recommendations.map((r: any, i: number) => ( + + + {r.priority} + + {r.text} + + ))} + + + )} + {score.calculated_at && ( + + Updated: {new Date(score.calculated_at).toLocaleString()} + + )} + + + + )} + + + + + ); +} + interface DashboardData { total_cash: string; total_receivables: string; @@ -33,6 +238,7 @@ interface DashboardData { export function DashboardPage() { const currentOrg = useAuthStore((s) => s.currentOrg); + const queryClient = useQueryClient(); const { data, isLoading } = useQuery({ queryKey: ['dashboard'], @@ -40,6 +246,19 @@ export function DashboardPage() { enabled: !!currentOrg, }); + const { data: healthScores } = useQuery({ + queryKey: ['health-scores'], + queryFn: async () => { const { data } = await api.get('/health-scores/latest'); return data; }, + enabled: !!currentOrg, + }); + + const recalcMutation = useMutation({ + mutationFn: () => api.post('/health-scores/calculate'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['health-scores'] }); + }, + }); + const fmt = (v: string | number) => parseFloat(String(v || '0')).toLocaleString('en-US', { style: 'currency', currency: 'USD' }); @@ -66,6 +285,41 @@ export function DashboardPage() {
) : ( <> + + AI Health Scores + + + + + + + + + } + /> + + + + } + /> + +