whisper-money/tests/Feature/CashflowAnalyticsTest.php

533 lines
17 KiB
PHP

<?php
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
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()->subMonths($i),
]);
Transaction::factory()->create([
'user_id' => $this->user->id,
'account_id' => $account->id,
'category_id' => $expenseCategory->id,
'amount' => -(50000 + ($i * 5000)),
'transaction_date' => now()->subMonths($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 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')
);
});
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('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%
});