Reserve health: add projected cash flow with special assessments; add Last Updated to cards

Reserve fund analysis now includes 12-month forward projection with
special assessment income (by frequency), monthly budget data,
capital project costs, and investment maturities. AI prompt updated
to evaluate projected reserve liquidity and timing risks.

Both health score dashboard cards now show a subtle "Last updated"
timestamp at the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 10:18:34 -05:00
parent aa7f2dab32
commit b0b36df4e4
2 changed files with 174 additions and 5 deletions

View File

@@ -447,8 +447,11 @@ export class HealthScoresService {
private async gatherReserveData(qr: any) {
const year = new Date().getFullYear();
const currentMonth = new Date().getMonth(); // 0-indexed
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt'];
const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const [accounts, investments, reserveComponents, projects, budgets] = await Promise.all([
const [accounts, investments, reserveComponents, projects, budgets, assessments] = await Promise.all([
// Reserve accounts with balances
qr.query(`
SELECT a.name, a.account_number, a.account_type, a.fund_type,
@@ -468,7 +471,7 @@ export class HealthScoresService {
// Investment accounts (reserve fund)
qr.query(`
SELECT name, institution, investment_type, fund_type,
principal, interest_rate, maturity_date, current_value
principal, interest_rate, maturity_date, purchase_date, current_value
FROM investment_accounts
WHERE is_active = true AND fund_type = 'reserve'
ORDER BY maturity_date NULLS LAST
@@ -488,17 +491,25 @@ export class HealthScoresService {
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
ORDER BY target_year, target_month NULLS LAST
`),
// Reserve budget
// Reserve budget (with monthly breakdown)
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.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 = 'reserve'
ORDER BY a.account_type, a.account_number`,
[year],
),
// Assessment groups (for special assessment income → reserve fund)
qr.query(`
SELECT ag.name, ag.frequency, ag.special_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
`),
]);
const reserveCash = accounts
@@ -534,6 +545,128 @@ export class HealthScoresService {
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
);
// ── Build 12-month forward reserve cash flow projection ──
// Special assessment income helper (respects frequency)
const getSpecialAssessmentIncome = (month: number): number => {
let total = 0;
for (const ag of assessments) {
const units = parseInt(ag.unit_count) || 0;
const special = parseFloat(ag.special_assessment) || 0;
if (special === 0) continue;
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 += special * units;
}
return total;
};
const monthlySpecialAssessmentIncome = getSpecialAssessmentIncome(1); // representative monthly (for display)
// Next year's reserve budget
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 = 'reserve'`,
[year + 1],
);
// Build per-month budget totals
const budgetByYearMonth: Record<string, { income: number; expense: number }> = {};
for (const yr of [year, year + 1]) {
const rows = yr === year ? 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;
}
}
}
// Capital project costs by month (reserve-funded only)
const projectByMonth: Record<string, number> = {};
for (const p of projects) {
if (p.fund_source === 'operating') continue;
const yr = parseInt(p.target_year);
const mo = parseInt(p.target_month) || 6;
if (!yr || isNaN(yr)) continue;
const key = `${yr}-${mo}`;
projectByMonth[key] = (projectByMonth[key] || 0) + (parseFloat(p.estimated_cost) || 0);
}
// Investment maturities by month
const maturityByMonth: Record<string, number> = {};
for (const inv of investments) {
if (!inv.maturity_date) continue;
const d = new Date(inv.maturity_date);
if (d <= new Date()) continue;
const key = `${d.getFullYear()}-${d.getMonth() + 1}`;
const val = parseFloat(inv.current_value) || 0;
const rate = parseFloat(inv.interest_rate) || 0;
const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date();
const daysHeld = Math.max((d.getTime() - purchaseDate.getTime()) / 86400000, 1);
const interestEarned = val * (rate / 100) * (daysHeld / 365);
maturityByMonth[key] = (maturityByMonth[key] || 0) + val + interestEarned;
}
// Build 12-month projection
let runningCash = reserveCash;
let runningInvestments = totalInvestments;
const forecast: Array<{
month: string; income: number; specialAssessment: number; expense: number;
projectCost: number; maturity: number; netChange: number; endingCash: number;
endingTotal: number;
}> = [];
let lowestCash = reserveCash;
let lowestCashMonth = '';
for (let i = 0; i < 12; i++) {
const projYear = year + 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 };
const specialAssessment = getSpecialAssessmentIncome(projMonth);
// Use budget income if available, else fall back to special assessment income
const income = budgetData.income > 0 ? budgetData.income : specialAssessment;
const expense = budgetData.expense;
const projectCost = projectByMonth[key] || 0;
const maturity = maturityByMonth[key] || 0;
const netChange = income - expense - projectCost + maturity;
runningCash += netChange;
// Subtract maturing investment principal from investment balance
if (maturity > 0) runningInvestments = Math.max(0, runningInvestments - (maturity * 0.96));
forecast.push({
month: label,
income: Math.round(income * 100) / 100,
specialAssessment: Math.round(specialAssessment * 100) / 100,
expense: Math.round(expense * 100) / 100,
projectCost: Math.round(projectCost * 100) / 100,
maturity: Math.round(maturity * 100) / 100,
netChange: Math.round(netChange * 100) / 100,
endingCash: Math.round(runningCash * 100) / 100,
endingTotal: Math.round((runningCash + runningInvestments) * 100) / 100,
});
if (runningCash < lowestCash) {
lowestCash = runningCash;
lowestCashMonth = label;
}
}
return {
reserveCash,
totalInvestments,
@@ -543,6 +676,7 @@ export class HealthScoresService {
reserveComponents,
projects,
budgets,
assessments,
totalReplacementCost,
totalComponentFunded,
percentFunded,
@@ -550,7 +684,12 @@ export class HealthScoresService {
annualReserveContribution,
annualReserveExpenses,
urgentComponents,
monthlySpecialAssessmentIncome,
year,
forecast,
lowestCash: Math.round(lowestCash * 100) / 100,
lowestCashMonth,
projectedYearEndTotal: forecast.length > 0 ? forecast[forecast.length - 1].endingTotal : totalReserveFund,
};
}
@@ -684,6 +823,7 @@ KEY FACTORS TO EVALUATE:
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?)
7. CRITICAL — Projected cash flow: Use the 12-MONTH RESERVE CASH FLOW FORECAST to assess future liquidity. The forecast shows month-by-month projected income (from special assessments collected from homeowners AND budgeted reserve income), expenses, capital project costs, and investment maturities returning cash. Check whether the reserve fund will have sufficient liquidity when capital projects are due. If special assessment income arrives before project costs, the position may be adequate even if current cash seems low.
RESPONSE FORMAT:
Respond with ONLY valid JSON (no markdown, no code fences):
@@ -774,7 +914,31 @@ ${projectLines}
Total Planned Project Cost: $${data.totalProjectCost.toFixed(2)}
=== RESERVE BUDGET (${data.year}) ===
${budgetLines}`;
${budgetLines}
=== SPECIAL ASSESSMENT INCOME (Reserve Fund) ===
${data.assessments.length === 0 ? 'No special assessments configured.' :
data.assessments.map((a: any) => {
const special = parseFloat(a.special_assessment || '0');
if (special === 0) return null;
return `- ${a.name}: $${special.toFixed(2)}/unit × ${a.unit_count} units (${a.frequency}) = $${(special * parseInt(a.unit_count || '0')).toFixed(2)}/period → Reserve Fund`;
}).filter(Boolean).join('\n') || 'No special assessments currently being collected.'}
=== 12-MONTH PROJECTED CASH FLOW (Reserve Fund) ===
Starting Reserve Cash: $${data.reserveCash.toFixed(2)}
Starting Reserve Investments: $${data.totalInvestments.toFixed(2)}
${data.forecast.map((f: any) => {
const drivers: string[] = [];
if (f.income > 0) drivers.push(`Income:$${f.income.toFixed(0)}`);
if (f.specialAssessment > 0 && f.income !== f.specialAssessment) drivers.push(`SpecialAssmt:$${f.specialAssessment.toFixed(0)}`);
if (f.expense > 0) drivers.push(`Expense:$${f.expense.toFixed(0)}`);
if (f.projectCost > 0) drivers.push(`ProjectCost:$${f.projectCost.toFixed(0)}`);
if (f.maturity > 0) drivers.push(`Maturity:$${f.maturity.toFixed(0)}`);
return `- ${f.month} | Net: $${f.netChange >= 0 ? '+' : ''}${f.netChange.toFixed(0)} | Cash: $${f.endingCash.toFixed(0)} | Total(Cash+Inv): $${f.endingTotal.toFixed(0)} | ${drivers.join(', ')}`;
}).join('\n')}
Projected Cash Low Point: $${data.lowestCash.toFixed(0)}${data.lowestCashMonth ? ` in ${data.lowestCashMonth}` : ''}
Projected Year-End Total (Cash + Investments): $${data.projectedYearEndTotal.toFixed(0)}`;
return [
{ role: 'system', content: systemPrompt },

View File

@@ -214,6 +214,11 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
</Group>
</Stack>
</Group>
{score.calculated_at && (
<Text size="10px" c="dimmed" ta="right" mt={6} style={{ opacity: 0.7 }}>
Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
</Text>
)}
</Card>
);
}