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 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 10:10:51 -05:00
parent d2d553eed6
commit aa7f2dab32

View File

@@ -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<string, { income: number; expense: number }> = {};
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<string, number> = {};
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 },