Fix account balances showing $0 and dashboard KPIs on new tenants

Root cause: multiple issues with balance computation across the system.

Accounts list:
- findAll() was doing SELECT * FROM accounts, returning the stale
  denormalized `balance` column (always 0). Now computes balances from
  journal entries using proper double-entry logic (debit-credit for
  assets/expenses, credit-debit for liabilities/equity/income).

Dashboard KPIs:
- Total Cash filtered by name LIKE '%Cash%' which missed accounts not
  named "Cash". Now queries ALL asset accounts regardless of name.
- Reserve Fund queried the legacy reserve_components.current_fund_balance
  column. Now computes from journal entries on reserve asset accounts.

Opening balance journal entries:
- On blank tenants, equity offset accounts (3000/3100) don't exist, so
  the balancing journal entry line was silently skipped, leaving entries
  unbalanced. Now auto-creates Operating Fund Balance (3000) and Reserve
  Fund Balance (3100) equity accounts when needed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 19:11:00 -05:00
parent b0634f7263
commit 739ccaeed4
2 changed files with 56 additions and 16 deletions

View File

@@ -8,22 +8,34 @@ export class AccountsService {
constructor(private tenant: TenantService) {}
async findAll(fundType?: string, includeArchived?: boolean) {
let sql = 'SELECT * FROM accounts';
const params: any[] = [];
const conditions: string[] = [];
const params: any[] = [];
if (!includeArchived) {
conditions.push('is_active = true');
conditions.push('a.is_active = true');
}
if (fundType) {
params.push(fundType);
conditions.push(`fund_type = $${params.length}`);
conditions.push(`a.fund_type = $${params.length}`);
}
if (conditions.length) {
sql += ' WHERE ' + conditions.join(' AND ');
}
sql += ' ORDER BY account_number';
const whereClause = conditions.length ? 'WHERE ' + conditions.join(' AND ') : '';
const sql = `
SELECT a.*,
CASE
WHEN a.account_type IN ('asset', 'expense')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
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
${whereClause}
GROUP BY a.id
ORDER BY a.account_number
`;
return this.tenant.query(sql, params);
}
@@ -79,12 +91,24 @@ export class AccountsService {
const acctDebit = isDebitNormal ? absAmount : 0;
const acctCredit = isDebitNormal ? 0 : absAmount;
// Determine equity offset account based on fund type
// Determine equity offset account based on fund type (auto-create if missing)
const equityAccountNumber = dto.fundType === 'reserve' ? 3100 : 3000;
const equityRows = await this.tenant.query(
const equityName = dto.fundType === 'reserve' ? 'Reserve Fund Balance' : 'Operating Fund Balance';
let equityRows = await this.tenant.query(
'SELECT id FROM accounts WHERE account_number = $1',
[equityAccountNumber],
);
if (!equityRows.length) {
await this.tenant.query(
`INSERT INTO accounts (account_number, name, account_type, fund_type, is_system)
VALUES ($1, $2, 'equity', $3, true)`,
[equityAccountNumber, equityName, dto.fundType],
);
equityRows = await this.tenant.query(
'SELECT id FROM accounts WHERE account_number = $1',
[equityAccountNumber],
);
}
// Create the journal entry
const jeInsert = await this.tenant.query(

View File

@@ -394,28 +394,44 @@ export class ReportsService {
}
async getDashboardKPIs() {
// Total cash (all asset accounts with 'Cash' in name)
// Total cash: ALL asset accounts (not just those named "Cash")
// Uses proper double-entry balance: debit - credit for assets
const cash = await this.tenant.query(`
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0) as balance
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.name LIKE '%Cash%' AND a.is_active = true
WHERE a.account_type = 'asset' AND a.is_active = true
GROUP BY a.id
) sub
`);
const totalCash = parseFloat(cash[0]?.total || '0');
// Receivables
// Receivables: sum of unpaid invoices
const ar = await this.tenant.query(`
SELECT COALESCE(SUM(amount - amount_paid), 0) as total
FROM invoices WHERE status NOT IN ('paid', 'void', 'written_off')
`);
// Reserve fund balance
// Reserve fund balance: computed from journal entries on reserve fund_type accounts
// credit - debit for equity/liability/income accounts (reserve equity + reserve income - reserve expenses)
const reserves = await this.tenant.query(`
SELECT COALESCE(SUM(current_fund_balance), 0) as total FROM reserve_components
SELECT COALESCE(SUM(sub.balance), 0) as total FROM (
SELECT
CASE
WHEN a.account_type IN ('asset')
THEN COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0)
WHEN a.account_type IN ('expense')
THEN -(COALESCE(SUM(jel.debit), 0) - COALESCE(SUM(jel.credit), 0))
ELSE COALESCE(SUM(jel.credit), 0) - COALESCE(SUM(jel.debit), 0)
END as balance
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 IN ('asset') AND a.is_active = true
GROUP BY a.id, a.account_type
) sub
`);
// Delinquent count (overdue invoices)
@@ -434,7 +450,7 @@ export class ReportsService {
return {
total_cash: totalCash.toFixed(2),
total_receivables: ar[0]?.total || '0.00',
reserve_fund_balance: reserves[0]?.total || '0.00',
reserve_fund_balance: parseFloat(reserves[0]?.total || '0').toFixed(2),
delinquent_units: parseInt(delinquent[0]?.count || '0'),
recent_transactions: recentTx,
};