feat(analysis): add expense/income analysis screen

New top-level Analysis screen to drill into income and expenses across
category, month, account, and label dimensions. Reuses transaction
filters (category + labels surfaced inline, search hidden) and converts
all amounts to the user currency. Click a breakdown row to drill in.

Also make category and label filters combine with OR in the shared
transaction filter scope (affects the transactions screen too).
This commit is contained in:
Víctor Falcón 2026-06-01 15:11:38 +02:00
parent d68fee6c2d
commit f66e9cda8a
15 changed files with 1582 additions and 274 deletions

View File

@ -0,0 +1,50 @@
<?php
namespace App\Http\Controllers;
use App\Models\Account;
use App\Models\Bank;
use App\Models\Category;
use App\Models\Label;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class AnalysisController extends Controller
{
public function __invoke(Request $request): Response
{
$user = $request->user();
$categories = Category::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction']);
$accounts = Account::query()
->where('user_id', $user->id)
->with('bank:id,name,logo')
->orderBy('name')
->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']);
$banks = Bank::query()
->where(function ($query) use ($user) {
$query->whereNull('user_id')
->orWhere('user_id', $user->id);
})
->orderBy('name')
->get(['id', 'name', 'logo']);
$labels = Label::query()
->where('user_id', $user->id)
->orderBy('name')
->get(['id', 'name', 'color']);
return Inertia::render('analysis/index', [
'categories' => $categories,
'accounts' => $accounts,
'banks' => $banks,
'labels' => $labels,
]);
}
}

View File

@ -0,0 +1,150 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\AnalysisRequest;
use App\Models\Transaction;
use App\Services\ExchangeRateService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Collection;
class AnalysisController extends Controller
{
public function __construct(private ExchangeRateService $exchangeRateService) {}
public function index(AnalysisRequest $request): JsonResponse
{
$user = $request->user();
$userCurrency = $user->currency_code;
$groupBy = $request->validated()['group_by'];
$transactions = Transaction::query()
->where('user_id', $user->id)
->with(['account:id,currency_code', 'category:id,name,icon,color,type', 'labels:id,name,color'])
->applyFilters($request->filters())
->get();
$this->preloadExchangeRates($transactions, $userCurrency);
$converted = $transactions->map(fn (Transaction $transaction): array => [
'transaction' => $transaction,
'amount' => $this->convertTransactionAmount($transaction, $userCurrency),
]);
return response()
->json([
'summary' => $this->summary($converted),
'groups' => $this->groups($converted, $groupBy)->values(),
])
->header('Cache-Control', 'no-store, private');
}
/**
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
* @return array{income: int, expense: int, net: int, count: int}
*/
private function summary(Collection $converted): array
{
$income = $converted->filter(fn (array $row): bool => $row['amount'] > 0)->sum('amount');
$expense = abs($converted->filter(fn (array $row): bool => $row['amount'] < 0)->sum('amount'));
return [
'income' => $income,
'expense' => $expense,
'net' => $income - $expense,
'count' => $converted->count(),
];
}
/**
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
* @return Collection<int, array{key: ?string, amount: int, count: int}>
*/
private function groups(Collection $converted, string $groupBy): Collection
{
if ($groupBy === 'label') {
return $this->labelGroups($converted);
}
return $converted
->groupBy(fn (array $row): string => $this->groupKey($row['transaction'], $groupBy) ?? '__null__')
->map(fn (Collection $rows, string $key): array => [
'key' => $key === '__null__' ? null : $key,
'amount' => $rows->sum('amount'),
'count' => $rows->count(),
])
->sortByDesc(fn (array $group): int => abs($group['amount']));
}
/**
* A transaction with several labels contributes to each label's bucket.
* Transactions without labels fall into a single null bucket.
*
* @param Collection<int, array{transaction: Transaction, amount: int}> $converted
* @return Collection<int, array{key: ?string, amount: int, count: int}>
*/
private function labelGroups(Collection $converted): Collection
{
$buckets = [];
foreach ($converted as $row) {
$labels = $row['transaction']->labels;
if ($labels->isEmpty()) {
$buckets['__null__'] ??= ['key' => null, 'amount' => 0, 'count' => 0];
$buckets['__null__']['amount'] += $row['amount'];
$buckets['__null__']['count']++;
continue;
}
foreach ($labels as $label) {
$buckets[$label->id] ??= ['key' => $label->id, 'amount' => 0, 'count' => 0];
$buckets[$label->id]['amount'] += $row['amount'];
$buckets[$label->id]['count']++;
}
}
return collect(array_values($buckets))
->sortByDesc(fn (array $group): int => abs($group['amount']));
}
private function groupKey(Transaction $transaction, string $groupBy): ?string
{
return match ($groupBy) {
'category' => $transaction->category_id,
'account' => $transaction->account_id,
'month' => $transaction->transaction_date->format('Y-m'),
default => null,
};
}
private function convertTransactionAmount(Transaction $transaction, string $userCurrency): int
{
return $this->exchangeRateService->convert(
$transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency,
$userCurrency,
$transaction->amount,
$transaction->transaction_date->toDateString(),
);
}
/**
* @param Collection<int, Transaction> $transactions
*/
private function preloadExchangeRates(Collection $transactions, string $userCurrency): void
{
$dates = $transactions
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $userCurrency, $userCurrency) !== 0)
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
->unique()
->values();
if ($dates->isEmpty()) {
return;
}
$this->exchangeRateService->preloadRates($userCurrency, $dates);
}
}

View File

@ -0,0 +1,67 @@
<?php
namespace App\Http\Requests\Api;
use Illuminate\Foundation\Http\FormRequest;
class AnalysisRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
protected function prepareForValidation(): void
{
$fields = ['category_ids', 'account_ids', 'label_ids'];
foreach ($fields as $field) {
$value = $this->input($field);
if (is_string($value)) {
$this->merge([
$field => array_filter(explode(',', $value)),
]);
}
}
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'group_by' => ['required', 'string', 'in:category,month,account,label'],
'date_from' => ['nullable', 'date'],
'date_to' => ['nullable', 'date'],
'amount_min' => ['nullable', 'numeric'],
'amount_max' => ['nullable', 'numeric'],
'category_ids' => ['nullable', 'array'],
'category_ids.*' => ['string'],
'account_ids' => ['nullable', 'array'],
'account_ids.*' => ['string', 'uuid'],
'label_ids' => ['nullable', 'array'],
'label_ids.*' => ['string', 'uuid'],
'creditor_name' => ['nullable', 'string', 'max:255'],
'debtor_name' => ['nullable', 'string', 'max:255'],
'search' => ['nullable', 'string', 'max:200'],
];
}
/** @return array<string, mixed> */
public function filters(): array
{
$validated = $this->validated();
return 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): bool => $value !== null);
}
}

View File

@ -117,17 +117,29 @@ class Transaction extends Model
$query->where('amount', '<=', $filters['amount_max'] * 100);
}
if (! empty($filters['category_ids'])) {
$ids = collect($filters['category_ids']);
$hasUncategorized = $ids->contains('uncategorized');
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
$hasCategoryFilter = ! empty($filters['category_ids']);
$hasLabelFilter = ! empty($filters['label_ids']);
$query->where(function (Builder $q) use ($realIds, $hasUncategorized) {
if (! empty($realIds)) {
$q->whereIn('category_id', $realIds);
if ($hasCategoryFilter || $hasLabelFilter) {
$query->where(function (Builder $q) use ($filters, $hasCategoryFilter, $hasLabelFilter) {
if ($hasCategoryFilter) {
$ids = collect($filters['category_ids']);
$hasUncategorized = $ids->contains('uncategorized');
$realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all();
$q->where(function (Builder $categoryQuery) use ($realIds, $hasUncategorized) {
if (! empty($realIds)) {
$categoryQuery->whereIn('category_id', $realIds);
}
if ($hasUncategorized) {
$categoryQuery->orWhereNull('category_id');
}
});
}
if ($hasUncategorized) {
$q->orWhereNull('category_id');
if ($hasLabelFilter) {
$method = $hasCategoryFilter ? 'orWhereHas' : 'whereHas';
$q->{$method}('labels', fn (Builder $labelQuery) => $labelQuery->whereIn('labels.id', $filters['label_ids']));
}
});
}
@ -136,10 +148,6 @@ class Transaction extends Model
$query->whereIn('account_id', $filters['account_ids']);
}
if (! empty($filters['label_ids'])) {
$query->whereHas('labels', fn (Builder $q) => $q->whereIn('labels.id', $filters['label_ids']));
}
if (! empty($filters['creditor_name'])) {
$term = '%'.$filters['creditor_name'].'%';
$query->where('creditor_name', 'LIKE', $term);

View File

@ -0,0 +1,151 @@
import { CategoryIcon } from '@/components/shared/category-combobox';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { cn } from '@/lib/utils';
import { type Account } from '@/types/account';
import { type Category } from '@/types/category';
import { getLabelColorClasses } from '@/types/label';
import { __ } from '@/utils/i18n';
import { CreditCard, Tag } from 'lucide-react';
export interface ResolvedRow {
key: string | null;
label: string;
amount: number;
count: number;
category?: Category;
labelColor?: string;
account?: Account;
}
interface AnalysisBreakdownProps {
title: string;
rows: ResolvedRow[];
currency: string;
loading?: boolean;
onDrill?: (row: ResolvedRow) => void;
}
export function AnalysisBreakdown({
title,
rows,
currency,
loading,
onDrill,
}: AnalysisBreakdownProps) {
const { categoryBarColor } = useChartColors();
const maxAmount = Math.max(...rows.map((row) => Math.abs(row.amount)), 1);
return (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center gap-3">
<div className="size-6 animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
<div className="ml-auto h-4 w-16 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-1.5 w-full animate-pulse rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
) : rows.length === 0 ? (
<div className="py-10 text-center text-sm text-muted-foreground">
{__('No transactions match these filters')}
</div>
) : (
<div className="space-y-1">
{rows.map((row, index) => {
const barColor = row.category
? categoryBarColor(row.category.color, index)
: 'var(--chart-1)';
const widthPercentage =
(Math.abs(row.amount) / maxAmount) * 100;
return (
<button
key={row.key ?? '__null__'}
type="button"
onClick={() => onDrill?.(row)}
disabled={!onDrill}
className={cn(
'-mx-1.5 block w-full space-y-1.5 rounded-md px-1.5 py-1.5 text-left transition-colors',
onDrill &&
'cursor-pointer hover:bg-muted',
)}
>
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<RowIcon row={row} />
<span className="min-w-0 truncate text-sm font-medium">
{row.label}
</span>
<span className="shrink-0 text-xs text-muted-foreground">
({row.count})
</span>
</div>
<AmountDisplay
amountInCents={row.amount}
currencyCode={currency}
variant="compact"
showSign
minimumFractionDigits={0}
maximumFractionDigits={0}
/>
</div>
<Progress
value={widthPercentage}
className="h-1.5"
indicatorColor={barColor}
/>
</button>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
function RowIcon({ row }: { row: ResolvedRow }) {
if (row.category) {
return (
<div className="shrink-0">
<CategoryIcon category={row.category} />
</div>
);
}
if (row.account) {
return (
<div className="flex size-6 shrink-0 items-center justify-center rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-200">
<CreditCard className="size-3.5" />
</div>
);
}
if (row.labelColor) {
const colorClasses = getLabelColorClasses(row.labelColor);
return (
<div
className={cn(
'flex size-6 shrink-0 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<Tag className={cn('size-3.5', colorClasses.text)} />
</div>
);
}
return null;
}

View File

@ -0,0 +1,166 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { ChartConfig, ChartContainer } from '@/components/ui/chart';
import { useChartColors } from '@/hooks/use-chart-color-scheme';
import { useLocale } from '@/hooks/use-locale';
import { formatCompactNumber } from '@/utils/date';
import { __ } from '@/utils/i18n';
import {
Bar,
BarChart,
CartesianGrid,
Cell,
ReferenceLine,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
export interface AnalysisChartPoint {
key: string;
label: string;
amount: number;
}
interface AnalysisChartProps {
title: string;
data: AnalysisChartPoint[];
currency: string;
loading?: boolean;
scrollable?: boolean;
}
interface TooltipPayloadItem {
payload?: AnalysisChartPoint;
}
function CustomTooltip({
active,
payload,
currency,
}: {
active?: boolean;
payload?: TooltipPayloadItem[];
currency: string;
}) {
if (!active || !payload?.length) {
return null;
}
const point = payload[0].payload;
if (!point) {
return null;
}
return (
<div className="rounded-lg border border-border/50 bg-background px-3 py-2 text-xs shadow-xl">
<div className="mb-1 font-medium">{point.label}</div>
<AmountDisplay
amountInCents={point.amount}
currencyCode={currency}
showSign
className="font-mono font-medium tabular-nums"
/>
</div>
);
}
export function AnalysisChart({
title,
data,
currency,
loading,
scrollable,
}: AnalysisChartProps) {
const locale = useLocale();
const { cashflowIncomeColor, cashflowExpenseColor } = useChartColors();
const chartConfig: ChartConfig = {
amount: { label: __('Amount'), color: 'var(--color-chart-1)' },
};
const minChartWidth = scrollable ? data.length * 56 : undefined;
return (
<Card>
<CardHeader className="pb-4">
<CardTitle className="text-base">{title}</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="h-[300px] animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
) : data.length === 0 ? (
<div className="flex h-[300px] items-center justify-center text-sm text-muted-foreground">
{__('No transactions match these filters')}
</div>
) : (
<div className="overflow-x-auto">
<ChartContainer
config={chartConfig}
className="h-[300px] w-full"
style={
minChartWidth
? { minWidth: `${minChartWidth}px` }
: undefined
}
>
<BarChart accessibilityLayer data={data}>
<CartesianGrid
strokeDasharray="3 3"
vertical={false}
stroke="var(--color-border)"
opacity={0.3}
/>
<XAxis
dataKey="label"
tickLine={false}
tickMargin={10}
axisLine={false}
interval="preserveStartEnd"
/>
<YAxis
tickLine={false}
axisLine={false}
width={60}
tickFormatter={(value: number) =>
formatCompactNumber(
value / 100,
locale,
currency,
)
}
/>
<ReferenceLine
y={0}
stroke="var(--color-border)"
strokeDasharray="3 3"
/>
<Tooltip
content={
<CustomTooltip currency={currency} />
}
cursor={{
fill: 'var(--color-muted)',
opacity: 0.3,
}}
/>
<Bar dataKey="amount" radius={[4, 4, 0, 0]}>
{data.map((point) => (
<Cell
key={point.key}
fill={
point.amount >= 0
? cashflowIncomeColor
: cashflowExpenseColor
}
/>
))}
</Bar>
</BarChart>
</ChartContainer>
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,85 @@
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
import { type AnalysisSummary } from '@/hooks/use-analysis-data';
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
interface AnalysisSummaryCardsProps {
summary: AnalysisSummary;
currency: string;
loading?: boolean;
}
export function AnalysisSummaryCards({
summary,
currency,
loading,
}: AnalysisSummaryCardsProps) {
return (
<div className="grid grid-cols-2 gap-4 lg:grid-cols-4">
<SummaryCard label={__('Income')} loading={loading}>
<AmountDisplay
amountInCents={summary.income}
currencyCode={currency}
weight="semibold"
size="xl"
className="text-emerald-600 dark:text-emerald-400"
/>
</SummaryCard>
<SummaryCard label={__('Spent')} loading={loading}>
<AmountDisplay
amountInCents={summary.expense}
currencyCode={currency}
weight="semibold"
size="xl"
className="text-rose-600 dark:text-rose-400"
/>
</SummaryCard>
<SummaryCard label={__('Net')} loading={loading}>
<AmountDisplay
amountInCents={summary.net}
currencyCode={currency}
weight="semibold"
size="xl"
showSign
className={cn(
summary.net >= 0
? 'text-emerald-600 dark:text-emerald-400'
: 'text-rose-600 dark:text-rose-400',
)}
/>
</SummaryCard>
<SummaryCard label={__('Transactions')} loading={loading}>
<span className="text-xl font-semibold tabular-nums">
{summary.count.toLocaleString()}
</span>
</SummaryCard>
</div>
);
}
function SummaryCard({
label,
loading,
children,
}: {
label: string;
loading?: boolean;
children: React.ReactNode;
}) {
return (
<Card>
<CardContent className="flex flex-col gap-1 p-4">
<span className="text-xs text-muted-foreground">{label}</span>
{loading ? (
<div className="h-7 w-24 animate-pulse rounded bg-gray-200 dark:bg-gray-700" />
) : (
children
)}
</CardContent>
</Card>
);
}

View File

@ -37,6 +37,8 @@ interface TransactionFiltersProps {
isKeySet: boolean;
actions?: ReactNode;
hideAccountFilter?: boolean;
hideSearch?: boolean;
inlineCategoryLabel?: boolean;
}
export function TransactionFilters({
@ -47,16 +49,13 @@ export function TransactionFilters({
accounts,
actions,
hideAccountFilter = false,
hideSearch = false,
inlineCategoryLabel = false,
}: TransactionFiltersProps) {
const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const [searchText, setSearchText] = useState(filters.searchText);
const [creditorName, setCreditorName] = useState(filters.creditorName);
const [debtorName, setDebtorName] = useState(filters.debtorName);
const isUncategorizedSelected = filters.categoryIds.includes(
UNCATEGORIZED_CATEGORY_ID,
);
// Debounce text filter updates
useEffect(() => {
@ -101,7 +100,7 @@ export function TransactionFilters({
onFiltersChange({ ...filters, categoryIds: newCategoryIds });
}
function handleAccountToggle(accountId: number) {
function handleAccountToggle(accountId: string) {
const newAccountIds = filters.accountIds.includes(accountId)
? filters.accountIds.filter((id) => id !== accountId)
: [...filters.accountIds, accountId];
@ -140,22 +139,42 @@ export function TransactionFilters({
(filters.dateTo ? 1 : 0) +
(filters.amountMin !== null ? 1 : 0) +
(filters.amountMax !== null ? 1 : 0) +
filters.categoryIds.length +
filters.labelIds.length +
(filters.creditorName ? 1 : 0) +
(filters.debtorName ? 1 : 0) +
(inlineCategoryLabel
? 0
: filters.categoryIds.length + filters.labelIds.length) +
(hideAccountFilter ? 0 : filters.accountIds.length);
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<div className="flex w-full flex-row items-center gap-2">
<Input
placeholder={__('Search description or notes...')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="min-w-0 flex-1 md:min-w-[350px]"
/>
<div className="flex w-full flex-wrap items-center gap-2">
{!hideSearch && (
<Input
placeholder={__('Search description or notes...')}
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
className="min-w-0 flex-1 md:min-w-[350px]"
/>
)}
{inlineCategoryLabel && (
<>
<CategoryMultiSelect
categories={categories}
selectedIds={filters.categoryIds}
onToggle={handleCategoryToggle}
triggerClassName="w-[180px]"
/>
<LabelMultiSelect
labels={labels}
selectedIds={filters.labelIds}
onToggle={handleLabelToggle}
triggerClassName="w-[160px]"
/>
</>
)}
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverTrigger asChild>
@ -295,254 +314,37 @@ export function TransactionFilters({
</div>
</div>
<div className="space-y-2">
<FormLabel>{__('Categories')}</FormLabel>
<div className="pt-2">
<Popover
open={categoryDropdownOpen}
onOpenChange={
setCategoryDropdownOpen
}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{filters.categoryIds
.length > 0 ? (
<span className="truncate">
{
filters
.categoryIds
.length
}{' '}
selected
</span>
) : (
<span className="text-muted-foreground">
{__(
'Select categories...',
)}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput
placeholder={__(
'Search categories...',
)}
/>
<CommandList>
<CommandEmpty>
{__(
'No category found.',
)}
</CommandEmpty>
<CommandItem
onSelect={() =>
handleCategoryToggle(
UNCATEGORIZED_CATEGORY_ID,
)
}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isUncategorizedSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<Icons.HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
{__(
'Uncategorized',
)}
</div>
</CommandItem>
{categories.map(
(category) => {
const isSelected =
filters.categoryIds.includes(
category.id,
);
const colorClasses =
getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={
category.id
}
onSelect={() =>
handleCategoryToggle(
category.id,
)
}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<CategoryIcon
category={
category
}
size={
4
}
/>
</div>
<span>
{
category.name
}
</span>
</div>
</CommandItem>
);
},
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{!inlineCategoryLabel && (
<div className="space-y-2">
<FormLabel>
{__('Categories')}
</FormLabel>
<div className="pt-2">
<CategoryMultiSelect
categories={categories}
selectedIds={
filters.categoryIds
}
onToggle={handleCategoryToggle}
triggerClassName="w-full"
/>
</div>
</div>
</div>
)}
<div className="space-y-2">
<FormLabel>{__('Labels')}</FormLabel>
<div className="pt-2">
<Popover
open={labelDropdownOpen}
onOpenChange={setLabelDropdownOpen}
>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className="w-full justify-between"
>
{filters.labelIds.length >
0 ? (
<span className="truncate">
{
filters.labelIds
.length
}{' '}
selected
</span>
) : (
<span className="text-muted-foreground">
{__(
'Select labels...',
)}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent
className="w-full p-0"
align="start"
>
<Command>
<CommandInput
placeholder={__(
'Search labels...',
)}
/>
<CommandList>
<CommandEmpty>
{__(
'No labels found.',
)}
</CommandEmpty>
{labels.map((label) => {
const isSelected =
filters.labelIds.includes(
label.id,
);
const colorClasses =
getLabelColorClasses(
label.color,
);
return (
<CommandItem
key={
label.id
}
onSelect={() =>
handleLabelToggle(
label.id,
)
}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<Tag
className={cn(
'h-3 w-3',
colorClasses.text,
)}
/>
</div>
<span>
{
label.name
}
</span>
</div>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
{!inlineCategoryLabel && (
<div className="space-y-2">
<FormLabel>{__('Labels')}</FormLabel>
<div className="pt-2">
<LabelMultiSelect
labels={labels}
selectedIds={filters.labelIds}
onToggle={handleLabelToggle}
triggerClassName="w-full"
/>
</div>
</div>
</div>
)}
{!hideAccountFilter && (
<div className="space-y-2">
@ -604,4 +406,196 @@ export function TransactionFilters({
);
}
interface CategoryMultiSelectProps {
categories: Category[];
selectedIds: string[];
onToggle: (id: string) => void;
triggerClassName?: string;
}
function CategoryMultiSelect({
categories,
selectedIds,
onToggle,
triggerClassName,
}: CategoryMultiSelectProps) {
const [open, setOpen] = useState(false);
const isUncategorizedSelected = selectedIds.includes(
UNCATEGORIZED_CATEGORY_ID,
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn('justify-between', triggerClassName)}
>
{selectedIds.length > 0 ? (
<span className="truncate">
{selectedIds.length} {__('selected')}
</span>
) : (
<span className="text-muted-foreground">
{__('Categories')}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder={__('Search categories...')} />
<CommandList>
<CommandEmpty>{__('No category found.')}</CommandEmpty>
<CommandItem
onSelect={() => onToggle(UNCATEGORIZED_CATEGORY_ID)}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isUncategorizedSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<Icons.HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
{__('Uncategorized')}
</div>
</CommandItem>
{categories.map((category) => {
const isSelected = selectedIds.includes(
category.id,
);
const colorClasses = getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={category.id}
onSelect={() => onToggle(category.id)}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<CategoryIcon category={category} />
</div>
<span>{category.name}</span>
</div>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
interface LabelMultiSelectProps {
labels: Label[];
selectedIds: string[];
onToggle: (id: string) => void;
triggerClassName?: string;
}
function LabelMultiSelect({
labels,
selectedIds,
onToggle,
triggerClassName,
}: LabelMultiSelectProps) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
className={cn('justify-between', triggerClassName)}
>
{selectedIds.length > 0 ? (
<span className="truncate">
{selectedIds.length} {__('selected')}
</span>
) : (
<span className="text-muted-foreground">
{__('Labels')}
</span>
)}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0" align="start">
<Command>
<CommandInput placeholder={__('Search labels...')} />
<CommandList>
<CommandEmpty>{__('No labels found.')}</CommandEmpty>
{labels.map((label) => {
const isSelected = selectedIds.includes(label.id);
const colorClasses = getLabelColorClasses(
label.color,
);
return (
<CommandItem
key={label.id}
onSelect={() => onToggle(label.id)}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
<Tag
className={cn(
'h-3 w-3',
colorClasses.text,
)}
/>
</div>
<span>{label.name}</span>
</div>
</CommandItem>
);
})}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
const UNCATEGORIZED_CATEGORY_ID = 'uncategorized';

View File

@ -0,0 +1,109 @@
import { type TransactionFilters } from '@/types/transaction';
import { format } from 'date-fns';
import { useCallback, useEffect, useState } from 'react';
export const ANALYSIS_DIMENSIONS = [
'category',
'month',
'account',
'label',
] as const;
export type AnalysisDimension = (typeof ANALYSIS_DIMENSIONS)[number];
export interface AnalysisSummary {
income: number;
expense: number;
net: number;
count: number;
}
export interface AnalysisGroup {
key: string | null;
amount: number;
count: number;
}
export interface AnalysisData {
summary: AnalysisSummary;
groups: AnalysisGroup[];
isLoading: boolean;
}
const emptySummary: AnalysisSummary = {
income: 0,
expense: 0,
net: 0,
count: 0,
};
function buildQuery(
filters: TransactionFilters,
groupBy: AnalysisDimension,
): string {
const params = new URLSearchParams({ group_by: groupBy });
if (filters.dateFrom) {
params.set('date_from', format(filters.dateFrom, 'yyyy-MM-dd'));
}
if (filters.dateTo) {
params.set('date_to', format(filters.dateTo, 'yyyy-MM-dd'));
}
if (filters.amountMin !== null) {
params.set('amount_min', filters.amountMin.toString());
}
if (filters.amountMax !== null) {
params.set('amount_max', filters.amountMax.toString());
}
if (filters.categoryIds.length > 0) {
params.set('category_ids', filters.categoryIds.join(','));
}
if (filters.accountIds.length > 0) {
params.set('account_ids', filters.accountIds.join(','));
}
if (filters.labelIds.length > 0) {
params.set('label_ids', filters.labelIds.join(','));
}
if (filters.creditorName) {
params.set('creditor_name', filters.creditorName);
}
if (filters.debtorName) {
params.set('debtor_name', filters.debtorName);
}
if (filters.searchText) {
params.set('search', filters.searchText);
}
return params.toString();
}
export function useAnalysisData(
filters: TransactionFilters,
groupBy: AnalysisDimension,
): AnalysisData & { refetch: () => void } {
const [summary, setSummary] = useState<AnalysisSummary>(emptySummary);
const [groups, setGroups] = useState<AnalysisGroup[]>([]);
const [isLoading, setIsLoading] = useState(true);
const query = buildQuery(filters, groupBy);
const fetchData = useCallback(async () => {
setIsLoading(true);
try {
const response = await fetch(`/api/analysis?${query}`);
const data = await response.json();
setSummary(data.summary ?? emptySummary);
setGroups(data.groups ?? []);
} catch (error) {
console.error('Failed to fetch analysis data:', error);
} finally {
setIsLoading(false);
}
}, [query]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { summary, groups, isLoading, refetch: fetchData };
}

View File

@ -0,0 +1,276 @@
import {
AnalysisBreakdown,
type ResolvedRow,
} from '@/components/analysis/analysis-breakdown';
import {
AnalysisChart,
type AnalysisChartPoint,
} from '@/components/analysis/analysis-chart';
import { AnalysisSummaryCards } from '@/components/analysis/analysis-summary-cards';
import HeadingSmall from '@/components/heading-small';
import { TransactionFilters as TransactionFiltersComponent } from '@/components/transactions/transaction-filters';
import { Button } from '@/components/ui/button';
import {
ANALYSIS_DIMENSIONS,
type AnalysisDimension,
useAnalysisData,
} from '@/hooks/use-analysis-data';
import { useLocale } from '@/hooks/use-locale';
import AppSidebarLayout from '@/layouts/app/app-sidebar-layout';
import { cn } from '@/lib/utils';
import { analysis } from '@/routes';
import { type BreadcrumbItem } from '@/types';
import { type Account } from '@/types/account';
import { type Category } from '@/types/category';
import { type Label } from '@/types/label';
import { type TransactionFilters } from '@/types/transaction';
import { formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { Head, usePage } from '@inertiajs/react';
import { endOfMonth, parseISO, startOfMonth } from 'date-fns';
import { useMemo, useState } from 'react';
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Analysis',
href: analysis().url,
},
];
const emptyFilters: TransactionFilters = {
dateFrom: null,
dateTo: null,
amountMin: null,
amountMax: null,
categoryIds: [],
accountIds: [],
labelIds: [],
creditorName: '',
debtorName: '',
searchText: '',
};
const dimensionLabels: Record<AnalysisDimension, string> = {
category: __('Category'),
month: __('Month'),
account: __('Account'),
label: __('Label'),
};
const isTimeDimension = (dimension: AnalysisDimension): boolean =>
dimension === 'month';
interface Props {
categories: Category[];
accounts: Account[];
labels: Label[];
}
export default function AnalysisPage({ categories, accounts, labels }: Props) {
const locale = useLocale();
const { auth } = usePage<{
auth: { user: { currency_code: string } };
}>().props;
const currency = auth.user.currency_code;
const [filters, setFilters] = useState<TransactionFilters>(emptyFilters);
const [dimension, setDimension] = useState<AnalysisDimension>('category');
const { summary, groups, isLoading } = useAnalysisData(filters, dimension);
const categoriesById = useMemo(
() => new Map(categories.map((category) => [category.id, category])),
[categories],
);
const accountsById = useMemo(
() => new Map(accounts.map((account) => [account.id, account])),
[accounts],
);
const labelsById = useMemo(
() => new Map(labels.map((label) => [label.id, label])),
[labels],
);
const resolveLabel = (key: string | null): string => {
switch (dimension) {
case 'category':
return key
? (categoriesById.get(key)?.name ?? __('Uncategorized'))
: __('Uncategorized');
case 'account':
return key
? (accountsById.get(key)?.name ?? __('Unknown account'))
: __('No account');
case 'label':
return key
? (labelsById.get(key)?.name ?? __('Unknown label'))
: __('No label');
case 'month':
return key ? formatMonthFromYearMonth(key, locale) : '';
}
};
const rows: ResolvedRow[] = useMemo(
() =>
groups.map((group) => ({
key: group.key,
label: resolveLabel(group.key),
amount: group.amount,
count: group.count,
category: group.key ? categoriesById.get(group.key) : undefined,
account:
dimension === 'account' && group.key
? accountsById.get(group.key)
: undefined,
labelColor:
dimension === 'label' && group.key
? labelsById.get(group.key)?.color
: undefined,
})),
// eslint-disable-next-line react-hooks/exhaustive-deps
[groups, dimension, categoriesById, accountsById, labelsById, locale],
);
// Time dimensions read most naturally newest-first in the list.
const breakdownRows = useMemo(() => {
if (!isTimeDimension(dimension)) {
return rows;
}
return [...rows].sort((a, b) =>
(b.key ?? '').localeCompare(a.key ?? ''),
);
}, [rows, dimension]);
// The chart shows time ascending; categorical dimensions show the top buckets.
const chartData: AnalysisChartPoint[] = useMemo(() => {
const points = rows.map((row) => ({
key: row.key ?? '__null__',
label: row.label,
amount: row.amount,
}));
if (isTimeDimension(dimension)) {
return points.sort((a, b) => a.key.localeCompare(b.key));
}
return points.slice(0, 12);
}, [rows, dimension]);
function handleDrill(row: ResolvedRow) {
switch (dimension) {
case 'category':
setFilters((previous) => ({
...previous,
categoryIds: [row.key ?? 'uncategorized'],
}));
setDimension('month');
break;
case 'account':
if (!row.key) {
return;
}
setFilters((previous) => ({
...previous,
accountIds: previous.accountIds.includes(row.key!)
? previous.accountIds
: [...previous.accountIds, row.key!],
}));
setDimension('category');
break;
case 'label':
if (!row.key) {
return;
}
setFilters((previous) => ({
...previous,
labelIds: previous.labelIds.includes(row.key!)
? previous.labelIds
: [...previous.labelIds, row.key!],
}));
setDimension('category');
break;
case 'month':
if (!row.key) {
return;
}
setFilters((previous) => ({
...previous,
dateFrom: startOfMonth(parseISO(`${row.key}-01`)),
dateTo: endOfMonth(parseISO(`${row.key}-01`)),
}));
setDimension('category');
break;
}
}
const breakdownTitle = __('Breakdown by :dimension', {
dimension: dimensionLabels[dimension].toLowerCase(),
});
return (
<AppSidebarLayout breadcrumbs={breadcrumbs}>
<Head title={__('Analysis')} />
<div className="space-y-6 p-6">
<HeadingSmall
title={__('Analysis')}
description={__(
'Drill into your income and expenses across any dimension',
)}
/>
<TransactionFiltersComponent
filters={filters}
onFiltersChange={setFilters}
categories={categories}
labels={labels}
accounts={accounts}
isKeySet={true}
hideSearch
inlineCategoryLabel
/>
<AnalysisSummaryCards
summary={summary}
currency={currency}
loading={isLoading}
/>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm text-muted-foreground">
{__('Group by')}
</span>
{ANALYSIS_DIMENSIONS.map((option) => (
<Button
key={option}
size="sm"
variant={
dimension === option ? 'default' : 'outline'
}
onClick={() => setDimension(option)}
className={cn(dimension === option && 'shadow-sm')}
>
{dimensionLabels[option]}
</Button>
))}
</div>
<AnalysisChart
title={breakdownTitle}
data={chartData}
currency={currency}
loading={isLoading}
scrollable={dimension === 'month'}
/>
<AnalysisBreakdown
title={breakdownTitle}
rows={breakdownRows}
currency={currency}
loading={isLoading}
onDrill={handleDrill}
/>
</div>
</AppSidebarLayout>
);
}

View File

@ -1,9 +1,10 @@
import { index as accountsIndex } from '@/actions/App/Http/Controllers/AccountController';
import { index as budgetsIndex } from '@/actions/App/Http/Controllers/BudgetController';
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { cashflow, dashboard } from '@/routes';
import { analysis, cashflow, dashboard } from '@/routes';
import { Features, NavItem } from '@/types';
import {
ChartColumnBig,
CreditCard,
LayoutGrid,
PiggyBank,
@ -18,6 +19,7 @@ const mobileLabels: Record<string, Record<string, string>> = {
accounts: 'Accounts',
transactions: 'Movements',
budgets: 'Budget',
analysis: 'Analysis',
},
es: {
dashboard: 'Inicio',
@ -25,6 +27,7 @@ const mobileLabels: Record<string, Record<string, string>> = {
accounts: 'Cuentas',
transactions: 'Movim.',
budgets: 'Presup.',
analysis: 'Análisis',
},
};
@ -75,6 +78,13 @@ export function getMainNavItems(features: Features, locale: string): NavItem[] {
href: budgetsIndex(),
icon: PiggyBank,
},
{
type: 'nav-item',
title: 'Analysis',
mobileTitle: getMobileLabel('analysis', locale),
href: analysis(),
icon: ChartColumnBig,
},
);
return items;

View File

@ -2,6 +2,7 @@
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\Api\AccountController;
use App\Http\Controllers\Api\AnalysisController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
@ -56,4 +57,7 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('trend', [CashflowAnalyticsController::class, 'trend']);
Route::get('breakdown', [CashflowAnalyticsController::class, 'breakdown']);
});
// Expense / income analysis
Route::get('analysis', [AnalysisController::class, 'index'])->name('api.analysis.index');
});

View File

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\AccountController;
use App\Http\Controllers\AnalysisController;
use App\Http\Controllers\BudgetController;
use App\Http\Controllers\CashflowController;
use App\Http\Controllers\DashboardController;
@ -108,6 +109,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
Route::get('dashboard', DashboardController::class)->name('dashboard');
Route::get('cashflow', CashflowController::class)->name('cashflow');
Route::get('analysis', AnalysisController::class)->name('analysis');
Route::get('accounts', [AccountController::class, 'index'])->name('accounts.list');
Route::get('accounts/{account}', [AccountController::class, 'show'])->name('accounts.show');

View File

@ -0,0 +1,200 @@
<?php
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\ExchangeRate;
use App\Models\Label;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Http;
beforeEach(function () {
Http::fake();
$this->user = User::factory()->create(['currency_code' => 'USD']);
$this->actingAs($this->user);
$this->account = Account::factory()->create([
'user_id' => $this->user->id,
'currency_code' => 'USD',
]);
});
function makeTransaction(array $attributes = []): Transaction
{
return Transaction::factory()->create(array_merge([
'user_id' => test()->user->id,
'account_id' => test()->account->id,
'currency_code' => 'USD',
'transaction_date' => now(),
], $attributes));
}
test('analysis endpoint requires authentication', function () {
auth()->logout();
$this->getJson('/api/analysis?group_by=category')->assertUnauthorized();
});
test('analysis responses are not cached between users', function () {
$this->getJson('/api/analysis?group_by=category')
->assertOk()
->assertHeader('Cache-Control', 'no-store, private');
});
test('group_by is required and constrained', function () {
$this->getJson('/api/analysis')->assertStatus(422);
$this->getJson('/api/analysis?group_by=bogus')->assertStatus(422);
});
test('summary splits income, expense, net and count by sign', function () {
makeTransaction(['amount' => 100000]);
makeTransaction(['amount' => 30000]);
makeTransaction(['amount' => -40000]);
$this->getJson('/api/analysis?group_by=category')
->assertOk()
->assertJson([
'summary' => [
'income' => 130000,
'expense' => 40000,
'net' => 90000,
'count' => 3,
],
]);
});
test('groups by category sum signed amounts and sort by magnitude', function () {
$food = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
$rent = Category::factory()->create([
'user_id' => $this->user->id,
'type' => CategoryType::Expense,
]);
makeTransaction(['category_id' => $food->id, 'amount' => -2000]);
makeTransaction(['category_id' => $food->id, 'amount' => -3000]);
makeTransaction(['category_id' => $rent->id, 'amount' => -90000]);
$groups = $this->getJson('/api/analysis?group_by=category')
->assertOk()
->json('groups');
expect($groups)->toHaveCount(2);
expect($groups[0])->toMatchArray(['key' => $rent->id, 'amount' => -90000, 'count' => 1]);
expect($groups[1])->toMatchArray(['key' => $food->id, 'amount' => -5000, 'count' => 2]);
});
test('uncategorized transactions group under a null key', function () {
makeTransaction(['category_id' => null, 'amount' => -1500]);
$groups = $this->getJson('/api/analysis?group_by=category')
->assertOk()
->json('groups');
expect($groups)->toHaveCount(1);
expect($groups[0]['key'])->toBeNull();
expect($groups[0]['amount'])->toBe(-1500);
});
test('groups by month bucket transactions by year-month', function () {
makeTransaction(['amount' => -1000, 'transaction_date' => '2026-01-15']);
makeTransaction(['amount' => -2000, 'transaction_date' => '2026-01-20']);
makeTransaction(['amount' => -5000, 'transaction_date' => '2026-02-10']);
$groups = collect($this->getJson('/api/analysis?group_by=month')
->assertOk()
->json('groups'))
->keyBy('key');
expect($groups['2026-01']['amount'])->toBe(-3000);
expect($groups['2026-01']['count'])->toBe(2);
expect($groups['2026-02']['amount'])->toBe(-5000);
});
test('groups by label count a transaction once per label and bucket unlabeled separately', function () {
$trip = Label::factory()->create(['user_id' => $this->user->id]);
$work = Label::factory()->create(['user_id' => $this->user->id]);
$multi = makeTransaction(['amount' => -6000]);
$multi->labels()->sync([$trip->id, $work->id]);
makeTransaction(['amount' => -1000]); // no label
$groups = collect($this->getJson('/api/analysis?group_by=label')
->assertOk()
->json('groups'))
->keyBy(fn (array $group): string => $group['key'] ?? '__null__');
expect($groups[$trip->id]['amount'])->toBe(-6000);
expect($groups[$work->id]['amount'])->toBe(-6000);
expect($groups['__null__']['amount'])->toBe(-1000);
});
test('filters restrict the analyzed transactions', function () {
$keep = Category::factory()->create(['user_id' => $this->user->id]);
$drop = Category::factory()->create(['user_id' => $this->user->id]);
makeTransaction(['category_id' => $keep->id, 'amount' => -1000]);
makeTransaction(['category_id' => $drop->id, 'amount' => -9000]);
$response = $this->getJson('/api/analysis?'.http_build_query([
'group_by' => 'category',
'category_ids' => $keep->id,
]))->assertOk();
expect($response->json('summary.expense'))->toBe(1000);
expect($response->json('groups'))->toHaveCount(1);
expect($response->json('groups.0.key'))->toBe($keep->id);
});
test('category and label filters combine with OR', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
makeTransaction(['category_id' => $category->id, 'amount' => -1000]);
$labelled = makeTransaction(['category_id' => null, 'amount' => -2000]);
$labelled->labels()->sync([$label->id]);
makeTransaction(['category_id' => null, 'amount' => -9000]); // matches neither
$response = $this->getJson('/api/analysis?'.http_build_query([
'group_by' => 'category',
'category_ids' => $category->id,
'label_ids' => $label->id,
]))->assertOk();
expect($response->json('summary.expense'))->toBe(3000);
expect($response->json('summary.count'))->toBe(2);
});
test('foreign currency amounts are converted to the user currency', function () {
$date = '2026-03-10';
$eurAccount = Account::factory()->create([
'user_id' => $this->user->id,
'currency_code' => 'EUR',
]);
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => $date,
'rates' => ['eur' => 0.80],
]);
// 8000 EUR cents / 0.80 = 10000 USD cents
makeTransaction([
'account_id' => $eurAccount->id,
'currency_code' => 'EUR',
'amount' => -8000,
'transaction_date' => $date,
]);
$this->getJson('/api/analysis?group_by=category')
->assertOk()
->assertJson(['summary' => ['expense' => 10000]]);
});

View File

@ -191,6 +191,42 @@ test('filter by label', function () {
);
});
test('category and label filters combine with OR', function () {
$category = Category::factory()->create(['user_id' => $this->user->id]);
$label = Label::factory()->create(['user_id' => $this->user->id]);
// Matches by category only
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => $category->id,
]);
// Matches by label only (different category)
$txWithLabel = Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$txWithLabel->labels()->attach($label->id);
// Matches neither
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,
'account_id' => $this->account->id,
'category_id' => null,
]);
$response = actingAs($this->user)->get(route('transactions.index', [
'category_ids' => $category->id,
'label_ids' => $label->id,
]));
$response->assertInertia(fn ($page) => $page
->has('transactions.data', 2)
);
});
test('search matches description', function () {
Transaction::factory()->plaintext()->create([
'user_id' => $this->user->id,