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:
@@ -321,6 +321,106 @@ export class HealthScoresService {
|
|||||||
return s + (regular * units);
|
return s + (regular * units);
|
||||||
}, 0);
|
}, 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 {
|
return {
|
||||||
operatingCash,
|
operatingCash,
|
||||||
accounts,
|
accounts,
|
||||||
@@ -338,6 +438,10 @@ export class HealthScoresService {
|
|||||||
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
|
monthsOfExpenses: budgetedExpenseAnnual > 0 ? (operatingCash / (budgetedExpenseAnnual / 12)) : 0,
|
||||||
year,
|
year,
|
||||||
currentMonth: currentMonth + 1,
|
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
|
3. Assessment collection rate and delinquency
|
||||||
4. Income-to-expense ratio
|
4. Income-to-expense ratio
|
||||||
5. Emergency buffer adequacy
|
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:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
@@ -538,7 +643,20 @@ Monthly Assessment Income: $${data.monthlyAssessmentIncome.toFixed(2)}
|
|||||||
=== DELINQUENCY ===
|
=== DELINQUENCY ===
|
||||||
Overdue Invoices: ${data.delinquentCount}
|
Overdue Invoices: ${data.delinquentCount}
|
||||||
Total Overdue Amount: $${data.delinquentAmount.toFixed(2)}
|
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 [
|
return [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
|
|||||||
Reference in New Issue
Block a user