diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index 3a155b51..367c160a 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; +use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Models\Category; use App\Models\Transaction; @@ -17,6 +18,8 @@ use Illuminate\Support\Collection; class CashflowAnalyticsController extends Controller { + use ConvertsTransactionCurrency; + private const MAX_TREND_MONTHS = 24; public function __construct( @@ -222,7 +225,7 @@ class CashflowAnalyticsController extends Controller { return $transactions ->filter(function (Transaction $transaction) use ($type): bool { - if ($this->categoryType($transaction) === $type) { + if ($transaction->categoryType() === $type) { return true; } @@ -235,7 +238,7 @@ class CashflowAnalyticsController extends Controller private function sumOutflowTransactions(Collection $transactions, string $userCurrency, CategoryType $type): int { return abs($transactions - ->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type + ->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type && $transaction->amount < 0) ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency))); } @@ -254,7 +257,7 @@ class CashflowAnalyticsController extends Controller $regularCategories = $transactions ->filter(function (Transaction $transaction) use ($type): bool { - $categoryType = $this->categoryType($transaction); + $categoryType = $transaction->categoryType(); return $transaction->category_id !== null && ($categoryType === $type @@ -282,7 +285,7 @@ class CashflowAnalyticsController extends Controller $transferCategories = $transactions ->filter(function (Transaction $transaction) use ($isIncome): bool { return $transaction->category_id !== null - && $this->categoryType($transaction) === CategoryType::Transfer + && $transaction->categoryType() === CategoryType::Transfer && $this->categoryCashflowDirection($transaction) === ($isIncome ? CategoryCashflowDirection::Inflow : CategoryCashflowDirection::Outflow); @@ -359,7 +362,7 @@ class CashflowAnalyticsController extends Controller foreach ($categorized as $categoryTransactions) { $firstTransaction = $categoryTransactions->first(); - $type = $this->categoryType($firstTransaction); + $type = $firstTransaction->categoryType(); if (! in_array($type, [CategoryType::Income, CategoryType::Expense], true)) { continue; @@ -406,7 +409,7 @@ class CashflowAnalyticsController extends Controller $this->preloadExchangeRates($transactions, $userCurrency); $categorized = $transactions - ->filter(fn (Transaction $transaction): bool => $this->categoryType($transaction) === $type) + ->filter(fn (Transaction $transaction): bool => $transaction->categoryType() === $type) ->groupBy('category_id') ->map(function (Collection $transactions) use ($userCurrency): array { $totalAmount = $transactions->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); @@ -461,42 +464,6 @@ class CashflowAnalyticsController extends Controller }); } - private function convertTransactionAmount(Transaction $transaction, string $userCurrency): int - { - return $this->exchangeRateService->convert( - $transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, - $userCurrency, - $transaction->amount, - $transaction->transaction_date->toDateString(), - ); - } - - private function preloadExchangeRates(Collection $transactions, string $userCurrency): void - { - $dates = $transactions - ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, $userCurrency) !== 0) - ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) - ->unique() - ->values(); - - if ($dates->isEmpty()) { - return; - } - - $this->exchangeRateService->preloadRates($userCurrency, $dates); - } - - private function categoryType(Transaction $transaction): ?CategoryType - { - $type = $transaction->category?->getAttribute('type'); - - if ($type instanceof CategoryType) { - return $type; - } - - return is_string($type) ? CategoryType::tryFrom($type) : null; - } - private function categoryCashflowDirection(Transaction $transaction): ?CategoryCashflowDirection { $direction = $transaction->category?->getAttribute('cashflow_direction'); diff --git a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php index 3b68bf7d..7e005a83 100644 --- a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php +++ b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers\Api; use App\Enums\CategoryType; +use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Models\Category; use App\Models\Transaction; @@ -14,6 +15,8 @@ use Illuminate\Support\Collection; class CategoryMonthlyBreakdownController extends Controller { + use ConvertsTransactionCurrency; + /** * The rolling window shown on the chart, in months (including the current). */ @@ -276,32 +279,4 @@ class CategoryMonthlyBreakdownController extends Controller return $months; } - - private function convertTransactionAmount(Transaction $transaction, string $currency): int - { - return $this->exchangeRateService->convert( - $transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, - $currency, - $transaction->amount, - $transaction->transaction_date->toDateString(), - ); - } - - /** - * @param Collection $transactions - */ - private function preloadExchangeRates(Collection $transactions, string $currency): void - { - $dates = $transactions - ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0) - ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) - ->unique() - ->values(); - - if ($dates->isEmpty()) { - return; - } - - $this->exchangeRateService->preloadRates($currency, $dates); - } } diff --git a/app/Http/Controllers/Api/Concerns/ConvertsTransactionCurrency.php b/app/Http/Controllers/Api/Concerns/ConvertsTransactionCurrency.php new file mode 100644 index 00000000..ffb434f2 --- /dev/null +++ b/app/Http/Controllers/Api/Concerns/ConvertsTransactionCurrency.php @@ -0,0 +1,43 @@ +exchangeRateService->convert( + $transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, + $currency, + $transaction->amount, + $transaction->transaction_date->toDateString(), + ); + } + + /** + * @param Collection $transactions + */ + protected function preloadExchangeRates(Collection $transactions, string $currency): void + { + $dates = $transactions + ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0) + ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) + ->unique() + ->values(); + + if ($dates->isEmpty()) { + return; + } + + $this->exchangeRateService->preloadRates($currency, $dates); + } +} diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index 2f46310a..2c471161 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -2,6 +2,8 @@ namespace App\Http\Controllers\Api; +use App\Enums\CategoryType; +use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Http\Requests\IndexTransactionRequest; use App\Models\Label; @@ -14,6 +16,8 @@ use Illuminate\Support\Collection; class TransactionAnalysisController extends Controller { + use ConvertsTransactionCurrency; + /** * A daily breakdown is used while the filtered set spans this many days or * fewer; beyond that the chart switches to monthly buckets. @@ -91,15 +95,19 @@ class TransactionAnalysisController extends Controller $expense = 0; foreach ($transactions as $transaction) { - $amount = $this->convertTransactionAmount($transaction, $currency); - - if ($amount > 0) { - $income += $amount; - } else { - $expense += abs($amount); + if ($this->isIncomeSide($transaction)) { + $income += $this->convertTransactionAmount($transaction, $currency); + } elseif ($this->isExpenseSide($transaction)) { + $expense += $this->convertTransactionAmount($transaction, $currency); } } + // Refunds net against their side before it is clamped, so a credit in + // an expense category lowers spending instead of inflating income — + // matching how the cashflow screen reconciles the same transactions. + $income = max(0, $income); + $expense = max(0, -$expense); + $days = $this->spanInDays($transactions); return [ @@ -120,7 +128,7 @@ class TransactionAnalysisController extends Controller private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection { $expenses = $transactions->filter( - fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0, + fn (Transaction $transaction): bool => $this->isExpenseSide($transaction), ); $grouped = $expenses @@ -128,8 +136,9 @@ class TransactionAnalysisController extends Controller ->groupBy('category_id') ->map(fn (Collection $group): array => [ 'category_id' => $group->first()->category_id, - 'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))), + 'amount' => -$group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)), ]) + ->filter(fn (array $node): bool => $node['amount'] > 0) ->values() ->all(); @@ -148,9 +157,9 @@ class TransactionAnalysisController extends Controller ], $node['children']), ], $this->tree->spendingBreakdown($grouped, $userId)); - $uncategorized = abs($expenses + $uncategorized = -$expenses ->filter(fn (Transaction $transaction): bool => $transaction->category_id === null) - ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))); + ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)); if ($uncategorized > 0) { $rows[] = [ @@ -178,15 +187,15 @@ class TransactionAnalysisController extends Controller $totals = []; foreach ($transactions as $transaction) { - $amount = $this->convertTransactionAmount($transaction, $currency); - - if ($amount >= 0) { + if (! $this->isExpenseSide($transaction)) { continue; } + $amount = -$this->convertTransactionAmount($transaction, $currency); + foreach ($transaction->labels as $label) { $totals[$label->id] ??= ['id' => $label->id, 'name' => $label->name, 'color' => $label->color, 'amount' => 0]; - $totals[$label->id]['amount'] += abs($amount); + $totals[$label->id]['amount'] += $amount; } } @@ -206,12 +215,12 @@ class TransactionAnalysisController extends Controller $totals = []; foreach ($transactions as $transaction) { - $amount = $this->convertTransactionAmount($transaction, $currency); - - if ($amount >= 0) { + if (! $this->isExpenseSide($transaction)) { continue; } + $amount = -$this->convertTransactionAmount($transaction, $currency); + $name = trim((string) $transaction->creditor_name); if ($name === '') { @@ -219,10 +228,11 @@ class TransactionAnalysisController extends Controller } $totals[$name] ??= ['name' => $name, 'amount' => 0]; - $totals[$name]['amount'] += abs($amount); + $totals[$name]['amount'] += $amount; } return collect($totals) + ->filter(fn (array $payee): bool => $payee['amount'] > 0) ->sortByDesc('amount') ->values(); } @@ -236,12 +246,12 @@ class TransactionAnalysisController extends Controller $totals = []; foreach ($transactions as $transaction) { - $amount = $this->convertTransactionAmount($transaction, $currency); - - if ($amount >= 0) { + if (! $this->isExpenseSide($transaction)) { continue; } + $amount = -$this->convertTransactionAmount($transaction, $currency); + $account = $transaction->account; $totals[$account->id] ??= [ @@ -250,10 +260,11 @@ class TransactionAnalysisController extends Controller 'bank' => $account->bank ? ['name' => $account->bank->name, 'logo' => $account->bank->logo] : null, 'amount' => 0, ]; - $totals[$account->id]['amount'] += abs($amount); + $totals[$account->id]['amount'] += $amount; } return collect($totals) + ->filter(fn (array $account): bool => $account['amount'] > 0) ->sortByDesc('amount') ->values(); } @@ -268,7 +279,7 @@ class TransactionAnalysisController extends Controller private function largestExpenses(Collection $transactions, string $currency): array { return $transactions - ->filter(fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0) + ->filter(fn (Transaction $transaction): bool => $this->isExpenseSide($transaction) && $transaction->amount < 0) ->sortBy(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)) ->take(self::LARGEST_EXPENSES_LIMIT) ->map(fn (Transaction $transaction): array => [ @@ -319,13 +330,12 @@ class TransactionAnalysisController extends Controller $buckets = []; foreach ($transactions as $transaction) { $key = $transaction->transaction_date->format($keyFormat); - $amount = $this->convertTransactionAmount($transaction, $currency); $buckets[$key] ??= ['income' => 0, 'expense' => 0]; - if ($amount > 0) { - $buckets[$key]['income'] += $amount; - } else { - $buckets[$key]['expense'] += abs($amount); + if ($this->isIncomeSide($transaction)) { + $buckets[$key]['income'] += $this->convertTransactionAmount($transaction, $currency); + } elseif ($this->isExpenseSide($transaction)) { + $buckets[$key]['expense'] -= $this->convertTransactionAmount($transaction, $currency); } } @@ -368,28 +378,26 @@ class TransactionAnalysisController extends Controller return (int) $dates->min()->diffInDays($dates->max()) + 1; } - private function convertTransactionAmount(Transaction $transaction, string $currency): int + /** + * Whether a transaction belongs to the expense side: anything booked to an + * expense category (a refund there nets back out), plus uncategorized + * outflows. Transfers, savings and investments are internal movements, so + * they sit on neither side — matching the cashflow screen. + */ + private function isExpenseSide(Transaction $transaction): bool { - return $this->exchangeRateService->convert( - $transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, - $currency, - $transaction->amount, - $transaction->transaction_date->toDateString(), - ); + return $transaction->categoryType() === CategoryType::Expense + || ($transaction->category_id === null && $transaction->amount < 0); } - private function preloadExchangeRates(Collection $transactions, string $currency): void + /** + * Whether a transaction belongs to the income side: anything booked to an + * income category (a reversal there nets back out), plus uncategorized + * inflows. Internal movements are excluded for the same reason as expenses. + */ + private function isIncomeSide(Transaction $transaction): bool { - $dates = $transactions - ->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0) - ->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString()) - ->unique() - ->values(); - - if ($dates->isEmpty()) { - return; - } - - $this->exchangeRateService->preloadRates($currency, $dates); + return $transaction->categoryType() === CategoryType::Income + || ($transaction->category_id === null && $transaction->amount > 0); } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 12e3ebe6..2fea5d2a 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\CategorySource; +use App\Enums\CategoryType; use App\Enums\RuleOrigin; use App\Enums\TransactionSource; use App\Events\TransactionCreated; @@ -116,6 +117,22 @@ class Transaction extends Model return $this->belongsTo(Category::class); } + /** + * The type of the assigned category, resilient to phantom categories that + * are force-filled with a raw string type (e.g. the synthetic + * "uncategorized" rows the analytics controllers build). + */ + public function categoryType(): ?CategoryType + { + $type = $this->category?->getAttribute('type'); + + if ($type instanceof CategoryType) { + return $type; + } + + return is_string($type) ? CategoryType::tryFrom($type) : null; + } + /** @return BelongsTo */ public function categorizedByRule(): BelongsTo { diff --git a/tests/Feature/TransactionAnalysisTest.php b/tests/Feature/TransactionAnalysisTest.php index dca09186..044050f2 100644 --- a/tests/Feature/TransactionAnalysisTest.php +++ b/tests/Feature/TransactionAnalysisTest.php @@ -4,6 +4,7 @@ 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; @@ -27,6 +28,7 @@ function makeTransaction(array $attributes = []): Transaction 'user_id' => test()->user->id, 'account_id' => test()->account->id, 'currency_code' => 'USD', + 'category_id' => null, ...$attributes, ]); } @@ -255,3 +257,152 @@ test('account breakdown sums expenses per funding account', function () { 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]]); +});