feat(analysis): project-aware transaction analysis (#513)

## What

Reworks the filtered **transaction analysis drawer** to behave like a
*project view* — a coherent set of related transactions (a trip, a
rental, a renovation, a side hustle) — and surfaces data that was
already computable but never shown.

### Two shapes, auto-detected
The drawer adapts to whether the set is **expense-only** or **income +
expense**, detected from the income share (`income >= 15% of expense`).
A stray refund won't flip a trip into a P&L view; a rental's rent will.

- **Expense mode:** total spent, daily average, cumulative spend line.
- **Income mode:** net result + margin %, income vs expense bars,
cumulative **net** line.

The mode can be overridden per filter (Auto / Expenses only / Income &
expenses), mirroring the existing daily-average override exactly:
remembered in the browser per filter fingerprint and synced to a
matching saved filter via a new `analysis_mode` column + PATCH endpoint.

### New panels (all from existing data)
- **Largest expenses** — top 5, expandable to 10, biggest spends first.
- **Spending by payee** — horizontal bars, gated to ≥2 named payees.
- **Spending by account** — list, gated to ≥2 accounts.

### Smarter table
The largest-expenses table hides any column the filter or the rows have
pinned to a single value (e.g. filtering by one label drops the Labels
column; a one-category result drops the Category column).

## Tests
- **Backend:** new Pest coverage for `largest_expenses`, payee/account
breakdowns, `cumulative_net`, and the `analysis_mode` endpoint
(`TransactionAnalysisTest`, `SavedFilterTest`) — all green.
- **Frontend:** 9 vitest cases covering mode detection/override, the day
override, and column hiding.
- New `__()` keys added to `lang/es.json`.

## Notes
- Adds migration `add_analysis_mode_to_saved_filters_table`.
This commit is contained in:
Víctor Falcón 2026-06-09 15:32:07 +02:00 committed by GitHub
parent 1530544b8b
commit 7cc49a5368
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1253 additions and 119 deletions

View File

@ -0,0 +1,9 @@
<?php
namespace App\Enums;
enum AnalysisMode: string
{
case Expense = 'expense';
case Income = 'income';
}

View File

@ -2,12 +2,14 @@
namespace App\Http\Controllers\Api;
use App\Enums\AnalysisMode;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\StoreSavedFilterRequest;
use App\Http\Requests\Api\UpdateSavedFilterRequest;
use App\Models\SavedFilter;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
class SavedFilterController extends Controller
{
@ -68,4 +70,19 @@ class SavedFilterController extends Controller
'data' => $savedFilter,
]);
}
public function updateAnalysisMode(Request $request, SavedFilter $savedFilter): JsonResponse
{
abort_unless($savedFilter->user_id === $request->user()->id, 403);
$validated = $request->validate([
'analysis_mode' => ['nullable', Rule::enum(AnalysisMode::class)],
]);
$savedFilter->update(['analysis_mode' => $validated['analysis_mode'] ?? null]);
return response()->json([
'data' => $savedFilter,
]);
}
}

View File

@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Features\TransactionAnalysis;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexTransactionRequest;
use App\Models\Label;
use App\Models\Transaction;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
@ -21,6 +22,12 @@ class TransactionAnalysisController extends Controller
*/
private const DAILY_BUCKET_MAX_DAYS = 62;
/**
* The drawer lists the five biggest expenses with an option to reveal the
* rest, so ten covers both states without shipping the whole set.
*/
private const LARGEST_EXPENSES_LIMIT = 10;
public function __construct(
private ExchangeRateService $exchangeRateService,
private CategoryTree $tree,
@ -50,7 +57,7 @@ class TransactionAnalysisController extends Controller
$transactions = Transaction::query()
->where('user_id', $user->id)
->with(['account', 'category', 'labels'])
->with(['account.bank', 'category', 'labels'])
->applyFilters($filters)
->get();
@ -58,6 +65,8 @@ class TransactionAnalysisController extends Controller
$byCategory = $this->categoryBreakdown($transactions, $currency, $user->id);
$byTag = $this->tagBreakdown($transactions, $currency);
$byPayee = $this->payeeBreakdown($transactions, $currency);
$byAccount = $this->accountBreakdown($transactions, $currency);
return response()
->json([
@ -67,6 +76,11 @@ class TransactionAnalysisController extends Controller
'distinct_category_count' => $byCategory->count(),
'by_tag' => $byTag->values(),
'distinct_label_count' => $byTag->count(),
'by_payee' => $byPayee->values(),
'distinct_payee_count' => $byPayee->count(),
'by_account' => $byAccount->values(),
'distinct_account_count' => $byAccount->count(),
'largest_expenses' => $this->largestExpenses($transactions, $currency),
'over_time' => $this->overTime($transactions, $currency),
])
->header('Cache-Control', 'no-store, private');
@ -183,11 +197,112 @@ class TransactionAnalysisController extends Controller
->values();
}
/**
* Expenses grouped by the party paid (the creditor on the transaction).
* Transactions without a named creditor are skipped, since an unnamed
* bucket carries no meaning for the user.
*/
private function payeeBreakdown(Collection $transactions, string $currency): Collection
{
$totals = [];
foreach ($transactions as $transaction) {
$amount = $this->convertTransactionAmount($transaction, $currency);
if ($amount >= 0) {
continue;
}
$name = trim((string) $transaction->creditor_name);
if ($name === '') {
continue;
}
$totals[$name] ??= ['name' => $name, 'amount' => 0];
$totals[$name]['amount'] += abs($amount);
}
return collect($totals)
->sortByDesc('amount')
->values();
}
/**
* Expenses grouped by the account that funded them, so a set spanning
* several cards shows where the spending was charged.
*/
private function accountBreakdown(Collection $transactions, string $currency): Collection
{
$totals = [];
foreach ($transactions as $transaction) {
$amount = $this->convertTransactionAmount($transaction, $currency);
if ($amount >= 0) {
continue;
}
$account = $transaction->account;
$totals[$account->id] ??= [
'id' => $account->id,
'name' => $account->name,
'bank' => $account->bank ? ['name' => $account->bank->name, 'logo' => $account->bank->logo] : null,
'amount' => 0,
];
$totals[$account->id]['amount'] += abs($amount);
}
return collect($totals)
->sortByDesc('amount')
->values();
}
/**
* The biggest individual expenses, richest-first, each carrying the same
* display fields the transaction table shows so the drawer can render a
* familiar row. Capped at the limit the drawer can reveal.
*
* @return array<int, array{id: string, date: string, description: ?string, amount: int, category: ?array{name: string, color: ?string, icon: ?string}, account: array{name: string, bank: ?array{name: string, logo: ?string}}, labels: array<int, array{id: string, name: string, color: ?string}>}>
*/
private function largestExpenses(Collection $transactions, string $currency): array
{
return $transactions
->filter(fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0)
->sortBy(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))
->take(self::LARGEST_EXPENSES_LIMIT)
->map(fn (Transaction $transaction): array => [
'id' => $transaction->id,
'date' => $transaction->transaction_date->toDateString(),
'description' => $transaction->description,
'amount' => abs($this->convertTransactionAmount($transaction, $currency)),
'category' => $transaction->category ? [
'name' => $transaction->category->name,
'color' => $transaction->category->color,
'icon' => $transaction->category->icon,
] : null,
'account' => [
'name' => $transaction->account->name,
'bank' => $transaction->account->bank ? [
'name' => $transaction->account->bank->name,
'logo' => $transaction->account->bank->logo,
] : null,
],
'labels' => $transaction->labels
->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name, 'color' => $label->color])
->values()
->all(),
])
->values()
->all();
}
/**
* Income and expense bucketed over the filtered span, plus a running
* expense total so the pace of spending is visible.
*
* @return array{bucket: string, points: array<int, array{date: string, label: string, income: int, expense: int, cumulative_expense: int}>}
* @return array{bucket: string, points: array<int, array{date: string, label: string, income: int, expense: int, cumulative_expense: int, cumulative_net: int}>}
*/
private function overTime(Collection $transactions, string $currency): array
{
@ -216,7 +331,8 @@ class TransactionAnalysisController extends Controller
}
$points = [];
$cumulative = 0;
$cumulativeExpense = 0;
$cumulativeNet = 0;
$cursor = $daily ? $start->copy()->startOfDay() : $start->copy()->startOfMonth();
$last = $daily ? $end->copy()->startOfDay() : $end->copy()->startOfMonth();
@ -224,14 +340,16 @@ class TransactionAnalysisController extends Controller
$key = $cursor->format($keyFormat);
$income = $buckets[$key]['income'] ?? 0;
$expense = $buckets[$key]['expense'] ?? 0;
$cumulative += $expense;
$cumulativeExpense += $expense;
$cumulativeNet += $income - $expense;
$points[] = [
'date' => $key,
'label' => $daily ? $cursor->format('M j') : $cursor->format('M Y'),
'income' => $income,
'expense' => $expense,
'cumulative_expense' => $cumulative,
'cumulative_expense' => $cumulativeExpense,
'cumulative_net' => $cumulativeNet,
];
$daily ? $cursor->addDay() : $cursor->addMonth();

View File

@ -2,6 +2,7 @@
namespace App\Models;
use App\Enums\AnalysisMode;
use Database\Factories\SavedFilterFactory;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@ -18,6 +19,7 @@ class SavedFilter extends Model
'name',
'filters',
'analysis_days',
'analysis_mode',
];
/** @var list<string> */
@ -32,6 +34,7 @@ class SavedFilter extends Model
return [
'filters' => 'array',
'analysis_days' => 'integer',
'analysis_mode' => AnalysisMode::class,
];
}

View File

@ -96,6 +96,7 @@ class Transaction extends Model
return $this->belongsTo(Category::class);
}
/** @return BelongsToMany<Label, $this, LabelTransaction, 'pivot'> */
public function labels(): BelongsToMany
{
return $this->belongsToMany(Label::class)

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('saved_filters', function (Blueprint $table) {
$table->string('analysis_mode')->nullable()->after('analysis_days');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('saved_filters', function (Blueprint $table) {
$table->dropColumn('analysis_mode');
});
}
};

View File

@ -11,6 +11,19 @@
"Cumulative spend": "Gasto acumulado",
"Spending by category": "Gasto por categoría",
"Spending by tag": "Gasto por etiqueta",
"Spending by payee": "Gasto por beneficiario",
"Spending by account": "Gasto por cuenta",
"Cumulative net": "Neto acumulado",
"Net result": "Resultado neto",
"Margin": "Margen",
"Total spent": "Total gastado",
"Largest expenses": "Mayores gastos",
"Show more": "Ver más",
"Show less": "Ver menos",
"Analysis view": "Vista de análisis",
"Automatic": "Automático",
"Expenses only": "Solo gastos",
"Income & expenses": "Ingresos y gastos",
"adjusted": "ajustado",
"Adjust number of days": "Ajustar número de días",
"Days for daily average": "Días para la media diaria",

View File

@ -1,5 +1,11 @@
import { type TransactionFilters } from '@/types/transaction';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
@ -51,19 +57,27 @@ const analysisResponse = {
distinct_category_count: 0,
by_tag: [],
distinct_label_count: 0,
by_payee: [],
distinct_payee_count: 0,
by_account: [],
distinct_account_count: 0,
largest_expenses: [],
over_time: { bucket: 'day', points: [] },
};
function mockAnalysisFetch() {
function mockAnalysisFetch(response: unknown = analysisResponse) {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => analysisResponse,
json: async () => response,
}) as unknown as typeof fetch;
}
// The Avg/day card is the 4th amount rendered (Income, Expenses, Net, Avg).
// In expense-only mode the Avg/day amount lives in the card labelled "Avg / day".
function avgPerDay(): number {
return Number(screen.getAllByTestId('amount')[3].textContent);
const card = screen
.getByText('Avg / day')
.closest('div.rounded-lg') as HTMLElement;
return Number(within(card).getByTestId('amount').textContent);
}
function stubLocalStorage() {
@ -197,3 +211,191 @@ describe('TransactionAnalysisDrawer day override', () => {
expect(avgPerDay()).toBe(15000);
});
});
describe('TransactionAnalysisDrawer view mode', () => {
const incomeResponse = {
...analysisResponse,
summary: {
income: 100000,
expense: 40000,
net: 60000,
count: 4,
days: 30,
average_expense_per_day: 1333,
},
};
it('auto-detects income mode when income is a meaningful share', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
mockAnalysisFetch(incomeResponse);
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() =>
expect(screen.getByText('Net result')).toBeInTheDocument(),
);
expect(screen.getByText('Margin')).toBeInTheDocument();
// 60000 / 100000 = 60%.
expect(screen.getByText('60%')).toBeInTheDocument();
expect(screen.queryByText('Avg / day')).not.toBeInTheDocument();
});
it('stays in expense mode for a stray refund below the threshold', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
mockAnalysisFetch({
...analysisResponse,
summary: {
income: 5000,
expense: 90000,
net: -85000,
count: 6,
days: 90,
average_expense_per_day: 944,
},
});
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() =>
expect(screen.getByText('Avg / day')).toBeInTheDocument(),
);
expect(screen.queryByText('Net result')).not.toBeInTheDocument();
});
it('persists a forced view mode to the matched saved filter', async () => {
axiosGet.mockResolvedValue({
data: {
data: [
{
id: 'saved-1',
filters: { label_ids: ['label-1'] },
analysis_days: null,
analysis_mode: null,
},
],
},
});
axiosPatch.mockResolvedValue({ data: {} });
mockAnalysisFetch(incomeResponse);
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() =>
expect(screen.getByText('Net result')).toBeInTheDocument(),
);
fireEvent.click(
screen.getByRole('button', { name: /Income & expenses/i }),
);
fireEvent.click(screen.getByText('Expenses only'));
await waitFor(() =>
expect(axiosPatch).toHaveBeenCalledWith(
'/api/saved-filters/saved-1/analysis-mode',
{ analysis_mode: 'expense' },
),
);
await waitFor(() =>
expect(screen.getByText('Avg / day')).toBeInTheDocument(),
);
});
});
describe('TransactionAnalysisDrawer largest expenses columns', () => {
function largestExpense(overrides: Record<string, unknown> = {}) {
return {
id: 'tx-1',
date: '2026-01-10',
description: 'Grand Hotel',
amount: 50000,
category: { name: 'Hotel', color: 'blue', icon: 'Building' },
account: { name: 'Visa', bank: null },
labels: [{ id: 'l1', name: 'Miami', color: 'blue' }],
...overrides,
};
}
it('hides columns the filter or the rows have pinned to one value', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
mockAnalysisFetch({
...analysisResponse,
// Two rows sharing one category and one account.
largest_expenses: [
largestExpense({ id: 'tx-1' }),
largestExpense({ id: 'tx-2', description: 'Room service' }),
],
});
render(
// filters pins a single label.
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() =>
expect(screen.getByText('Largest expenses')).toBeInTheDocument(),
);
expect(screen.queryByText('Category')).not.toBeInTheDocument();
expect(screen.queryByText('Account')).not.toBeInTheDocument();
expect(screen.queryByText('Labels')).not.toBeInTheDocument();
expect(screen.getByText('Description')).toBeInTheDocument();
});
it('keeps columns that vary across the rows', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
mockAnalysisFetch({
...analysisResponse,
largest_expenses: [
largestExpense({ id: 'tx-1' }),
largestExpense({
id: 'tx-2',
category: {
name: 'Meals',
color: 'amber',
icon: 'Utensils',
},
account: { name: 'Amex', bank: null },
}),
],
});
render(
// No label pinned, so the labels column stays too.
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={{ ...filters, labelIds: [] }}
/>,
);
await waitFor(() =>
expect(screen.getByText('Largest expenses')).toBeInTheDocument(),
);
expect(screen.getByText('Category')).toBeInTheDocument();
expect(screen.getByText('Account')).toBeInTheDocument();
expect(screen.getByText('Labels')).toBeInTheDocument();
});
});

View File

@ -65,5 +65,6 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store');
Route::patch('saved-filters/{savedFilter}', [SavedFilterController::class, 'update'])->name('api.saved-filters.update');
Route::patch('saved-filters/{savedFilter}/analysis-days', [SavedFilterController::class, 'updateAnalysisDays'])->name('api.saved-filters.analysis-days');
Route::patch('saved-filters/{savedFilter}/analysis-mode', [SavedFilterController::class, 'updateAnalysisMode'])->name('api.saved-filters.analysis-mode');
Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy');
});

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\AnalysisMode;
use App\Models\SavedFilter;
use App\Models\User;
@ -63,6 +64,43 @@ test('rejects an invalid analysis day override', function () {
->assertJsonValidationErrors('analysis_days');
});
test('updates the analysis view mode on a saved filter', function () {
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'income'])
->assertOk()
->assertJsonPath('data.analysis_mode', 'income');
expect($savedFilter->fresh()->analysis_mode)->toBe(AnalysisMode::Income);
});
test('clears the analysis view mode when sent null', function () {
$savedFilter = SavedFilter::factory()->create([
'user_id' => $this->user->id,
'analysis_mode' => 'expense',
]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => null])
->assertOk()
->assertJsonPath('data.analysis_mode', null);
expect($savedFilter->fresh()->analysis_mode)->toBeNull();
});
test('cannot update the analysis mode of another user saved filter', function () {
$savedFilter = SavedFilter::factory()->create(['name' => 'Not mine']);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'income'])
->assertForbidden();
});
test('rejects an unknown analysis view mode', function () {
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'sideways'])
->assertJsonValidationErrors('analysis_mode');
});
test('stores a saved filter for the current user', function () {
$payload = [
'name' => 'Trip to Japan',

View File

@ -3,6 +3,7 @@
use App\Enums\CategoryType;
use App\Features\TransactionAnalysis;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
@ -181,3 +182,85 @@ test('over time switches to monthly buckets for long spans', function () {
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]);
});