diff --git a/app/Http/Controllers/Api/SavedFilterController.php b/app/Http/Controllers/Api/SavedFilterController.php index 9dfaf81d..d9f5ec1e 100644 --- a/app/Http/Controllers/Api/SavedFilterController.php +++ b/app/Http/Controllers/Api/SavedFilterController.php @@ -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, + ]); + } } diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php new file mode 100644 index 00000000..d4b169d7 --- /dev/null +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -0,0 +1,276 @@ +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} + */ + 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); + } +} diff --git a/app/Models/SavedFilter.php b/app/Models/SavedFilter.php index 34f39163..1cd49de6 100644 --- a/app/Models/SavedFilter.php +++ b/app/Models/SavedFilter.php @@ -17,12 +17,21 @@ class SavedFilter extends Model 'user_id', 'name', 'filters', + 'analysis_days', + ]; + + /** @var list */ + protected $hidden = [ + 'user_id', + 'created_at', + 'updated_at', ]; protected function casts(): array { return [ 'filters' => 'array', + 'analysis_days' => 'integer', ]; } diff --git a/database/migrations/2026_06_08_114044_add_analysis_days_to_saved_filters_table.php b/database/migrations/2026_06_08_114044_add_analysis_days_to_saved_filters_table.php new file mode 100644 index 00000000..fbe59030 --- /dev/null +++ b/database/migrations/2026_06_08_114044_add_analysis_days_to_saved_filters_table.php @@ -0,0 +1,28 @@ +unsignedInteger('analysis_days')->nullable()->after('filters'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('saved_filters', function (Blueprint $table) { + $table->dropColumn('analysis_days'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index b9c82762..092c628e 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/resources/js/components/transactions/transaction-actions-menu.test.tsx b/resources/js/components/transactions/transaction-actions-menu.test.tsx new file mode 100644 index 00000000..8a58b0f6 --- /dev/null +++ b/resources/js/components/transactions/transaction-actions-menu.test.tsx @@ -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 }) => ( + {children} + ), + usePage: () => ({ props: { features } }), +})); + +vi.mock('./import-transactions-drawer', () => ({ + ImportTransactionsDrawer: () => null, +})); + +vi.mock('./transaction-analysis-drawer', () => ({ + TransactionAnalysisDrawer: ({ open }: { open: boolean }) => + open ?
: null, +})); + +const emptyFilters: TransactionFilters = { + dateFrom: null, + dateTo: null, + amountMin: null, + amountMax: null, + categoryIds: [], + accountIds: [], + labelIds: [], + creditorName: '', + debtorName: '', + searchText: '', +}; + +function renderMenu(filters: TransactionFilters) { + return render( + , + ); +} + +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(); + }); +}); diff --git a/resources/js/components/transactions/transaction-actions-menu.tsx b/resources/js/components/transactions/transaction-actions-menu.tsx index 3375dcb7..0570beb3 100644 --- a/resources/js/components/transactions/transaction-actions-menu.tsx +++ b/resources/js/components/transactions/transaction-actions-menu.tsx @@ -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().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 ( <> + {features.transactionAnalysis && + (isMobile ? ( + + ) : ( + + + + + + {!canAnalyze && ( + + {__( + 'Apply a filter to enable this button', + )} + + )} + + + ))} + @@ -200,6 +282,25 @@ export function TransactionActionsMenu({ automationRules={automationRules} onImportComplete={onImportComplete} /> + + {features.transactionAnalysis && ( + + )} + + + + + {__('Analysis')} + + {__('Apply a filter to enable this button')} + + + + ); } diff --git a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx new file mode 100644 index 00000000..9f200ecd --- /dev/null +++ b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx @@ -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 }) => ( + {amountInCents} + ), +})); + +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(); + 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( + , + ); + + 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( + , + ); + + // 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( + , + ); + + // 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( + , + ); + + 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); + }); +}); diff --git a/resources/js/components/transactions/transaction-analysis-drawer.tsx b/resources/js/components/transactions/transaction-analysis-drawer.tsx new file mode 100644 index 00000000..3740ad0f --- /dev/null +++ b/resources/js/components/transactions/transaction-analysis-drawer.tsx @@ -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(null); + const [savedFilterId, setSavedFilterId] = useState(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(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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 ( + + +
+ + {__('Analysis')} + + {__( + 'A breakdown of the transactions matching your current filters.', + )} + + + + {isLoading && } + + {!isLoading && error && ( +

+ {error} +

+ )} + + {!isLoading && !error && !hasTransactions && ( +

+ {__('No transactions match the current filters.')} +

+ )} + + {!isLoading && !error && data && hasTransactions && ( +
+ + + + + {data.distinct_category_count > 1 && ( + + )} + + {data.distinct_label_count > 1 && ( + + )} +
+ )} +
+
+
+ ); +} + +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 ( +
+ {cards.map((card) => ( +
+

+ {card.label} +

+ +
+ ))} + +
+
+

+ {__('Avg / day')} +

+ +
+ +
+ +

+ {summary.count} {__('transactions')} · {days} {__('days')} + {isOverridden && ` (${__('adjusted')})`} +

+
+ ); +} + +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 ( + + + + + +
+
+

+ {__('Days for daily average')} +

+

+ {__( + 'Override the date span when it does not match the real duration.', + )} +

+
+ setValue(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + save(); + } + }} + /> + {isSaved && ( +

+ {__('Saved with this filter.')} +

+ )} +
+ + +
+
+
+
+ ); +} + +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 ( +
+

{__('Spending over time')}

+ + + + + } + cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }} + /> + + + + + +
+ ); +} + +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 ( +
+
{point?.label}
+ {rows.map((row) => ( +
+ {row.label} + +
+ ))} +
+ ); +} + +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 ( +
+

+ {__('Spending by category')} +

+
+ + + + + {slices.map((slice, index) => ( + + ))} + + + + + +
    + {slices.map((slice, index) => ( +
  • + + + {slice.name} + + + {total > 0 + ? Math.round((slice.amount / total) * 100) + : 0} + % + + +
  • + ))} +
+
+
+ ); +} + +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 ( +
+

{__('Spending by tag')}

+ + + + + + } + /> + + + + +
+ ); +} + +function TagTooltip({ + active, + payload, + currency, +}: { + active?: boolean; + payload?: { payload?: TagSlice }[]; + currency: string; +}) { + if (!active || !payload?.length) { + return null; + } + + const slice = payload[0]?.payload; + + return ( +
+
{slice?.name}
+ +
+ ); +} + +function AnalysisSkeleton() { + return ( +
+
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+
+
+
+ ); +} diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx index 23baa035..c01b228d 100644 --- a/resources/js/pages/transactions/index.tsx +++ b/resources/js/pages/transactions/index.tsx @@ -1184,6 +1184,7 @@ export default function Transactions({ onImportComplete={() => refreshTransactions() } + filters={filters} /> diff --git a/routes/api.php b/routes/api.php index 06f9277a..cb5d7af0 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); }); diff --git a/tests/Feature/SavedFilterTest.php b/tests/Feature/SavedFilterTest.php index 7c628802..302bb9b0 100644 --- a/tests/Feature/SavedFilterTest.php +++ b/tests/Feature/SavedFilterTest.php @@ -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', diff --git a/tests/Feature/TransactionAnalysisTest.php b/tests/Feature/TransactionAnalysisTest.php new file mode 100644 index 00000000..992e646f --- /dev/null +++ b/tests/Feature/TransactionAnalysisTest.php @@ -0,0 +1,134 @@ +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 +});