feat(transactions): filtered analysis dashboard (#507)

## Summary

Adds an **Analysis** button to the transactions toolbar (left of
Categorize) that opens a bottom-sheet dashboard summarizing the
transactions matching the current filters. Built around the Miami-trip
use case: tag a trip, filter to it, and see where the money went.

Everything is behind the existing `TransactionAnalysis` feature flag
(button hidden + endpoint returns 403 when off).

## Behavior

- **Button**: hidden unless `features.transactionAnalysis`. Disabled
until at least one filter is applied — desktop shows a hover tooltip,
mobile a tap dialog, both reading *"Apply a filter to enable this
button."*
- **Drawer** (vaul bottom-sheet, like the importer): fetches
`/api/transactions/analysis` with the current filters on open. Skeleton
+ empty/error states.
- **Charts**: KPI cards · spending over time (income/expense bars +
cumulative line, auto daily/monthly bucketing) · category donut + ranked
list (only when >1 category) · per-tag bars (only when >1 label).
- **Manual day override**: the *Avg / day* card has an editor to
override the date span used for the daily average (tickets bought months
ahead skew the span). Resolution precedence: matching saved filter's
`analysis_days` → `localStorage[fingerprint]` → auto span. Edits persist
to localStorage always, and sync to the saved filter when the current
filters match one.

## Backend

- `Api/TransactionAnalysisController@summary` — reuses
`Transaction::applyFilters()` + `IndexTransactionRequest`, converts to
the user's base currency via `ExchangeRateService` (rates preloaded),
rolls categories up through `CategoryTree`.
- `GET /api/transactions/analysis`.
- `analysis_days` (nullable) on `saved_filters` + `PATCH
/api/saved-filters/{id}/analysis-days`.

## Tests

- Pest: analysis endpoint (flag gating, totals, category/tag breakdown,
day/month bucketing, cumulative) + saved-filter day override
(set/clear/forbidden/invalid).
- Vitest: button gating (hidden/disabled/opens) + day-override
resolution (auto / saved-precedence / localStorage fallback / persists
to saved filter).
- New `lang/es.json` keys for all added strings.

## Notes

- Data is always sourced from the backend.
- The flag is still off by default — enable `TransactionAnalysis`
per-user to try it.
This commit is contained in:
Víctor Falcón 2026-06-08 14:04:00 +02:00 committed by GitHub
parent c53c40cf0b
commit 8375fd490e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 1727 additions and 6 deletions

View File

@ -16,7 +16,7 @@ class SavedFilterController extends Controller
$savedFilters = SavedFilter::query()
->where('user_id', $request->user()->id)
->orderBy('name')
->get(['id', 'name', 'filters']);
->get();
return response()->json(['data' => $savedFilters]);
}
@ -30,7 +30,7 @@ class SavedFilterController extends Controller
]);
return response()->json([
'data' => $savedFilter->only(['id', 'name', 'filters']),
'data' => $savedFilter,
], 201);
}
@ -41,7 +41,7 @@ class SavedFilterController extends Controller
$savedFilter->update(['filters' => $request->validated('filters')]);
return response()->json([
'data' => $savedFilter->only(['id', 'name', 'filters']),
'data' => $savedFilter,
]);
}
@ -53,4 +53,19 @@ class SavedFilterController extends Controller
return response()->json(['message' => 'Saved filter deleted']);
}
public function updateAnalysisDays(Request $request, SavedFilter $savedFilter): JsonResponse
{
abort_unless($savedFilter->user_id === $request->user()->id, 403);
$validated = $request->validate([
'analysis_days' => ['nullable', 'integer', 'min:1', 'max:36500'],
]);
$savedFilter->update(['analysis_days' => $validated['analysis_days'] ?? null]);
return response()->json([
'data' => $savedFilter,
]);
}
}

View File

@ -0,0 +1,276 @@
<?php
namespace App\Http\Controllers\Api;
use App\Features\TransactionAnalysis;
use App\Http\Controllers\Controller;
use App\Http\Requests\IndexTransactionRequest;
use App\Models\Transaction;
use App\Services\CategoryTree;
use App\Services\ExchangeRateService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
use Laravel\Pennant\Feature;
class TransactionAnalysisController extends Controller
{
/**
* A daily breakdown is used while the filtered set spans this many days or
* fewer; beyond that the chart switches to monthly buckets.
*/
private const DAILY_BUCKET_MAX_DAYS = 62;
public function __construct(
private ExchangeRateService $exchangeRateService,
private CategoryTree $tree,
) {}
public function summary(IndexTransactionRequest $request): JsonResponse
{
$user = $request->user();
abort_unless(Feature::for($user)->active(TransactionAnalysis::class), 403);
$validated = $request->validated();
$currency = $user->currency_code;
$filters = array_filter([
'date_from' => $validated['date_from'] ?? null,
'date_to' => $validated['date_to'] ?? null,
'amount_min' => $validated['amount_min'] ?? null,
'amount_max' => $validated['amount_max'] ?? null,
'category_ids' => $validated['category_ids'] ?? null,
'account_ids' => $validated['account_ids'] ?? null,
'label_ids' => $validated['label_ids'] ?? null,
'creditor_name' => $validated['creditor_name'] ?? null,
'debtor_name' => $validated['debtor_name'] ?? null,
'search' => $validated['search'] ?? null,
], fn ($value) => $value !== null);
$transactions = Transaction::query()
->where('user_id', $user->id)
->with(['account', 'category', 'labels'])
->applyFilters($filters)
->get();
$this->preloadExchangeRates($transactions, $currency);
$byCategory = $this->categoryBreakdown($transactions, $currency, $user->id);
$byTag = $this->tagBreakdown($transactions, $currency);
return response()
->json([
'currency' => $currency,
'summary' => $this->summaryTotals($transactions, $currency),
'by_category' => $byCategory->values(),
'distinct_category_count' => $byCategory->count(),
'by_tag' => $byTag->values(),
'distinct_label_count' => $byTag->count(),
'over_time' => $this->overTime($transactions, $currency),
])
->header('Cache-Control', 'no-store, private');
}
/**
* @return array{income: int, expense: int, net: int, count: int, days: int, average_expense_per_day: int}
*/
private function summaryTotals(Collection $transactions, string $currency): array
{
$income = 0;
$expense = 0;
foreach ($transactions as $transaction) {
$amount = $this->convertTransactionAmount($transaction, $currency);
if ($amount > 0) {
$income += $amount;
} else {
$expense += abs($amount);
}
}
$days = $this->spanInDays($transactions);
return [
'income' => $income,
'expense' => $expense,
'net' => $income - $expense,
'count' => $transactions->count(),
'days' => $days,
'average_expense_per_day' => $days > 0 ? intdiv($expense, $days) : $expense,
];
}
/**
* Expenses grouped by their top-level category, rolled up through the
* category tree so parents absorb their children's spending.
*/
private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection
{
$expenses = $transactions->filter(
fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0,
);
$grouped = $expenses
->filter(fn (Transaction $transaction): bool => $transaction->category_id !== null)
->groupBy('category_id')
->map(function (Collection $group) use ($currency): array {
$first = $group->first();
return [
'category_id' => $first->category_id,
'category' => $first->category,
'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))),
];
})
->values()
->all();
$breakdown = collect($this->tree->rollUp($grouped, $userId, null))
->map(fn (array $node): array => [
'category_id' => $node['category_id'],
'name' => $node['category']->name,
'color' => $node['category']->color,
'amount' => $node['amount'],
]);
$uncategorized = abs($expenses
->filter(fn (Transaction $transaction): bool => $transaction->category_id === null)
->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)));
if ($uncategorized > 0) {
$breakdown->push([
'category_id' => null,
'name' => __('Uncategorized'),
'color' => 'gray',
'amount' => $uncategorized,
]);
}
return $breakdown
->filter(fn (array $node): bool => $node['amount'] > 0)
->sortByDesc('amount')
->values();
}
/**
* Spending grouped by label. A transaction contributes to every label
* attached to it, so the totals can exceed overall expenses.
*/
private function tagBreakdown(Collection $transactions, string $currency): Collection
{
$totals = [];
foreach ($transactions as $transaction) {
$amount = $this->convertTransactionAmount($transaction, $currency);
if ($amount >= 0) {
continue;
}
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);
}
}
return collect($totals)
->filter(fn (array $tag): bool => $tag['amount'] > 0)
->sortByDesc('amount')
->values();
}
/**
* 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}>}
*/
private function overTime(Collection $transactions, string $currency): array
{
if ($transactions->isEmpty()) {
return ['bucket' => 'day', 'points' => []];
}
$dates = $transactions->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date->copy());
$start = $dates->min();
$end = $dates->max();
$daily = $start->diffInDays($end) <= self::DAILY_BUCKET_MAX_DAYS;
$keyFormat = $daily ? 'Y-m-d' : 'Y-m';
$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);
}
}
$points = [];
$cumulative = 0;
$cursor = $daily ? $start->copy()->startOfDay() : $start->copy()->startOfMonth();
$last = $daily ? $end->copy()->startOfDay() : $end->copy()->startOfMonth();
while ($cursor->lte($last)) {
$key = $cursor->format($keyFormat);
$income = $buckets[$key]['income'] ?? 0;
$expense = $buckets[$key]['expense'] ?? 0;
$cumulative += $expense;
$points[] = [
'date' => $key,
'label' => $daily ? $cursor->format('M j') : $cursor->format('M Y'),
'income' => $income,
'expense' => $expense,
'cumulative_expense' => $cumulative,
];
$daily ? $cursor->addDay() : $cursor->addMonth();
}
return ['bucket' => $daily ? 'day' : 'month', 'points' => $points];
}
private function spanInDays(Collection $transactions): int
{
if ($transactions->isEmpty()) {
return 0;
}
$dates = $transactions->map(fn (Transaction $transaction): Carbon => $transaction->transaction_date);
return (int) $dates->min()->diffInDays($dates->max()) + 1;
}
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(),
);
}
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

@ -17,12 +17,21 @@ class SavedFilter extends Model
'user_id',
'name',
'filters',
'analysis_days',
];
/** @var list<string> */
protected $hidden = [
'user_id',
'created_at',
'updated_at',
];
protected function casts(): array
{
return [
'filters' => 'array',
'analysis_days' => 'integer',
];
}

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->unsignedInteger('analysis_days')->nullable()->after('filters');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('saved_filters', function (Blueprint $table) {
$table->dropColumn('analysis_days');
});
}
};

View File

@ -1,4 +1,22 @@
{
"Analysis": "Análisis",
"Apply a filter to enable this button": "Aplica un filtro para activar este botón",
"A breakdown of the transactions matching your current filters.": "Un desglose de las transacciones que coinciden con tus filtros actuales.",
"Could not load the analysis. Please try again.": "No se pudo cargar el análisis. Inténtalo de nuevo.",
"No transactions match the current filters.": "Ninguna transacción coincide con los filtros actuales.",
"Avg / day": "Media / día",
"transactions": "transacciones",
"days": "días",
"Spending over time": "Gasto a lo largo del tiempo",
"Cumulative spend": "Gasto acumulado",
"Spending by category": "Gasto por categoría",
"Spending by tag": "Gasto por etiqueta",
"adjusted": "ajustado",
"Adjust number of days": "Ajustar número de días",
"Days for daily average": "Días para la media diaria",
"Override the date span when it does not match the real duration.": "Anula el rango de fechas cuando no coincide con la duración real.",
"Saved with this filter.": "Guardado con este filtro.",
"Reset to auto": "Restablecer a automático",
"Bank account connected": "Cuenta bancaria conectada",
"Connection unsuccessful": "Conexión fallida",
"You can close this window and go back to the app to continue.": "Puedes cerrar esta ventana y volver a la app para continuar.",

View File

@ -0,0 +1,97 @@
import { type TransactionFilters } from '@/types/transaction';
import { fireEvent, render, screen } from '@testing-library/react';
import type React from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { TransactionActionsMenu } from './transaction-actions-menu';
const features = { transactionAnalysis: true };
vi.mock('@/actions/App/Http/Controllers/TransactionController', () => ({
categorize: { url: () => '/transactions/categorize' },
}));
vi.mock('@/contexts/encryption-key-context', () => ({
useEncryptionKey: () => ({ isKeySet: true }),
}));
vi.mock('@/hooks/use-mobile', () => ({
useIsMobile: () => false,
}));
vi.mock('@/hooks/use-re-evaluate-all-transactions', () => ({
useReEvaluateAllTransactions: () => ({ reEvaluateAll: vi.fn() }),
}));
vi.mock('@inertiajs/react', () => ({
Link: ({ children, href }: { children: React.ReactNode; href: string }) => (
<a href={href}>{children}</a>
),
usePage: () => ({ props: { features } }),
}));
vi.mock('./import-transactions-drawer', () => ({
ImportTransactionsDrawer: () => null,
}));
vi.mock('./transaction-analysis-drawer', () => ({
TransactionAnalysisDrawer: ({ open }: { open: boolean }) =>
open ? <div data-testid="analysis-drawer" /> : null,
}));
const emptyFilters: TransactionFilters = {
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: [],
creditorName: '',
debtorName: '',
searchText: '',
};
function renderMenu(filters: TransactionFilters) {
return render(
<TransactionActionsMenu
categories={[]}
accounts={[]}
banks={[]}
onAddTransaction={vi.fn()}
transactions={[]}
filters={filters}
/>,
);
}
describe('TransactionActionsMenu analysis button', () => {
beforeEach(() => {
features.transactionAnalysis = true;
});
it('is hidden when the TransactionAnalysis feature flag is off', () => {
features.transactionAnalysis = false;
renderMenu(emptyFilters);
expect(screen.queryByText('Analysis')).not.toBeInTheDocument();
});
it('is disabled when no filter is applied', () => {
renderMenu(emptyFilters);
expect(screen.getByText('Analysis').closest('button')).toHaveAttribute(
'aria-disabled',
'true',
);
});
it('opens the analysis drawer when a filter is applied and clicked', () => {
renderMenu({ ...emptyFilters, labelIds: ['label-1'] });
const button = screen.getByText('Analysis').closest('button')!;
expect(button).toHaveAttribute('aria-disabled', 'false');
fireEvent.click(button);
expect(screen.getByTestId('analysis-drawer')).toBeInTheDocument();
});
});

View File

@ -1,6 +1,13 @@
import { categorize } from '@/actions/App/Http/Controllers/TransactionController';
import { Button } from '@/components/ui/button';
import { ButtonGroup } from '@/components/ui/button-group';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
@ -14,18 +21,31 @@ import {
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useEncryptionKey } from '@/contexts/encryption-key-context';
import { useIsMobile } from '@/hooks/use-mobile';
import { useReEvaluateAllTransactions } from '@/hooks/use-re-evaluate-all-transactions';
import { hasActiveFilters } from '@/lib/transaction-filter-serialization';
import { type SharedData } from '@/types';
import { type Account, type Bank } from '@/types/account';
import { type AutomationRule } from '@/types/automation-rule';
import { type Category } from '@/types/category';
import { type DecryptedTransaction } from '@/types/transaction';
import {
type DecryptedTransaction,
type TransactionFilters,
} from '@/types/transaction';
import { __ } from '@/utils/i18n';
import { Link } from '@inertiajs/react';
import { ChevronDown, Plus, Upload, WandSparkles } from 'lucide-react';
import { Link, usePage } from '@inertiajs/react';
import {
BarChart3,
ChevronDown,
Plus,
Upload,
WandSparkles,
} from 'lucide-react';
import { useState } from 'react';
import { toast } from 'sonner';
import { ImportTransactionsDrawer } from './import-transactions-drawer';
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
interface TransactionActionsMenuProps {
categories: Category[];
@ -36,6 +56,7 @@ interface TransactionActionsMenuProps {
transactions: DecryptedTransaction[];
onReEvaluateComplete?: () => void;
onImportComplete?: () => void;
filters: TransactionFilters;
}
export function TransactionActionsMenu({
@ -47,12 +68,29 @@ export function TransactionActionsMenu({
transactions,
onReEvaluateComplete,
onImportComplete,
filters,
}: TransactionActionsMenuProps) {
const { isKeySet } = useEncryptionKey();
const { features } = usePage<SharedData>().props;
const isMobile = useIsMobile();
const [importDrawerOpen, setImportDrawerOpen] = useState(false);
const [analysisDrawerOpen, setAnalysisDrawerOpen] = useState(false);
const [analysisHintOpen, setAnalysisHintOpen] = useState(false);
const [isReEvaluating, setIsReEvaluating] = useState(false);
const { reEvaluateAll } = useReEvaluateAllTransactions();
const canAnalyze = hasActiveFilters(filters);
const handleAnalysisClick = () => {
if (!canAnalyze) {
if (isMobile) {
setAnalysisHintOpen(true);
}
return;
}
setAnalysisDrawerOpen(true);
};
const handleAddTransaction = () => {
if (!isKeySet) {
toast.error(
@ -90,6 +128,50 @@ export function TransactionActionsMenu({
return (
<>
<ButtonGroup>
{features.transactionAnalysis &&
(isMobile ? (
<Button
variant="outline"
className={
!canAnalyze
? 'cursor-not-allowed opacity-50'
: ''
}
aria-disabled={!canAnalyze}
onClick={handleAnalysisClick}
>
<BarChart3 className="h-5 w-5" />
{__('Analysis')}
</Button>
) : (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
className={
!canAnalyze
? 'cursor-not-allowed opacity-50'
: ''
}
aria-disabled={!canAnalyze}
onClick={handleAnalysisClick}
>
<BarChart3 className="h-5 w-5" />
{__('Analysis')}
</Button>
</TooltipTrigger>
{!canAnalyze && (
<TooltipContent>
{__(
'Apply a filter to enable this button',
)}
</TooltipContent>
)}
</Tooltip>
</TooltipProvider>
))}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@ -200,6 +282,25 @@ export function TransactionActionsMenu({
automationRules={automationRules}
onImportComplete={onImportComplete}
/>
{features.transactionAnalysis && (
<TransactionAnalysisDrawer
open={analysisDrawerOpen}
onOpenChange={setAnalysisDrawerOpen}
filters={filters}
/>
)}
<Dialog open={analysisHintOpen} onOpenChange={setAnalysisHintOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{__('Analysis')}</DialogTitle>
<DialogDescription>
{__('Apply a filter to enable this button')}
</DialogDescription>
</DialogHeader>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,199 @@
import { type TransactionFilters } from '@/types/transaction';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { TransactionAnalysisDrawer } from './transaction-analysis-drawer';
const axiosGet = vi.fn();
const axiosPatch = vi.fn();
vi.mock('axios', () => ({
default: {
get: (...args: unknown[]) => axiosGet(...args),
patch: (...args: unknown[]) => axiosPatch(...args),
},
}));
vi.mock('@/hooks/use-locale', () => ({
useLocale: () => 'en',
}));
vi.mock('@/components/ui/amount-display', () => ({
AmountDisplay: ({ amountInCents }: { amountInCents: number }) => (
<span data-testid="amount">{amountInCents}</span>
),
}));
const filters: TransactionFilters = {
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: ['label-1'],
creditorName: '',
debtorName: '',
searchText: '',
};
// expense 90000 cents over a 90-day span → auto avg = 1000/day.
const analysisResponse = {
currency: 'USD',
summary: {
income: 0,
expense: 90000,
net: -90000,
count: 5,
days: 90,
average_expense_per_day: 1000,
},
by_category: [],
distinct_category_count: 0,
by_tag: [],
distinct_label_count: 0,
over_time: { bucket: 'day', points: [] },
};
function mockAnalysisFetch() {
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => analysisResponse,
}) as unknown as typeof fetch;
}
// The Avg/day card is the 4th amount rendered (Income, Expenses, Net, Avg).
function avgPerDay(): number {
return Number(screen.getAllByTestId('amount')[3].textContent);
}
function stubLocalStorage() {
const store = new Map<string, string>();
vi.stubGlobal('localStorage', {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => store.set(key, value),
removeItem: (key: string) => store.delete(key),
clear: () => store.clear(),
});
}
beforeEach(() => {
stubLocalStorage();
axiosGet.mockReset();
axiosPatch.mockReset();
mockAnalysisFetch();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('TransactionAnalysisDrawer day override', () => {
it('averages over the auto date span when there is no override', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() => expect(avgPerDay()).toBe(1000));
});
it('prefers a matching saved filter day override over the span', async () => {
axiosGet.mockResolvedValue({
data: {
data: [
{
id: 'saved-1',
filters: { label_ids: ['label-1'] },
analysis_days: 3,
},
],
},
});
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
// 90000 / 3 = 30000.
await waitFor(() => expect(avgPerDay()).toBe(30000));
});
it('falls back to a browser override when no saved filter matches', async () => {
axiosGet.mockResolvedValue({ data: { data: [] } });
localStorage.setItem(
`wm.analysis-days.${JSON.stringify({
date_from: null,
date_to: null,
amount_min: null,
amount_max: null,
category_ids: [],
account_ids: [],
label_ids: ['label-1'],
creditor_name: '',
debtor_name: '',
search: '',
})}`,
'5',
);
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
// 90000 / 5 = 18000.
await waitFor(() => expect(avgPerDay()).toBe(18000));
});
it('persists a new override to the matched saved filter', async () => {
axiosGet.mockResolvedValue({
data: {
data: [
{
id: 'saved-1',
filters: { label_ids: ['label-1'] },
analysis_days: null,
},
],
},
});
axiosPatch.mockResolvedValue({ data: {} });
render(
<TransactionAnalysisDrawer
open
onOpenChange={vi.fn()}
filters={filters}
/>,
);
await waitFor(() => expect(avgPerDay()).toBe(1000));
fireEvent.click(screen.getByLabelText('Adjust number of days'));
fireEvent.change(screen.getByRole('spinbutton'), {
target: { value: '6' },
});
fireEvent.click(screen.getByText('Apply'));
await waitFor(() =>
expect(axiosPatch).toHaveBeenCalledWith(
'/api/saved-filters/saved-1/analysis-days',
{ analysis_days: 6 },
),
);
// 90000 / 6 = 15000.
expect(avgPerDay()).toBe(15000);
});
});

View File

@ -0,0 +1,803 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import { Button } from '@/components/ui/button';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { Input } from '@/components/ui/input';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { useLocale } from '@/hooks/use-locale';
import {
filtersFingerprint,
serializeFilters,
type SerializedFilters,
} from '@/lib/transaction-filter-serialization';
import { cn } from '@/lib/utils';
import { type TransactionFilters } from '@/types/transaction';
import { type UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import axios from 'axios';
import { Settings2 } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Bar,
Cell,
ComposedChart,
Line,
Pie,
PieChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
interface AnalysisSummary {
income: number;
expense: number;
net: number;
count: number;
days: number;
average_expense_per_day: number;
}
interface CategorySlice {
category_id: string | null;
name: string;
color: string;
amount: number;
}
interface TagSlice {
id: string;
name: string;
color: string;
amount: number;
}
interface OverTimePoint {
date: string;
label: string;
income: number;
expense: number;
cumulative_expense: number;
}
interface AnalysisData {
currency: string;
summary: AnalysisSummary;
by_category: CategorySlice[];
distinct_category_count: number;
by_tag: TagSlice[];
distinct_label_count: number;
over_time: { bucket: 'day' | 'month'; points: OverTimePoint[] };
}
interface TransactionAnalysisDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
filters: TransactionFilters;
}
const CHART_PALETTE = [
'var(--color-chart-1)',
'var(--color-chart-2)',
'var(--color-chart-3)',
'var(--color-chart-4)',
'var(--color-chart-5)',
'var(--color-chart-6)',
'var(--color-chart-7)',
'var(--color-chart-8)',
];
function buildQueryString(filters: SerializedFilters): string {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (Array.isArray(value)) {
if (value.length > 0) {
params.set(key, value.join(','));
}
} else if (value !== undefined && value !== null && value !== '') {
params.set(key, String(value));
}
});
return params.toString();
}
interface SavedFilterSummary {
id: UUID;
filters: SerializedFilters;
analysis_days: number | null;
}
const DAY_OVERRIDE_STORAGE_PREFIX = 'wm.analysis-days.';
function readStoredDays(key: string): number | null {
const raw = localStorage.getItem(key);
if (raw === null) {
return null;
}
const parsed = Number.parseInt(raw, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
/**
* Resolves the number of days used to average daily spending for a filter set.
*
* The date span between the first and last transaction is the default, but a
* user can override it (e.g. tickets bought months ahead skew the span). The
* override is remembered per filter fingerprint in the browser, and also
* synced to the backend when the current filters match a saved filter.
*/
function useAnalysisDays(
open: boolean,
filters: TransactionFilters,
autoDays: number,
) {
const fingerprint = useMemo(
() => filtersFingerprint(serializeFilters(filters)),
[filters],
);
const storageKey = `${DAY_OVERRIDE_STORAGE_PREFIX}${fingerprint}`;
const [override, setOverride] = useState<number | null>(null);
const [savedFilterId, setSavedFilterId] = useState<UUID | null>(null);
useEffect(() => {
if (!open) {
return;
}
const local = readStoredDays(storageKey);
let active = true;
axios
.get<{ data: SavedFilterSummary[] }>('/api/saved-filters')
.then((response) => {
if (!active) {
return;
}
const match =
response.data.data.find(
(saved) =>
filtersFingerprint(saved.filters) === fingerprint,
) ?? null;
setSavedFilterId(match?.id ?? null);
setOverride(match?.analysis_days ?? local);
})
.catch(() => {
if (!active) {
return;
}
setSavedFilterId(null);
setOverride(local);
});
return () => {
active = false;
};
}, [open, fingerprint, storageKey]);
const applyDays = useCallback(
(value: number | null) => {
setOverride(value);
if (value === null) {
localStorage.removeItem(storageKey);
} else {
localStorage.setItem(storageKey, String(value));
}
if (savedFilterId) {
void axios.patch(
`/api/saved-filters/${savedFilterId}/analysis-days`,
{ analysis_days: value },
);
}
},
[storageKey, savedFilterId],
);
return {
effectiveDays: override ?? autoDays,
isOverridden: override !== null,
isSaved: savedFilterId !== null,
applyDays,
};
}
export function TransactionAnalysisDrawer({
open,
onOpenChange,
filters,
}: TransactionAnalysisDrawerProps) {
const locale = useLocale();
const [data, setData] = useState<AnalysisData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const query = buildQueryString(serializeFilters(filters));
const response = await fetch(
`/api/transactions/analysis?${query}`,
{
headers: { Accept: 'application/json' },
},
);
if (!response.ok) {
throw new Error('Request failed');
}
setData((await response.json()) as AnalysisData);
} catch {
setError(__('Could not load the analysis. Please try again.'));
} finally {
setIsLoading(false);
}
}, [filters]);
useEffect(() => {
if (open) {
void loadAnalysis();
}
}, [open, loadAnalysis]);
const currency = data?.currency ?? '';
const hasTransactions = (data?.summary.count ?? 0) > 0;
const { effectiveDays, isOverridden, isSaved, applyDays } = useAnalysisDays(
open,
filters,
data?.summary.days ?? 0,
);
const expense = data?.summary.expense ?? 0;
const averagePerDay =
effectiveDays > 0 ? Math.round(expense / effectiveDays) : expense;
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<div className="mx-auto w-full max-w-5xl overflow-y-auto p-6">
<DrawerHeader className="px-0">
<DrawerTitle>{__('Analysis')}</DrawerTitle>
<DrawerDescription>
{__(
'A breakdown of the transactions matching your current filters.',
)}
</DrawerDescription>
</DrawerHeader>
{isLoading && <AnalysisSkeleton />}
{!isLoading && error && (
<p className="py-12 text-center text-sm text-muted-foreground">
{error}
</p>
)}
{!isLoading && !error && !hasTransactions && (
<p className="py-12 text-center text-sm text-muted-foreground">
{__('No transactions match the current filters.')}
</p>
)}
{!isLoading && !error && data && hasTransactions && (
<div className="flex flex-col gap-8">
<SummaryCards
summary={data.summary}
currency={currency}
days={effectiveDays}
averagePerDay={averagePerDay}
isOverridden={isOverridden}
isSaved={isSaved}
onApplyDays={applyDays}
/>
<OverTimeChart
points={data.over_time.points}
currency={currency}
locale={locale}
/>
{data.distinct_category_count > 1 && (
<CategoryBreakdown
slices={data.by_category}
currency={currency}
/>
)}
{data.distinct_label_count > 1 && (
<TagBreakdown
slices={data.by_tag}
currency={currency}
locale={locale}
/>
)}
</div>
)}
</div>
</DrawerContent>
</Drawer>
);
}
function SummaryCards({
summary,
currency,
days,
averagePerDay,
isOverridden,
isSaved,
onApplyDays,
}: {
summary: AnalysisSummary;
currency: string;
days: number;
averagePerDay: number;
isOverridden: boolean;
isSaved: boolean;
onApplyDays: (value: number | null) => void;
}) {
const cards = [
{ label: __('Income'), amount: summary.income, tone: 'income' },
{ label: __('Expenses'), amount: summary.expense, tone: 'expense' },
{ label: __('Net'), amount: summary.net, tone: 'net' },
] as const;
return (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{cards.map((card) => (
<div key={card.label} className="rounded-lg border bg-card p-4">
<p className="text-xs text-muted-foreground">
{card.label}
</p>
<AmountDisplay
amountInCents={card.amount}
currencyCode={currency}
className={cn(
'mt-1 text-lg font-semibold tabular-nums',
card.tone === 'income' && 'text-emerald-600',
card.tone === 'expense' && 'text-red-600',
)}
/>
</div>
))}
<div className="rounded-lg border bg-card p-4">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
{__('Avg / day')}
</p>
<DayEditorPopover
days={days}
isOverridden={isOverridden}
isSaved={isSaved}
onApply={onApplyDays}
/>
</div>
<AmountDisplay
amountInCents={averagePerDay}
currencyCode={currency}
className="mt-1 text-lg font-semibold text-red-600 tabular-nums"
/>
</div>
<p className="col-span-2 text-xs text-muted-foreground sm:col-span-4">
{summary.count} {__('transactions')} · {days} {__('days')}
{isOverridden && ` (${__('adjusted')})`}
</p>
</div>
);
}
function DayEditorPopover({
days,
isOverridden,
isSaved,
onApply,
}: {
days: number;
isOverridden: boolean;
isSaved: boolean;
onApply: (value: number | null) => void;
}) {
const [open, setOpen] = useState(false);
const [value, setValue] = useState(String(days));
useEffect(() => {
if (open) {
setValue(String(days));
}
}, [open, days]);
const save = () => {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
onApply(parsed);
setOpen(false);
}
};
const reset = () => {
onApply(null);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground"
aria-label={__('Adjust number of days')}
>
<Settings2 className="h-3.5 w-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72" align="end">
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">
{__('Days for daily average')}
</p>
<p className="text-xs text-muted-foreground">
{__(
'Override the date span when it does not match the real duration.',
)}
</p>
</div>
<Input
type="number"
min={1}
value={value}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
save();
}
}}
/>
{isSaved && (
<p className="text-xs text-muted-foreground">
{__('Saved with this filter.')}
</p>
)}
<div className="flex justify-between gap-2">
<Button
variant="ghost"
size="sm"
onClick={reset}
disabled={!isOverridden}
>
{__('Reset to auto')}
</Button>
<Button size="sm" onClick={save}>
{__('Apply')}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
);
}
function OverTimeChart({
points,
currency,
locale,
}: {
points: OverTimePoint[];
currency: string;
locale: string;
}) {
const config: ChartConfig = {
income: { label: __('Income'), color: 'var(--color-chart-2)' },
expense: { label: __('Expenses'), color: 'var(--color-chart-5)' },
cumulative_expense: {
label: __('Cumulative spend'),
color: 'var(--color-chart-1)',
},
};
const compact = (value: number) =>
new Intl.NumberFormat(locale, {
notation: 'compact',
compactDisplay: 'short',
}).format(value / 100);
return (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">{__('Spending over time')}</h3>
<ChartContainer config={config} className="h-64 w-full">
<ComposedChart data={points}>
<XAxis
dataKey="label"
tickLine={false}
axisLine={false}
tickMargin={8}
minTickGap={16}
/>
<YAxis
tickLine={false}
axisLine={false}
width={48}
tickFormatter={compact}
/>
<Tooltip
content={<OverTimeTooltip currency={currency} />}
cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }}
/>
<Bar
dataKey="income"
fill="var(--color-chart-2)"
radius={[3, 3, 0, 0]}
/>
<Bar
dataKey="expense"
fill="var(--color-chart-5)"
radius={[3, 3, 0, 0]}
/>
<Line
type="monotone"
dataKey="cumulative_expense"
stroke="var(--color-chart-1)"
strokeWidth={2}
dot={false}
/>
</ComposedChart>
</ChartContainer>
</section>
);
}
interface TooltipPayloadItem {
name?: string;
dataKey?: string;
value?: number;
payload?: OverTimePoint;
}
function OverTimeTooltip({
active,
payload,
currency,
}: {
active?: boolean;
payload?: TooltipPayloadItem[];
currency: string;
}) {
if (!active || !payload?.length) {
return null;
}
const point = payload[0]?.payload;
const rows: {
label: string;
key: 'income' | 'expense' | 'cumulative_expense';
}[] = [
{ label: __('Income'), key: 'income' },
{ label: __('Expenses'), key: 'expense' },
{ label: __('Cumulative spend'), key: 'cumulative_expense' },
];
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="font-medium">{point?.label}</div>
{rows.map((row) => (
<div
key={row.key}
className="mt-1 flex items-center justify-between gap-4"
>
<span className="text-muted-foreground">{row.label}</span>
<AmountDisplay
amountInCents={point ? point[row.key] : 0}
currencyCode={currency}
className="font-mono tabular-nums"
/>
</div>
))}
</div>
);
}
function CategoryBreakdown({
slices,
currency,
}: {
slices: CategorySlice[];
currency: string;
}) {
const total = slices.reduce((sum, slice) => sum + slice.amount, 0);
const config: ChartConfig = { amount: { label: __('Spent') } };
return (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">
{__('Spending by category')}
</h3>
<div className="flex flex-col items-center gap-6 sm:flex-row">
<ChartContainer config={config} className="h-52 w-52 shrink-0">
<ResponsiveContainer>
<PieChart>
<Pie
data={slices}
dataKey="amount"
nameKey="name"
innerRadius={55}
outerRadius={85}
paddingAngle={2}
>
{slices.map((slice, index) => (
<Cell
key={
slice.category_id ??
`slice-${index}`
}
fill={
CHART_PALETTE[
index % CHART_PALETTE.length
]
}
/>
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</ChartContainer>
<ul className="flex w-full flex-col gap-2">
{slices.map((slice, index) => (
<li
key={slice.category_id ?? `row-${index}`}
className="flex items-center gap-3 text-sm"
>
<span
className="h-3 w-3 shrink-0 rounded-full"
style={{
backgroundColor:
CHART_PALETTE[
index % CHART_PALETTE.length
],
}}
/>
<span className="flex-1 truncate">
{slice.name}
</span>
<span className="text-xs text-muted-foreground">
{total > 0
? Math.round((slice.amount / total) * 100)
: 0}
%
</span>
<AmountDisplay
amountInCents={slice.amount}
currencyCode={currency}
className="font-mono tabular-nums"
/>
</li>
))}
</ul>
</div>
</section>
);
}
function TagBreakdown({
slices,
currency,
locale,
}: {
slices: TagSlice[];
currency: string;
locale: string;
}) {
const config: ChartConfig = {
amount: { label: __('Spent'), color: 'var(--color-chart-1)' },
};
const compact = (value: number) =>
new Intl.NumberFormat(locale, {
notation: 'compact',
compactDisplay: 'short',
}).format(value / 100);
return (
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium">{__('Spending by tag')}</h3>
<ChartContainer
config={config}
className="w-full"
style={{ height: `${Math.max(slices.length * 44, 88)}px` }}
>
<ResponsiveContainer>
<ComposedChart
layout="vertical"
data={slices}
margin={{ left: 8, right: 16 }}
>
<XAxis type="number" hide tickFormatter={compact} />
<YAxis
type="category"
dataKey="name"
tickLine={false}
axisLine={false}
width={96}
/>
<Tooltip
cursor={{
fill: 'var(--color-muted)',
opacity: 0.3,
}}
content={<TagTooltip currency={currency} />}
/>
<Bar
dataKey="amount"
fill="var(--color-chart-1)"
radius={[0, 3, 3, 0]}
/>
</ComposedChart>
</ResponsiveContainer>
</ChartContainer>
</section>
);
}
function TagTooltip({
active,
payload,
currency,
}: {
active?: boolean;
payload?: { payload?: TagSlice }[];
currency: string;
}) {
if (!active || !payload?.length) {
return null;
}
const slice = payload[0]?.payload;
return (
<div className="rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl">
<div className="font-medium">{slice?.name}</div>
<AmountDisplay
amountInCents={slice?.amount ?? 0}
currencyCode={currency}
className="mt-1 font-mono tabular-nums"
/>
</div>
);
}
function AnalysisSkeleton() {
return (
<div className="flex animate-pulse flex-col gap-8">
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<div
key={index}
className="h-20 rounded-lg border bg-muted/50"
/>
))}
</div>
<div className="h-64 rounded-lg border bg-muted/50" />
<div className="h-52 rounded-lg border bg-muted/50" />
</div>
);
}

View File

@ -1184,6 +1184,7 @@ export default function Transactions({
onImportComplete={() =>
refreshTransactions()
}
filters={filters}
/>
<DataTableViewOptions table={table} />

View File

@ -6,6 +6,7 @@ use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
use App\Http\Controllers\Api\SavedFilterController;
use App\Http\Controllers\Api\TransactionAnalysisController;
use App\Http\Controllers\Api\TransactionController;
use App\Http\Controllers\EncryptionController;
use App\Http\Controllers\Sync\TransactionSyncController;
@ -26,6 +27,7 @@ Route::middleware(['web', 'auth'])->group(function () {
// Transactions
Route::get('transactions', [TransactionController::class, 'index'])->name('api.transactions.index');
Route::get('transactions/analysis', [TransactionAnalysisController::class, 'summary'])->name('api.transactions.analysis');
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update');
// Accounts
@ -62,5 +64,6 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('saved-filters', [SavedFilterController::class, 'index'])->name('api.saved-filters.index');
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::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy');
});

View File

@ -26,6 +26,43 @@ test('lists only the current user saved filters ordered by name', function () {
expect($response->json('data.1.name'))->toBe('Zurich trip');
});
test('updates the analysis day override on a saved filter', function () {
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => 21])
->assertOk()
->assertJsonPath('data.analysis_days', 21);
expect($savedFilter->fresh()->analysis_days)->toBe(21);
});
test('clears the analysis day override when sent null', function () {
$savedFilter = SavedFilter::factory()->create([
'user_id' => $this->user->id,
'analysis_days' => 14,
]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => null])
->assertOk()
->assertJsonPath('data.analysis_days', null);
expect($savedFilter->fresh()->analysis_days)->toBeNull();
});
test('cannot update the analysis days of another user saved filter', function () {
$savedFilter = SavedFilter::factory()->create(['name' => 'Not mine']);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => 5])
->assertForbidden();
});
test('rejects an invalid analysis day override', function () {
$savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]);
$this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-days", ['analysis_days' => 0])
->assertJsonValidationErrors('analysis_days');
});
test('stores a saved filter for the current user', function () {
$payload = [
'name' => 'Trip to Japan',

View File

@ -0,0 +1,134 @@
<?php
use App\Enums\CategoryType;
use App\Features\TransactionAnalysis;
use App\Models\Account;
use App\Models\Category;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Laravel\Pennant\Feature;
beforeEach(function () {
Http::fake();
$this->user = User::factory()->create(['currency_code' => 'USD']);
$this->actingAs($this->user);
Feature::for($this->user)->activate(TransactionAnalysis::class);
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
'currency_code' => 'USD',
]);
});
function makeTransaction(array $attributes = []): Transaction
{
return Transaction::factory()->create([
'user_id' => test()->user->id,
'account_id' => test()->account->id,
'currency_code' => 'USD',
...$attributes,
]);
}
test('analysis endpoint is gated behind the TransactionAnalysis feature flag', function () {
Feature::for($this->user)->deactivate(TransactionAnalysis::class);
$this->getJson('/api/transactions/analysis')->assertForbidden();
});
test('analysis response is not cached between users', function () {
$this->getJson('/api/transactions/analysis')
->assertOk()
->assertHeader('Cache-Control', 'no-store, private');
});
test('summary totals income, expense, net and count from the filtered set', function () {
$label = Label::factory()->create(['user_id' => $this->user->id]);
$income = makeTransaction(['amount' => 100000, 'transaction_date' => '2026-01-10']);
$income->labels()->attach($label);
$expense = makeTransaction(['amount' => -40000, 'transaction_date' => '2026-01-12']);
$expense->labels()->attach($label);
// Outside the label filter, must be excluded.
makeTransaction(['amount' => -99999, 'transaction_date' => '2026-01-12']);
$response = $this->getJson('/api/transactions/analysis?'.http_build_query([
'label_ids' => $label->id,
]));
$response->assertOk()
->assertJson([
'currency' => 'USD',
'summary' => [
'income' => 100000,
'expense' => 40000,
'net' => 60000,
'count' => 2,
],
]);
});
test('category breakdown groups expenses by top-level category', function () {
$hotel = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel']);
$meals = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Meals']);
makeTransaction(['amount' => -50000, 'category_id' => $hotel->id, 'transaction_date' => '2026-01-10']);
makeTransaction(['amount' => -20000, 'category_id' => $meals->id, 'transaction_date' => '2026-01-11']);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('distinct_category_count'))->toBe(2);
expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000]);
expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000]);
});
test('tag breakdown sums expenses per label', function () {
$trip = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Miami']);
$food = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Food']);
$meal = makeTransaction(['amount' => -3000, 'transaction_date' => '2026-01-10']);
$meal->labels()->attach([$trip->id, $food->id]);
$hotel = makeTransaction(['amount' => -7000, 'transaction_date' => '2026-01-11']);
$hotel->labels()->attach($trip->id);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('distinct_label_count'))->toBe(2);
expect($response->json('by_tag.0'))->toMatchArray(['name' => 'Miami', 'amount' => 10000]);
expect($response->json('by_tag.1'))->toMatchArray(['name' => 'Food', 'amount' => 3000]);
});
test('over time uses daily buckets for short spans and carries a cumulative expense', function () {
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-10']);
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-12']);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('over_time.bucket'))->toBe('day');
$points = $response->json('over_time.points');
expect($points)->toHaveCount(3); // Jan 10, 11 (gap filled), 12
expect($points[0])->toMatchArray(['date' => '2026-01-10', 'expense' => 1000, 'cumulative_expense' => 1000]);
expect($points[1])->toMatchArray(['date' => '2026-01-11', 'expense' => 0, 'cumulative_expense' => 1000]);
expect($points[2])->toMatchArray(['date' => '2026-01-12', 'expense' => 2000, 'cumulative_expense' => 3000]);
});
test('over time switches to monthly buckets for long spans', function () {
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-10']);
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-06-10']);
$response = $this->getJson('/api/transactions/analysis');
$response->assertOk();
expect($response->json('over_time.bucket'))->toBe('month');
expect($response->json('over_time.points'))->toHaveCount(6); // Jan..Jun
});