Fix cash flow forecast double-counting by using asset accounts consistently

The forecast was counting money twice: operating used asset accounts while
reserve used equity accounts, but both sides of the opening balance journal
entry represent the same funds. Changed all cash balance queries (current,
opening, and historical) to use asset accounts (debit - credit) for both
operating and reserve. Also fixed a LEFT JOIN bug where date filters on
journal_entries didn't prevent journal_entry_lines from being summed,
causing opening balances to include all entries regardless of date.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 13:34:58 -05:00
parent b1a28f7a85
commit f210b05beb

View File

@@ -508,25 +508,25 @@ export class ReportsService {
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)
// Use ASSET accounts for both operating and reserve — these represent actual cash holdings.
// Equity accounts are the bookkeeping counterpart and would double-count.
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
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
`);
// 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
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.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
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
`);
@@ -641,13 +641,14 @@ export class ReportsService {
// For historical months, compute cumulative balances from journal entries
// We'll track running balances
// First compute opening balance at start of startYear
// First compute opening balance at start of startYear using asset accounts
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
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
@@ -656,12 +657,13 @@ export class ReportsService {
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
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
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.fund_type = 'reserve' AND a.account_type = 'equity' AND a.is_active = true
WHERE a.account_type = 'asset' AND a.fund_type = 'reserve' AND a.is_active = true
GROUP BY a.id
) sub
`, [`${startYear}-01-01`]);
@@ -719,19 +721,13 @@ export class ReportsService {
const label = `${monthLabels[month - 1]} ${year}`;
if (isHistorical) {
// Use actual journal entry changes
// Use actual journal entry changes from asset accounts
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');
// Reserve also uses asset account changes (already in histIndex from section 4)
const resChange = histIndex[`${year}-${month}-reserve`] || 0;
runResCash += resChange;
datapoints.push({
month: label,