fix(analysis): respect category types like the cashflow screen (#612)

## Why

On the **analysis** drawer, income/expense were derived from each
transaction's amount sign alone, ignoring the category type.
Transactions in `transfer` / `savings` / `investment` categories
therefore leaked into income/expense totals and every breakdown — e.g.
moving money between your own accounts inflated both sides. The
**cashflow** screen already keys off the category type; analysis now
does the same.

## What changed

Three commits:

1. **fix(analysis): exclude transfer, savings and investment categories
from income and expense**
Only `expense` categories (or uncategorized outflows) count as expenses,
and only `income` categories (or uncategorized inflows) count as income.
`transfer` / `savings` / `investment` are internal movements and sit on
neither side — identical to how cashflow computes income/expense/net.

2. **refactor(analytics): de-duplicate currency conversion and
category-type helpers**
`convertTransactionAmount()` / `preloadExchangeRates()` were
byte-identical in three controllers, and `categoryType()` in two.
Extracted the currency helpers into a shared
`Api\Concerns\ConvertsTransactionCurrency` trait (following the existing
`OpenBanking/Concerns` pattern) and moved `categoryType()` onto the
`Transaction` model. No behavior change.

3. **fix(analysis): net refunds within a category so totals reconcile
with cashflow**
Classification keyed off each transaction's own sign dropped contra-sign
rows entirely, so a refund booked to an expense category disappeared
instead of netting the spend down — and analysis disagreed with cashflow
on the same data (a `-10000` charge + `3000` refund read as `10000`
spent, not `7000`). A transaction's side is now decided by its category
type and signed amounts are summed before clamping, mirroring cashflow's
reconciliation across summary, category, payee, account, tag and
over-time. The largest-expenses list still shows only genuine outflows.

## Tests

Added analysis coverage for: transfer/savings/investment exclusion
(summary, breakdowns, largest, over-time), refund netting (asserts
parity with cashflow's `7000`), income-category reversals,
foreign-currency conversion, and uncategorized inflows. Full non-browser
suite green (1823 passed); `pint --test`, `bun run format`, `bun run
lint` all clean.

## Follow-up for product (not in this PR)

On **cashflow**, savings/investment outflows are excluded from expense
but re-surfaced in a dedicated "Saved & Invested" card. The **analysis**
drawer has no equivalent surface, so money categorized as
savings/investment is now correctly excluded from spending but isn't
shown anywhere. If we want analysis to fully account for it, we should
add a small "set aside" summary there. Flagged for a product decision
rather than bundled into this bugfix.
This commit is contained in:
Víctor Falcón 2026-06-29 21:04:18 +02:00 committed by GitHub
parent 6727a9c64a
commit 986f43705a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 278 additions and 117 deletions

View File

@ -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');

View File

@ -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<int, Transaction> $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);
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Http\Controllers\Api\Concerns;
use App\Models\Transaction;
use App\Services\ExchangeRateService;
use Illuminate\Support\Collection;
/**
* Shared currency conversion for the analytics controllers. Each consumer
* injects an {@see ExchangeRateService} as `$exchangeRateService`, then reads
* transaction amounts in the user's currency through these helpers.
*/
trait ConvertsTransactionCurrency
{
protected 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<int, Transaction> $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);
}
}

View File

@ -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);
}
}

View File

@ -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<AutomationRule, $this> */
public function categorizedByRule(): BelongsTo
{

View File

@ -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]]);
});