- Refresh Recommendations now shows inline processing banner with animated progress bar while keeping existing results visible (dimmed). Auto-scrolls to AI section and shows titled notification on completion. - Investment recommendations now auto-calculate purchase and maturity dates from a configurable start date (defaults to today) in the "Add to Plan" modal, so scenarios build projections immediately. - Projection engine computes per-investment and total interest earned, ROI percentage, and total principal invested. Summary cards on the Investment Scenario detail page display these metrics prominently. - Replaced dropdown action menu with inline Edit/Execute/Remove icon buttons matching the assessment scenarios pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
547 lines
24 KiB
TypeScript
547 lines
24 KiB
TypeScript
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<string, number> = {};
|
|
|
|
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<string, any> = {};
|
|
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<string, number> = {};
|
|
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<string, { operating: number; reserve: number }> = {};
|
|
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<string, { operating: number; reserve: number }> = {};
|
|
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<string, number> = {};
|
|
|
|
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<string, number> = {},
|
|
) {
|
|
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,
|
|
};
|
|
}
|
|
}
|