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:
@@ -447,8 +447,11 @@ export class HealthScoresService {
|
|||||||
|
|
||||||
private async gatherReserveData(qr: any) {
|
private async gatherReserveData(qr: any) {
|
||||||
const year = new Date().getFullYear();
|
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
|
// Reserve accounts with balances
|
||||||
qr.query(`
|
qr.query(`
|
||||||
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
SELECT a.name, a.account_number, a.account_type, a.fund_type,
|
||||||
@@ -468,7 +471,7 @@ export class HealthScoresService {
|
|||||||
// Investment accounts (reserve fund)
|
// Investment accounts (reserve fund)
|
||||||
qr.query(`
|
qr.query(`
|
||||||
SELECT name, institution, investment_type, fund_type,
|
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
|
FROM investment_accounts
|
||||||
WHERE is_active = true AND fund_type = 'reserve'
|
WHERE is_active = true AND fund_type = 'reserve'
|
||||||
ORDER BY maturity_date NULLS LAST
|
ORDER BY maturity_date NULLS LAST
|
||||||
@@ -488,17 +491,25 @@ export class HealthScoresService {
|
|||||||
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
WHERE is_active = true AND status IN ('planned', 'approved', 'in_progress')
|
||||||
ORDER BY target_year, target_month NULLS LAST
|
ORDER BY target_year, target_month NULLS LAST
|
||||||
`),
|
`),
|
||||||
// Reserve budget
|
// Reserve budget (with monthly breakdown)
|
||||||
qr.query(
|
qr.query(
|
||||||
`SELECT a.name, a.account_number, a.account_type,
|
`SELECT a.name, a.account_number, a.account_type,
|
||||||
(b.jan + b.feb + b.mar + b.apr + b.may + b.jun +
|
(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
|
FROM budgets b
|
||||||
JOIN accounts a ON a.id = b.account_id
|
JOIN accounts a ON a.id = b.account_id
|
||||||
WHERE b.fiscal_year = $1 AND b.fund_type = 'reserve'
|
WHERE b.fiscal_year = $1 AND b.fund_type = 'reserve'
|
||||||
ORDER BY a.account_type, a.account_number`,
|
ORDER BY a.account_type, a.account_number`,
|
||||||
[year],
|
[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
|
const reserveCash = accounts
|
||||||
@@ -534,6 +545,128 @@ export class HealthScoresService {
|
|||||||
(c: any) => c.remaining_life_years !== null && parseFloat(c.remaining_life_years) <= 5,
|
(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 {
|
return {
|
||||||
reserveCash,
|
reserveCash,
|
||||||
totalInvestments,
|
totalInvestments,
|
||||||
@@ -543,6 +676,7 @@ export class HealthScoresService {
|
|||||||
reserveComponents,
|
reserveComponents,
|
||||||
projects,
|
projects,
|
||||||
budgets,
|
budgets,
|
||||||
|
assessments,
|
||||||
totalReplacementCost,
|
totalReplacementCost,
|
||||||
totalComponentFunded,
|
totalComponentFunded,
|
||||||
percentFunded,
|
percentFunded,
|
||||||
@@ -550,7 +684,12 @@ export class HealthScoresService {
|
|||||||
annualReserveContribution,
|
annualReserveContribution,
|
||||||
annualReserveExpenses,
|
annualReserveExpenses,
|
||||||
urgentComponents,
|
urgentComponents,
|
||||||
|
monthlySpecialAssessmentIncome,
|
||||||
year,
|
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?)
|
4. Capital project readiness (are planned projects adequately funded?)
|
||||||
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
5. Investment strategy (are reserves earning returns through CDs, money markets, etc.?)
|
||||||
6. Diversity of reserve components (is the full building covered?)
|
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:
|
RESPONSE FORMAT:
|
||||||
Respond with ONLY valid JSON (no markdown, no code fences):
|
Respond with ONLY valid JSON (no markdown, no code fences):
|
||||||
@@ -774,7 +914,31 @@ ${projectLines}
|
|||||||
Total Planned Project Cost: $${data.totalProjectCost.toFixed(2)}
|
Total Planned Project Cost: $${data.totalProjectCost.toFixed(2)}
|
||||||
|
|
||||||
=== RESERVE BUDGET (${data.year}) ===
|
=== 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 [
|
return [
|
||||||
{ role: 'system', content: systemPrompt },
|
{ role: 'system', content: systemPrompt },
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti
|
|||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Group>
|
</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>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user