import { Injectable, NotFoundException } from '@nestjs/common'; import { TenantService } from '../../database/tenant.service'; const monthLabels = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec_amt']; const round2 = (v: number) => Math.round(v * 100) / 100; @Injectable() export class BoardPlanningProjectionService { constructor(private tenant: TenantService) {} /** Return cached projection if fresh, otherwise compute. */ async getProjection(scenarioId: string) { const rows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]); if (!rows.length) throw new NotFoundException('Scenario not found'); const scenario = rows[0]; // Return cache if it exists and is less than 1 hour old if (scenario.projection_cache && scenario.projection_cached_at) { const age = Date.now() - new Date(scenario.projection_cached_at).getTime(); if (age < 3600000) return scenario.projection_cache; } return this.computeProjection(scenarioId); } /** Compute full projection for a scenario. */ async computeProjection(scenarioId: string) { const scenarioRows = await this.tenant.query('SELECT * FROM board_scenarios WHERE id = $1', [scenarioId]); if (!scenarioRows.length) throw new NotFoundException('Scenario not found'); const scenario = scenarioRows[0]; const investments = await this.tenant.query( 'SELECT * FROM scenario_investments WHERE scenario_id = $1 ORDER BY purchase_date', [scenarioId], ); const assessments = await this.tenant.query( 'SELECT * FROM scenario_assessments WHERE scenario_id = $1 ORDER BY effective_date', [scenarioId], ); const months = scenario.projection_months || 36; const now = new Date(); const startYear = now.getFullYear(); const currentMonth = now.getMonth() + 1; // ── 1. Baseline state (mirrors reports.service.ts getCashFlowForecast) ── const baseline = await this.getBaselineState(startYear, months); // ── 2. Build month-by-month projection ── let { opCash, resCash, opInv, resInv } = baseline.openingBalances; const datapoints: any[] = []; let totalInterestEarned = 0; const interestByInvestment: Record = {}; for (let i = 0; i < months; i++) { const year = startYear + Math.floor(i / 12); const month = (i % 12) + 1; const key = `${year}-${month}`; const label = `${monthLabels[month - 1]} ${year}`; const isHistorical = year < startYear || (year === startYear && month < currentMonth); // Baseline income/expenses from budget const budget = baseline.budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 }; const baseAssessment = this.getAssessmentIncome(baseline.assessmentGroups, month); const existingMaturity = baseline.maturityIndex[key] || { operating: 0, reserve: 0 }; const project = baseline.projectIndex[key] || { operating: 0, reserve: 0 }; // Scenario investment deltas for this month const invDelta = this.computeInvestmentDelta(investments, year, month); totalInterestEarned += invDelta.interestEarned; for (const [invId, amt] of Object.entries(invDelta.interestByInvestment)) { interestByInvestment[invId] = (interestByInvestment[invId] || 0) + amt; } // Scenario assessment deltas for this month const asmtDelta = this.computeAssessmentDelta(assessments, baseline.assessmentGroups, year, month); if (isHistorical) { // Historical months: use actual changes + scenario deltas const opChange = baseline.histIndex[`${year}-${month}-operating`] || 0; const resChange = baseline.histIndex[`${year}-${month}-reserve`] || 0; opCash += opChange + invDelta.opCashFlow + asmtDelta.operating; resCash += resChange + invDelta.resCashFlow + asmtDelta.reserve; } else { // Forecast months: budget + assessments + scenario deltas const opIncomeMonth = (budget.opIncome > 0 ? budget.opIncome : baseAssessment.operating) + asmtDelta.operating; const resIncomeMonth = (budget.resIncome > 0 ? budget.resIncome : baseAssessment.reserve) + asmtDelta.reserve; opCash += opIncomeMonth - budget.opExpense - project.operating + existingMaturity.operating + invDelta.opCashFlow; resCash += resIncomeMonth - budget.resExpense - project.reserve + existingMaturity.reserve + invDelta.resCashFlow; // Existing maturities reduce investment balances if (existingMaturity.operating > 0) { opInv -= existingMaturity.operating * 0.96; // approximate principal if (opInv < 0) opInv = 0; } if (existingMaturity.reserve > 0) { resInv -= existingMaturity.reserve * 0.96; if (resInv < 0) resInv = 0; } } // Scenario investment balance changes opInv += invDelta.opInvChange; resInv += invDelta.resInvChange; if (opInv < 0) opInv = 0; if (resInv < 0) resInv = 0; datapoints.push({ month: label, year, monthNum: month, is_forecast: !isHistorical, operating_cash: round2(opCash), operating_investments: round2(opInv), reserve_cash: round2(resCash), reserve_investments: round2(resInv), }); } // ── 3. Summary metrics ── const summary = this.computeSummary(datapoints, baseline, assessments, investments, totalInterestEarned, interestByInvestment); const result = { datapoints, summary }; // ── 4. Cache ── await this.tenant.query( `UPDATE board_scenarios SET projection_cache = $1, projection_cached_at = NOW() WHERE id = $2`, [JSON.stringify(result), scenarioId], ); return result; } /** Compare multiple scenarios side-by-side. */ async compareScenarios(scenarioIds: string[]) { if (!scenarioIds.length || scenarioIds.length > 4) { throw new NotFoundException('Provide 1 to 4 scenario IDs'); } const scenarios = await Promise.all( scenarioIds.map(async (id) => { const rows = await this.tenant.query('SELECT id, name, scenario_type, status FROM board_scenarios WHERE id = $1', [id]); if (!rows.length) throw new NotFoundException(`Scenario ${id} not found`); const projection = await this.getProjection(id); return { ...rows[0], projection }; }), ); return { scenarios }; } // ── Private Helpers ── private async getBaselineState(startYear: number, months: number) { // Current balances from asset accounts const opCashRows = await this.tenant.query(` SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id = a.id JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true GROUP BY a.id ) sub `); const resCashRows = await this.tenant.query(` SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id = a.id JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true GROUP BY a.id ) sub `); const opInvRows = await this.tenant.query(` SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'operating' AND is_active = true `); const resInvRows = await this.tenant.query(` SELECT COALESCE(SUM(current_value), 0) as total FROM investment_accounts WHERE fund_type = 'reserve' AND is_active = true `); // Opening balances at start of startYear const openingOp = await this.tenant.query(` SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id = a.id JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date WHERE a.account_type = 'asset' AND a.fund_type = 'operating' AND a.is_active = true GROUP BY a.id ) sub `, [`${startYear}-01-01`]); const openingRes = await this.tenant.query(` SELECT COALESCE(SUM(sub.bal), 0) as total FROM ( SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as bal FROM accounts a JOIN journal_entry_lines jel ON jel.account_id = a.id JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false AND je.entry_date < $1::date WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true GROUP BY a.id ) sub `, [`${startYear}-01-01`]); // Assessment groups const assessmentGroups = await this.tenant.query( `SELECT frequency, regular_assessment, special_assessment, unit_count FROM assessment_groups WHERE is_active = true`, ); // Budgets (official + planned budget fallback) const budgetsByYearMonth: Record = {}; const endYear = startYear + Math.ceil(months / 12) + 1; for (let yr = startYear; yr <= endYear; yr++) { let budgetRows: any[]; try { budgetRows = await this.tenant.query( `SELECT fund_type, account_type, jan, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec_amt FROM ( SELECT b.account_id, b.fund_type, 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, 1 as source_priority FROM budgets b JOIN accounts a ON a.id = b.account_id WHERE b.fiscal_year = $1 UNION ALL SELECT bpl.account_id, bpl.fund_type, a.account_type, bpl.jan, bpl.feb, bpl.mar, bpl.apr, bpl.may, bpl.jun, bpl.jul, bpl.aug, bpl.sep, bpl.oct, bpl.nov, bpl.dec_amt, 2 as source_priority FROM budget_plan_lines bpl JOIN budget_plans bp ON bp.id = bpl.budget_plan_id JOIN accounts a ON a.id = bpl.account_id WHERE bp.fiscal_year = $1 ) combined ORDER BY account_id, fund_type, source_priority`, [yr], ); } catch { // budget_plan_lines may not exist yet - fall back to official only budgetRows = await this.tenant.query( `SELECT b.fund_type, 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`, [yr], ); } for (let m = 0; m < 12; m++) { const key = `${yr}-${m + 1}`; if (!budgetsByYearMonth[key]) budgetsByYearMonth[key] = { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 }; for (const row of budgetRows) { const amt = parseFloat(row[monthNames[m]]) || 0; if (amt === 0) continue; const isOp = row.fund_type === 'operating'; if (row.account_type === 'income') { if (isOp) budgetsByYearMonth[key].opIncome += amt; else budgetsByYearMonth[key].resIncome += amt; } else if (row.account_type === 'expense') { if (isOp) budgetsByYearMonth[key].opExpense += amt; else budgetsByYearMonth[key].resExpense += amt; } } } } // Historical cash changes const historicalCash = await this.tenant.query(` SELECT EXTRACT(YEAR FROM je.entry_date)::int as yr, EXTRACT(MONTH FROM je.entry_date)::int as mo, a.fund_type, COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as net_change FROM journal_entry_lines jel JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false JOIN accounts a ON a.id = jel.account_id AND a.account_type = 'asset' AND a.is_active = true WHERE je.entry_date >= $1::date GROUP BY yr, mo, a.fund_type ORDER BY yr, mo `, [`${startYear}-01-01`]); const histIndex: Record = {}; for (const row of historicalCash) { histIndex[`${row.yr}-${row.mo}-${row.fund_type}`] = parseFloat(row.net_change) || 0; } // Investment maturities const maturities = await this.tenant.query(` SELECT fund_type, current_value, maturity_date, interest_rate, purchase_date FROM investment_accounts WHERE is_active = true AND maturity_date IS NOT NULL AND maturity_date > CURRENT_DATE `); const maturityIndex: Record = {}; for (const inv of maturities) { const d = new Date(inv.maturity_date); const key = `${d.getFullYear()}-${d.getMonth() + 1}`; if (!maturityIndex[key]) maturityIndex[key] = { operating: 0, reserve: 0 }; 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 matDate = new Date(inv.maturity_date); const daysHeld = Math.max((matDate.getTime() - purchaseDate.getTime()) / 86400000, 1); const interestEarned = val * (rate / 100) * (daysHeld / 365); const maturityTotal = val + interestEarned; if (inv.fund_type === 'operating') maturityIndex[key].operating += maturityTotal; else maturityIndex[key].reserve += maturityTotal; } // Capital project expenses (from unified projects table) const projectExpenses = await this.tenant.query(` SELECT estimated_cost, target_year, target_month, fund_source FROM projects WHERE is_active = true AND status IN ('planned', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0 `); const projectIndex: Record = {}; for (const p of projectExpenses) { const yr = parseInt(p.target_year); const mo = parseInt(p.target_month) || 6; const key = `${yr}-${mo}`; if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 }; const cost = parseFloat(p.estimated_cost) || 0; if (p.fund_source === 'operating') projectIndex[key].operating += cost; else projectIndex[key].reserve += cost; } // Also include capital_projects table (Capital Planning page) try { const capitalProjectExpenses = await this.tenant.query(` SELECT estimated_cost, target_year, target_month, fund_source FROM capital_projects WHERE status IN ('planned', 'approved', 'in_progress') AND target_year IS NOT NULL AND estimated_cost > 0 `); for (const p of capitalProjectExpenses) { const yr = parseInt(p.target_year); const mo = parseInt(p.target_month) || 6; const key = `${yr}-${mo}`; if (!projectIndex[key]) projectIndex[key] = { operating: 0, reserve: 0 }; const cost = parseFloat(p.estimated_cost) || 0; if (p.fund_source === 'operating') projectIndex[key].operating += cost; else projectIndex[key].reserve += cost; } } catch { // capital_projects table may not exist in all tenants } return { openingBalances: { opCash: parseFloat(openingOp[0]?.total || '0'), resCash: parseFloat(openingRes[0]?.total || '0'), opInv: parseFloat(opInvRows[0]?.total || '0'), resInv: parseFloat(resInvRows[0]?.total || '0'), }, assessmentGroups, budgetsByYearMonth, histIndex, maturityIndex, projectIndex, }; } private getAssessmentIncome(assessmentGroups: any[], month: number) { let operating = 0; let reserve = 0; for (const g of assessmentGroups) { const units = parseInt(g.unit_count) || 0; const regular = parseFloat(g.regular_assessment) || 0; const special = parseFloat(g.special_assessment) || 0; const freq = g.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) { operating += regular * units; reserve += special * units; } } return { operating, reserve }; } /** Compute investment cash flow and balance deltas for a given month from scenario investments. */ private computeInvestmentDelta(investments: any[], year: number, month: number) { let opCashFlow = 0; let resCashFlow = 0; let opInvChange = 0; let resInvChange = 0; let interestEarned = 0; const interestByInvestment: Record = {}; for (const inv of investments) { if (inv.executed_investment_id) continue; // skip already-executed investments const principal = parseFloat(inv.principal) || 0; const rate = parseFloat(inv.interest_rate) || 0; const isOp = inv.fund_type === 'operating'; // Purchase: cash leaves, investment balance increases if (inv.purchase_date) { const pd = new Date(inv.purchase_date); if (pd.getFullYear() === year && pd.getMonth() + 1 === month) { if (isOp) { opCashFlow -= principal; opInvChange += principal; } else { resCashFlow -= principal; resInvChange += principal; } } } // Maturity: investment returns to cash with interest if (inv.maturity_date) { const md = new Date(inv.maturity_date); if (md.getFullYear() === year && md.getMonth() + 1 === month) { const purchaseDate = inv.purchase_date ? new Date(inv.purchase_date) : new Date(); const daysHeld = Math.max((md.getTime() - purchaseDate.getTime()) / 86400000, 1); const invInterest = principal * (rate / 100) * (daysHeld / 365); const maturityTotal = principal + invInterest; interestEarned += invInterest; interestByInvestment[inv.id] = (interestByInvestment[inv.id] || 0) + invInterest; if (isOp) { opCashFlow += maturityTotal; opInvChange -= principal; } else { resCashFlow += maturityTotal; resInvChange -= principal; } // Auto-renew: immediately reinvest if (inv.auto_renew) { if (isOp) { opCashFlow -= principal; opInvChange += principal; } else { resCashFlow -= principal; resInvChange += principal; } } } } } return { opCashFlow, resCashFlow, opInvChange, resInvChange, interestEarned, interestByInvestment }; } /** Compute assessment income delta for a given month from scenario assessment changes. */ private computeAssessmentDelta(scenarioAssessments: any[], assessmentGroups: any[], year: number, month: number) { let operating = 0; let reserve = 0; const monthDate = new Date(year, month - 1, 1); // Get total units across all assessment groups let totalUnits = 0; for (const g of assessmentGroups) { totalUnits += parseInt(g.unit_count) || 0; } for (const a of scenarioAssessments) { const effectiveDate = new Date(a.effective_date); const endDate = a.end_date ? new Date(a.end_date) : null; // Only apply if within the active window if (monthDate < effectiveDate) continue; if (endDate && monthDate > endDate) continue; if (a.change_type === 'dues_increase' || a.change_type === 'dues_decrease') { const baseIncome = this.getAssessmentIncome(assessmentGroups, month); const pctChange = parseFloat(a.percentage_change) || 0; const flatChange = parseFloat(a.flat_amount_change) || 0; const sign = a.change_type === 'dues_decrease' ? -1 : 1; let delta = 0; if (pctChange > 0) { // Percentage change of base assessment income const target = a.target_fund || 'operating'; if (target === 'operating' || target === 'both') { delta = baseIncome.operating * (pctChange / 100) * sign; operating += delta; } if (target === 'reserve' || target === 'both') { delta = baseIncome.reserve * (pctChange / 100) * sign; reserve += delta; } } else if (flatChange > 0) { // Flat per-unit change times total units const target = a.target_fund || 'operating'; if (target === 'operating' || target === 'both') { operating += flatChange * totalUnits * sign; } if (target === 'reserve' || target === 'both') { reserve += flatChange * totalUnits * sign; } } } else if (a.change_type === 'special_assessment') { // Special assessment distributed across installments const perUnit = parseFloat(a.special_per_unit) || 0; const installments = parseInt(a.special_installments) || 1; const monthsFromStart = (year - effectiveDate.getFullYear()) * 12 + (month - (effectiveDate.getMonth() + 1)); if (monthsFromStart >= 0 && monthsFromStart < installments) { const monthlyIncome = (perUnit * totalUnits) / installments; const target = a.target_fund || 'reserve'; if (target === 'operating' || target === 'both') operating += monthlyIncome; if (target === 'reserve' || target === 'both') reserve += monthlyIncome; } } } return { operating, reserve }; } private computeSummary( datapoints: any[], baseline: any, scenarioAssessments: any[], investments?: any[], totalInterestEarned = 0, interestByInvestment: Record = {}, ) { if (!datapoints.length) return {}; const last = datapoints[datapoints.length - 1]; const first = datapoints[0]; const allLiquidity = datapoints.map( (d) => d.operating_cash + d.operating_investments + d.reserve_cash + d.reserve_investments, ); const minLiquidity = Math.min(...allLiquidity); const endLiquidity = allLiquidity[allLiquidity.length - 1]; // Reserve coverage: reserve balance / avg monthly reserve expenditure from planned capital projects let totalReserveProjectCost = 0; const projectionYears = Math.max(1, Math.ceil(datapoints.length / 12)); for (const key of Object.keys(baseline.projectIndex)) { totalReserveProjectCost += baseline.projectIndex[key].reserve || 0; } const avgMonthlyReserveExpenditure = totalReserveProjectCost > 0 ? totalReserveProjectCost / (projectionYears * 12) : 0; const reserveCoverageMonths = avgMonthlyReserveExpenditure > 0 ? (last.reserve_cash + last.reserve_investments) / avgMonthlyReserveExpenditure : 0; // No planned projects = show 0 (N/A) // Calculate total principal from scenario investments let totalPrincipal = 0; const investmentInterestDetails: Array<{ id: string; label: string; principal: number; interest: number }> = []; if (investments) { for (const inv of investments) { if (inv.executed_investment_id) continue; const principal = parseFloat(inv.principal) || 0; totalPrincipal += principal; const interest = interestByInvestment[inv.id] || 0; investmentInterestDetails.push({ id: inv.id, label: inv.label, principal: round2(principal), interest: round2(interest), }); } } return { end_liquidity: round2(endLiquidity), min_liquidity: round2(minLiquidity), reserve_coverage_months: round2(reserveCoverageMonths), end_operating_cash: last.operating_cash, end_reserve_cash: last.reserve_cash, end_operating_investments: last.operating_investments, end_reserve_investments: last.reserve_investments, period_change: round2(endLiquidity - allLiquidity[0]), total_interest_earned: round2(totalInterestEarned), total_principal_invested: round2(totalPrincipal), roi_percentage: totalPrincipal > 0 ? round2((totalInterestEarned / totalPrincipal) * 100) : 0, investment_interest_details: investmentInterestDetails, }; } }