diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index d4b169d7..e3fe6871 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -103,8 +103,9 @@ class TransactionAnalysisController extends Controller } /** - * Expenses grouped by their top-level category, rolled up through the - * category tree so parents absorb their children's spending. + * Expenses grouped by their top-level category, with the sub-categories + * that carry spending nested beneath each parent so the split is visible + * instead of folded into the parent total. */ private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection { @@ -115,40 +116,41 @@ class TransactionAnalysisController extends Controller $grouped = $expenses ->filter(fn (Transaction $transaction): bool => $transaction->category_id !== null) ->groupBy('category_id') - ->map(function (Collection $group) use ($currency): array { - $first = $group->first(); - - return [ - 'category_id' => $first->category_id, - 'category' => $first->category, - 'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))), - ]; - }) + ->map(fn (Collection $group): array => [ + 'category_id' => $group->first()->category_id, + 'amount' => abs($group->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))), + ]) ->values() ->all(); - $breakdown = collect($this->tree->rollUp($grouped, $userId, null)) - ->map(fn (array $node): array => [ - 'category_id' => $node['category_id'], - 'name' => $node['category']->name, - 'color' => $node['category']->color, - 'amount' => $node['amount'], - ]); + $rows = array_map(fn (array $node): array => [ + 'category_id' => $node['category_id'], + 'name' => $node['category']->name, + 'color' => $node['category']->color, + 'amount' => $node['amount'], + 'children' => array_map(fn (array $child): array => [ + 'category_id' => $child['category_id'], + 'name' => $child['category']->name, + 'color' => $child['category']->color, + 'amount' => $child['amount'], + ], $node['children']), + ], $this->tree->spendingBreakdown($grouped, $userId)); $uncategorized = abs($expenses ->filter(fn (Transaction $transaction): bool => $transaction->category_id === null) ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $currency))); if ($uncategorized > 0) { - $breakdown->push([ + $rows[] = [ 'category_id' => null, 'name' => __('Uncategorized'), 'color' => 'gray', 'amount' => $uncategorized, - ]); + 'children' => [], + ]; } - return $breakdown + return collect($rows) ->filter(fn (array $node): bool => $node['amount'] > 0) ->sortByDesc('amount') ->values(); diff --git a/app/Services/CategoryTree.php b/app/Services/CategoryTree.php index fb0392df..cd427776 100644 --- a/app/Services/CategoryTree.php +++ b/app/Services/CategoryTree.php @@ -220,12 +220,100 @@ class CategoryTree } /** - * Resolve which node a category's amount should be attributed to. + * Build a two-level spending breakdown: each top-level category with its + * rolled-up total, and the immediate sub-categories that carry spending + * nested beneath it. Grand-children fold into their level-2 ancestor; + * spend sitting directly on a parent that is also split across + * sub-categories surfaces as a "Direct" child so the children always sum + * to the parent total. Uncategorized items are left for the caller. + * + * @param array $categorized + * @return array}> + */ + public function spendingBreakdown(array $categorized, string $userId): array + { + $categories = Category::query() + ->where('user_id', $userId) + ->forDisplay() + ->get() + ->keyBy('id'); + + $parentMap = $categories->mapWithKeys(fn (Category $category): array => [$category->id => $category->parent_id])->all(); + + $roots = []; + foreach ($categorized as $item) { + $categoryId = $item['category_id']; + + if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) { + continue; + } + + $chain = $this->chainFromRoot($categoryId, $parentMap); + $rootId = $chain[0]; + + $roots[$rootId] ??= [ + 'category_id' => $rootId, + 'category' => $categories->get($rootId), + 'amount' => 0, + 'direct' => 0, + 'children' => [], + ]; + $roots[$rootId]['amount'] += $item['amount']; + + if (count($chain) === 1) { + $roots[$rootId]['direct'] += $item['amount']; + + continue; + } + + $childId = $chain[1]; + $roots[$rootId]['children'][$childId] ??= [ + 'category_id' => $childId, + 'category' => $categories->get($childId), + 'amount' => 0, + ]; + $roots[$rootId]['children'][$childId]['amount'] += $item['amount']; + } + + return collect($roots) + ->map(function (array $root): array { + $children = collect($root['children'])->values(); + + if ($root['direct'] > 0 && $children->isNotEmpty()) { + $parent = $root['category']; + $children->push([ + 'category_id' => $root['category_id'], + 'category' => (new Category)->forceFill([ + 'id' => $parent->id, + 'name' => __('Direct'), + 'icon' => $parent->icon, + 'color' => $parent->color, + 'type' => $parent->type, + 'cashflow_direction' => $parent->cashflow_direction, + 'parent_id' => $parent->id, + ]), + 'amount' => $root['direct'], + ]); + } + + return [ + 'category_id' => $root['category_id'], + 'category' => $root['category'], + 'amount' => $root['amount'], + 'children' => $children->sortByDesc('amount')->values()->all(), + ]; + }) + ->values() + ->all(); + } + + /** + * The category ids from the top-level ancestor down to the category itself. * * @param array $parentMap - * @return array{id: string, is_direct: bool}|null + * @return array */ - private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array + private function chainFromRoot(string $categoryId, array $parentMap): array { $chain = []; $current = $categoryId; @@ -236,6 +324,19 @@ class CategoryTree $current = $parentMap[$current] ?? null; } + return $chain; + } + + /** + * Resolve which node a category's amount should be attributed to. + * + * @param array $parentMap + * @return array{id: string, is_direct: bool}|null + */ + private function displayNodeFor(string $categoryId, array $parentMap, ?string $drillParentId): ?array + { + $chain = $this->chainFromRoot($categoryId, $parentMap); + if ($drillParentId === null) { return ['id' => $chain[0], 'is_direct' => false]; } diff --git a/lang/es.json b/lang/es.json index 092c628e..127ef434 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1085,6 +1085,7 @@ "Page": "Página", "Paraguayan Guarani": "Guaraní paraguayo", "Parent": "Padre", + "Direct": "Directo", "Parent category": "Categoría padre", "Password": "Contraseña", "Password changes are disabled on the demo account.": "Los cambios de contraseña están deshabilitados en la cuenta demo.", diff --git a/resources/js/components/transactions/transaction-analysis-drawer.tsx b/resources/js/components/transactions/transaction-analysis-drawer.tsx index 3740ad0f..167a2bb4 100644 --- a/resources/js/components/transactions/transaction-analysis-drawer.tsx +++ b/resources/js/components/transactions/transaction-analysis-drawer.tsx @@ -54,6 +54,7 @@ interface CategorySlice { name: string; color: string; amount: number; + children: CategorySlice[]; } interface TagSlice { @@ -98,6 +99,29 @@ const CHART_PALETTE = [ 'var(--color-chart-8)', ]; +/** + * Reorders a sequential palette so neighbouring entries alternate between its + * dark and light ends. Monochrome schemes (blue, pink, neutral) run from + * darkest to lightest, so picking adjacent shades leaves consecutive slices + * nearly indistinguishable; zipping the two halves maximises the contrast + * between one slice and the next. + */ +function alternateContrast(palette: string[]): string[] { + const half = Math.ceil(palette.length / 2); + const result: string[] = []; + + for (let index = 0; index < half; index++) { + result.push(palette[index]); + if (index + half < palette.length) { + result.push(palette[index + half]); + } + } + + return result; +} + +const CHART_COLORS = alternateContrast(CHART_PALETTE); + function buildQueryString(filters: SerializedFilters): string { const params = new URLSearchParams(); @@ -650,8 +674,8 @@ function CategoryBreakdown({ `slice-${index}` } fill={ - CHART_PALETTE[ - index % CHART_PALETTE.length + CHART_COLORS[ + index % CHART_COLORS.length ] } /> @@ -661,37 +685,84 @@ function CategoryBreakdown({ -
    - {slices.map((slice, index) => ( -
  • - - - {slice.name} - - - {total > 0 - ? Math.round((slice.amount / total) * 100) - : 0} - % - - -
  • - ))} +
      + {slices.map((slice, index) => { + const color = CHART_COLORS[index % CHART_COLORS.length]; + + return ( +
    • +
      + + + {slice.name} + + + {total > 0 + ? Math.round( + (slice.amount / total) * 100, + ) + : 0} + % + + +
      + + {slice.children.length > 0 && ( +
        + {slice.children.map( + (child, childIndex) => ( +
      • + + + {child.name} + + + {slice.amount > 0 + ? Math.round( + (child.amount / + slice.amount) * + 100, + ) + : 0} + % + + +
      • + ), + )} +
      + )} +
    • + ); + })}
    diff --git a/tests/Feature/TransactionAnalysisTest.php b/tests/Feature/TransactionAnalysisTest.php index 992e646f..2591ac17 100644 --- a/tests/Feature/TransactionAnalysisTest.php +++ b/tests/Feature/TransactionAnalysisTest.php @@ -84,8 +84,57 @@ test('category breakdown groups expenses by top-level category', function () { $response->assertOk(); expect($response->json('distinct_category_count'))->toBe(2); - expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000]); - expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000]); + expect($response->json('by_category.0'))->toMatchArray(['name' => 'Hotel', 'amount' => 50000, 'children' => []]); + expect($response->json('by_category.1'))->toMatchArray(['name' => 'Meals', 'amount' => 20000, 'children' => []]); +}); + +test('category breakdown nests sub-categories under their parent total', function () { + $food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']); + $groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]); + $restaurants = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Restaurants', 'parent_id' => $food->id]); + + makeTransaction(['amount' => -30000, 'category_id' => $groceries->id, 'transaction_date' => '2026-01-10']); + makeTransaction(['amount' => -10000, 'category_id' => $restaurants->id, 'transaction_date' => '2026-01-11']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + expect($response->json('distinct_category_count'))->toBe(1); + expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 40000]); + expect($response->json('by_category.0.children.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 30000]); + expect($response->json('by_category.0.children.1'))->toMatchArray(['name' => 'Restaurants', 'amount' => 10000]); +}); + +test('spend booked directly on a split parent surfaces as a Direct child', function () { + $food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']); + $groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]); + + makeTransaction(['amount' => -30000, 'category_id' => $groceries->id, 'transaction_date' => '2026-01-10']); + makeTransaction(['amount' => -5000, 'category_id' => $food->id, 'transaction_date' => '2026-01-11']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 35000]); + + $children = collect($response->json('by_category.0.children')); + expect($children)->toHaveCount(2); + expect($children->firstWhere('name', 'Groceries')['amount'])->toBe(30000); + expect($children->firstWhere('name', 'Direct')['amount'])->toBe(5000); +}); + +test('grand-children fold into their level-two sub-category', function () { + $food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']); + $groceries = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Groceries', 'parent_id' => $food->id]); + $organic = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Organic', 'parent_id' => $groceries->id]); + + makeTransaction(['amount' => -12000, 'category_id' => $organic->id, 'transaction_date' => '2026-01-10']); + + $response = $this->getJson('/api/transactions/analysis'); + + $response->assertOk(); + expect($response->json('by_category.0'))->toMatchArray(['name' => 'Food', 'amount' => 12000]); + expect($response->json('by_category.0.children.0'))->toMatchArray(['name' => 'Groceries', 'amount' => 12000]); }); test('tag breakdown sums expenses per label', function () {