Add Phase 4 Cash Flow Visualization with forecast endpoint and Recharts chart
New feature: Cash Flow page under Financials showing stacked area chart of operating/reserve cash and investment balances over time. Backend forecast endpoint integrates assessment income schedules, budget expenses, capital project costs, and investment maturities to project 24+ months forward. Historical months show actual journal entry balances; future months are projected. Includes Operating/Reserve/All fund filter, 12-month sliding window navigation, forecast reference line, and monthly detail table. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -50,4 +50,14 @@ export class ReportsController {
|
||||
getDashboardKPIs() {
|
||||
return this.reportsService.getDashboardKPIs();
|
||||
}
|
||||
|
||||
@Get('cash-flow-forecast')
|
||||
getCashFlowForecast(
|
||||
@Query('startYear') startYear?: string,
|
||||
@Query('months') months?: string,
|
||||
) {
|
||||
const yr = parseInt(startYear || '') || new Date().getFullYear();
|
||||
const mo = Math.min(parseInt(months || '') || 24, 48);
|
||||
return this.reportsService.getCashFlowForecast(yr, mo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -493,4 +493,294 @@ export class ReportsService {
|
||||
recent_transactions: recentTx,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Cash Flow Forecast: monthly datapoints with actuals (historical) and projections (future).
|
||||
* Each month has: operating_cash, operating_investments, reserve_cash, reserve_investments.
|
||||
* Historical months use journal entry balances; future months project from budgets,
|
||||
* assessment income schedules, capital project expenses, and investment maturities.
|
||||
*/
|
||||
async getCashFlowForecast(startYear: number, months: number) {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonth = now.getMonth() + 1; // 1-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'];
|
||||
|
||||
// ── 1) Get current balances as of now ──
|
||||
// Operating cash (asset accounts with fund_type=operating)
|
||||
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
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT 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
|
||||
`);
|
||||
// Reserve cash (equity fund balance for reserve)
|
||||
const resCashRows = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(sub.bal), 0) as total FROM (
|
||||
SELECT COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0) as bal
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT JOIN journal_entries je ON je.id = jel.journal_entry_id AND je.is_posted = true AND je.is_void = false
|
||||
WHERE a.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`);
|
||||
// Operating investments
|
||||
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
|
||||
`);
|
||||
// Reserve investments
|
||||
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
|
||||
`);
|
||||
|
||||
let opCash = parseFloat(opCashRows[0]?.total || '0');
|
||||
let resCash = parseFloat(resCashRows[0]?.total || '0');
|
||||
let opInv = parseFloat(opInvRows[0]?.total || '0');
|
||||
let resInv = parseFloat(resInvRows[0]?.total || '0');
|
||||
|
||||
// ── 2) Get assessment income schedule ──
|
||||
// Assessment groups define income frequency: monthly, quarterly (Jan/Apr/Jul/Oct), annual (Jan)
|
||||
const assessmentGroups = await this.tenant.query(`
|
||||
SELECT frequency, regular_assessment, special_assessment, unit_count
|
||||
FROM assessment_groups WHERE is_active = true
|
||||
`);
|
||||
|
||||
// Compute per-month income from assessments
|
||||
const getAssessmentIncome = (month: number): { operating: number; reserve: 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 };
|
||||
};
|
||||
|
||||
// ── 3) Get budget expenses by month (for forecast months) ──
|
||||
// We need budgets for startYear and startYear+1 to cover 24 months
|
||||
const budgetsByYearMonth: Record<string, { opIncome: number; opExpense: number; resIncome: number; resExpense: number }> = {};
|
||||
|
||||
for (const yr of [startYear, startYear + 1, startYear + 2]) {
|
||||
const 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4) Get historical monthly balances ──
|
||||
// For months before current month, compute actual cash position at end of each month
|
||||
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`]);
|
||||
|
||||
// ── 5) Get investment maturities (for forecast: when CDs mature, money returns to cash) ──
|
||||
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
|
||||
`);
|
||||
|
||||
// ── 6) Get capital project planned expenses ──
|
||||
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
|
||||
`);
|
||||
|
||||
// ── Build monthly datapoints ──
|
||||
const datapoints: any[] = [];
|
||||
|
||||
// For historical months, compute cumulative balances from journal entries
|
||||
// We'll track running balances
|
||||
// First compute opening balance 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
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT 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.credit), 0) - COALESCE(SUM(jel.debit), 0) as bal
|
||||
FROM accounts a
|
||||
LEFT JOIN journal_entry_lines jel ON jel.account_id = a.id
|
||||
LEFT 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.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
|
||||
GROUP BY a.id
|
||||
) sub
|
||||
`, [`${startYear}-01-01`]);
|
||||
|
||||
let runOpCash = parseFloat(openingOp[0]?.total || '0');
|
||||
let runResCash = parseFloat(openingRes[0]?.total || '0');
|
||||
|
||||
// Index historical cash changes by year-month-fund
|
||||
const histIndex: Record<string, number> = {};
|
||||
for (const row of historicalCash) {
|
||||
const key = `${row.yr}-${row.mo}-${row.fund_type}`;
|
||||
histIndex[key] = parseFloat(row.net_change) || 0;
|
||||
}
|
||||
|
||||
// Index maturities by year-month
|
||||
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 };
|
||||
// At maturity, investment value returns to cash
|
||||
const val = parseFloat(inv.current_value) || 0;
|
||||
// Estimate simple interest earned by maturity
|
||||
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;
|
||||
}
|
||||
|
||||
// Index project expenses by year-month
|
||||
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; // default mid-year if no month
|
||||
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;
|
||||
}
|
||||
|
||||
// Investment opening balances at start of period (approximate: use current values)
|
||||
let runOpInv = opInv;
|
||||
let runResInv = resInv;
|
||||
|
||||
for (let i = 0; i < months; i++) {
|
||||
const year = startYear + Math.floor(i / 12);
|
||||
const month = (i % 12) + 1;
|
||||
const key = `${year}-${month}`;
|
||||
const isHistorical = year < currentYear || (year === currentYear && month <= currentMonth);
|
||||
const label = `${monthLabels[month - 1]} ${year}`;
|
||||
|
||||
if (isHistorical) {
|
||||
// Use actual journal entry changes
|
||||
const opChange = histIndex[`${year}-${month}-operating`] || 0;
|
||||
runOpCash += opChange;
|
||||
|
||||
// For reserve, we need the equity-based changes
|
||||
const resEquityChange = await this.tenant.query(`
|
||||
SELECT COALESCE(SUM(jel.credit - jel.debit), 0) as net
|
||||
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.fund_type = 'reserve' AND a.account_type = 'equity'
|
||||
WHERE EXTRACT(YEAR FROM je.entry_date) = $1 AND EXTRACT(MONTH FROM je.entry_date) = $2
|
||||
`, [year, month]);
|
||||
runResCash += parseFloat(resEquityChange[0]?.net || '0');
|
||||
|
||||
datapoints.push({
|
||||
month: label,
|
||||
year, monthNum: month,
|
||||
is_forecast: false,
|
||||
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||
});
|
||||
} else {
|
||||
// Forecast: use budget + assessment data
|
||||
const assessments = getAssessmentIncome(month);
|
||||
const budget = budgetsByYearMonth[key] || { opIncome: 0, opExpense: 0, resIncome: 0, resExpense: 0 };
|
||||
const maturity = maturityIndex[key] || { operating: 0, reserve: 0 };
|
||||
const project = projectIndex[key] || { operating: 0, reserve: 0 };
|
||||
|
||||
// Use budget income if available, else assessment income
|
||||
const opIncomeMonth = budget.opIncome > 0 ? budget.opIncome : assessments.operating;
|
||||
const resIncomeMonth = budget.resIncome > 0 ? budget.resIncome : assessments.reserve;
|
||||
|
||||
// Net change: income - expenses - project costs + maturity returns
|
||||
runOpCash += opIncomeMonth - budget.opExpense - project.operating + maturity.operating;
|
||||
runResCash += resIncomeMonth - budget.resExpense - project.reserve + maturity.reserve;
|
||||
|
||||
// Subtract maturing investment values from investment balances
|
||||
runOpInv -= maturity.operating > 0 ? (maturity.operating - (maturity.operating * 0.04 * 0.5)) : 0; // rough: subtract principal
|
||||
runResInv -= maturity.reserve > 0 ? (maturity.reserve - (maturity.reserve * 0.04 * 0.5)) : 0;
|
||||
// Floor at 0
|
||||
if (runOpInv < 0) runOpInv = 0;
|
||||
if (runResInv < 0) runResInv = 0;
|
||||
|
||||
datapoints.push({
|
||||
month: label,
|
||||
year, monthNum: month,
|
||||
is_forecast: true,
|
||||
operating_cash: Math.round(runOpCash * 100) / 100,
|
||||
operating_investments: Math.round(runOpInv * 100) / 100,
|
||||
reserve_cash: Math.round(runResCash * 100) / 100,
|
||||
reserve_investments: Math.round(runResInv * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start_year: startYear,
|
||||
months: months,
|
||||
current_month: `${monthLabels[currentMonth - 1]} ${currentYear}`,
|
||||
datapoints,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user