409 lines
20 KiB
PHP
409 lines
20 KiB
PHP
<?php
|
|
|
|
use App\Enums\CategoryType;
|
|
use App\Models\Account;
|
|
use App\Models\Bank;
|
|
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([
|
|
'user_id' => test()->user->id,
|
|
'account_id' => test()->account->id,
|
|
'currency_code' => 'USD',
|
|
'category_id' => null,
|
|
...$attributes,
|
|
]);
|
|
}
|
|
|
|
test('analysis response is not cached between users', function () {
|
|
$this->getJson('/api/transactions/analysis')
|
|
->assertOk()
|
|
->assertHeader('Cache-Control', 'no-store, private');
|
|
});
|
|
|
|
test('summary totals income, expense, net and count from the filtered set', function () {
|
|
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
|
|
|
$income = makeTransaction(['amount' => 100000, 'transaction_date' => '2026-01-10']);
|
|
$income->labels()->attach($label);
|
|
|
|
$expense = makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-12']);
|
|
$expense->labels()->attach($label);
|
|
|
|
// Outside the label filter, must be excluded.
|
|
makeTransaction(['amount' => -99999, 'transaction_date' => '2026-01-12']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis?'.http_build_query([
|
|
'label_ids' => $label->id,
|
|
]));
|
|
|
|
$response->assertOk()
|
|
->assertJson([
|
|
'currency' => 'USD',
|
|
'summary' => [
|
|
'income' => 100000,
|
|
'expense' => 40000,
|
|
'net' => 60000,
|
|
'count' => 2,
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('category breakdown groups expenses by top-level category', function () {
|
|
$hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel', 'color' => 'blue', 'icon' => 'Building']);
|
|
$meals = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Meals']);
|
|
|
|
makeTransaction(['amount' => -50000, 'category_id' => $hotel->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -20000, 'category_id' => $meals->id, 'transaction_date' => '2026-01-11']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('distinct_category_count'))->toBe(2);
|
|
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'color' => 'blue', 'icon' => 'Building', 'children' => []]);
|
|
expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000, 'children' => []]);
|
|
});
|
|
|
|
test('category breakdown nests sub-categories under their parent total', function () {
|
|
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
|
$groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]);
|
|
$restaurants = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Restaurants', 'parent_id' => $food->id]);
|
|
|
|
makeTransaction(['amount' => -30000, 'category_id' => $groceries->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -10000, 'category_id' => $restaurants->id, 'transaction_date' => '2026-01-11']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('distinct_category_count'))->toBe(1);
|
|
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 40000]);
|
|
expect($response->json('by_category.0.children.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 30000]);
|
|
expect($response->json('by_category.0.children.1'))->toMatchArray(['name' => 'Restaurants', 'amount' => 10000]);
|
|
});
|
|
|
|
test('spend booked directly on a split parent surfaces as a Direct child', function () {
|
|
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
|
$groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]);
|
|
|
|
makeTransaction(['amount' => -30000, 'category_id' => $groceries->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -5000, 'category_id' => $food->id, 'transaction_date' => '2026-01-11']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 35000]);
|
|
|
|
$children = collect($response->json('by_category.0.children'));
|
|
expect($children)->toHaveCount(2);
|
|
expect($children->firstWhere('name', 'Groceries')['amount'])->toBe(30000);
|
|
expect($children->firstWhere('name', 'Direct')['amount'])->toBe(5000);
|
|
});
|
|
|
|
test('grand-children fold into their level-two sub-category', function () {
|
|
$food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
|
|
$groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]);
|
|
$organic = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Organic', 'parent_id' => $groceries->id]);
|
|
|
|
makeTransaction(['amount' => -12000, 'category_id' => $organic->id, 'transaction_date' => '2026-01-10']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 12000]);
|
|
expect($response->json('by_category.0.children.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 12000]);
|
|
});
|
|
|
|
test('tag breakdown sums expenses per label', function () {
|
|
$trip = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Miami']);
|
|
$food = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Food']);
|
|
|
|
$meal = makeTransaction(['amount' => -3000, 'transaction_date' => '2026-01-10']);
|
|
$meal->labels()->attach([$trip->id, $food->id]);
|
|
|
|
$hotel = makeTransaction(['amount' => -7000, 'transaction_date' => '2026-01-11']);
|
|
$hotel->labels()->attach($trip->id);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('distinct_label_count'))->toBe(2);
|
|
expect($response->json('by_tag.0'))->toMatchArray(['name' => 'Miami', 'amount' => 10000]);
|
|
expect($response->json('by_tag.1'))->toMatchArray(['name' => 'Food', 'amount' => 3000]);
|
|
});
|
|
|
|
test('over time uses daily buckets for short spans and carries a cumulative expense', function () {
|
|
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-12']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('over_time.bucket'))->toBe('day');
|
|
|
|
$points = $response->json('over_time.points');
|
|
expect($points)->toHaveCount(3); // Jan 10, 11 (gap filled), 12
|
|
expect($points[0])->toMatchArray(['date' => '2026-01-10', 'expense' => 1000, 'cumulative_expense' => 1000]);
|
|
expect($points[1])->toMatchArray(['date' => '2026-01-11', 'expense' => 0, 'cumulative_expense' => 1000]);
|
|
expect($points[2])->toMatchArray(['date' => '2026-01-12', 'expense' => 2000, 'cumulative_expense' => 3000]);
|
|
});
|
|
|
|
test('over time switches to monthly buckets for long spans', function () {
|
|
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-06-10']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('over_time.bucket'))->toBe('month');
|
|
expect($response->json('over_time.points'))->toHaveCount(6); // Jan..Jun
|
|
});
|
|
|
|
test('over time carries a cumulative net alongside the cumulative expense', function () {
|
|
makeTransaction(['amount' => 5000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-11']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
$points = $response->json('over_time.points');
|
|
expect($points[0])->toMatchArray(['cumulative_expense' => 0, 'cumulative_net' => 5000]);
|
|
expect($points[1])->toMatchArray(['cumulative_expense' => 2000, 'cumulative_net' => 3000]);
|
|
});
|
|
|
|
test('largest expenses lists the biggest spends richest-first, capped at ten', function () {
|
|
foreach (range(1, 12) as $index) {
|
|
makeTransaction(['amount' => -$index * 1000, 'description' => "Expense {$index}", 'transaction_date' => '2026-01-10']);
|
|
}
|
|
// Income must never appear among the largest expenses.
|
|
makeTransaction(['amount' => 999999, 'transaction_date' => '2026-01-10']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
$largest = $response->json('largest_expenses');
|
|
expect($largest)->toHaveCount(10);
|
|
expect($largest[0])->toMatchArray(['description' => 'Expense 12', 'amount' => 12000]);
|
|
expect($largest[9])->toMatchArray(['description' => 'Expense 3', 'amount' => 3000]);
|
|
});
|
|
|
|
test('largest expenses carry the category, account and labels for display', function () {
|
|
$bank = Bank::factory()->create(['name' => 'Acme Bank']);
|
|
$account = Account::factory()->create(['user_id' => $this->user->id, 'currency_code' => 'USD', 'bank_id' => $bank->id]);
|
|
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel']);
|
|
$label = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Trip']);
|
|
|
|
$transaction = makeTransaction([
|
|
'amount' => -50000,
|
|
'account_id' => $account->id,
|
|
'category_id' => $category->id,
|
|
'description' => 'Grand Hotel',
|
|
'transaction_date' => '2026-01-10',
|
|
]);
|
|
$transaction->labels()->attach($label);
|
|
|
|
$row = $this->getJson('/api/transactions/analysis')->assertOk()->json('largest_expenses.0');
|
|
|
|
expect($row)->toMatchArray([
|
|
'description' => 'Grand Hotel',
|
|
'amount' => 50000,
|
|
'category' => ['name' => 'Hotel', 'color' => $category->color, 'icon' => $category->icon],
|
|
'account' => ['name' => $account->name, 'bank' => ['name' => 'Acme Bank', 'logo' => $bank->logo]],
|
|
]);
|
|
expect($row['labels'])->toHaveCount(1);
|
|
expect($row['labels'][0])->toMatchArray(['name' => 'Trip']);
|
|
});
|
|
|
|
test('payee breakdown sums named creditors and ignores blank ones', function () {
|
|
makeTransaction(['amount' => -3000, 'creditor_name' => 'Hotel Paradiso', 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -2000, 'creditor_name' => 'Hotel Paradiso', 'transaction_date' => '2026-01-11']);
|
|
makeTransaction(['amount' => -1000, 'creditor_name' => 'Cafe Roma', 'transaction_date' => '2026-01-12']);
|
|
makeTransaction(['amount' => -9000, 'creditor_name' => null, 'transaction_date' => '2026-01-13']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('distinct_payee_count'))->toBe(2);
|
|
expect($response->json('by_payee.0'))->toMatchArray(['name' => 'Hotel Paradiso', 'amount' => 5000]);
|
|
expect($response->json('by_payee.1'))->toMatchArray(['name' => 'Cafe Roma', 'amount' => 1000]);
|
|
});
|
|
|
|
test('account breakdown sums expenses per funding account', function () {
|
|
$other = Account::factory()->create(['user_id' => $this->user->id, 'currency_code' => 'USD', 'name' => 'Travel card']);
|
|
|
|
makeTransaction(['amount' => -4000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -6000, 'account_id' => $other->id, 'transaction_date' => '2026-01-11']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis');
|
|
|
|
$response->assertOk();
|
|
expect($response->json('distinct_account_count'))->toBe(2);
|
|
expect($response->json('by_account.0'))->toMatchArray(['name' => 'Travel card', 'amount' => 6000]);
|
|
});
|
|
|
|
test('summary excludes transfer category transactions from income and expense', function () {
|
|
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
|
|
|
makeTransaction(['amount' => 100000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-11']);
|
|
|
|
// Internal movements between own accounts: must not move income or expense.
|
|
makeTransaction(['amount' => 70000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-12']);
|
|
makeTransaction(['amount' => -70000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-13']);
|
|
|
|
$this->getJson('/api/transactions/analysis')
|
|
->assertOk()
|
|
->assertJson([
|
|
'summary' => [
|
|
'income' => 100000,
|
|
'expense' => 40000,
|
|
'net' => 60000,
|
|
],
|
|
]);
|
|
});
|
|
|
|
test('summary excludes savings and investment outflows from expense, matching cashflow', function () {
|
|
$savings = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Savings]);
|
|
$investment = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Investment]);
|
|
|
|
makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -25000, 'category_id' => $savings->id, 'transaction_date' => '2026-01-11']);
|
|
makeTransaction(['amount' => -15000, 'category_id' => $investment->id, 'transaction_date' => '2026-01-12']);
|
|
|
|
$this->getJson('/api/transactions/analysis')
|
|
->assertOk()
|
|
->assertJson(['summary' => ['expense' => 40000]]);
|
|
});
|
|
|
|
test('category breakdown ignores transfer, savings and investment categories', function () {
|
|
$expense = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries']);
|
|
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer, 'name' => 'Move money']);
|
|
$savings = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Savings, 'name' => 'Rainy day']);
|
|
|
|
makeTransaction(['amount' => -30000, 'category_id' => $expense->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -50000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-11']);
|
|
makeTransaction(['amount' => -20000, 'category_id' => $savings->id, 'transaction_date' => '2026-01-12']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
|
|
|
expect($response->json('distinct_category_count'))->toBe(1);
|
|
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 30000]);
|
|
});
|
|
|
|
test('tag, payee and account breakdowns ignore transfer categories', function () {
|
|
$label = Label::factory()->create(['user_id' => $this->user->id]);
|
|
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
|
|
|
makeTransaction(['amount' => -10000, 'creditor_name' => 'Shop', 'transaction_date' => '2026-01-10'])
|
|
->labels()->attach($label);
|
|
|
|
$tagged = makeTransaction(['amount' => -90000, 'category_id' => $transfer->id, 'creditor_name' => 'My other account', 'transaction_date' => '2026-01-11']);
|
|
$tagged->labels()->attach($label);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
|
|
|
expect($response->json('distinct_label_count'))->toBe(1);
|
|
expect($response->json('by_tag.0.amount'))->toBe(10000);
|
|
expect($response->json('distinct_payee_count'))->toBe(1);
|
|
expect($response->json('by_payee.0'))->toMatchArray(['name' => 'Shop', 'amount' => 10000]);
|
|
expect($response->json('distinct_account_count'))->toBe(1);
|
|
expect($response->json('by_account.0.amount'))->toBe(10000);
|
|
});
|
|
|
|
test('largest expenses ignores transfer category outflows', function () {
|
|
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
|
|
|
makeTransaction(['amount' => -5000, 'description' => 'Dinner', 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -99000, 'category_id' => $transfer->id, 'description' => 'Transfer out', 'transaction_date' => '2026-01-11']);
|
|
|
|
$largest = $this->getJson('/api/transactions/analysis')->assertOk()->json('largest_expenses');
|
|
|
|
expect($largest)->toHaveCount(1);
|
|
expect($largest[0])->toMatchArray(['description' => 'Dinner', 'amount' => 5000]);
|
|
});
|
|
|
|
test('over time buckets ignore transfer categories', function () {
|
|
$transfer = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Transfer]);
|
|
|
|
makeTransaction(['amount' => 30000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -10000, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => 50000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -50000, 'category_id' => $transfer->id, 'transaction_date' => '2026-01-10']);
|
|
|
|
$points = collect($this->getJson('/api/transactions/analysis')->assertOk()->json('over_time.points'));
|
|
$day = $points->firstWhere('date', '2026-01-10');
|
|
|
|
expect($day)->toMatchArray(['income' => 30000, 'expense' => 10000]);
|
|
});
|
|
|
|
test('refunds net against spending within an expense category, matching cashflow', function () {
|
|
$shopping = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Shopping']);
|
|
|
|
makeTransaction(['amount' => -10000, 'category_id' => $shopping->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => 3000, 'category_id' => $shopping->id, 'transaction_date' => '2026-01-15']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
|
|
|
// The +3000 refund is not income: it nets the expense down to 7000.
|
|
$response->assertJson(['summary' => ['income' => 0, 'expense' => 7000, 'net' => -7000]]);
|
|
expect($response->json('distinct_category_count'))->toBe(1);
|
|
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Shopping', 'amount' => 7000]);
|
|
});
|
|
|
|
test('reversals net against income within an income category', function () {
|
|
$salary = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Income, 'name' => 'Salary']);
|
|
|
|
makeTransaction(['amount' => 10000, 'category_id' => $salary->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -2000, 'category_id' => $salary->id, 'transaction_date' => '2026-01-15']);
|
|
|
|
$this->getJson('/api/transactions/analysis')
|
|
->assertOk()
|
|
->assertJson(['summary' => ['income' => 8000, 'expense' => 0, 'net' => 8000]]);
|
|
});
|
|
|
|
test('an uncategorized inflow is income, never an expense category row', function () {
|
|
makeTransaction(['amount' => 5000, 'transaction_date' => '2026-01-10']);
|
|
|
|
$response = $this->getJson('/api/transactions/analysis')->assertOk();
|
|
|
|
$response->assertJson(['summary' => ['income' => 5000, 'expense' => 0, 'net' => 5000]]);
|
|
expect($response->json('distinct_category_count'))->toBe(0);
|
|
expect($response->json('by_category'))->toBe([]);
|
|
});
|
|
|
|
test('foreign currency expenses are converted to the user currency', function () {
|
|
$shopping = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
|
|
$eurAccount = Account::factory()->create(['user_id' => $this->user->id, 'currency_code' => 'EUR']);
|
|
|
|
ExchangeRate::factory()->create([
|
|
'base_currency' => 'usd',
|
|
'date' => '2026-01-10',
|
|
'rates' => ['eur' => 0.80],
|
|
]);
|
|
|
|
makeTransaction(['amount' => -5000, 'category_id' => $shopping->id, 'transaction_date' => '2026-01-10']);
|
|
makeTransaction(['amount' => -4000, 'category_id' => $shopping->id, 'account_id' => $eurAccount->id, 'currency_code' => 'EUR', 'transaction_date' => '2026-01-10']);
|
|
|
|
// 4000 EUR / 0.80 = 5000 USD, on top of the 5000 USD spend.
|
|
$this->getJson('/api/transactions/analysis')
|
|
->assertOk()
|
|
->assertJson(['summary' => ['expense' => 10000]]);
|
|
});
|