whisper-money/tests/Feature/CashflowAnalyticsTest.php

1000 lines
33 KiB
PHP

<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
beforeEach(function () {
$this->user = User::factory()->create();
$this->actingAs($this->user);
});
test('cashflow summary returns income, expense, net, and savings rate', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Income: $1000
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000,
'transaction_date' => now(),
]);
// Expense: $400
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -40000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 100000,
'expense' => 40000,
'net' => 60000,
'savings_rate' => 60.0, // (1000 - 400) / 1000 * 100 = 60%
],
]);
});
test('cashflow summary handles zero income for savings rate', function () {
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Only expense, no income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -10000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 0,
'expense' => 10000,
'net' => -10000,
'savings_rate' => 0, // Avoid division by zero
],
]);
});
test('cashflow summary returns previous period data', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Current period income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 50000,
'transaction_date' => now(),
]);
// Previous period income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 30000,
'transaction_date' => now()->subMonthNoOverflow(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk();
$data = $response->json();
expect($data['current']['income'])->toBe(50000);
expect($data['previous']['income'])->toBe(30000);
});
test('sankey returns income and expense categories with amounts', function () {
$incomeCategory1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$incomeCategory2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Freelance',
]);
$expenseCategory1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Housing',
]);
$expenseCategory2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Food',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Income transactions
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory1->id,
'amount' => 500000, // $5000
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory2->id,
'amount' => 100000, // $1000
'transaction_date' => now(),
]);
// Expense transactions
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory1->id,
'amount' => -150000, // $1500
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory2->id,
'amount' => -50000, // $500
'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)->toHaveKeys(['income_categories', 'expense_categories', 'total_income', 'total_expense']);
expect($data['total_income'])->toBe(600000);
expect($data['total_expense'])->toBe(200000);
expect($data['income_categories'])->toHaveCount(2);
expect($data['expense_categories'])->toHaveCount(2);
// Check that categories include required data
$incomeIds = collect($data['income_categories'])->pluck('category.id')->toArray();
expect($incomeIds)->toContain($incomeCategory1->id);
expect($incomeIds)->toContain($incomeCategory2->id);
});
test('cashflow trend returns monthly data for specified months', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Create transactions for 3 months
for ($i = 0; $i < 3; $i++) {
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000 + ($i * 10000),
'transaction_date' => now()->subMonthsNoOverflow($i),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -(50000 + ($i * 5000)),
'transaction_date' => now()->subMonthsNoOverflow($i),
]);
}
$response = $this->getJson('/api/cashflow/trend?months=3');
$response->assertOk();
$data = $response->json();
expect($data['data'])->toHaveCount(3);
expect($data['data'][0])->toHaveKeys(['month', 'income', 'expense', 'net']);
});
test('cashflow trend does not include tracked transfers', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
]);
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$investmentCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Outflow,
'name' => 'Investment',
]);
$rebalanceCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Inflow,
'name' => 'Investment Return Transfer',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 100000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -40000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $investmentCategory->id,
'amount' => -25000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $rebalanceCategory->id,
'amount' => 15000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/trend?months=1');
$response->assertOk();
$point = $response->json('data.0');
// Only income/expense categories affect the trend — transfers are excluded
expect($point['income'])->toBe(100000);
expect($point['expense'])->toBe(40000);
expect($point['net'])->toBe(60000);
expect($point)->not->toHaveKey('transfer_outflow');
expect($point)->not->toHaveKey('transfer_inflow');
});
test('cashflow trend anchors the 12-month series to the requested period end month', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 32000,
'transaction_date' => '2025-04-14',
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 48000,
'transaction_date' => '2025-05-07',
]);
$response = $this->getJson('/api/cashflow/trend?months=12&to=2025-05-31');
$response->assertOk();
$data = collect($response->json('data'))->keyBy('month');
expect($data)->toHaveCount(12);
expect($data->keys()->first())->toBe('2024-06');
expect($data->keys()->last())->toBe('2025-05');
expect($data['2025-04']['income'])->toBe(32000);
expect($data['2025-05']['income'])->toBe(48000);
});
test('cashflow trend defaults to 12 months', function () {
$response = $this->getJson('/api/cashflow/trend');
$response->assertOk();
$data = $response->json();
expect($data['data'])->toHaveCount(12);
});
test('breakdown returns category amounts with percentages for expenses', function () {
$cat1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Housing',
]);
$cat2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Food',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Housing: 60% of total
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat1->id,
'amount' => -60000,
'transaction_date' => now(),
]);
// Food: 40% of total
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat2->id,
'amount' => -40000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'expense',
]));
$response->assertOk();
$data = $response->json();
expect($data)->toHaveKeys(['data', 'total', 'previous_total']);
expect($data['total'])->toBe(100000);
expect($data['data'])->toHaveCount(2);
// Should be sorted by amount desc (Housing first)
expect($data['data'][0]['category']['name'])->toBe('Housing');
expect($data['data'][0]['amount'])->toBe(60000);
expect($data['data'][0]['percentage'])->toEqual(60.0);
expect($data['data'][0])->toHaveKey('previous_amount');
});
test('breakdown returns category amounts for income', function () {
$cat1 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$cat2 = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Freelance',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat1->id,
'amount' => 500000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $cat2->id,
'amount' => 100000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'income',
]));
$response->assertOk();
$data = $response->json();
expect($data['total'])->toBe(600000);
expect($data['data'])->toHaveCount(2);
expect($data['data'][0]['category']['name'])->toBe('Salary');
});
test('breakdown requires type parameter', function () {
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertUnprocessable();
});
test('cashflow page is accessible', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
$response = $this->get('/cashflow');
$response->assertOk();
$response->assertInertia(fn ($page) => $page->component('cashflow/index'));
});
test('cashflow page returns categories with type', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user);
Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
Category::factory()->create([
'user_id' => $user->id,
'type' => CategoryType::Expense,
'name' => 'Food',
]);
$response = $this->get('/cashflow');
$response->assertOk();
$response->assertInertia(fn ($page) => $page
->component('cashflow/index')
->has('categories', 2)
->has('categories.0.type')
->has('categories.0.cashflow_direction')
);
});
test('cashflow summary includes uncategorized income', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Uncategorized positive amount = income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => 50000, // $500
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 50000,
'expense' => 0,
'net' => 50000,
],
]);
});
test('cashflow summary includes uncategorized expenses', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Uncategorized negative amount = expense
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => -30000, // $300
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/summary?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
]));
$response->assertOk()
->assertJson([
'current' => [
'income' => 0,
'expense' => 30000,
'net' => -30000,
],
]);
});
test('sankey includes unknown income and expense categories', function () {
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Uncategorized income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => 100000,
'transaction_date' => now(),
]);
// Uncategorized expense
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => -50000,
'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['income_categories'])->toHaveCount(1);
expect($data['expense_categories'])->toHaveCount(1);
expect($data['income_categories'][0]['category']['name'])->toBe('Unknown Income');
expect($data['income_categories'][0]['amount'])->toBe(100000);
expect($data['expense_categories'][0]['category']['name'])->toBe('Unknown Expense');
expect($data['expense_categories'][0]['amount'])->toBe(50000);
});
test('sankey income category with mixed positive and negative transactions shows only positive amounts', function () {
// Reproduces a real-world scenario where an "Income from Rents" category contains
// both income receipts and property-related expense payments. The Sankey should
// show only the actual money received (positive flows), not abs(net).
$incomeFromRents = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Income from Rents',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Mar 13: -€124.03 — BIZUM sent (Lavadora)
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeFromRents->id,
'amount' => -12403,
'transaction_date' => '2026-03-13',
]);
// Mar 10: +€38.04 — BIZUM received (luz febrero 2026)
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeFromRents->id,
'amount' => 3804,
'transaction_date' => '2026-03-10',
]);
// Mar 9: -€38.04 — Endesa energy payment
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeFromRents->id,
'amount' => -3804,
'transaction_date' => '2026-03-09',
]);
// Mar 4: +€41.34 — BIZUM received (agua Febrero 2026)
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeFromRents->id,
'amount' => 4134,
'transaction_date' => '2026-03-04',
]);
// Mar 2: -€105.92 — Comunidad de Propietarios
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeFromRents->id,
'amount' => -10592,
'transaction_date' => '2026-03-02',
]);
$response = $this->getJson('/api/cashflow/sankey?'.http_build_query([
'from' => '2026-03-01',
'to' => '2026-03-31',
]));
$response->assertOk();
$data = $response->json();
// Positive flows only → income side: €38.04 + €41.34 = €79.38 (7938 cents)
$rentIncome = collect($data['income_categories'])->firstWhere('category.name', 'Income from Rents');
expect($rentIncome)->not->toBeNull();
expect($rentIncome['amount'])->toBe(7938);
expect($data['total_income'])->toBe(7938);
// Negative flows → expense side: €124.03 + €38.04 + €105.92 = €267.99 (26799 cents)
// The category appears on BOTH sides of the Sankey.
$rentExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Income from Rents');
expect($rentExpense)->not->toBeNull();
expect($rentExpense['amount'])->toBe(26799);
expect($data['total_expense'])->toBe(26799);
});
test('sankey excludes hidden transfer categories from both sides', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Groceries',
]);
$hiddenTransferCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Hidden,
'name' => 'Internal Transfer',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 200000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -50000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $hiddenTransferCategory->id,
'amount' => 100000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $hiddenTransferCategory->id,
'amount' => -75000,
'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(200000);
expect($data['total_expense'])->toBe(50000);
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Internal Transfer');
expect(collect($data['expense_categories'])->pluck('category.name'))->not->toContain('Internal Transfer');
});
test('sankey includes outflow transfer categories on the expense side', function () {
$incomeCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$investmentCategory = 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' => $incomeCategory->id,
'amount' => 300000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $investmentCategory->id,
'amount' => -50000,
'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(50000);
$investmentExpense = collect($data['expense_categories'])->firstWhere('category.name', 'Investments');
expect($investmentExpense)->not->toBeNull();
expect($investmentExpense['amount'])->toBe(50000);
// Outflow transfers should not appear on the income side
expect(collect($data['income_categories'])->pluck('category.name'))->not->toContain('Investments');
});
test('sankey includes inflow transfer categories on the income side', function () {
$expenseCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
'name' => 'Groceries',
]);
$inflowCategory = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Transfer,
'cashflow_direction' => CategoryCashflowDirection::Inflow,
'name' => 'From 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' => 80000,
'transaction_date' => now(),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -20000,
'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(80000);
expect($data['total_expense'])->toBe(20000);
$inflowIncome = collect($data['income_categories'])->firstWhere('category.name', 'From Relatives');
expect($inflowIncome)->not->toBeNull();
expect($inflowIncome['amount'])->toBe(80000);
// Inflow transfers should not appear on the expense side
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,
'type' => CategoryType::Income,
'name' => 'Salary',
]);
$account = Account::factory()->create(['user_id' => $this->user->id]);
// Categorized income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $incomeCategory->id,
'amount' => 200000,
'transaction_date' => now(),
]);
// Uncategorized income
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => null,
'amount' => 50000,
'transaction_date' => now(),
]);
$response = $this->getJson('/api/cashflow/breakdown?'.http_build_query([
'from' => now()->startOfMonth()->toDateString(),
'to' => now()->endOfMonth()->toDateString(),
'type' => 'income',
]));
$response->assertOk();
$data = $response->json();
expect($data['total'])->toBe(250000);
expect($data['data'])->toHaveCount(2);
$unknownCategory = collect($data['data'])->firstWhere('category.name', 'Unknown Income');
expect($unknownCategory)->not->toBeNull();
expect($unknownCategory['amount'])->toBe(50000);
expect($unknownCategory['percentage'])->toEqual(20.0); // 50k / 250k = 20%
});