diff --git a/resources/js/components/dashboard/net-worth-chart.tsx b/resources/js/components/dashboard/net-worth-chart.tsx index f83cf430..b63e3364 100644 --- a/resources/js/components/dashboard/net-worth-chart.tsx +++ b/resources/js/components/dashboard/net-worth-chart.tsx @@ -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); } }); } diff --git a/resources/js/hooks/use-dashboard-data.ts b/resources/js/hooks/use-dashboard-data.ts index 39b0ce96..0501e299 100644 --- a/resources/js/hooks/use-dashboard-data.ts +++ b/resources/js/hooks/use-dashboard-data.ts @@ -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 diff --git a/resources/js/lib/chart-calculations.test.ts b/resources/js/lib/chart-calculations.test.ts index 30641ecd..2d6a5b6d 100644 --- a/resources/js/lib/chart-calculations.test.ts +++ b/resources/js/lib/chart-calculations.test.ts @@ -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, diff --git a/resources/js/lib/chart-calculations.ts b/resources/js/lib/chart-calculations.ts index 142aa48d..e81ff961 100644 --- a/resources/js/lib/chart-calculations.ts +++ b/resources/js/lib/chart-calculations.ts @@ -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 */