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
This commit is contained in:
Víctor Falcón 2026-04-01 14:48:38 +01:00 committed by GitHub
parent c42a48a952
commit 83f7e83a13
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 159 additions and 19 deletions

View File

@ -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])

View File

@ -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,