From cbeea2fc34c1800300d906c911aa4010b39ae911 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Mon, 13 Jul 2026 09:27:52 +0200 Subject: [PATCH] fix(accounts): show credit cards as positive and exclude them from net worth (#673) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary A credit card is a spending account, not wealth. This change: - **Excludes credit cards from net worth entirely** — they no longer add to *or* subtract from the net worth total, the evolution chart, or its trends. They are filtered out at the source, so every net-worth aggregation (backend summary + frontend chart/trend/MoM) is consistent. - **Shows the credit card balance as a positive figure** on the per-account cards, on both the dashboard and the Accounts page (like any other account). - **Fixes a loan sign inconsistency**: a loan used to show *positive* on the Accounts page but *negative* on the dashboard. The Accounts list now applies the liability sign too, so loans render negative in both places. ### Why A user reported that a credit card showed as a positive balance on the Accounts screen but dragged their net worth down on the dashboard — the same account rendered with opposite signs on the two screens. The root cause was that credit cards were modelled as liabilities (like loans) and each screen computed the balance through a different path. Product decision: a credit card is spendable credit, so its balance is shown as-is and simply doesn't participate in net worth. ## Behavior | Account (e.g. 90k / 50k) | Per-account cards (dashboard + Accounts) | Net worth chart & total | | --- | --- | --- | | **Credit card** | `+90,000` | **excluded** (not counted) | | **Loan** | `−50,000` (was `+50,000` on Accounts) | subtracts (unchanged) | | Checking / savings / … | unchanged | unchanged (asset) | ## ⚠️ Existing-data impact Previously credit cards were forced to **reduce** net worth (`-abs(balance)`). With this change they are excluded, so **users with existing credit-card accounts will see their net worth rise** by whatever their cards used to subtract. This is a deliberate semantic change, not a migration — no stored balances are altered. ## Implementation - `AccountType::countsInNetWorth()` — new predicate, `false` only for credit cards; `calculateNetWorthAt()` skips excluded types. - `AccountType::reducesNetWorth()` / `LIABILITY_TYPES` — now only `loan`, so `netWorthContribution()` renders credit cards positive on the per-account cards. - `net-worth-chart.tsx` — credit cards filtered out of `includedAccounts` (one point that cascades to segments, totals, trends and `useChartViews`). - `Accounts/Index.tsx` — applies `netWorthContribution()` so loans render negative, matching the dashboard. ## Testing - Backend: `AccountTypeTest` (32) and `DashboardAnalyticsTest` (credit card excluded, loan still subtracts) — green. - Frontend: `chart-calculations.test.ts` (37) — green. - `pint`, `prettier`, `eslint` — clean. Browser QA skipped per request (the logged-in flow is also currently blocked locally by pending Spaces migrations). Logic is fully covered by the tests above. --- app/Enums/AccountType.php | 12 ++++++++- .../Api/DashboardAnalyticsController.php | 4 +++ .../components/dashboard/net-worth-chart.tsx | 4 +++ resources/js/lib/chart-calculations.test.ts | 23 +++++++--------- resources/js/lib/chart-calculations.ts | 8 ++++-- resources/js/pages/Accounts/Index.tsx | 27 +++++++++++++++---- tests/Feature/DashboardAnalyticsTest.php | 14 +++++----- tests/Unit/Enums/AccountTypeTest.php | 18 ++++++++++++- 8 files changed, 80 insertions(+), 30 deletions(-) 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([