feat(analysis): per-category 12-month spending drawer (#519)

## Summary

Adds a **category-analysis drawer** so you can see how much you spend on
a single category, month by month, over the last 12 months — answering
"am I spending more or less on food delivery?".

Reachable via an **Analyze** button on three cards:
- Dashboard → Top spending categories
- Cashflow → Income Sources
- Cashflow → Expense Categories

Each button opens a shared drawer with a full-width category chooser
(all categories) and a 12-month stacked bar chart.

## Chart semantics
- X = last 12 rolling months (gaps filled with zero). Y = user display
currency.
- Stacked segments = the chosen category's immediate children
(grandchildren rolled in) + a **Direct** segment (spend on the parent
itself) + an **Other** segment (children beyond the top 6, ranked by
total).
- A leaf category renders a single series named after the category.
- Amounts convert to the user currency and **net per month**, oriented
by category type so the expected direction reads as a positive bar; an
against-grain month (e.g. refunds > spend) dips below zero.
- Colors use the theme-aware chart palette (same as the net-worth
chart).

## Summary stats
Two glowing cards above the chart: **Monthly average** and **Trend**
(recent 6-month average vs earlier 6-month average; null when there's no
earlier baseline).

## Persistence
Each widget remembers its own chosen category in `localStorage`
(category only), prefilling the first category of its list otherwise; a
remembered-but-deleted category falls back gracefully.

## Tests
- 11 Pest feature tests (rollup, Direct/Other, net-with-refunds,
12-month window, currency conversion, income orientation, summary
average + trend).
- 5 vitest tests for the prefill/persistence precedence.
- es.json keys added.
This commit is contained in:
Víctor Falcón 2026-06-11 09:52:53 +02:00 committed by GitHub
parent 49acc8a884
commit 9c3c4d573e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 991 additions and 11 deletions

View File

@ -0,0 +1,307 @@
<?php
namespace App\Http\Controllers\Api;
use App\Enums\CategoryType;
use App\Http\Controllers\Controller;
use App\Models\Category;
use App\Models\Transaction;
use App\Services\ExchangeRateService;
use Carbon\Carbon;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
class CategoryMonthlyBreakdownController extends Controller
{
/**
* The rolling window shown on the chart, in months (including the current).
*/
private const MONTHS = 12;
/**
* The richest children get their own stacked segment; everything past this
* folds into a single "Other" segment so the stack stays legible.
*/
private const TOP_CHILDREN = 6;
public function __construct(
private ExchangeRateService $exchangeRateService,
) {}
public function __invoke(Request $request, Category $category): JsonResponse
{
$user = $request->user();
abort_unless($category->user_id === $user->id, 403);
$currency = $user->currency_code;
$start = Carbon::now()->startOfMonth()->subMonths(self::MONTHS - 1);
$parentMap = Category::query()
->where('user_id', $user->id)
->pluck('parent_id', 'id')
->all();
$children = Category::query()
->where('user_id', $user->id)
->where('parent_id', $category->id)
->orderBy('name')
->get()
->keyBy('id');
$subtreeIds = array_values(array_filter(
array_keys($parentMap),
fn (string $id): bool => $this->belongsToSubtree($id, $category->id, $parentMap),
));
$transactions = Transaction::query()
->where('user_id', $user->id)
->whereIn('category_id', $subtreeIds)
->where('transaction_date', '>=', $start->toDateString())
->with('account')
->get();
$this->preloadExchangeRates($transactions, $currency);
// Outflow categories store spending as negative amounts; flip the sign so
// the expected direction reads as a positive bar. A transaction that runs
// against the grain (a refund on an expense category) then nets the bar
// down and can dip below zero, which is truthful.
$orientation = $category->type === CategoryType::Income ? 1 : -1;
$hasChildren = $children->isNotEmpty();
$buckets = [];
$childTotals = [];
$directTotal = 0;
foreach ($transactions as $transaction) {
$amount = $this->convertTransactionAmount($transaction, $currency) * $orientation;
$monthKey = $transaction->transaction_date->format('Y-m');
$segment = $this->segmentFor($transaction->category_id, $category->id, $parentMap, $hasChildren);
$buckets[$monthKey][$segment] = ($buckets[$monthKey][$segment] ?? 0) + $amount;
if ($segment === 'direct') {
$directTotal += $amount;
} elseif ($hasChildren) {
$childTotals[$segment] = ($childTotals[$segment] ?? 0) + $amount;
}
}
$series = $this->buildSeries($category, $children, $childTotals, $directTotal, $hasChildren);
$months = $this->buildMonths($start, $buckets, $series);
return response()
->json([
'currency' => $currency,
'category' => ['id' => $category->id, 'name' => $category->name],
'series' => $series,
'months' => $months,
'summary' => $this->summarize($months),
])
->header('Cache-Control', 'no-store, private');
}
/**
* Headline figures for the window: the average spent per month and the
* trend, measured as the change between the average of the most recent half
* and the average of the earlier half. The trend is null when the earlier
* half is empty, since there is no baseline to compare against.
*
* @param array<int, array<string, int|string>> $months
* @return array{average_per_month: int, trend_percentage: float|null}
*/
private function summarize(array $months): array
{
$totals = array_map(function (array $point): int {
$total = 0;
foreach ($point as $key => $value) {
if ($key !== 'key') {
$total += (int) $value;
}
}
return $total;
}, $months);
$count = count($totals);
$half = intdiv($count, 2);
$earlier = array_slice($totals, 0, $half);
$recent = array_slice($totals, $count - $half);
$earlierAverage = $earlier === [] ? 0 : array_sum($earlier) / count($earlier);
$recentAverage = $recent === [] ? 0 : array_sum($recent) / count($recent);
return [
'average_per_month' => $count > 0 ? (int) round(array_sum($totals) / $count) : 0,
'trend_percentage' => $earlierAverage != 0
? round((($recentAverage - $earlierAverage) / abs($earlierAverage)) * 100, 1)
: null,
];
}
/**
* Whether a category sits within the chosen category's subtree (itself or a
* descendant, bounded by the tree's maximum depth).
*
* @param array<string, ?string> $parentMap
*/
private function belongsToSubtree(string $categoryId, string $rootId, array $parentMap): bool
{
$current = $categoryId;
$guard = 0;
while ($current !== null && $guard++ <= Category::MAX_DEPTH) {
if ($current === $rootId) {
return true;
}
$current = $parentMap[$current] ?? null;
}
return false;
}
/**
* Resolve the stacked segment a transaction's category contributes to: the
* chosen category's own spend ("direct"), or the immediate child whose
* subtree the category lives in. A leaf category has no children, so all of
* its spend collapses onto a single segment keyed by the category itself.
*
* @param array<string, ?string> $parentMap
*/
private function segmentFor(?string $categoryId, string $rootId, array $parentMap, bool $hasChildren): string
{
if (! $hasChildren) {
return $rootId;
}
if ($categoryId === null || $categoryId === $rootId) {
return 'direct';
}
$current = $categoryId;
$guard = 0;
while ($current !== null && $guard++ <= Category::MAX_DEPTH) {
$parent = $parentMap[$current] ?? null;
if ($parent === $rootId) {
return $current;
}
$current = $parent;
}
return 'direct';
}
/**
* Order the segments richest-first, cap the children at the top N, and fold
* the remainder into an "Other" segment. A leaf category yields a single
* segment named after the category; a parent yields its children plus the
* optional "Other" and "Direct" segments.
*
* @param Collection<string, Category> $children
* @param array<string, int> $childTotals
* @return array<int, array{key: string, label: string}>
*/
private function buildSeries(Category $category, Collection $children, array $childTotals, int $directTotal, bool $hasChildren): array
{
if (! $hasChildren) {
return [['key' => $category->id, 'label' => $category->name]];
}
$ranked = collect($childTotals)
->sortByDesc(fn (int $total): int => $total)
->keys()
->all();
$series = [];
foreach (array_slice($ranked, 0, self::TOP_CHILDREN) as $childId) {
$series[] = ['key' => $childId, 'label' => $children->get($childId)->name];
}
if (count($ranked) > self::TOP_CHILDREN) {
$series[] = ['key' => 'other', 'label' => __('Other')];
}
if ($directTotal !== 0) {
$series[] = ['key' => 'direct', 'label' => __('Direct')];
}
return $series;
}
/**
* Build a point per month across the whole window, filling gaps with zero so
* the trend is never broken. Segments outside the kept set (overflow
* children) are summed into the "Other" segment.
*
* @param array<string, array<string, int>> $buckets
* @param array<int, array{key: string, label: string}> $series
* @return array<int, array<string, int|string>>
*/
private function buildMonths(Carbon $start, array $buckets, array $series): array
{
$seriesKeys = array_column($series, 'key');
$seriesKeySet = array_flip($seriesKeys);
$hasOther = isset($seriesKeySet['other']);
$months = [];
$cursor = $start->copy();
for ($index = 0; $index < self::MONTHS; $index++) {
$monthKey = $cursor->format('Y-m');
$point = ['key' => $monthKey];
foreach ($seriesKeys as $seriesKey) {
$point[$seriesKey] = 0;
}
foreach (($buckets[$monthKey] ?? []) as $segment => $amount) {
$target = isset($seriesKeySet[$segment]) ? $segment : ($hasOther ? 'other' : null);
if ($target !== null) {
$point[$target] += $amount;
}
}
$months[] = $point;
$cursor->addMonth();
}
return $months;
}
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(),
);
}
/**
* @param Collection<int, Transaction> $transactions
*/
private function preloadExchangeRates(Collection $transactions, string $currency): void
{
$dates = $transactions
->filter(fn (Transaction $transaction): bool => strcasecmp($transaction->currency_code ?: $transaction->account?->currency_code ?: $currency, $currency) !== 0)
->map(fn (Transaction $transaction): string => $transaction->transaction_date->toDateString())
->unique()
->values();
if ($dates->isEmpty()) {
return;
}
$this->exchangeRateService->preloadRates($currency, $dates);
}
}

View File

@ -1,4 +1,13 @@
{
"Analyze": "Analizar",
"Monthly average": "Media mensual",
"Trend": "Tendencia",
"Not enough history": "Historial insuficiente",
"vs. previous 6 months": "vs. 6 meses anteriores",
"Category analysis": "Análisis de categoría",
"How much you spent on this category each month over the last 12 months.": "Cuánto gastaste en esta categoría cada mes durante los últimos 12 meses.",
"Pick a category to see its monthly spending.": "Elige una categoría para ver su gasto mensual.",
"No spending in the last 12 months.": "Sin gastos en los últimos 12 meses.",
"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.",

View File

@ -1,4 +1,5 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { CategoryAnalysisButton } from '@/components/categories/category-analysis-button';
import {
CategoryBreakdownRow,
type CategoryBreakdownAdapter,
@ -176,18 +177,25 @@ export function BreakdownCard({
return (
<Card>
<CardHeader className="gap-1 pb-4">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{title}</CardTitle>
<AmountDisplay
amountInCents={data.total}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="semibold"
highlightPositive
<div className="flex items-start justify-between gap-2">
<div className="flex flex-col gap-1">
<CardTitle className="text-base">{title}</CardTitle>
<CardDescription>{description}</CardDescription>
<AmountDisplay
amountInCents={data.total}
currencyCode={currency}
minimumFractionDigits={0}
maximumFractionDigits={0}
weight="semibold"
highlightPositive
className="text-lg"
/>
</div>
<CategoryAnalysisButton
widgetKey={`cashflow-${type}`}
firstCategoryId={data.data[0]?.category_id ?? null}
/>
</div>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2.5">

View File

@ -0,0 +1,39 @@
import { CategoryAnalysisDrawer } from '@/components/categories/category-analysis-drawer';
import { Button } from '@/components/ui/button';
import { __ } from '@/utils/i18n';
import { ChartColumnBig } from 'lucide-react';
import { useState } from 'react';
interface CategoryAnalysisButtonProps {
/** Distinct localStorage slot so each widget remembers its own category. */
widgetKey: string;
/** Category prefilled the first time this widget opens the drawer. */
firstCategoryId?: string | null;
}
export function CategoryAnalysisButton({
widgetKey,
firstCategoryId,
}: CategoryAnalysisButtonProps) {
const [open, setOpen] = useState(false);
return (
<>
<Button
variant="ghost"
size="sm"
className="gap-1.5 text-muted-foreground"
onClick={() => setOpen(true)}
>
<ChartColumnBig className="size-4" />
{__('Analyze')}
</Button>
<CategoryAnalysisDrawer
open={open}
onOpenChange={setOpen}
widgetKey={widgetKey}
firstCategoryId={firstCategoryId}
/>
</>
);
}

View File

@ -0,0 +1,73 @@
import {
CATEGORY_ANALYSIS_STORAGE_PREFIX,
resolveInitialCategory,
} from '@/components/categories/category-analysis-drawer';
import { type Category } from '@/types/category';
import { beforeEach, describe, expect, it } from 'vitest';
function category(id: string): Category {
return {
id,
name: id,
icon: 'HelpCircle',
color: 'gray',
type: 'expense',
cashflow_direction: 'outflow',
parent_id: null,
} as Category;
}
const categories = [category('a'), category('b'), category('c')];
function installMemoryStorage(): void {
const store = new Map<string, string>();
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: {
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(),
},
});
}
describe('resolveInitialCategory', () => {
beforeEach(() => {
installMemoryStorage();
});
it('prefers a stored category that still exists', () => {
localStorage.setItem(`${CATEGORY_ANALYSIS_STORAGE_PREFIX}widget`, 'b');
expect(resolveInitialCategory('widget', 'a', categories)).toBe('b');
});
it('falls back to the first category when nothing is stored', () => {
expect(resolveInitialCategory('widget', 'a', categories)).toBe('a');
});
it('ignores a stored category that no longer exists', () => {
localStorage.setItem(
`${CATEGORY_ANALYSIS_STORAGE_PREFIX}widget`,
'deleted',
);
expect(resolveInitialCategory('widget', 'c', categories)).toBe('c');
});
it('remembers each widget independently', () => {
localStorage.setItem(`${CATEGORY_ANALYSIS_STORAGE_PREFIX}left`, 'a');
localStorage.setItem(`${CATEGORY_ANALYSIS_STORAGE_PREFIX}right`, 'c');
expect(resolveInitialCategory('left', 'b', categories)).toBe('a');
expect(resolveInitialCategory('right', 'b', categories)).toBe('c');
});
it('returns null when neither a stored nor a first category is valid', () => {
expect(resolveInitialCategory('widget', null, categories)).toBeNull();
expect(
resolveInitialCategory('widget', 'missing', categories),
).toBeNull();
});
});

View File

@ -0,0 +1,329 @@
import { CategoryCombobox } from '@/components/shared/category-combobox';
import { AmountDisplay } from '@/components/ui/amount-display';
import { Card, CardContent } from '@/components/ui/card';
import { ChartConfig } from '@/components/ui/chart';
import {
Drawer,
DrawerContent,
DrawerDescription,
DrawerHeader,
DrawerTitle,
} from '@/components/ui/drawer';
import { StackedBarChart } from '@/components/ui/stacked-bar-chart';
import { useLocale } from '@/hooks/use-locale';
import { cn } from '@/lib/utils';
import { SharedData } from '@/types';
import { type Category } from '@/types/category';
import { formatCurrency } from '@/utils/currency';
import { formatMonthFromYearMonth } from '@/utils/date';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { Minus, TrendingDown, TrendingUp } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export const CATEGORY_ANALYSIS_STORAGE_PREFIX = 'wm.category-analysis.';
interface BreakdownSeries {
key: string;
label: string;
}
interface MonthPoint {
key: string;
[seriesKey: string]: number | string;
}
interface BreakdownSummary {
average_per_month: number;
trend_percentage: number | null;
}
interface BreakdownData {
currency: string;
category: { id: string; name: string };
series: BreakdownSeries[];
months: MonthPoint[];
summary: BreakdownSummary;
}
interface CategoryAnalysisDrawerProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Distinct localStorage slot so each widget remembers its own category. */
widgetKey: string;
/** Category prefilled the first time the widget is opened. */
firstCategoryId?: string | null;
}
/**
* Resolves the category to show when the drawer opens: the one this widget
* remembered last, falling back to the widget's first category when nothing
* valid is stored (a remembered category that was since deleted is ignored).
*/
export function resolveInitialCategory(
widgetKey: string,
firstCategoryId: string | null | undefined,
categories: Category[],
): string | null {
const stored = localStorage.getItem(
`${CATEGORY_ANALYSIS_STORAGE_PREFIX}${widgetKey}`,
);
if (stored && categories.some((category) => category.id === stored)) {
return stored;
}
if (firstCategoryId && categories.some((c) => c.id === firstCategoryId)) {
return firstCategoryId;
}
return null;
}
export function CategoryAnalysisDrawer({
open,
onOpenChange,
widgetKey,
firstCategoryId,
}: CategoryAnalysisDrawerProps) {
const locale = useLocale();
const { categories = [] } = usePage<
SharedData & { categories: Category[] }
>().props;
const [categoryId, setCategoryId] = useState<string | null>(null);
const [data, setData] = useState<BreakdownData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (open) {
setCategoryId(
resolveInitialCategory(widgetKey, firstCategoryId, categories),
);
}
// Re-resolve only when the drawer is (re)opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, widgetKey, firstCategoryId]);
const selectCategory = useCallback(
(next: string) => {
if (next === 'null') {
return;
}
setCategoryId(next);
localStorage.setItem(
`${CATEGORY_ANALYSIS_STORAGE_PREFIX}${widgetKey}`,
next,
);
},
[widgetKey],
);
useEffect(() => {
if (!open || !categoryId) {
return;
}
let active = true;
setIsLoading(true);
setError(null);
fetch(`/api/categories/${categoryId}/monthly-breakdown`, {
headers: { Accept: 'application/json' },
})
.then((response) => {
if (!response.ok) {
throw new Error('Request failed');
}
return response.json();
})
.then((json: BreakdownData) => {
if (active) {
setData(json);
}
})
.catch(() => {
if (active) {
setError(
__('Could not load the analysis. Please try again.'),
);
}
})
.finally(() => {
if (active) {
setIsLoading(false);
}
});
return () => {
active = false;
};
}, [open, categoryId]);
const currency = data?.currency ?? '';
const dataKeys = useMemo(
() => data?.series.map((series) => series.key) ?? [],
[data],
);
const config = useMemo<ChartConfig>(
() =>
Object.fromEntries(
(data?.series ?? []).map((series) => [
series.key,
{ label: series.label },
]),
),
[data],
);
const valueFormatter = useCallback(
(value: number) => formatCurrency(value, currency, locale, 0, 0),
[currency, locale],
);
const xAxisFormatter = useCallback(
(value: string) => formatMonthFromYearMonth(value, locale),
[locale],
);
const hasData = data !== null && data.series.length > 0;
return (
<Drawer open={open} onOpenChange={onOpenChange}>
<DrawerContent className="h-[90vh] data-[vaul-drawer-direction=bottom]:max-h-[90vh]">
<div className="mx-auto flex w-full max-w-3xl flex-col gap-6 overflow-y-auto p-6">
<DrawerHeader className="gap-2 px-0 text-center">
<DrawerTitle className="text-xl">
{__('Category analysis')}
</DrawerTitle>
<DrawerDescription className="text-sm text-pretty">
{__(
'How much you spent on this category each month over the last 12 months.',
)}
</DrawerDescription>
</DrawerHeader>
<CategoryCombobox
value={categoryId}
onValueChange={selectCategory}
categories={categories}
showUncategorized={false}
placeholder={__('Select a category')}
/>
{isLoading && (
<div className="h-[320px] w-full animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700" />
)}
{!isLoading && error && (
<p className="py-12 text-center text-sm text-muted-foreground">
{error}
</p>
)}
{!isLoading && !error && !categoryId && (
<p className="py-12 text-center text-sm text-muted-foreground">
{__('Pick a category to see its monthly spending.')}
</p>
)}
{!isLoading && !error && categoryId && !hasData && (
<p className="py-12 text-center text-sm text-muted-foreground">
{__('No spending in the last 12 months.')}
</p>
)}
{!isLoading && !error && hasData && (
<div className="flex flex-col gap-6">
<SummaryCards
summary={data.summary}
currency={currency}
/>
<Card className="py-4">
<CardContent>
<StackedBarChart
data={data.months}
dataKeys={dataKeys}
config={config}
xAxisKey="key"
xAxisFormatter={xAxisFormatter}
valueFormatter={valueFormatter}
displayCurrency={currency}
className="h-[320px] w-full"
/>
</CardContent>
</Card>
</div>
)}
</div>
</DrawerContent>
</Drawer>
);
}
function SummaryCards({
summary,
currency,
}: {
summary: BreakdownSummary;
currency: string;
}) {
const trend = summary.trend_percentage;
const TrendIcon =
trend === null || trend === 0
? Minus
: trend > 0
? TrendingUp
: TrendingDown;
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Card className="gap-0 py-4">
<CardContent className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
{__('Monthly average')}
</p>
<AmountDisplay
amountInCents={summary.average_per_month}
currencyCode={currency}
className="text-lg font-semibold tabular-nums"
/>
</CardContent>
</Card>
<Card className="gap-0 py-4">
<CardContent className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
{__('Trend')}
</p>
{trend === null ? (
<p className="text-lg font-semibold text-muted-foreground">
{__('Not enough history')}
</p>
) : (
<div className="flex items-center gap-1.5">
<TrendIcon
className={cn(
'size-4',
trend > 0 && 'text-amber-500',
trend < 0 && 'text-emerald-500',
trend === 0 && 'text-muted-foreground',
)}
/>
<span className="text-lg font-semibold tabular-nums">
{trend > 0 ? '+' : ''}
{trend}%
</span>
<span className="text-xs text-muted-foreground">
{__('vs. previous 6 months')}
</span>
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -1,4 +1,5 @@
import { index as transactionsIndex } from '@/actions/App/Http/Controllers/TransactionController';
import { CategoryAnalysisButton } from '@/components/categories/category-analysis-button';
import {
CategoryBreakdownRow,
type CategoryBreakdownAdapter,
@ -165,7 +166,17 @@ export function TopCategoriesCard({
return (
<Card className="w-full">
<CardHeader className="gap-2">
<CardTitle>{__('Top spending categories')}</CardTitle>
<div className="flex items-start justify-between gap-2">
<CardTitle>{__('Top spending categories')}</CardTitle>
<CategoryAnalysisButton
widgetKey="dashboard-top-categories"
firstCategoryId={
categories[0]?.category?.id ??
categories[0]?.category_id ??
null
}
/>
</div>
<CardDescription>{__('on the last 30 days')}</CardDescription>
</CardHeader>
<CardContent>

View File

@ -3,6 +3,7 @@
use App\Http\Controllers\AccountBalanceController;
use App\Http\Controllers\Api\AccountController;
use App\Http\Controllers\Api\CashflowAnalyticsController;
use App\Http\Controllers\Api\CategoryMonthlyBreakdownController;
use App\Http\Controllers\Api\DashboardAnalyticsController;
use App\Http\Controllers\Api\ImportDataController;
use App\Http\Controllers\Api\SavedFilterController;
@ -30,6 +31,9 @@ Route::middleware(['web', 'auth'])->group(function () {
Route::get('transactions/analysis', [TransactionAnalysisController::class, 'summary'])->name('api.transactions.analysis');
Route::patch('transactions/bulk', [TransactionController::class, 'bulkUpdate'])->name('api.transactions.bulk-update');
// Category analysis
Route::get('categories/{category}/monthly-breakdown', CategoryMonthlyBreakdownController::class)->name('api.categories.monthly-breakdown');
// Accounts
Route::get('accounts', [AccountController::class, 'index'])->name('api.accounts.index');
Route::put('accounts/{account}', [AccountController::class, 'update'])->name('api.accounts.update');

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\Transaction;
use App\Models\User;
use Carbon\Carbon;
use Illuminate\Support\Facades\Http;
use Illuminate\Testing\TestResponse;
beforeEach(function () {
Http::fake();
Carbon::setTestNow('2026-06-15');
$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',
]);
});
afterEach(function () {
Carbon::setTestNow();
});
function makeBreakdownTransaction(array $attributes = []): Transaction
{
return Transaction::factory()->create([
'user_id' => test()->user->id,
'account_id' => test()->account->id,
'currency_code' => 'USD',
...$attributes,
]);
}
function monthlyBreakdown(Category $category): TestResponse
{
return test()->getJson("/api/categories/{$category->id}/monthly-breakdown");
}
test('it forbids analysing a category owned by another user', function () {
$other = User::factory()->create();
$category = Category::factory()->create(['user_id' => $other->id, 'type' => CategoryType::Expense]);
monthlyBreakdown($category)->assertForbidden();
});
test('the response is private and not cached between users', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
monthlyBreakdown($category)
->assertOk()
->assertHeader('Cache-Control', 'no-store, private');
});
test('a leaf category returns a single series named after the category across twelve months', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food Delivery']);
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-04']);
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $category->id, 'transaction_date' => '2026-05-09']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('series'))->toBe([['key' => $category->id, 'label' => 'Food Delivery']]);
expect($response->json('months'))->toHaveCount(12);
expect($response->json('months.0.key'))->toBe('2025-07');
expect($response->json('months.11.key'))->toBe('2026-06');
expect($response->json('months.11.'.$category->id))->toBe(4000);
expect($response->json('months.10.'.$category->id))->toBe(1000);
expect($response->json('months.0.'.$category->id))->toBe(0);
});
test('spend nets within a month and a refund-dominant month dips below zero', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food Delivery']);
// -40, -40, +20 -> nets to 60 of spend.
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-04']);
makeBreakdownTransaction(['amount' => -4000, 'category_id' => $category->id, 'transaction_date' => '2026-06-05']);
makeBreakdownTransaction(['amount' => 2000, 'category_id' => $category->id, 'transaction_date' => '2026-06-06']);
// Refunds exceed spend this month -> the bar dips below zero.
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $category->id, 'transaction_date' => '2026-05-09']);
makeBreakdownTransaction(['amount' => 3000, 'category_id' => $category->id, 'transaction_date' => '2026-05-10']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('months.11.'.$category->id))->toBe(6000);
expect($response->json('months.10.'.$category->id))->toBe(-2000);
});
test('a parent rolls grandchildren into their immediate child and surfaces direct spend', function () {
$parent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']);
$delivery = Category::factory()->childOf($parent)->create(['name' => 'Delivery']);
$grocery = Category::factory()->childOf($parent)->create(['name' => 'Grocery']);
$uberEats = Category::factory()->childOf($delivery)->create(['name' => 'Uber Eats']);
makeBreakdownTransaction(['amount' => -1000, 'category_id' => $delivery->id, 'transaction_date' => '2026-06-04']);
makeBreakdownTransaction(['amount' => -500, 'category_id' => $uberEats->id, 'transaction_date' => '2026-06-05']);
makeBreakdownTransaction(['amount' => -2000, 'category_id' => $grocery->id, 'transaction_date' => '2026-06-06']);
makeBreakdownTransaction(['amount' => -300, 'category_id' => $parent->id, 'transaction_date' => '2026-06-07']);
$response = monthlyBreakdown($parent)->assertOk();
// Richest child first (Grocery 2000, then Delivery 1500), then Direct.
expect($response->json('series'))->toBe([
['key' => $grocery->id, 'label' => 'Grocery'],
['key' => $delivery->id, 'label' => 'Delivery'],
['key' => 'direct', 'label' => 'Direct'],
]);
expect($response->json('months.11.'.$grocery->id))->toBe(2000);
expect($response->json('months.11.'.$delivery->id))->toBe(1500);
expect($response->json('months.11.direct'))->toBe(300);
});
test('children beyond the top six fold into an Other segment', function () {
$parent = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Shopping']);
foreach (range(1, 8) as $rank) {
$child = Category::factory()->childOf($parent)->create(['name' => "Child {$rank}"]);
makeBreakdownTransaction(['amount' => -1000 * $rank, 'category_id' => $child->id, 'transaction_date' => '2026-06-10']);
}
$response = monthlyBreakdown($parent)->assertOk();
$series = $response->json('series');
expect($series)->toHaveCount(7);
expect(collect($series)->pluck('key')->last())->toBe('other');
// The two smallest (1000 + 2000) are folded into Other.
expect($response->json('months.11.other'))->toBe(3000);
});
test('only the trailing twelve months are included', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
makeBreakdownTransaction(['amount' => -5000, 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
// Older than the window start (2025-07-01) -> excluded.
makeBreakdownTransaction(['amount' => -9999, 'category_id' => $category->id, 'transaction_date' => '2025-06-30']);
$response = monthlyBreakdown($category)->assertOk();
$total = collect($response->json('months'))->sum($category->id);
expect($total)->toBe(5000);
});
test('income categories keep inflows positive and net expenses against them', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Income, 'name' => 'Salary']);
makeBreakdownTransaction(['amount' => 500000, 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
makeBreakdownTransaction(['amount' => -100000, 'category_id' => $category->id, 'transaction_date' => '2026-06-02']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('months.11.'.$category->id))->toBe(400000);
});
test('summary reports the monthly average and the half-over-half trend', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
// Earlier half (2025-07..2025-12): one month of 6000 -> average 1000.
makeBreakdownTransaction(['amount' => -6000, 'category_id' => $category->id, 'transaction_date' => '2025-09-15']);
// Recent half (2026-01..2026-06): one month of 9000 -> average 1500.
makeBreakdownTransaction(['amount' => -9000, 'category_id' => $category->id, 'transaction_date' => '2026-02-15']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('summary.average_per_month'))->toBe(1250);
expect($response->json('summary.trend_percentage'))->toEqual(50);
});
test('summary trend is null when the earlier half has no spending', function () {
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
makeBreakdownTransaction(['amount' => -6000, 'category_id' => $category->id, 'transaction_date' => '2026-06-10']);
$response = monthlyBreakdown($category)->assertOk();
expect($response->json('summary.average_per_month'))->toBe(500);
expect($response->json('summary.trend_percentage'))->toBeNull();
});
test('foreign-currency transactions are converted to the user currency', function () {
ExchangeRate::factory()->create([
'base_currency' => 'usd',
'date' => '2026-06-01',
'rates' => ['eur' => 0.5],
]);
$category = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense]);
makeBreakdownTransaction(['amount' => -1000, 'currency_code' => 'EUR', 'category_id' => $category->id, 'transaction_date' => '2026-06-01']);
$response = monthlyBreakdown($category)->assertOk();
// 1000 EUR cents / 0.5 = 2000 USD cents.
expect($response->json('months.11.'.$category->id))->toBe(2000);
});