diff --git a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php new file mode 100644 index 00000000..3b68bf7d --- /dev/null +++ b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php @@ -0,0 +1,307 @@ +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> $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 $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 $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 $children + * @param array $childTotals + * @return array + */ + 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> $buckets + * @param array $series + * @return array> + */ + 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 $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); + } +} diff --git a/lang/es.json b/lang/es.json index 112484e5..cfd7b919 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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.", diff --git a/resources/js/components/cashflow/breakdown-card.tsx b/resources/js/components/cashflow/breakdown-card.tsx index 4bcf8885..7a6cc80c 100644 --- a/resources/js/components/cashflow/breakdown-card.tsx +++ b/resources/js/components/cashflow/breakdown-card.tsx @@ -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 ( -
- {title} - +
+ {title} + {description} + +
+
- {description}
diff --git a/resources/js/components/categories/category-analysis-button.tsx b/resources/js/components/categories/category-analysis-button.tsx new file mode 100644 index 00000000..713a007c --- /dev/null +++ b/resources/js/components/categories/category-analysis-button.tsx @@ -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 ( + <> + + + + ); +} diff --git a/resources/js/components/categories/category-analysis-drawer.test.tsx b/resources/js/components/categories/category-analysis-drawer.test.tsx new file mode 100644 index 00000000..a7c94da3 --- /dev/null +++ b/resources/js/components/categories/category-analysis-drawer.test.tsx @@ -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(); + 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(); + }); +}); diff --git a/resources/js/components/categories/category-analysis-drawer.tsx b/resources/js/components/categories/category-analysis-drawer.tsx new file mode 100644 index 00000000..503b6a24 --- /dev/null +++ b/resources/js/components/categories/category-analysis-drawer.tsx @@ -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(null); + const [data, setData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(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( + () => + 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 ( + + +
+ + + {__('Category analysis')} + + + {__( + 'How much you spent on this category each month over the last 12 months.', + )} + + + + + + {isLoading && ( +
+ )} + + {!isLoading && error && ( +

+ {error} +

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

+ {__('Pick a category to see its monthly spending.')} +

+ )} + + {!isLoading && !error && categoryId && !hasData && ( +

+ {__('No spending in the last 12 months.')} +

+ )} + + {!isLoading && !error && hasData && ( +
+ + + + + + + +
+ )} +
+ + + ); +} + +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 ( +
+ + +

+ {__('Monthly average')} +

+ +
+
+ + + +

+ {__('Trend')} +

+ {trend === null ? ( +

+ {__('Not enough history')} +

+ ) : ( +
+ 0 && 'text-amber-500', + trend < 0 && 'text-emerald-500', + trend === 0 && 'text-muted-foreground', + )} + /> + + {trend > 0 ? '+' : ''} + {trend}% + + + {__('vs. previous 6 months')} + +
+ )} +
+
+
+ ); +} diff --git a/resources/js/components/dashboard/top-categories-card.tsx b/resources/js/components/dashboard/top-categories-card.tsx index 90d4206c..a063533c 100644 --- a/resources/js/components/dashboard/top-categories-card.tsx +++ b/resources/js/components/dashboard/top-categories-card.tsx @@ -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 ( - {__('Top spending categories')} +
+ {__('Top spending categories')} + +
{__('on the last 30 days')}
diff --git a/routes/api.php b/routes/api.php index 34e6f9c7..9e100920 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); diff --git a/tests/Feature/CategoryMonthlyBreakdownTest.php b/tests/Feature/CategoryMonthlyBreakdownTest.php new file mode 100644 index 00000000..188718ab --- /dev/null +++ b/tests/Feature/CategoryMonthlyBreakdownTest.php @@ -0,0 +1,200 @@ +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); +});