fix(dashboard): stop negative asset balances inflating net worth (#660)

## Problem

An account of type `checking`/`savings` with a genuinely **negative**
balance was:
- displayed as **positive** on the dashboard account card, and
- **added** to the net worth chart instead of subtracted,

while the **accounts page** rendered the same balance correctly as
negative.

## Root cause

The dashboard used `getAccountSign(type) * Math.abs(value)`. Liabilities
(`credit_card`/`loan`) are stored as positive magnitudes, so `-abs()`
was
correct for them — but for an **asset** holding a negative balance,
`+abs()`
flipped the sign to positive and summed it into net worth. The accounts
page
was correct because it uses the raw signed balance from
`AccountMetricsService`
with no sign transform.

## Fix

- Add `netWorthContribution(type, balance)` in `chart-calculations.ts`:
liabilities subtract their magnitude, assets keep their real sign (so an
  overdrawn asset correctly reduces net worth).
- Dashboard account card (`use-dashboard-data.ts`) now shows the raw
signed
  balance, matching the accounts page.
- Net worth headline total, short/long trend indicators and the
stacked-asset
total (`net-worth-chart.tsx`) use the raw signed value instead of
`Math.abs`.

## Tests

- 3 new unit tests in `chart-calculations.test.ts`, including a
regression case
  for a negative asset balance. Full suite: 37 passing.
This commit is contained in:
Víctor Falcón 2026-07-08 17:22:16 +02:00 committed by GitHub
parent e339aaa193
commit 1a62b8f42e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 43 additions and 8 deletions

View File

@ -28,8 +28,8 @@ import { useLocale } from '@/hooks/use-locale';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { import {
AccountInfo, AccountInfo,
getAccountSign,
isLiabilityType, isLiabilityType,
netWorthContribution,
} from '@/lib/chart-calculations'; } from '@/lib/chart-calculations';
import { SharedData } from '@/types'; import { SharedData } from '@/types';
import { formatDayFromDate } from '@/utils/date'; import { formatDayFromDate } from '@/utils/date';
@ -96,7 +96,7 @@ function calculateTrend(
const account = accounts[id]; const account = accounts[id];
return sum + getAccountSign(account.type) * Math.abs(value); return sum + netWorthContribution(account.type, value);
}, 0); }, 0);
const previousTotal = accountIds.reduce((sum, id) => { const previousTotal = accountIds.reduce((sum, id) => {
@ -107,7 +107,7 @@ function calculateTrend(
const account = accounts[id]; const account = accounts[id];
return sum + getAccountSign(account.type) * Math.abs(value); return sum + netWorthContribution(account.type, value);
}, 0); }, 0);
if (previousTotal === 0) return null; if (previousTotal === 0) return null;
@ -316,7 +316,7 @@ export function NetWorthChart({
chartAccountIds.forEach((id) => { chartAccountIds.forEach((id) => {
const value = point[id]; const value = point[id];
if (typeof value === 'number') { if (typeof value === 'number') {
totalAssets += Math.abs(value); totalAssets += value;
} }
}); });
@ -380,7 +380,7 @@ export function NetWorthChart({
const value = lastDataPoint[id]; const value = lastDataPoint[id];
if (typeof value === 'number') { if (typeof value === 'number') {
const account = includedAccounts[id]; const account = includedAccounts[id];
total += getAccountSign(account.type) * Math.abs(value); total += netWorthContribution(account.type, value);
} }
}); });
} }

View File

@ -1,5 +1,5 @@
import { useLocale } from '@/hooks/use-locale'; import { useLocale } from '@/hooks/use-locale';
import { getAccountSign } from '@/lib/chart-calculations'; import { netWorthContribution } from '@/lib/chart-calculations';
import { Account, AccountType, Bank } from '@/types/account'; import { Account, AccountType, Bank } from '@/types/account';
import { Category } from '@/types/category'; import { Category } from '@/types/category';
import { formatMonthFromYearMonth } from '@/utils/date'; import { formatMonthFromYearMonth } from '@/utils/date';
@ -75,8 +75,10 @@ export function deriveAccountMetrics(
date: formatMonthFromYearMonth(point.month as string, locale), date: formatMonthFromYearMonth(point.month as string, locale),
value: value:
typeof point[account.id] === 'number' typeof point[account.id] === 'number'
? getAccountSign(account.type) * ? netWorthContribution(
Math.abs(point[account.id] as number) account.type,
point[account.id] as number,
)
: 0, : 0,
investedAmount: investedAmount:
investedKey in point investedKey in point

View File

@ -8,6 +8,7 @@ import {
getAccountSign, getAccountSign,
isLiabilityType, isLiabilityType,
MonthDataPoint, MonthDataPoint,
netWorthContribution,
} from './chart-calculations'; } from './chart-calculations';
describe('isLiabilityType', () => { describe('isLiabilityType', () => {
@ -55,6 +56,23 @@ describe('getAccountSign', () => {
}); });
}); });
describe('netWorthContribution', () => {
it('subtracts the magnitude of liabilities (stored as positive)', () => {
expect(netWorthContribution('credit_card', 5000)).toBe(-5000);
expect(netWorthContribution('loan', 80000)).toBe(-80000);
});
it('keeps the real sign of assets', () => {
expect(netWorthContribution('checking', 288399)).toBe(288399);
expect(netWorthContribution('savings', 0)).toBe(0);
});
it('lets a negative asset balance reduce net worth', () => {
// Regression: an overdrawn checking account must subtract, not add.
expect(netWorthContribution('checking', -23528)).toBe(-23528);
});
});
describe('computeNetWorthSeries', () => { describe('computeNetWorthSeries', () => {
const createAccounts = ( const createAccounts = (
types: Record<string, 'checking' | 'savings' | 'credit_card' | 'loan'>, types: Record<string, 'checking' | 'savings' | 'credit_card' | 'loan'>,

View File

@ -20,6 +20,21 @@ export function getAccountSign(type: AccountType): 1 | -1 {
return isLiabilityType(type) ? -1 : 1; return isLiabilityType(type) ? -1 : 1;
} }
/**
* Signed contribution of a raw account balance to net worth.
*
* Liabilities are stored as positive magnitudes, so they always subtract.
* Assets keep their real sign, so a genuinely negative asset balance (e.g. an
* overdrawn checking account) correctly reduces net worth instead of being
* flipped positive and added.
*/
export function netWorthContribution(
type: AccountType,
balance: number,
): number {
return isLiabilityType(type) ? -Math.abs(balance) : balance;
}
/** /**
* Data point representing net worth for a month * Data point representing net worth for a month
*/ */