From b0b36df4e4f7481b3bcd8a00ca69a8736150b707 Mon Sep 17 00:00:00 2001 From: olsch01 Date: Mon, 2 Mar 2026 10:18:34 -0500 Subject: [PATCH] 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 --- .../health-scores/health-scores.service.ts | 174 +++++++++++++++++- .../src/pages/dashboard/DashboardPage.tsx | 5 + 2 files changed, 174 insertions(+), 5 deletions(-) diff --git a/backend/src/modules/health-scores/health-scores.service.ts b/backend/src/modules/health-scores/health-scores.service.ts index e14f7c5..f0046da 100644 --- a/backend/src/modules/health-scores/health-scores.service.ts +++ b/backend/src/modules/health-scores/health-scores.service.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + 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 }, diff --git a/frontend/src/pages/dashboard/DashboardPage.tsx b/frontend/src/pages/dashboard/DashboardPage.tsx index 068e680..74e414f 100644 --- a/frontend/src/pages/dashboard/DashboardPage.tsx +++ b/frontend/src/pages/dashboard/DashboardPage.tsx @@ -214,6 +214,11 @@ function HealthScoreCard({ score, title, icon }: { score: HealthScore | null; ti + {score.calculated_at && ( + + Last updated {new Date(score.calculated_at).toLocaleDateString()} at {new Date(score.calculated_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} + + )} ); }