diff --git a/app/Enums/AccountType.php b/app/Enums/AccountType.php index 88c5f01f..353a03ec 100644 --- a/app/Enums/AccountType.php +++ b/app/Enums/AccountType.php @@ -23,7 +23,17 @@ enum AccountType: string public function reducesNetWorth(): bool { - return in_array($this, [self::CreditCard, self::Loan], true); + return $this === self::Loan; + } + + /** + * Whether this account type is part of the net worth total at all. Credit + * cards are spending accounts, not wealth, so they are excluded entirely + * (neither added nor subtracted) while still being tracked on their own. + */ + public function countsInNetWorth(): bool + { + return $this !== self::CreditCard; } /** diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 5659d931..b80a9772 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -482,6 +482,10 @@ class DashboardAnalyticsController extends Controller $total = 0; foreach ($accounts as $account) { + if (! $account->type->countsInNetWorth()) { + continue; + } + $balance = $lookup->getBalanceAt($account->id, $date); $convertedBalance = $this->exchangeRateService->convert( diff --git a/resources/js/components/dashboard/net-worth-chart.tsx b/resources/js/components/dashboard/net-worth-chart.tsx index b63e3364..b96a4a6a 100644 --- a/resources/js/components/dashboard/net-worth-chart.tsx +++ b/resources/js/components/dashboard/net-worth-chart.tsx @@ -209,6 +209,10 @@ export function NetWorthChart({ // All accounts included based on the toggles – used for totals & trends. const includedAccounts = Object.fromEntries( Object.entries(accounts).filter(([, account]) => { + // Credit cards are spending accounts, never part of net worth. + if (account.type === 'credit_card') { + return false; + } if (!includeLoansInNetWorthChart && account.type === 'loan') { return false; } diff --git a/resources/js/lib/chart-calculations.test.ts b/resources/js/lib/chart-calculations.test.ts index 2d6a5b6d..f9fbfab9 100644 --- a/resources/js/lib/chart-calculations.test.ts +++ b/resources/js/lib/chart-calculations.test.ts @@ -12,14 +12,14 @@ import { } from './chart-calculations'; describe('isLiabilityType', () => { - it('returns true for credit_card', () => { - expect(isLiabilityType('credit_card')).toBe(true); - }); - it('returns true for loan', () => { expect(isLiabilityType('loan')).toBe(true); }); + it('returns false for credit_card', () => { + expect(isLiabilityType('credit_card')).toBe(false); + }); + it('returns false for checking', () => { expect(isLiabilityType('checking')).toBe(false); }); @@ -43,11 +43,11 @@ describe('isLiabilityType', () => { describe('getAccountSign', () => { it('returns -1 for liabilities', () => { - expect(getAccountSign('credit_card')).toBe(-1); expect(getAccountSign('loan')).toBe(-1); }); it('returns 1 for assets', () => { + expect(getAccountSign('credit_card')).toBe(1); expect(getAccountSign('checking')).toBe(1); expect(getAccountSign('savings')).toBe(1); expect(getAccountSign('investment')).toBe(1); @@ -58,7 +58,6 @@ 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); }); @@ -104,12 +103,10 @@ describe('computeNetWorthSeries', () => { }); it('subtracts liabilities from net worth', () => { - const data = [ - { month: '2025-01', checking: 50000, credit_card: 10000 }, - ]; + const data = [{ month: '2025-01', checking: 50000, loan: 10000 }]; const accounts = createAccounts({ checking: 'checking', - credit_card: 'credit_card', + loan: 'loan', }); const result = computeNetWorthSeries(data, accounts); @@ -123,21 +120,19 @@ describe('computeNetWorthSeries', () => { month: '2025-01', savings: 100000, checking: 20000, - credit_card: 5000, loan: 30000, }, ]; const accounts = createAccounts({ savings: 'savings', checking: 'checking', - credit_card: 'credit_card', loan: 'loan', }); const result = computeNetWorthSeries(data, accounts); - // Net worth = 100000 + 20000 - 5000 - 30000 = 85000 - expect(result[0].value).toBe(85000); + // Net worth = 100000 + 20000 - 30000 = 90000 + expect(result[0].value).toBe(90000); }); it('handles negative net worth (more liabilities than assets)', () => { diff --git a/resources/js/lib/chart-calculations.ts b/resources/js/lib/chart-calculations.ts index e81ff961..a9042859 100644 --- a/resources/js/lib/chart-calculations.ts +++ b/resources/js/lib/chart-calculations.ts @@ -1,9 +1,13 @@ import { AccountType } from '@/types/account'; /** - * Account types that reduce net worth (liabilities) + * Account types that reduce net worth (liabilities). + * + * A credit card is deliberately NOT a liability: its balance is spendable + * credit the user treats as an asset, so it adds to net worth like a regular + * account. Only loans (debt that must be repaid) subtract. */ -export const LIABILITY_TYPES: AccountType[] = ['credit_card', 'loan']; +export const LIABILITY_TYPES: AccountType[] = ['loan']; /** * Check if an account type is a liability diff --git a/resources/js/pages/Accounts/Index.tsx b/resources/js/pages/Accounts/Index.tsx index 5c6eb49b..0d6d343e 100644 --- a/resources/js/pages/Accounts/Index.tsx +++ b/resources/js/pages/Accounts/Index.tsx @@ -9,6 +9,7 @@ import { SortableGrid } from '@/components/sortable-grid'; import { Card, CardContent } from '@/components/ui/card'; import { AccountWithMetrics } from '@/hooks/use-dashboard-data'; import AppSidebarLayout from '@/layouts/app/app-sidebar-layout'; +import { netWorthContribution } from '@/lib/chart-calculations'; import { BreadcrumbItem, SharedData } from '@/types'; import { Account } from '@/types/account'; import { __ } from '@/utils/i18n'; @@ -61,12 +62,25 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) { const accountsWithMetrics: AccountWithMetrics[] = useMemo(() => { return accounts.map((account) => { const metrics = accountMetrics?.[account.id]; + // Apply the liability sign so the Accounts list agrees with the + // dashboard: loans render negative, everything else keeps its value. + const currentBalance = netWorthContribution( + account.type, + metrics?.currentBalance ?? 0, + ); + const previousBalance = netWorthContribution( + account.type, + metrics?.previousBalance ?? 0, + ); return { ...account, - currentBalance: metrics?.currentBalance ?? 0, - previousBalance: metrics?.previousBalance ?? 0, - diff: metrics?.diff ?? 0, - history: metrics?.history ?? [], + currentBalance, + previousBalance, + diff: currentBalance - previousBalance, + history: (metrics?.history ?? []).map((point) => ({ + ...point, + value: netWorthContribution(account.type, point.value), + })), investedAmount: metrics?.investedAmount ?? null, }; }); @@ -116,7 +130,10 @@ export default function AccountsIndex({ accounts, accountMetrics }: Props) { ); }, []); - // Build a map of linked loan metrics keyed by real estate account ID + // Build a map of linked loan metrics keyed by real estate account ID. + // Intentionally reads the RAW (unsigned) accountMetrics prop, not the + // signed accountsWithMetrics: the equity math subtracts this owed amount + // (marketValue - mortgageOwed), so it must stay a positive magnitude. const linkedLoanMetricsMap = useMemo(() => { if (!accountMetrics) return {}; const map: Record< diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php index 2d48e268..a4a79360 100644 --- a/tests/Feature/DashboardAnalyticsTest.php +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -21,8 +21,7 @@ beforeEach(function () { $this->actingAs($this->user); }); -test('net worth calculates assets minus liabilities', function () { - // Assets +test('net worth excludes credit card balances entirely', function () { $checking = Account::factory()->create([ 'user_id' => $this->user->id, 'type' => AccountType::Checking, @@ -34,7 +33,8 @@ test('net worth calculates assets minus liabilities', function () { 'balance' => 500000, // $5,000.00 ]); - // Liabilities + // A credit card is a spending account, not wealth: it must neither add to + // nor subtract from net worth. $creditCard = Account::factory()->create([ 'user_id' => $this->user->id, 'type' => AccountType::CreditCard, @@ -43,7 +43,7 @@ test('net worth calculates assets minus liabilities', function () { AccountBalance::factory()->create([ 'account_id' => $creditCard->id, 'balance_date' => now(), - 'balance' => -100000, // -$1,000.00 + 'balance' => 100000, // $1,000.00 ]); // Previous period data (30 days ago) @@ -55,7 +55,7 @@ test('net worth calculates assets minus liabilities', function () { AccountBalance::factory()->create([ 'account_id' => $creditCard->id, 'balance_date' => now()->subDays(30), - 'balance' => -50000, // -$500.00 + 'balance' => 50000, // $500.00 ]); $response = $this->getJson('/api/dashboard/net-worth?'.http_build_query([ @@ -65,8 +65,8 @@ test('net worth calculates assets minus liabilities', function () { $response->assertOk() ->assertJson([ - 'current' => 400000, // 5000 - 1000 = 4000 - 'previous' => 350000, // 4000 - 500 = 3500 + 'current' => 500000, // only the checking account counts + 'previous' => 400000, 'currency_code' => 'USD', ]); }); diff --git a/tests/Unit/Enums/AccountTypeTest.php b/tests/Unit/Enums/AccountTypeTest.php index 8b9ad852..fdb5cec7 100644 --- a/tests/Unit/Enums/AccountTypeTest.php +++ b/tests/Unit/Enums/AccountTypeTest.php @@ -27,7 +27,6 @@ it('does not support invested amount for non-investment account types', function it('reduces net worth for liability account types', function (AccountType $type) { expect($type->reducesNetWorth())->toBeTrue(); })->with([ - 'credit card' => AccountType::CreditCard, 'loan' => AccountType::Loan, ]); @@ -35,6 +34,7 @@ it('does not reduce net worth for asset account types', function (AccountType $t expect($type->reducesNetWorth())->toBeFalse(); })->with([ 'checking' => AccountType::Checking, + 'credit card' => AccountType::CreditCard, 'savings' => AccountType::Savings, 'investment' => AccountType::Investment, 'retirement' => AccountType::Retirement, @@ -42,6 +42,22 @@ it('does not reduce net worth for asset account types', function (AccountType $t 'others' => AccountType::Others, ]); +it('excludes credit cards from the net worth total', function () { + expect(AccountType::CreditCard->countsInNetWorth())->toBeFalse(); +}); + +it('counts non credit card account types in the net worth total', function (AccountType $type) { + expect($type->countsInNetWorth())->toBeTrue(); +})->with([ + 'checking' => AccountType::Checking, + 'savings' => AccountType::Savings, + 'investment' => AccountType::Investment, + 'retirement' => AccountType::Retirement, + 'real estate' => AccountType::RealEstate, + 'loan' => AccountType::Loan, + 'others' => AccountType::Others, +]); + it('is non-transactional for balance-only account types', function (AccountType $type) { expect($type->isNonTransactional())->toBeTrue(); })->with([