From aa7f2dab3249453ac18ffee72a7b4b46f7dd6397 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 2 Mar 2026 10:10:51 -0500 Subject: [PATCH] Add 12-month projected cash flow to operating health score analysis The operating health score now includes forward-looking cash flow projections using monthly budget data, assessment income schedules, and operating project costs. AI prompt updated to evaluate projected liquidity, timing risks, and year-end cash position. Co-Authored-By: Claude Opus 4.6 --- .../health-scores/health-scores.service.ts | 120 +++++++++++++++++- 1 file changed, 119 insertions(+), 1 deletion(-) diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index efb84e9..e14f7c5 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -321,6 +321,106 @@ export class HealthScoresService { return s + (regular * units); }, 0); + // ── Build 12-month forward operating cash flow projection ── + const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; + const currentYear = year; + + // Get next year's budget too (for months that spill into next year) + const nextYearBudgets = await qr.query( + `SELECT 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 + FROM budgets b + JOIN accounts a ON a.id = b.account_id + WHERE b.fiscal_year = $1 AND b.fund_type = 'operating'`, + [currentYear + 1], + ); + + // Build per-month budget totals for current and next year + const budgetByYearMonth: Record = {}; + for (const yr of [currentYear, currentYear + 1]) { + const rows = yr === currentYear ? budgets : nextYearBudgets; + for (let m = 0; m < 12; m++) { + const key = `${yr}-${m + 1}`; + if (!budgetByYearMonth[key]) budgetByYearMonth[key] = { income: 0, expense: 0 }; + for (const row of rows) { + const amt = parseFloat(row[monthNames[m]]) || 0; + if (amt === 0) continue; + if (row.account_type === 'income') budgetByYearMonth[key].income += amt; + else if (row.account_type === 'expense') budgetByYearMonth[key].expense += amt; + } + } + } + + // Assessment income helper (respects frequency) + const getAssessmentIncome = (month: number): number => { + let total = 0; + for (const ag of assessments) { + const units = parseInt(ag.unit_count) || 0; + const regular = parseFloat(ag.regular_assessment) || 0; + const freq = ag.frequency || 'monthly'; + let applies = false; + if (freq === 'monthly') applies = true; + else if (freq === 'quarterly') applies = [1, 4, 7, 10].includes(month); + else if (freq === 'annual') applies = month === 1; + if (applies) total += regular * units; + } + return total; + }; + + // Operating projects scheduled in the future + const operatingProjects = await qr.query(` + SELECT estimated_cost, target_year, target_month + FROM projects + WHERE is_active = true AND status IN ('planned', 'in_progress') + AND fund_source = 'operating' + AND target_year IS NOT NULL AND estimated_cost > 0 + `); + const projectByMonth: Record = {}; + for (const p of operatingProjects) { + const key = `${parseInt(p.target_year)}-${parseInt(p.target_month) || 6}`; + projectByMonth[key] = (projectByMonth[key] || 0) + (parseFloat(p.estimated_cost) || 0); + } + + // Build 12-month projection + let runningCash = operatingCash; + const forecast: Array<{ + month: string; income: number; expense: number; projectCost: number; + netChange: number; endingCash: number; + }> = []; + let lowestCash = operatingCash; + let lowestCashMonth = ''; + + for (let i = 0; i < 12; i++) { + const projYear = currentYear + Math.floor((currentMonth + i) / 12); + const projMonth = ((currentMonth + i) % 12) + 1; + const key = `${projYear}-${projMonth}`; + const label = `${monthLabels[projMonth - 1]} ${projYear}`; + + const budgetData = budgetByYearMonth[key] || { income: 0, expense: 0 }; + // Use budget income if available, else fall back to assessment income + const income = budgetData.income > 0 ? budgetData.income : getAssessmentIncome(projMonth); + const expense = budgetData.expense; + const projectCost = projectByMonth[key] || 0; + const netChange = income - expense - projectCost; + + runningCash += netChange; + + forecast.push({ + month: label, + income: Math.round(income * 100) / 100, + expense: Math.round(expense * 100) / 100, + projectCost: Math.round(projectCost * 100) / 100, + netChange: Math.round(netChange * 100) / 100, + endingCash: Math.round(runningCash * 100) / 100, + }); + + if (runningCash < lowestCash) { + lowestCash = runningCash; + lowestCashMonth = label; + } + } + return { operatingCash, accounts, @@ -338,6 +438,10 @@ export class HealthScoresService { monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0, year, currentMonth: currentMonth + 1, + forecast, + lowestCash: Math.round(lowestCash * 100) / 100, + lowestCashMonth, + projectedYearEndCash: forecast.length > 0 ? forecast[forecast.length - 1].endingCash : operatingCash, }; } @@ -471,6 +575,7 @@ KEY FACTORS TO EVALUATE: 3. Assessment collection rate and delinquency 4. Income-to-expense ratio 5. Emergency buffer adequacy +6. CRITICAL — Projected cash flow: Use the 12-MONTH CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from assessments and budgeted sources), expenses (from budget), and project costs. Check whether cash will go negative or dangerously low in any future month. If projected income arrives before projected expenses, the position may be adequate even if current cash seems low. Conversely, if a large expense precedes income in a given month, flag the timing risk. RESPONSE FORMAT: Respond with ONLY valid JSON (no markdown, no code fences): @@ -538,7 +643,20 @@ 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`; +Delinquency Rate: ${data.monthlyAssessmentIncome > 0 ? ((data.delinquentAmount / (data.monthlyAssessmentIncome * 3)) * 100).toFixed(1) : 0}% of quarterly income + +=== 12-MONTH PROJECTED CASH FLOW (Operating Fund) === +Starting Cash: $${data.operatingCash.toFixed(2)} +${data.forecast.map((f: any) => { + const drivers: string[] = []; + if (f.income > 0) drivers.push(`Income:$${f.income.toFixed(0)}`); + if (f.expense > 0) drivers.push(`Expense:$${f.expense.toFixed(0)}`); + if (f.projectCost > 0) drivers.push(`Project:$${f.projectCost.toFixed(0)}`); + return `- ${f.month} | Net: $${f.netChange >= 0 ? '+' : ''}${f.netChange.toFixed(0)} | Ending Cash: $${f.endingCash.toFixed(0)} | ${drivers.join(', ')}`; + }).join('\n')} + +Projected Low Point: $${data.lowestCash.toFixed(0)}${data.lowestCashMonth ? ` in ${data.lowestCashMonth}` : ''} +Projected Year-End Cash: $${data.projectedYearEndCash.toFixed(0)}`; return [ { role: 'system', content: systemPrompt },