$ids * @return array */ public function expand(string $userId, array $ids): array { $ids = array_values(array_unique(array_filter($ids))); if ($ids === []) { return []; } $collected = $ids; $frontier = $ids; for ($level = 1; $level < Category::MAX_DEPTH; $level++) { $childIds = Category::query() ->where('user_id', $userId) ->whereIn('parent_id', $frontier) ->pluck('id') ->all(); $childIds = array_values(array_diff($childIds, $collected)); if ($childIds === []) { break; } $collected = array_merge($collected, $childIds); $frontier = $childIds; } return $collected; } /** * Collect the descendant ids of a single category (excluding itself). * * @return array */ public function descendantIds(Category $category): array { return array_values(array_diff( $this->expand($category->user_id, [$category->id]), [$category->id], )); } /** * A category's id together with every ancestor id, walking up to the root. * * @return array */ public function ancestorAndSelfIds(string $userId, string $categoryId): array { $ids = [$categoryId]; $parentId = Category::query() ->where('user_id', $userId) ->whereKey($categoryId) ->value('parent_id'); $guard = 0; while ($parentId !== null && $guard++ < Category::MAX_DEPTH) { $ids[] = $parentId; $parentId = Category::query() ->where('user_id', $userId) ->whereKey($parentId) ->value('parent_id'); } return $ids; } /** * The depth of a category, where a root category is level 1. */ public function depth(Category $category): int { $depth = 1; $parentId = $category->parent_id; while ($parentId !== null && $depth <= Category::MAX_DEPTH) { $parentId = Category::query() ->where('user_id', $category->user_id) ->where('id', $parentId) ->value('parent_id'); $depth++; } return $depth; } /** * The number of levels contained in a category's subtree, including itself * (a leaf is 1). */ public function subtreeDepth(Category $category): int { $depth = 1; $frontier = [$category->id]; for ($level = 1; $level < Category::MAX_DEPTH; $level++) { $childIds = Category::query() ->where('user_id', $category->user_id) ->whereIn('parent_id', $frontier) ->pluck('id') ->all(); if ($childIds === []) { break; } $depth++; $frontier = $childIds; } return $depth; } /** * Roll category amounts up the tree. * * With no drill target, every amount folds into its top-level ancestor. * When drilling into a parent, the parent's children become the nodes (each * rolled up over its own subtree) plus a "Parent" node for transactions * sitting on the parent itself. Items outside the drilled subtree drop out. * * @param array $categorized * @return array */ public function rollUp(array $categorized, string $userId, ?string $drillParentId): 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(); $childCounts = []; foreach ($parentMap as $parentId) { if ($parentId !== null) { $childCounts[$parentId] = ($childCounts[$parentId] ?? 0) + 1; } } $nodes = []; foreach ($categorized as $item) { $categoryId = $item['category_id']; if ($categoryId === null || ! array_key_exists($categoryId, $parentMap)) { // Uncategorized only belongs in the top-level view. if ($drillParentId === null) { $key = 'uncategorized'; $nodes[$key] ??= ['category_id' => null, 'category' => $item['category'], 'amount' => 0, 'has_children' => false, 'is_direct' => false]; $nodes[$key]['amount'] += $item['amount']; } continue; } $target = $this->displayNodeFor($categoryId, $parentMap, $drillParentId); if ($target === null) { continue; } $displayCategory = $categories->get($target['id']); if ($displayCategory === null) { continue; } if ($target['is_direct']) { $key = $target['id'].':direct'; $category = (new Category)->forceFill([ 'id' => $displayCategory->id, 'name' => __('Parent'), 'icon' => $displayCategory->icon, 'color' => $displayCategory->color, 'type' => $displayCategory->type, 'cashflow_direction' => $displayCategory->cashflow_direction, 'parent_id' => $displayCategory->parent_id, ]); $nodes[$key] ??= ['category_id' => $displayCategory->id, 'category' => $category, 'amount' => 0, 'has_children' => false, 'is_direct' => true]; $nodes[$key]['amount'] += $item['amount']; continue; } $key = $target['id']; $nodes[$key] ??= [ 'category_id' => $displayCategory->id, 'category' => $displayCategory, 'amount' => 0, 'has_children' => ($childCounts[$displayCategory->id] ?? 0) > 0, 'is_direct' => false, ]; $nodes[$key]['amount'] += $item['amount']; } return array_values($nodes); } /** * 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 */ private function chainFromRoot(string $categoryId, array $parentMap): array { $chain = []; $current = $categoryId; $guard = 0; while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) { array_unshift($chain, $current); $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]; } $index = array_search($drillParentId, $chain, true); if ($index === false) { return null; } if ($index === count($chain) - 1) { return ['id' => $drillParentId, 'is_direct' => true]; } return ['id' => $chain[$index + 1], 'is_direct' => false]; } /** * Whether assigning $parentId as the parent of $category would create a * cycle (the parent is the category itself or one of its descendants). */ public function wouldCreateCycle(Category $category, string $parentId): bool { if ($parentId === $category->id) { return true; } return in_array($parentId, $this->descendantIds($category), true); } /** * Cascade a category's type and cashflow direction down to every * descendant, keeping the whole subtree on a single type. */ public function syncDescendantTypes(Category $category): void { $descendantIds = $this->descendantIds($category); if ($descendantIds === []) { return; } Category::query() ->where('user_id', $category->user_id) ->whereIn('id', $descendantIds) ->update([ 'type' => $category->type, 'cashflow_direction' => $category->cashflow_direction, ]); } /** * Soft-delete a category together with its whole subtree, uncategorizing * any transactions that pointed at the removed categories. */ public function deleteSubtree(Category $category): void { $ids = [$category->id, ...$this->descendantIds($category)]; Transaction::query() ->where('user_id', $category->user_id) ->whereIn('category_id', $ids) ->update(['category_id' => null]); Category::query() ->where('user_id', $category->user_id) ->whereIn('id', $ids) ->delete(); } }