diff --git a/app/Enums/AnalysisMode.php b/app/Enums/AnalysisMode.php new file mode 100644 index 00000000..a4b37044 --- /dev/null +++ b/app/Enums/AnalysisMode.php @@ -0,0 +1,9 @@ + $savedFilter, ]); } + + public function updateAnalysisMode(Request $request, SavedFilter $savedFilter): JsonResponse + { + abort_unless($savedFilter->user_id === $request->user()->id, 403); + + $validated = $request->validate([ + 'analysis_mode' => ['nullable', Rule::enum(AnalysisMode::class)], + ]); + + $savedFilter->update(['analysis_mode' => $validated['analysis_mode'] ?? null]); + + return response()->json([ + 'data' => $savedFilter, + ]); + } } diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index e3fe6871..ffc0d86a 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api; use App\Features\TransactionAnalysis; use App\Http\Controllers\Controller; use App\Http\Requests\IndexTransactionRequest; +use App\Models\Label; use App\Models\Transaction; use App\Services\CategoryTree; use App\Services\ExchangeRateService; @@ -21,6 +22,12 @@ class TransactionAnalysisController extends Controller */ private const DAILY_BUCKET_MAX_DAYS = 62; + /** + * The drawer lists the five biggest expenses with an option to reveal the + * rest, so ten covers both states without shipping the whole set. + */ + private const LARGEST_EXPENSES_LIMIT = 10; + public function __construct( private ExchangeRateService $exchangeRateService, private CategoryTree $tree, @@ -50,7 +57,7 @@ class TransactionAnalysisController extends Controller $transactions = Transaction::query() ->where('user_id', $user->id) - ->with(['account', 'category', 'labels']) + ->with(['account.bank', 'category', 'labels']) ->applyFilters($filters) ->get(); @@ -58,6 +65,8 @@ class TransactionAnalysisController extends Controller $byCategory = $this->categoryBreakdown($transactions, $currency, $user->id); $byTag = $this->tagBreakdown($transactions, $currency); + $byPayee = $this->payeeBreakdown($transactions, $currency); + $byAccount = $this->accountBreakdown($transactions, $currency); return response() ->json([ @@ -67,6 +76,11 @@ class TransactionAnalysisController extends Controller 'distinct_category_count' => $byCategory->count(), 'by_tag' => $byTag->values(), 'distinct_label_count' => $byTag->count(), + 'by_payee' => $byPayee->values(), + 'distinct_payee_count' => $byPayee->count(), + 'by_account' => $byAccount->values(), + 'distinct_account_count' => $byAccount->count(), + 'largest_expenses' => $this->largestExpenses($transactions, $currency), 'over_time' => $this->overTime($transactions, $currency), ]) ->header('Cache-Control', 'no-store, private'); @@ -183,11 +197,112 @@ class TransactionAnalysisController extends Controller ->values(); } + /** + * Expenses grouped by the party paid (the creditor on the transaction). + * Transactions without a named creditor are skipped, since an unnamed + * bucket carries no meaning for the user. + */ + private function payeeBreakdown(Collection $transactions, string $currency): Collection + { + $totals = []; + + foreach ($transactions as $transaction) { + $amount = $this->convertTransactionAmount($transaction, $currency); + + if ($amount >= 0) { + continue; + } + + $name = trim((string) $transaction->creditor_name); + + if ($name === '') { + continue; + } + + $totals[$name] ??= ['name' => $name, 'amount' => 0]; + $totals[$name]['amount'] += abs($amount); + } + + return collect($totals) + ->sortByDesc('amount') + ->values(); + } + + /** + * Expenses grouped by the account that funded them, so a set spanning + * several cards shows where the spending was charged. + */ + private function accountBreakdown(Collection $transactions, string $currency): Collection + { + $totals = []; + + foreach ($transactions as $transaction) { + $amount = $this->convertTransactionAmount($transaction, $currency); + + if ($amount >= 0) { + continue; + } + + $account = $transaction->account; + + $totals[$account->id] ??= [ + 'id' => $account->id, + 'name' => $account->name, + 'bank' => $account->bank ? ['name' => $account->bank->name, 'logo' => $account->bank->logo] : null, + 'amount' => 0, + ]; + $totals[$account->id]['amount'] += abs($amount); + } + + return collect($totals) + ->sortByDesc('amount') + ->values(); + } + + /** + * The biggest individual expenses, richest-first, each carrying the same + * display fields the transaction table shows so the drawer can render a + * familiar row. Capped at the limit the drawer can reveal. + * + * @return array}> + */ + private function largestExpenses(Collection $transactions, string $currency): array + { + return $transactions + ->filter(fn (Transaction $transaction): bool => $this->convertTransactionAmount($transaction, $currency) < 0) + ->sortBy(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency)) + ->take(self::LARGEST_EXPENSES_LIMIT) + ->map(fn (Transaction $transaction): array => [ + 'id' => $transaction->id, + 'date' => $transaction->transaction_date->toDateString(), + 'description' => $transaction->description, + 'amount' => abs($this->convertTransactionAmount($transaction, $currency)), + 'category' => $transaction->category ? [ + 'name' => $transaction->category->name, + 'color' => $transaction->category->color, + 'icon' => $transaction->category->icon, + ] : null, + 'account' => [ + 'name' => $transaction->account->name, + 'bank' => $transaction->account->bank ? [ + 'name' => $transaction->account->bank->name, + 'logo' => $transaction->account->bank->logo, + ] : null, + ], + 'labels' => $transaction->labels + ->map(fn (Label $label): array => ['id' => $label->id, 'name' => $label->name, 'color' => $label->color]) + ->values() + ->all(), + ]) + ->values() + ->all(); + } + /** * Income and expense bucketed over the filtered span, plus a running * expense total so the pace of spending is visible. * - * @return array{bucket: string, points: array} + * @return array{bucket: string, points: array} */ private function overTime(Collection $transactions, string $currency): array { @@ -216,7 +331,8 @@ class TransactionAnalysisController extends Controller } $points = []; - $cumulative = 0; + $cumulativeExpense = 0; + $cumulativeNet = 0; $cursor = $daily ? $start->copy()->startOfDay() : $start->copy()->startOfMonth(); $last = $daily ? $end->copy()->startOfDay() : $end->copy()->startOfMonth(); @@ -224,14 +340,16 @@ class TransactionAnalysisController extends Controller $key = $cursor->format($keyFormat); $income = $buckets[$key]['income'] ?? 0; $expense = $buckets[$key]['expense'] ?? 0; - $cumulative += $expense; + $cumulativeExpense += $expense; + $cumulativeNet += $income - $expense; $points[] = [ 'date' => $key, 'label' => $daily ? $cursor->format('M j') : $cursor->format('M Y'), 'income' => $income, 'expense' => $expense, - 'cumulative_expense' => $cumulative, + 'cumulative_expense' => $cumulativeExpense, + 'cumulative_net' => $cumulativeNet, ]; $daily ? $cursor->addDay() : $cursor->addMonth(); diff --git a/app/Models/SavedFilter.php b/app/Models/SavedFilter.php index 1cd49de6..261dae15 100644 --- a/app/Models/SavedFilter.php +++ b/app/Models/SavedFilter.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\AnalysisMode; use Database\Factories\SavedFilterFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -18,6 +19,7 @@ class SavedFilter extends Model 'name', 'filters', 'analysis_days', + 'analysis_mode', ]; /** @var list */ @@ -32,6 +34,7 @@ class SavedFilter extends Model return [ 'filters' => 'array', 'analysis_days' => 'integer', + 'analysis_mode' => AnalysisMode::class, ]; } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index 7363c09a..2e5c4472 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -96,6 +96,7 @@ class Transaction extends Model return $this->belongsTo(Category::class); } + /** @return BelongsToMany */ public function labels(): BelongsToMany { return $this->belongsToMany(Label::class) diff --git a/database/migrations/2026_06_09_124010_add_analysis_mode_to_saved_filters_table.php b/database/migrations/2026_06_09_124010_add_analysis_mode_to_saved_filters_table.php new file mode 100644 index 00000000..5ebcfcfa --- /dev/null +++ b/database/migrations/2026_06_09_124010_add_analysis_mode_to_saved_filters_table.php @@ -0,0 +1,28 @@ +string('analysis_mode')->nullable()->after('analysis_days'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('saved_filters', function (Blueprint $table) { + $table->dropColumn('analysis_mode'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 127ef434..112484e5 100644 --- a/lang/es.json +++ b/lang/es.json @@ -11,6 +11,19 @@ "Cumulative spend": "Gasto acumulado", "Spending by category": "Gasto por categoría", "Spending by tag": "Gasto por etiqueta", + "Spending by payee": "Gasto por beneficiario", + "Spending by account": "Gasto por cuenta", + "Cumulative net": "Neto acumulado", + "Net result": "Resultado neto", + "Margin": "Margen", + "Total spent": "Total gastado", + "Largest expenses": "Mayores gastos", + "Show more": "Ver más", + "Show less": "Ver menos", + "Analysis view": "Vista de análisis", + "Automatic": "Automático", + "Expenses only": "Solo gastos", + "Income & expenses": "Ingresos y gastos", "adjusted": "ajustado", "Adjust number of days": "Ajustar número de días", "Days for daily average": "Días para la media diaria", diff --git a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx index 9f200ecd..366b4f88 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.test.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.test.tsx @@ -1,5 +1,11 @@ import { type TransactionFilters } from '@/types/transaction'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TransactionAnalysisDrawer } from './transaction-analysis-drawer'; @@ -51,19 +57,27 @@ const analysisResponse = { distinct_category_count: 0, by_tag: [], distinct_label_count: 0, + by_payee: [], + distinct_payee_count: 0, + by_account: [], + distinct_account_count: 0, + largest_expenses: [], over_time: { bucket: 'day', points: [] }, }; -function mockAnalysisFetch() { +function mockAnalysisFetch(response: unknown = analysisResponse) { global.fetch = vi.fn().mockResolvedValue({ ok: true, - json: async () => analysisResponse, + json: async () => response, }) as unknown as typeof fetch; } -// The Avg/day card is the 4th amount rendered (Income, Expenses, Net, Avg). +// In expense-only mode the Avg/day amount lives in the card labelled "Avg / day". function avgPerDay(): number { - return Number(screen.getAllByTestId('amount')[3].textContent); + const card = screen + .getByText('Avg / day') + .closest('div.rounded-lg') as HTMLElement; + return Number(within(card).getByTestId('amount').textContent); } function stubLocalStorage() { @@ -197,3 +211,191 @@ describe('TransactionAnalysisDrawer day override', () => { expect(avgPerDay()).toBe(15000); }); }); + +describe('TransactionAnalysisDrawer view mode', () => { + const incomeResponse = { + ...analysisResponse, + summary: { + income: 100000, + expense: 40000, + net: 60000, + count: 4, + days: 30, + average_expense_per_day: 1333, + }, + }; + + it('auto-detects income mode when income is a meaningful share', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch(incomeResponse); + + render( + , + ); + + await waitFor(() => + expect(screen.getByText('Net result')).toBeInTheDocument(), + ); + expect(screen.getByText('Margin')).toBeInTheDocument(); + // 60000 / 100000 = 60%. + expect(screen.getByText('60%')).toBeInTheDocument(); + expect(screen.queryByText('Avg / day')).not.toBeInTheDocument(); + }); + + it('stays in expense mode for a stray refund below the threshold', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch({ + ...analysisResponse, + summary: { + income: 5000, + expense: 90000, + net: -85000, + count: 6, + days: 90, + average_expense_per_day: 944, + }, + }); + + render( + , + ); + + await waitFor(() => + expect(screen.getByText('Avg / day')).toBeInTheDocument(), + ); + expect(screen.queryByText('Net result')).not.toBeInTheDocument(); + }); + + it('persists a forced view mode to the matched saved filter', async () => { + axiosGet.mockResolvedValue({ + data: { + data: [ + { + id: 'saved-1', + filters: { label_ids: ['label-1'] }, + analysis_days: null, + analysis_mode: null, + }, + ], + }, + }); + axiosPatch.mockResolvedValue({ data: {} }); + mockAnalysisFetch(incomeResponse); + + render( + , + ); + + await waitFor(() => + expect(screen.getByText('Net result')).toBeInTheDocument(), + ); + + fireEvent.click( + screen.getByRole('button', { name: /Income & expenses/i }), + ); + fireEvent.click(screen.getByText('Expenses only')); + + await waitFor(() => + expect(axiosPatch).toHaveBeenCalledWith( + '/api/saved-filters/saved-1/analysis-mode', + { analysis_mode: 'expense' }, + ), + ); + await waitFor(() => + expect(screen.getByText('Avg / day')).toBeInTheDocument(), + ); + }); +}); + +describe('TransactionAnalysisDrawer largest expenses columns', () => { + function largestExpense(overrides: Record = {}) { + return { + id: 'tx-1', + date: '2026-01-10', + description: 'Grand Hotel', + amount: 50000, + category: { name: 'Hotel', color: 'blue', icon: 'Building' }, + account: { name: 'Visa', bank: null }, + labels: [{ id: 'l1', name: 'Miami', color: 'blue' }], + ...overrides, + }; + } + + it('hides columns the filter or the rows have pinned to one value', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch({ + ...analysisResponse, + // Two rows sharing one category and one account. + largest_expenses: [ + largestExpense({ id: 'tx-1' }), + largestExpense({ id: 'tx-2', description: 'Room service' }), + ], + }); + + render( + // filters pins a single label. + , + ); + + await waitFor(() => + expect(screen.getByText('Largest expenses')).toBeInTheDocument(), + ); + + expect(screen.queryByText('Category')).not.toBeInTheDocument(); + expect(screen.queryByText('Account')).not.toBeInTheDocument(); + expect(screen.queryByText('Labels')).not.toBeInTheDocument(); + expect(screen.getByText('Description')).toBeInTheDocument(); + }); + + it('keeps columns that vary across the rows', async () => { + axiosGet.mockResolvedValue({ data: { data: [] } }); + mockAnalysisFetch({ + ...analysisResponse, + largest_expenses: [ + largestExpense({ id: 'tx-1' }), + largestExpense({ + id: 'tx-2', + category: { + name: 'Meals', + color: 'amber', + icon: 'Utensils', + }, + account: { name: 'Amex', bank: null }, + }), + ], + }); + + render( + // No label pinned, so the labels column stays too. + , + ); + + await waitFor(() => + expect(screen.getByText('Largest expenses')).toBeInTheDocument(), + ); + + expect(screen.getByText('Category')).toBeInTheDocument(); + expect(screen.getByText('Account')).toBeInTheDocument(); + expect(screen.getByText('Labels')).toBeInTheDocument(); + }); +}); diff --git a/resources/js/components/transactions/transaction-analysis-drawer.tsx b/resources/js/components/transactions/transaction-analysis-drawer.tsx index 167a2bb4..06acf631 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.tsx @@ -1,5 +1,9 @@ +import { AccountName } from '@/components/accounts/account-name'; +import { BankLogo } from '@/components/bank-logo'; +import { LabelBadges } from '@/components/shared/label-combobox'; import { AmountDisplay } from '@/components/ui/amount-display'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ChartConfig, ChartContainer } from '@/components/ui/chart'; import { Drawer, @@ -21,12 +25,33 @@ import { type SerializedFilters, } from '@/lib/transaction-filter-serialization'; import { cn } from '@/lib/utils'; +import { + getCategoryColorClasses, + type CategoryColor, + type CategoryIcon, +} from '@/types/category'; +import { type Label } from '@/types/label'; import { type TransactionFilters } from '@/types/transaction'; import { type UUID } from '@/types/uuid'; +import { formatDate } from '@/utils/date'; import { __ } from '@/utils/i18n'; import axios from 'axios'; -import { Settings2 } from 'lucide-react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { parseISO } from 'date-fns'; +import * as Icons from 'lucide-react'; +import { + Check, + HelpCircle, + Settings2, + SlidersHorizontal, + type LucideIcon, +} from 'lucide-react'; +import { + useCallback, + useEffect, + useMemo, + useState, + type ReactNode, +} from 'react'; import { Bar, Cell, @@ -40,6 +65,21 @@ import { YAxis, } from 'recharts'; +type AnalysisMode = 'expense' | 'income'; + +/** + * Income only changes the analysis into its income-and-expense shape once it + * is a meaningful share of the spending; a stray refund should not flip a trip + * into a profit-and-loss view. + */ +const INCOME_MODE_THRESHOLD = 0.15; + +function detectMode(income: number, expense: number): AnalysisMode { + return income > 0 && income >= expense * INCOME_MODE_THRESHOLD + ? 'income' + : 'expense'; +} + interface AnalysisSummary { income: number; expense: number; @@ -64,12 +104,42 @@ interface TagSlice { amount: number; } +interface PayeeSlice { + name: string; + amount: number; +} + +interface AccountSlice { + id: string | null; + name: string; + bank: { name: string; logo: string | null } | null; + amount: number; +} + +interface LargestExpense { + id: string; + date: string; + description: string | null; + amount: number; + category: { + name: string; + color: string | null; + icon: string | null; + } | null; + account: { + name: string; + bank: { name: string; logo: string | null } | null; + } | null; + labels: { id: string; name: string; color: string }[]; +} + interface OverTimePoint { date: string; label: string; income: number; expense: number; cumulative_expense: number; + cumulative_net: number; } interface AnalysisData { @@ -79,6 +149,11 @@ interface AnalysisData { distinct_category_count: number; by_tag: TagSlice[]; distinct_label_count: number; + by_payee: PayeeSlice[]; + distinct_payee_count: number; + by_account: AccountSlice[]; + distinct_account_count: number; + largest_expenses: LargestExpense[]; over_time: { bucket: 'day' | 'month'; points: OverTimePoint[] }; } @@ -142,9 +217,11 @@ interface SavedFilterSummary { id: UUID; filters: SerializedFilters; analysis_days: number | null; + analysis_mode: AnalysisMode | null; } const DAY_OVERRIDE_STORAGE_PREFIX = 'wm.analysis-days.'; +const MODE_OVERRIDE_STORAGE_PREFIX = 'wm.analysis-mode.'; function readStoredDays(key: string): number | null { const raw = localStorage.getItem(key); @@ -155,26 +232,35 @@ function readStoredDays(key: string): number | null { return Number.isFinite(parsed) && parsed > 0 ? parsed : null; } +function readStoredMode(key: string): AnalysisMode | null { + const raw = localStorage.getItem(key); + return raw === 'expense' || raw === 'income' ? raw : null; +} + /** - * Resolves the number of days used to average daily spending for a filter set. + * Resolves the day span and view mode used 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. + * Both follow the same rule: an automatic value (the transaction span; the + * income-share detection) unless the user overrides it. Overrides are + * remembered per filter fingerprint in the browser and, when the current + * filters match a saved filter, synced to the backend. A single lookup of the + * saved filters backs both, so the drawer hits the API once per open. */ -function useAnalysisDays( +function useAnalysisPreferences( open: boolean, filters: TransactionFilters, autoDays: number, + autoMode: AnalysisMode, ) { const fingerprint = useMemo( () => filtersFingerprint(serializeFilters(filters)), [filters], ); - const storageKey = `${DAY_OVERRIDE_STORAGE_PREFIX}${fingerprint}`; + const dayKey = `${DAY_OVERRIDE_STORAGE_PREFIX}${fingerprint}`; + const modeKey = `${MODE_OVERRIDE_STORAGE_PREFIX}${fingerprint}`; - const [override, setOverride] = useState(null); + const [dayOverride, setDayOverride] = useState(null); + const [modeOverride, setModeOverride] = useState(null); const [savedFilterId, setSavedFilterId] = useState(null); useEffect(() => { @@ -182,7 +268,8 @@ function useAnalysisDays( return; } - const local = readStoredDays(storageKey); + const localDays = readStoredDays(dayKey); + const localMode = readStoredMode(modeKey); let active = true; axios @@ -197,29 +284,31 @@ function useAnalysisDays( filtersFingerprint(saved.filters) === fingerprint, ) ?? null; setSavedFilterId(match?.id ?? null); - setOverride(match?.analysis_days ?? local); + setDayOverride(match?.analysis_days ?? localDays); + setModeOverride(match?.analysis_mode ?? localMode); }) .catch(() => { if (!active) { return; } setSavedFilterId(null); - setOverride(local); + setDayOverride(localDays); + setModeOverride(localMode); }); return () => { active = false; }; - }, [open, fingerprint, storageKey]); + }, [open, fingerprint, dayKey, modeKey]); const applyDays = useCallback( (value: number | null) => { - setOverride(value); + setDayOverride(value); if (value === null) { - localStorage.removeItem(storageKey); + localStorage.removeItem(dayKey); } else { - localStorage.setItem(storageKey, String(value)); + localStorage.setItem(dayKey, String(value)); } if (savedFilterId) { @@ -229,14 +318,37 @@ function useAnalysisDays( ); } }, - [storageKey, savedFilterId], + [dayKey, savedFilterId], + ); + + const applyMode = useCallback( + (value: AnalysisMode | null) => { + setModeOverride(value); + + if (value === null) { + localStorage.removeItem(modeKey); + } else { + localStorage.setItem(modeKey, value); + } + + if (savedFilterId) { + void axios.patch( + `/api/saved-filters/${savedFilterId}/analysis-mode`, + { analysis_mode: value }, + ); + } + }, + [modeKey, savedFilterId], ); return { - effectiveDays: override ?? autoDays, - isOverridden: override !== null, + effectiveDays: dayOverride ?? autoDays, + isDaysOverridden: dayOverride !== null, + effectiveMode: modeOverride ?? autoMode, + modeOverride, isSaved: savedFilterId !== null, applyDays, + applyMode, }; } @@ -283,13 +395,26 @@ export function TransactionAnalysisDrawer({ const currency = data?.currency ?? ''; const hasTransactions = (data?.summary.count ?? 0) > 0; + const income = data?.summary.income ?? 0; + const expense = data?.summary.expense ?? 0; + const net = data?.summary.net ?? 0; + const autoMode = detectMode(income, expense); - const { effectiveDays, isOverridden, isSaved, applyDays } = useAnalysisDays( + const { + effectiveDays, + isDaysOverridden, + effectiveMode, + modeOverride, + isSaved, + applyDays, + applyMode, + } = useAnalysisPreferences( open, filters, data?.summary.days ?? 0, + autoMode, ); - const expense = data?.summary.expense ?? 0; + const averagePerDay = effectiveDays > 0 ? Math.round(expense / effectiveDays) : expense; @@ -297,13 +422,27 @@ export function TransactionAnalysisDrawer({
- - {__('Analysis')} - - {__( - 'A breakdown of the transactions matching your current filters.', + +
+ {hasTransactions && ( + )} - +
+
+ + {__('Analysis')} + + + {__( + 'A breakdown of the transactions matching your current filters.', + )} + +
{isLoading && } @@ -321,13 +460,17 @@ export function TransactionAnalysisDrawer({ )} {!isLoading && !error && data && hasTransactions && ( -
+
@@ -336,6 +479,14 @@ export function TransactionAnalysisDrawer({ points={data.over_time.points} currency={currency} locale={locale} + mode={effectiveMode} + /> + + {data.distinct_category_count > 1 && ( @@ -345,6 +496,21 @@ export function TransactionAnalysisDrawer({ /> )} + {data.distinct_payee_count > 1 && ( + + )} + + {data.distinct_account_count > 1 && ( + + )} + {data.distinct_label_count > 1 && ( + {title && ( + + {title} + + )} + + {children} + + + ); +} + function SummaryCards({ - summary, + mode, + income, + expense, + net, + count, currency, days, averagePerDay, - isOverridden, + isDaysOverridden, isSaved, onApplyDays, }: { - summary: AnalysisSummary; + mode: AnalysisMode; + income: number; + expense: number; + net: number; + count: number; currency: string; days: number; averagePerDay: number; - isOverridden: boolean; + isDaysOverridden: 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; + const margin = income > 0 ? Math.round((net / income) * 100) : 0; return ( -
- {cards.map((card) => ( -
-

- {card.label} -

- + {mode === 'income' ? ( +
+ -
- ))} - -
-
-

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

- + = 0 ? 'income' : 'expense'} + /> +
+

+ {__('Margin')} +

+

= 0 ? 'text-emerald-600' : 'text-red-600', + )} + > + {margin}% +

+
- -
+ ) : ( +
+ +
+
+

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

+ +
+ +
+
+ )} -

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

+ {count} {__('transactions')} · {days} {__('days')} + {isDaysOverridden && + mode === 'expense' && + ` (${__('adjusted')})`}

+ + ); +} + +function SummaryCard({ + label, + amount, + currency, + tone, +}: { + label: string; + amount: number; + currency: string; + tone: 'income' | 'expense'; +}) { + return ( +
+

{label}

+
); } +function ModeToggle({ + override, + effectiveMode, + isSaved, + onApply, +}: { + override: AnalysisMode | null; + effectiveMode: AnalysisMode; + isSaved: boolean; + onApply: (value: AnalysisMode | null) => void; +}) { + const [open, setOpen] = useState(false); + + const options: { value: AnalysisMode | null; label: string }[] = [ + { value: null, label: __('Automatic') }, + { value: 'expense', label: __('Expenses only') }, + { value: 'income', label: __('Income & expenses') }, + ]; + + const triggerLabel = + override === null + ? effectiveMode === 'income' + ? __('Income & expenses') + : __('Expenses only') + : override === 'income' + ? __('Income & expenses') + : __('Expenses only'); + + const choose = (value: AnalysisMode | null) => { + onApply(value); + setOpen(false); + }; + + return ( + + + + + +
+

+ {__('Analysis view')} +

+ {options.map((option) => { + const selected = override === option.value; + + return ( + + ); + })} + {isSaved && ( +

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

+ )} +
+
+
+ ); +} + function DayEditorPopover({ days, isOverridden, @@ -525,16 +853,23 @@ function OverTimeChart({ points, currency, locale, + mode, }: { points: OverTimePoint[]; currency: string; locale: string; + mode: AnalysisMode; }) { + const cumulativeKey = + mode === 'income' ? 'cumulative_net' : 'cumulative_expense'; + const cumulativeLabel = + mode === 'income' ? __('Cumulative net') : __('Cumulative spend'); + const config: ChartConfig = { income: { label: __('Income'), color: 'var(--color-chart-2)' }, expense: { label: __('Expenses'), color: 'var(--color-chart-5)' }, - cumulative_expense: { - label: __('Cumulative spend'), + [cumulativeKey]: { + label: cumulativeLabel, color: 'var(--color-chart-1)', }, }; @@ -546,8 +881,7 @@ function OverTimeChart({ }).format(value / 100); return ( -
-

{__('Spending over time')}

+ } + content={ + + } cursor={{ fill: 'var(--color-muted)', opacity: 0.3 }} /> - + {mode === 'income' && ( + + )} -
+ ); } @@ -601,10 +944,16 @@ function OverTimeTooltip({ active, payload, currency, + cumulativeKey, + cumulativeLabel, + mode, }: { active?: boolean; payload?: TooltipPayloadItem[]; currency: string; + cumulativeKey: 'cumulative_expense' | 'cumulative_net'; + cumulativeLabel: string; + mode: AnalysisMode; }) { if (!active || !payload?.length) { return null; @@ -613,11 +962,13 @@ function OverTimeTooltip({ const point = payload[0]?.payload; const rows: { label: string; - key: 'income' | 'expense' | 'cumulative_expense'; + key: 'income' | 'expense' | 'cumulative_expense' | 'cumulative_net'; }[] = [ - { label: __('Income'), key: 'income' }, + ...(mode === 'income' + ? ([{ label: __('Income'), key: 'income' }] as const) + : []), { label: __('Expenses'), key: 'expense' }, - { label: __('Cumulative spend'), key: 'cumulative_expense' }, + { label: cumulativeLabel, key: cumulativeKey }, ]; return ( @@ -640,6 +991,192 @@ function OverTimeTooltip({ ); } +/** + * A sentinel that keeps an absent category/account from colliding with a real + * one named with an empty string when counting distinct values. + */ +const MISSING = ''; + +function LargestTransactions({ + items, + currency, + locale, + filters, +}: { + items: LargestExpense[]; + currency: string; + locale: string; + filters: TransactionFilters; +}) { + const [expanded, setExpanded] = useState(false); + + if (items.length === 0) { + return null; + } + + const visible = expanded ? items : items.slice(0, 5); + + // Drop a column whose value is identical across every row (so the filter + // has already pinned it, or the set just happens to share it) — it carries + // no information. Labels are filter-driven: filtering to a single label + // makes that column redundant even when rows carry extra labels. + const showCategory = + new Set(items.map((item) => item.category?.name ?? MISSING)).size > 1; + const showAccount = + new Set(items.map((item) => item.account?.name ?? MISSING)).size > 1; + const showLabels = + filters.labelIds.length !== 1 && + items.some((item) => item.labels.length > 0); + + return ( + +
+ + + + + {showCategory && ( + + )} + {showAccount && ( + + )} + + {showLabels && ( + + )} + + + + + {visible.map((item) => ( + + + {showCategory && ( + + )} + {showAccount && ( + + )} + + {showLabels && ( + + )} + + + ))} + +
+ {__('Date')} + + {__('Category')} + + {__('Account')} + + {__('Description')} + + {__('Labels')} + + {__('Amount')} +
+ {formatDate( + parseISO(item.date), + 'MMM d, yy', + locale, + )} + + + + {item.account ? ( +
+ + +
+ ) : ( + + — + + )} +
+ {item.description || ( + + — + + )} + + + + +
+
+ {items.length > 5 && ( + + )} +
+ ); +} + +function CategoryChip({ category }: { category: LargestExpense['category'] }) { + if (!category) { + return ; + } + + const classes = getCategoryColorClasses( + (category.color ?? 'gray') as CategoryColor, + ); + const Icon = (Icons[(category.icon ?? 'HelpCircle') as CategoryIcon] ?? + HelpCircle) as LucideIcon; + + return ( + + + {category.name} + + ); +} + function CategoryBreakdown({ slices, currency, @@ -651,10 +1188,7 @@ function CategoryBreakdown({ const config: ChartConfig = { amount: { label: __('Spent') } }; return ( -
-

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

+
@@ -765,21 +1299,25 @@ function CategoryBreakdown({ })}
-
+ ); } -function TagBreakdown({ - slices, +function HorizontalBarBreakdown({ + title, + data, currency, locale, + color, }: { - slices: TagSlice[]; + title: string; + data: { name: string; amount: number }[]; currency: string; locale: string; + color: string; }) { const config: ChartConfig = { - amount: { label: __('Spent'), color: 'var(--color-chart-1)' }, + amount: { label: __('Spent'), color }, }; const compact = (value: number) => @@ -789,17 +1327,16 @@ function TagBreakdown({ }).format(value / 100); return ( -
-

{__('Spending by tag')}

+ @@ -815,27 +1352,111 @@ function TagBreakdown({ fill: 'var(--color-muted)', opacity: 0.3, }} - content={} + content={} /> -
+ ); } -function TagTooltip({ +function TagBreakdown({ + slices, + currency, + locale, +}: { + slices: TagSlice[]; + currency: string; + locale: string; +}) { + return ( + + ); +} + +function PayeeBreakdown({ + slices, + currency, + locale, +}: { + slices: PayeeSlice[]; + currency: string; + locale: string; +}) { + return ( + + ); +} + +function AccountBreakdown({ + slices, + currency, +}: { + slices: AccountSlice[]; + currency: string; +}) { + const total = slices.reduce((sum, slice) => sum + slice.amount, 0); + + return ( + +
    + {slices.map((slice, index) => ( +
  • + + + {slice.name} + + + {total > 0 + ? Math.round((slice.amount / total) * 100) + : 0} + % + + +
  • + ))} +
+
+ ); +} + +function NamedAmountTooltip({ active, payload, currency, }: { active?: boolean; - payload?: { payload?: TagSlice }[]; + payload?: { payload?: { name: string; amount: number } }[]; currency: string; }) { if (!active || !payload?.length) { diff --git a/routes/api.php b/routes/api.php index cb5d7af0..34e6f9c7 100644 --- a/routes/api.php +++ b/routes/api.php @@ -65,5 +65,6 @@ Route::middleware(['web', 'auth'])->group(function () { Route::post('saved-filters', [SavedFilterController::class, 'store'])->name('api.saved-filters.store'); Route::patch('saved-filters/{savedFilter}', [SavedFilterController::class, 'update'])->name('api.saved-filters.update'); Route::patch('saved-filters/{savedFilter}/analysis-days', [SavedFilterController::class, 'updateAnalysisDays'])->name('api.saved-filters.analysis-days'); + Route::patch('saved-filters/{savedFilter}/analysis-mode', [SavedFilterController::class, 'updateAnalysisMode'])->name('api.saved-filters.analysis-mode'); Route::delete('saved-filters/{savedFilter}', [SavedFilterController::class, 'destroy'])->name('api.saved-filters.destroy'); }); diff --git a/tests/Feature/SavedFilterTest.php b/tests/Feature/SavedFilterTest.php index 302bb9b0..91d85e76 100644 --- a/tests/Feature/SavedFilterTest.php +++ b/tests/Feature/SavedFilterTest.php @@ -1,5 +1,6 @@ assertJsonValidationErrors('analysis_days'); }); +test('updates the analysis view mode on a saved filter', function () { + $savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]); + + $this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'income']) + ->assertOk() + ->assertJsonPath('data.analysis_mode', 'income'); + + expect($savedFilter->fresh()->analysis_mode)->toBe(AnalysisMode::Income); +}); + +test('clears the analysis view mode when sent null', function () { + $savedFilter = SavedFilter::factory()->create([ + 'user_id' => $this->user->id, + 'analysis_mode' => 'expense', + ]); + + $this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => null]) + ->assertOk() + ->assertJsonPath('data.analysis_mode', null); + + expect($savedFilter->fresh()->analysis_mode)->toBeNull(); +}); + +test('cannot update the analysis mode of another user saved filter', function () { + $savedFilter = SavedFilter::factory()->create(['name' => 'Not mine']); + + $this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'income']) + ->assertForbidden(); +}); + +test('rejects an unknown analysis view mode', function () { + $savedFilter = SavedFilter::factory()->create(['user_id' => $this->user->id]); + + $this->patchJson("/api/saved-filters/{$savedFilter->id}/analysis-mode", ['analysis_mode' => 'sideways']) + ->assertJsonValidationErrors('analysis_mode'); +}); + test('stores a saved filter for the current user', function () { $payload = [ 'name' => 'Trip to Japan', diff --git a/tests/Feature/TransactionAnalysisTest.php b/tests/Feature/TransactionAnalysisTest.php index 2591ac17..b26f0b21 100644 --- a/tests/Feature/TransactionAnalysisTest.php +++ b/tests/Feature/TransactionAnalysisTest.php @@ -3,6 +3,7 @@ use App\Enums\CategoryType; use App\Features\TransactionAnalysis; use App\Models\Account; +use App\Models\Bank; use App\Models\Category; use App\Models\Label; use App\Models\Transaction; @@ -181,3 +182,85 @@ test('over time switches to monthly buckets for long spans', function () { expect($response->json('over_time.bucket'))->toBe('month'); expect($response->json('over_time.points'))->toHaveCount(6); // Jan..Jun }); + +test('over time carries a cumulative net alongside the cumulative expense', function () { + makeTransaction(['amount' => 5000, 'transaction_date' => '2026-01-10']); + makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-11']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + $points = $response->json('over_time.points'); + expect($points[0])->toMatchArray(['cumulative_expense' => 0, 'cumulative_net' => 5000]); + expect($points[1])->toMatchArray(['cumulative_expense' => 2000, 'cumulative_net' => 3000]); +}); + +test('largest expenses lists the biggest spends richest-first, capped at ten', function () { + foreach (range(1, 12) as $index) { + makeTransaction(['amount' => -$index * 1000, 'description' => "Expense {$index}", 'transaction_date' => '2026-01-10']); + } + // Income must never appear among the largest expenses. + makeTransaction(['amount' => 999999, 'transaction_date' => '2026-01-10']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + $largest = $response->json('largest_expenses'); + expect($largest)->toHaveCount(10); + expect($largest[0])->toMatchArray(['description' => 'Expense 12', 'amount' => 12000]); + expect($largest[9])->toMatchArray(['description' => 'Expense 3', 'amount' => 3000]); +}); + +test('largest expenses carry the category, account and labels for display', function () { + $bank = Bank::factory()->create(['name' => 'Acme Bank']); + $account = Account::factory()->create(['user_id' => $this->user->id, 'currency_code' => 'USD', 'bank_id' => $bank->id]); + $category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Hotel']); + $label = Label::factory()->create(['user_id' => $this->user->id, 'name' => 'Trip']); + + $transaction = makeTransaction([ + 'amount' => -50000, + 'account_id' => $account->id, + 'category_id' => $category->id, + 'description' => 'Grand Hotel', + 'transaction_date' => '2026-01-10', + ]); + $transaction->labels()->attach($label); + + $row = $this->getJson('/api/transactions/analysis')->assertOk()->json('largest_expenses.0'); + + expect($row)->toMatchArray([ + 'description' => 'Grand Hotel', + 'amount' => 50000, + 'category' => ['name' => 'Hotel', 'color' => $category->color, 'icon' => $category->icon], + 'account' => ['name' => $account->name, 'bank' => ['name' => 'Acme Bank', 'logo' => $bank->logo]], + ]); + expect($row['labels'])->toHaveCount(1); + expect($row['labels'][0])->toMatchArray(['name' => 'Trip']); +}); + +test('payee breakdown sums named creditors and ignores blank ones', function () { + makeTransaction(['amount' => -3000, 'creditor_name' => 'Hotel Paradiso', 'transaction_date' => '2026-01-10']); + makeTransaction(['amount' => -2000, 'creditor_name' => 'Hotel Paradiso', 'transaction_date' => '2026-01-11']); + makeTransaction(['amount' => -1000, 'creditor_name' => 'Cafe Roma', 'transaction_date' => '2026-01-12']); + makeTransaction(['amount' => -9000, 'creditor_name' => null, 'transaction_date' => '2026-01-13']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + expect($response->json('distinct_payee_count'))->toBe(2); + expect($response->json('by_payee.0'))->toMatchArray(['name' => 'Hotel Paradiso', 'amount' => 5000]); + expect($response->json('by_payee.1'))->toMatchArray(['name' => 'Cafe Roma', 'amount' => 1000]); +}); + +test('account breakdown sums expenses per funding account', function () { + $other = Account::factory()->create(['user_id' => $this->user->id, 'currency_code' => 'USD', 'name' => 'Travel card']); + + makeTransaction(['amount' => -4000, 'transaction_date' => '2026-01-10']); + makeTransaction(['amount' => -6000, 'account_id' => $other->id, 'transaction_date' => '2026-01-11']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + expect($response->json('distinct_account_count'))->toBe(2); + expect($response->json('by_account.0'))->toMatchArray(['name' => 'Travel card', 'amount' => 6000]); +});