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:
parent
1530544b8b
commit
7cc49a5368
|
|
@ -0,0 +1,9 @@
|
|||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum AnalysisMode: string
|
||||
{
|
||||
case Expense = 'expense';
|
||||
case Income = 'income';
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
13
lang/es.json
13
lang/es.json
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue