From 83f7e83a134db2fe98f4b3ba75f173b7e0f44e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 1 Apr 2026 14:48:38 +0100 Subject: [PATCH] fix(cashflow): net transfer categories in sankey (#257) ## Summary - net transfer categories in the cashflow Sankey on their configured side instead of showing gross signed flows - keep the existing mixed-sign behavior for non-transfer categories so regular income and expense categories can still appear on both sides - add regression coverage for zero-net inflow transfers and partial inflow/outflow transfer netting ## Testing - vendor/bin/pint --dirty --format agent - php artisan test --compact tests/Feature/CashflowAnalyticsTest.php --filter=sankey ## Notes - running the full `CashflowAnalyticsTest` file in this workspace still hits two unrelated page tests because `public/build/manifest.json` is missing locally --- .../Api/CashflowAnalyticsController.php | 55 +++++--- tests/Feature/CashflowAnalyticsTest.php | 123 ++++++++++++++++++ 2 files changed, 159 insertions(+), 19 deletions(-) diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index df1364ac..8ffdf850 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -181,30 +181,16 @@ class CashflowAnalyticsController extends Controller { $isIncome = $operator === '>'; - // Group all transactions by category using the amount sign, not the category - // type. This allows one category to appear on both sides of the Sankey when - // it contains transactions of both signs (e.g. an income category that also - // holds property expense payments will show income on the left and the - // outgoing payments on the right). - $categorized = Transaction::query() + // Non-transfer categories keep the existing sign-based behavior so a + // category with mixed signs can appear on both sides of the Sankey. + $regularCategories = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->where('transactions.amount', $operator, 0) ->whereNotNull('transactions.category_id') - ->join('categories', function ($join) use ($isIncome) { + ->join('categories', function ($join) { $join->on('transactions.category_id', '=', 'categories.id') - ->where(function ($q) use ($isIncome) { - $q->where('categories.type', '!=', CategoryType::Transfer) - ->orWhere(function ($q) use ($isIncome) { - $q->where('categories.type', CategoryType::Transfer) - ->where( - 'categories.cashflow_direction', - $isIncome - ? CategoryCashflowDirection::Inflow - : CategoryCashflowDirection::Outflow, - ); - }); - }); + ->where('categories.type', '!=', CategoryType::Transfer); }) ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) ->groupBy('transactions.category_id') @@ -218,6 +204,37 @@ class CashflowAnalyticsController extends Controller ]; }); + $transferCategories = Transaction::query() + ->where('transactions.user_id', $userId) + ->whereBetween('transactions.transaction_date', [$from, $to]) + ->whereNotNull('transactions.category_id') + ->join('categories', function ($join) use ($isIncome) { + $join->on('transactions.category_id', '=', 'categories.id') + ->where('categories.type', CategoryType::Transfer) + ->where( + 'categories.cashflow_direction', + $isIncome + ? CategoryCashflowDirection::Inflow + : CategoryCashflowDirection::Outflow, + ); + }) + ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) + ->groupBy('transactions.category_id') + ->with('category') + ->get() + ->filter(function ($item) use ($isIncome) { + return $isIncome ? $item->total_amount > 0 : $item->total_amount < 0; + }) + ->map(function ($item) { + return [ + 'category_id' => $item->category_id, + 'category' => $item->category, + 'amount' => abs($item->total_amount), + ]; + }); + + $categorized = $regularCategories->concat($transferCategories)->values(); + $uncategorized = Transaction::query() ->where('user_id', $userId) ->whereBetween('transaction_date', [$from, $to]) diff --git a/tests/Feature/CashflowAnalyticsTest.php b/tests/Feature/CashflowAnalyticsTest.php index d10a3e86..51175ae5 100644 --- a/tests/Feature/CashflowAnalyticsTest.php +++ b/tests/Feature/CashflowAnalyticsTest.php @@ -830,6 +830,129 @@ test('sankey includes inflow transfer categories on the income side', function ( expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From Relatives'); }); +test('sankey nets mixed-sign inflow transfer categories on the income side', function () { + $inflowCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Inflow, + 'name' => 'From account of relatives', + ]); + + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $inflowCategory->id, + 'amount' => 300000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $inflowCategory->id, + 'amount' => -300000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data['total_income'])->toBe(0); + expect($data['total_expense'])->toBe(0); + expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('From account of relatives'); + expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From account of relatives'); +}); + +test('sankey includes only the net positive amount for inflow transfer categories', function () { + $inflowCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Inflow, + 'name' => 'From account of relatives', + ]); + + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $inflowCategory->id, + 'amount' => 500000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $inflowCategory->id, + 'amount' => -200000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data['total_income'])->toBe(300000); + expect($data['total_expense'])->toBe(0); + + $inflowIncome = collect($data['income_categories'])->firstWhere('category.name', 'From account of relatives'); + expect($inflowIncome)->not->toBeNull(); + expect($inflowIncome['amount'])->toBe(300000); + expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('From account of relatives'); +}); + +test('sankey includes only the net negative amount for outflow transfer categories', function () { + $outflowCategory = Category::factory()->create([ + 'user_id' => $this->user->id, + 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Outflow, + 'name' => 'Investments', + ]); + + $account = Account::factory()->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $outflowCategory->id, + 'amount' => -500000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $outflowCategory->id, + 'amount' => 200000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data['total_income'])->toBe(0); + expect($data['total_expense'])->toBe(300000); + + $outflowExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Investments'); + expect($outflowExpense)->not->toBeNull(); + expect($outflowExpense['amount'])->toBe(300000); + expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Investments'); +}); + test('breakdown includes unknown income category', function () { $incomeCategory = Category::factory()->create([ 'user_id' => $this->user->id,