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:
parent
e339aaa193
commit
1a62b8f42e
|
|
@ -28,8 +28,8 @@ import { useLocale } from '@/hooks/use-locale';
|
|||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import {
|
||||
AccountInfo,
|
||||
getAccountSign,
|
||||
isLiabilityType,
|
||||
netWorthContribution,
|
||||
} from '@/lib/chart-calculations';
|
||||
import { SharedData } from '@/types';
|
||||
import { formatDayFromDate } from '@/utils/date';
|
||||
|
|
@ -96,7 +96,7 @@ function calculateTrend(
|
|||
|
||||
const account = accounts[id];
|
||||
|
||||
return sum + getAccountSign(account.type) * Math.abs(value);
|
||||
return sum + netWorthContribution(account.type, value);
|
||||
}, 0);
|
||||
|
||||
const previousTotal = accountIds.reduce((sum, id) => {
|
||||
|
|
@ -107,7 +107,7 @@ function calculateTrend(
|
|||
|
||||
const account = accounts[id];
|
||||
|
||||
return sum + getAccountSign(account.type) * Math.abs(value);
|
||||
return sum + netWorthContribution(account.type, value);
|
||||
}, 0);
|
||||
|
||||
if (previousTotal === 0) return null;
|
||||
|
|
@ -316,7 +316,7 @@ export function NetWorthChart({
|
|||
chartAccountIds.forEach((id) => {
|
||||
const value = point[id];
|
||||
if (typeof value === 'number') {
|
||||
totalAssets += Math.abs(value);
|
||||
totalAssets += value;
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -380,7 +380,7 @@ export function NetWorthChart({
|
|||
const value = lastDataPoint[id];
|
||||
if (typeof value === 'number') {
|
||||
const account = includedAccounts[id];
|
||||
total += getAccountSign(account.type) * Math.abs(value);
|
||||
total += netWorthContribution(account.type, value);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
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 { Category } from '@/types/category';
|
||||
import { formatMonthFromYearMonth } from '@/utils/date';
|
||||
|
|
@ -75,8 +75,10 @@ export function deriveAccountMetrics(
|
|||
date: formatMonthFromYearMonth(point.month as string, locale),
|
||||
value:
|
||||
typeof point[account.id] === 'number'
|
||||
? getAccountSign(account.type) *
|
||||
Math.abs(point[account.id] as number)
|
||||
? netWorthContribution(
|
||||
account.type,
|
||||
point[account.id] as number,
|
||||
)
|
||||
: 0,
|
||||
investedAmount:
|
||||
investedKey in point
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import {
|
|||
getAccountSign,
|
||||
isLiabilityType,
|
||||
MonthDataPoint,
|
||||
netWorthContribution,
|
||||
} from './chart-calculations';
|
||||
|
||||
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', () => {
|
||||
const createAccounts = (
|
||||
types: Record<string, 'checking' | 'savings' | 'credit_card' | 'loan'>,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,21 @@ export function getAccountSign(type: AccountType): 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
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue