203 lines
6.8 KiB
PHP
203 lines
6.8 KiB
PHP
<?php
|
|
|
|
use App\Enums\CategoryType;
|
|
use App\Models\Account;
|
|
use App\Models\Category;
|
|
use App\Models\ExchangeRate;
|
|
use App\Models\Label;
|
|
use App\Models\Transaction;
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
beforeEach(function () {
|
|
Http::fake();
|
|
|
|
$this->user = User::factory()->create(['currency_code' => 'USD']);
|
|
$this->actingAs($this->user);
|
|
|
|
$this->account = Account::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'currency_code' => 'USD',
|
|
]);
|
|
});
|
|
|
|
function makeTransaction(array $attributes = []): Transaction
|
|
{
|
|
return Transaction::factory()->create(array_merge([
|
|
'user_id' => test()->user->id,
|
|
'account_id' => test()->account->id,
|
|
'currency_code' => 'USD',
|
|
'transaction_date' => now(),
|
|
], $attributes));
|
|
}
|
|
|
|
test('analysis endpoint requires authentication', function () {
|
|
auth()->logout();
|
|
|
|
$this->getJson('/api/analysis?group_by=category')->assertUnauthorized();
|
|
});
|
|
|
|
test('analysis responses are not cached between users', function () {
|
|
$this->getJson('/api/analysis?group_by=category')
|
|
->assertOk()
|
|
->assertHeader('Cache-Control', 'no-store, private');
|
|
});
|
|
|
|
test('group_by is required and constrained', function () {
|
|
$this->getJson('/api/analysis')->assertStatus(422);
|
|
$this->getJson('/api/analysis?group_by=bogus')->assertStatus(422);
|
|
});
|
|
|
|
test('summary splits income, expense, net and count by sign', function () {
|
|
makeTransaction(['amount' => 100000]);
|
|
makeTransaction(['amount' => 30000]);
|
|
makeTransaction(['amount' => -40000]);
|
|
|
|
$this->getJson('/api/analysis?group_by=category')
|
|
->assertOk()
|
|
->assertJson([
|
|
'summary' => [
|
|
'income' => 130000,
|
|
'expense' => 40000,
|
|
'net' => 90000,
|
|
'count' => 3,
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('groups by category sum signed amounts and sort by magnitude', function () {
|
|
$food = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
$rent = Category::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'type' => CategoryType::Expense,
|
|
]);
|
|
|
|
makeTransaction(['category_id' => $food->id, 'amount' => -2000]);
|
|
makeTransaction(['category_id' => $food->id, 'amount' => -3000]);
|
|
makeTransaction(['category_id' => $rent->id, 'amount' => -90000]);
|
|
|
|
$groups = $this->getJson('/api/analysis?group_by=category')
|
|
->assertOk()
|
|
->json('groups');
|
|
|
|
expect($groups)->toHaveCount(2);
|
|
expect($groups[0])->toMatchArray(['key' => $rent->id, 'amount' => -90000, 'count' => 1]);
|
|
expect($groups[1])->toMatchArray(['key' => $food->id, 'amount' => -5000, 'count' => 2]);
|
|
});
|
|
|
|
test('uncategorized transactions group under a null key', function () {
|
|
makeTransaction(['category_id' => null, 'amount' => -1500]);
|
|
|
|
$groups = $this->getJson('/api/analysis?group_by=category')
|
|
->assertOk()
|
|
->json('groups');
|
|
|
|
expect($groups)->toHaveCount(1);
|
|
expect($groups[0]['key'])->toBeNull();
|
|
expect($groups[0]['amount'])->toBe(-1500);
|
|
});
|
|
|
|
test('groups by month bucket transactions by calendar month across years', function () {
|
|
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-15']);
|
|
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-20']);
|
|
makeTransaction(['amount' => -4000, 'transaction_date' => '2025-01-10']); // different year, same month
|
|
makeTransaction(['amount' => -5000, 'transaction_date' => '2026-02-10']);
|
|
|
|
$groups = collect($this->getJson('/api/analysis?group_by=month')
|
|
->assertOk()
|
|
->json('groups'))
|
|
->keyBy('key');
|
|
|
|
// All January transactions collapse together regardless of year.
|
|
expect($groups['01']['amount'])->toBe(-7000);
|
|
expect($groups['01']['count'])->toBe(3);
|
|
expect($groups['02']['amount'])->toBe(-5000);
|
|
});
|
|
|
|
test('groups by label count a transaction once per label and bucket unlabeled separately', function () {
|
|
$trip = Label::factory()->create(['user_id' => $this->user->id]);
|
|
$work = Label::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
$multi = makeTransaction(['amount' => -6000]);
|
|
$multi->labels()->sync([$trip->id, $work->id]);
|
|
|
|
makeTransaction(['amount' => -1000]); // no label
|
|
|
|
$groups = collect($this->getJson('/api/analysis?group_by=label')
|
|
->assertOk()
|
|
->json('groups'))
|
|
->keyBy(fn (array $group): string => $group['key'] ?? '__null__');
|
|
|
|
expect($groups[$trip->id]['amount'])->toBe(-6000);
|
|
expect($groups[$work->id]['amount'])->toBe(-6000);
|
|
expect($groups['__null__']['amount'])->toBe(-1000);
|
|
});
|
|
|
|
test('filters restrict the analyzed transactions', function () {
|
|
$keep = Category::factory()->create(['user_id' => $this->user->id]);
|
|
$drop = Category::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
makeTransaction(['category_id' => $keep->id, 'amount' => -1000]);
|
|
makeTransaction(['category_id' => $drop->id, 'amount' => -9000]);
|
|
|
|
$response = $this->getJson('/api/analysis?'.http_build_query([
|
|
'group_by' => 'category',
|
|
'category_ids' => $keep->id,
|
|
]))->assertOk();
|
|
|
|
expect($response->json('summary.expense'))->toBe(1000);
|
|
expect($response->json('groups'))->toHaveCount(1);
|
|
expect($response->json('groups.0.key'))->toBe($keep->id);
|
|
});
|
|
|
|
test('category and label filters combine with OR', function () {
|
|
$category = Category::factory()->create(['user_id' => $this->user->id]);
|
|
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
makeTransaction(['category_id' => $category->id, 'amount' => -1000]);
|
|
|
|
$labelled = makeTransaction(['category_id' => null, 'amount' => -2000]);
|
|
$labelled->labels()->sync([$label->id]);
|
|
|
|
makeTransaction(['category_id' => null, 'amount' => -9000]); // matches neither
|
|
|
|
$response = $this->getJson('/api/analysis?'.http_build_query([
|
|
'group_by' => 'category',
|
|
'category_ids' => $category->id,
|
|
'label_ids' => $label->id,
|
|
]))->assertOk();
|
|
|
|
expect($response->json('summary.expense'))->toBe(3000);
|
|
expect($response->json('summary.count'))->toBe(2);
|
|
});
|
|
|
|
test('foreign currency amounts are converted to the user currency', function () {
|
|
$date = '2026-03-10';
|
|
|
|
$eurAccount = Account::factory()->create([
|
|
'user_id' => $this->user->id,
|
|
'currency_code' => 'EUR',
|
|
]);
|
|
|
|
ExchangeRate::factory()->create([
|
|
'base_currency' => 'usd',
|
|
'date' => $date,
|
|
'rates' => ['eur' => 0.80],
|
|
]);
|
|
|
|
// 8000 EUR cents / 0.80 = 10000 USD cents
|
|
makeTransaction([
|
|
'account_id' => $eurAccount->id,
|
|
'currency_code' => 'EUR',
|
|
'amount' => -8000,
|
|
'transaction_date' => $date,
|
|
]);
|
|
|
|
$this->getJson('/api/analysis?group_by=category')
|
|
->assertOk()
|
|
->assertJson(['summary' => ['expense' => 10000]]);
|
|
});
|