diff --git a/backend/src/modules/reports/reports.controller.ts b/backend/src/modules/reports/reports.controller.ts index 6d49ff4..d427fe0 100644 --- a/backend/src/modules/reports/reports.controller.ts +++ b/backend/src/modules/reports/reports.controller.ts @@ -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); + } } diff --git a/backend/src/modules/reports/reports.service.ts b/backend/src/modules/reports/reports.service.ts index 45f7646..094f73c 100644 --- a/backend/src/modules/reports/reports.service.ts +++ b/backend/src/modules/reports/reports.service.ts @@ -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 = {}; + + 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 = {}; + 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 = {}; + 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 = {}; + 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, + }; + } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 414cc7b..bb30982 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -25,6 +25,7 @@ import { YearEndPage } from './pages/reports/YearEndPage'; import { SettingsPage } from './pages/settings/SettingsPage'; import { AdminPage } from './pages/admin/AdminPage'; import { AssessmentGroupsPage } from './pages/assessment-groups/AssessmentGroupsPage'; +import { CashFlowForecastPage } from './pages/cash-flow/CashFlowForecastPage'; function ProtectedRoute({ children }: { children: React.ReactNode }) { const token = useAuthStore((s) => s.token); @@ -114,6 +115,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index d62df7f..439c67d 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -15,6 +15,7 @@ import { IconSettings, IconCrown, IconCategory, + IconChartAreaLine, } from '@tabler/icons-react'; import { useAuthStore } from '../../stores/authStore'; @@ -28,6 +29,7 @@ const navSections = [ label: 'Financials', items: [ { label: 'Accounts', icon: IconListDetails, path: '/accounts' }, + { label: 'Cash Flow', icon: IconChartAreaLine, path: '/cash-flow' }, { label: 'Budgets', icon: IconReportAnalytics, path: '/budgets/2026' }, ], }, diff --git a/frontend/src/pages/cash-flow/CashFlowForecastPage.tsx b/frontend/src/pages/cash-flow/CashFlowForecastPage.tsx new file mode 100644 index 0000000..fa70ebf --- /dev/null +++ b/frontend/src/pages/cash-flow/CashFlowForecastPage.tsx @@ -0,0 +1,475 @@ +import { useState, useMemo } from 'react'; +import { + Title, Text, Stack, Card, Group, SimpleGrid, ThemeIcon, + SegmentedControl, Loader, Center, ActionIcon, Tooltip, Badge, +} from '@mantine/core'; +import { + IconCash, IconBuildingBank, IconChartAreaLine, + IconArrowLeft, IconArrowRight, IconCalendar, +} from '@tabler/icons-react'; +import { useQuery } from '@tanstack/react-query'; +import { + AreaChart, Area, XAxis, YAxis, CartesianGrid, + Tooltip as RechartsTooltip, ResponsiveContainer, Legend, + ReferenceLine, +} from 'recharts'; +import api from '../../services/api'; + +interface Datapoint { + month: string; + year: number; + monthNum: number; + is_forecast: boolean; + operating_cash: number; + operating_investments: number; + reserve_cash: number; + reserve_investments: number; +} + +interface ForecastData { + start_year: number; + months: number; + current_month: string; + datapoints: Datapoint[]; +} + +const fmt = (v: number) => + v.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); + +const fmtAxis = (v: number) => { + if (Math.abs(v) >= 1000000) return `$${(v / 1000000).toFixed(1)}M`; + if (Math.abs(v) >= 1000) return `$${(v / 1000).toFixed(0)}K`; + return `$${v}`; +}; + +// Custom tooltip component +function CustomTooltip({ active, payload, label }: any) { + if (!active || !payload?.length) return null; + const dp = payload[0]?.payload as Datapoint; + const isForecast = dp?.is_forecast; + + return ( + + + {label} + {isForecast && ( + Forecast + )} + + {payload.map((entry: any) => ( + + +
+ {entry.name} + + {fmt(entry.value)} + + ))} + + Total + + {fmt(payload.reduce((s: number, p: any) => s + p.value, 0))} + + + + ); +} + +export function CashFlowForecastPage() { + const now = new Date(); + const currentYear = now.getFullYear(); + const currentMonth = now.getMonth() + 1; + + // Filter: All, Operating, Reserve + const [fundFilter, setFundFilter] = useState('all'); + // View window: which 12-month period to show + const [viewStartIndex, setViewStartIndex] = useState(0); + + // Fetch 36 months of data starting from current fiscal year (Jan) + const { data, isLoading } = useQuery({ + queryKey: ['cash-flow-forecast', currentYear], + queryFn: async () => { + const { data } = await api.get( + `/reports/cash-flow-forecast?startYear=${currentYear}&months=36`, + ); + return data; + }, + }); + + const datapoints = data?.datapoints || []; + + // Determine available view windows (12-month slices) + const maxStartIndex = Math.max(0, datapoints.length - 12); + + // Compute the chart data based on the current view window + const viewData = useMemo(() => { + return datapoints.slice(viewStartIndex, viewStartIndex + 12); + }, [datapoints, viewStartIndex]); + + // Compute summary stats for the current view + const summaryStats = useMemo(() => { + if (!viewData.length) return null; + const last = viewData[viewData.length - 1]; + const first = viewData[0]; + + const totalOperating = last.operating_cash + last.operating_investments; + const totalReserve = last.reserve_cash + last.reserve_investments; + const totalAll = totalOperating + totalReserve; + + const firstTotal = first.operating_cash + first.operating_investments + + first.reserve_cash + first.reserve_investments; + const netChange = totalAll - firstTotal; + + return { + totalOperating, + totalReserve, + totalAll, + netChange, + periodStart: first.month, + periodEnd: last.month, + }; + }, [viewData]); + + // Determine the first forecast month index within the view + const forecastStartLabel = useMemo(() => { + const idx = viewData.findIndex((d) => d.is_forecast); + return idx >= 0 ? viewData[idx].month : null; + }, [viewData]); + + // Transform data for chart based on filter + const chartData = useMemo(() => { + return viewData.map((d) => { + const base: any = { month: d.month, is_forecast: d.is_forecast }; + if (fundFilter === 'all' || fundFilter === 'operating') { + base['Operating Cash'] = d.operating_cash; + base['Operating Investments'] = d.operating_investments; + } + if (fundFilter === 'all' || fundFilter === 'reserve') { + base['Reserve Cash'] = d.reserve_cash; + base['Reserve Investments'] = d.reserve_investments; + } + return base; + }); + }, [viewData, fundFilter]); + + // Determine view period label + const viewLabel = viewData.length > 0 + ? `${viewData[0].month} - ${viewData[viewData.length - 1].month}` + : ''; + + if (isLoading) return
; + + return ( + + +
+ Cash Flow + + Cash and investment balances over time with forward projections + +
+ +
+ + {/* Summary Cards */} + {summaryStats && ( + + + + + + + Operating Total + + + {fmt(summaryStats.totalOperating)} + + + + + + + + Reserve Total + + + {fmt(summaryStats.totalReserve)} + + + + + + + + Combined Total + + + {fmt(summaryStats.totalAll)} + + + + + = 0 ? 'green' : 'red'} + size="sm" + > + + + Period Change + + = 0 ? 'green' : 'red'} + > + {fmt(summaryStats.netChange)} + + + + )} + + {/* Chart Navigation */} + + + + + setViewStartIndex(Math.max(0, viewStartIndex - 12))} + > + + + + + + {viewLabel} + + + = maxStartIndex} + onClick={() => setViewStartIndex(Math.min(maxStartIndex, viewStartIndex + 12))} + > + + + + + + {forecastStartLabel && ( + + Forecast begins {forecastStartLabel} + + )} + + {viewData.filter((d) => !d.is_forecast).length} actual + + + {viewData.filter((d) => d.is_forecast).length} projected + + + + + {/* Stacked Area Chart */} + + + + + + + + + + + + + + + + + + + + + + + + } /> + ( + {value} + )} + /> + + {/* Draw a reference line where forecast begins */} + {forecastStartLabel && ( + + )} + + {(fundFilter === 'all' || fundFilter === 'operating') && ( + <> + + + + )} + {(fundFilter === 'all' || fundFilter === 'reserve') && ( + <> + + + + )} + + + + + {/* Data Table */} + + Monthly Detail +
+ + + + + + {(fundFilter === 'all' || fundFilter === 'operating') && ( + <> + + + + + )} + {(fundFilter === 'all' || fundFilter === 'reserve') && ( + <> + + + + + )} + + + + + {viewData.map((d, i) => { + const opTotal = d.operating_cash + d.operating_investments; + const resTotal = d.reserve_cash + d.reserve_investments; + const grandTotal = + (fundFilter === 'all' ? opTotal + resTotal : + fundFilter === 'operating' ? opTotal : resTotal); + + return ( + + + + {(fundFilter === 'all' || fundFilter === 'operating') && ( + <> + + + + + )} + {(fundFilter === 'all' || fundFilter === 'reserve') && ( + <> + + + + + )} + + + ); + })} + +
MonthTypeOp. CashOp. InvestmentsOp. TotalRes. CashRes. InvestmentsRes. TotalGrand Total
{d.month} + + {d.is_forecast ? 'Forecast' : 'Actual'} + + + {fmt(d.operating_cash)} + + {fmt(d.operating_investments)} + + {fmt(opTotal)} + + {fmt(d.reserve_cash)} + + {fmt(d.reserve_investments)} + + {fmt(resTotal)} + + {fmt(grandTotal)} +
+
+
+
+ ); +}