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:
parent
c42a48a952
commit
83f7e83a13
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue