diff --git a/app/Actions/CreateDefaultCategories.php b/app/Actions/CreateDefaultCategories.php index 211b2372..c1dff91a 100644 --- a/app/Actions/CreateDefaultCategories.php +++ b/app/Actions/CreateDefaultCategories.php @@ -6,26 +6,27 @@ use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; use App\Models\Category; use App\Models\User; +use Illuminate\Support\Arr; use Illuminate\Support\Str; class CreateDefaultCategories { /** - * Create default categories for a newly registered user. + * Create default categories for a newly registered user, nesting child + * categories under their configured parent. */ public function handle(User $user): void { $locale = $user->locale ?? app()->getLocale(); $defaultCategories = self::getDefaultCategories($locale); - $existingCategoryNames = $user->categories() + $existingCategories = $user->categories() ->whereIn('name', array_column($defaultCategories, 'name')) - ->pluck('name') - ->all(); + ->pluck('id', 'name'); $now = now(); - $categories = collect($defaultCategories) - ->reject(fn (array $category): bool => in_array($category['name'], $existingCategoryNames, true)) + $pending = collect($defaultCategories) + ->reject(fn (array $category): bool => $existingCategories->has($category['name'])) ->map(fn (array $category): array => [ ...$category, 'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value, @@ -33,20 +34,39 @@ class CreateDefaultCategories 'user_id' => $user->id, 'created_at' => $now, 'updated_at' => $now, - ]) - ->all(); + ]); - if ($categories === []) { + if ($pending->isEmpty()) { return; } - Category::query()->insert($categories); + $idsByName = $existingCategories->merge($pending->pluck('id', 'name')); + + [$children, $roots] = $pending->partition(fn (array $category): bool => isset($category['parent'])); + + if ($roots->isNotEmpty()) { + Category::query()->insert( + $roots->map(fn (array $category): array => Arr::except($category, 'parent'))->values()->all() + ); + } + + if ($children->isNotEmpty()) { + Category::query()->insert( + $children + ->map(fn (array $category): array => [ + ...Arr::except($category, 'parent'), + 'parent_id' => $idsByName[$category['parent']] ?? null, + ]) + ->values() + ->all() + ); + } } /** * Get the default categories configuration for a given locale. * - * @return array + * @return array */ public static function getDefaultCategories(string $locale = 'en'): array { @@ -58,6 +78,10 @@ class CreateDefaultCategories return array_map(function (array $category) use ($translations) { $category['name'] = $translations[$category['name']] ?? $category['name']; + if (isset($category['parent'])) { + $category['parent'] = $translations[$category['parent']] ?? $category['parent']; + } + return $category; }, $categories); } @@ -66,9 +90,10 @@ class CreateDefaultCategories } /** - * Get the base (English) categories configuration. + * Get the base (English) categories configuration. A `parent` entry nests + * the category under the default category with that (English) name. * - * @return array + * @return array */ private static function getBaseCategories(): array { @@ -81,30 +106,35 @@ class CreateDefaultCategories ], [ 'name' => 'Cafes, restaurants, bars', + 'parent' => 'Food', 'icon' => 'Wine', 'color' => 'red', 'type' => 'expense', ], [ 'name' => 'Groceries', + 'parent' => 'Food', 'icon' => 'ShoppingBasket', 'color' => 'red', 'type' => 'expense', ], [ 'name' => 'Tobacco and alcohol', + 'parent' => 'Food', 'icon' => 'Cigarette', 'color' => 'red', 'type' => 'expense', ], [ 'name' => 'Other groceries', + 'parent' => 'Food', 'icon' => 'ShoppingBasket', 'color' => 'red', 'type' => 'expense', ], [ 'name' => 'Food delivery', + 'parent' => 'Food', 'icon' => 'Pizza', 'color' => 'red', 'type' => 'expense', @@ -117,42 +147,49 @@ class CreateDefaultCategories ], [ 'name' => 'Electricity', + 'parent' => 'Utility services', 'icon' => 'Bolt', 'color' => 'orange', 'type' => 'expense', ], [ 'name' => 'Natural gas', + 'parent' => 'Utility services', 'icon' => 'Flame', 'color' => 'orange', 'type' => 'expense', ], [ 'name' => 'Rent and maintanence', + 'parent' => 'Utility services', 'icon' => 'Wrench', 'color' => 'orange', 'type' => 'expense', ], [ 'name' => 'Telephone, internet, TV, computer', + 'parent' => 'Utility services', 'icon' => 'Wifi', 'color' => 'orange', 'type' => 'expense', ], [ 'name' => 'Water', + 'parent' => 'Utility services', 'icon' => 'Droplets', 'color' => 'orange', 'type' => 'expense', ], [ 'name' => 'Other utility expenses', + 'parent' => 'Utility services', 'icon' => 'Receipt', 'color' => 'orange', 'type' => 'expense', ], [ 'name' => 'Household goods', + 'parent' => 'Utility services', 'icon' => 'Home', 'color' => 'orange', 'type' => 'expense', @@ -165,24 +202,28 @@ class CreateDefaultCategories ], [ 'name' => 'Parking', + 'parent' => 'Transportation', 'icon' => 'ParkingMeter', 'color' => 'amber', 'type' => 'expense', ], [ 'name' => 'Fuel', + 'parent' => 'Transportation', 'icon' => 'Fuel', 'color' => 'amber', 'type' => 'expense', ], [ 'name' => 'Transportation expenses', + 'parent' => 'Transportation', 'icon' => 'Ticket', 'color' => 'amber', 'type' => 'expense', ], [ 'name' => 'Vehicle purchase, maintenance', + 'parent' => 'Transportation', 'icon' => 'Car', 'color' => 'amber', 'type' => 'expense', @@ -201,36 +242,42 @@ class CreateDefaultCategories ], [ 'name' => 'Gifts', + 'parent' => 'Leisure activities, traveling', 'icon' => 'Gift', 'color' => 'violet', 'type' => 'expense', ], [ 'name' => 'Books, newspapers, magazines', + 'parent' => 'Leisure activities, traveling', 'icon' => 'BookOpen', 'color' => 'violet', 'type' => 'expense', ], [ 'name' => 'Accommodation, travel expenses', + 'parent' => 'Leisure activities, traveling', 'icon' => 'Hotel', 'color' => 'violet', 'type' => 'expense', ], [ 'name' => 'Sport and sports goods', + 'parent' => 'Leisure activities, traveling', 'icon' => 'Dumbbell', 'color' => 'violet', 'type' => 'expense', ], [ 'name' => 'Theatre, music, cinema', + 'parent' => 'Leisure activities, traveling', 'icon' => 'Clapperboard', 'color' => 'violet', 'type' => 'expense', ], [ 'name' => 'Hobbies and other leisure time activites', + 'parent' => 'Leisure activities, traveling', 'icon' => 'Puzzle', 'color' => 'violet', 'type' => 'expense', @@ -243,18 +290,21 @@ class CreateDefaultCategories ], [ 'name' => 'Education and courses', + 'parent' => 'Education, health and beauty', 'icon' => 'GraduationCap', 'color' => 'rose', 'type' => 'expense', ], [ 'name' => 'Beauty, cosmetics', + 'parent' => 'Education, health and beauty', 'icon' => 'Sparkles', 'color' => 'rose', 'type' => 'expense', ], [ 'name' => 'Health and pharmaceuticals', + 'parent' => 'Education, health and beauty', 'icon' => 'HeartPulse', 'color' => 'rose', 'type' => 'expense', @@ -267,6 +317,7 @@ class CreateDefaultCategories ], [ 'name' => 'Online services', + 'parent' => 'Online transactions', 'icon' => 'Server', 'color' => 'fuchsia', 'type' => 'expense', @@ -293,6 +344,7 @@ class CreateDefaultCategories ], [ 'name' => 'Other investments', + 'parent' => 'Investments', 'icon' => 'TrendingUp', 'color' => 'lime', 'type' => CategoryType::Investment->value, @@ -336,6 +388,7 @@ class CreateDefaultCategories ], [ 'name' => 'Lottery', + 'parent' => 'Gambling', 'icon' => 'TicketPercent', 'color' => 'purple', 'type' => 'expense', @@ -360,6 +413,7 @@ class CreateDefaultCategories ], [ 'name' => 'Other personal transfers', + 'parent' => 'Personal transfers', 'icon' => 'ArrowLeftRight', 'color' => 'cyan', 'type' => 'transfer', diff --git a/app/Enums/CategoryDeletionStrategy.php b/app/Enums/CategoryDeletionStrategy.php new file mode 100644 index 00000000..0d105372 --- /dev/null +++ b/app/Enums/CategoryDeletionStrategy.php @@ -0,0 +1,10 @@ +validate([ 'from' => 'required|date', 'to' => 'required|date', + 'parent' => 'nullable|uuid', ]); $from = Carbon::parse($validated['from']); $to = Carbon::parse($validated['to']); $user = $request->user(); + $drillParentId = $validated['parent'] ?? null; // Split by sign, not by category type: a single category can appear on // both sides when it has both incoming and outgoing transactions. - $incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>'); - $expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<'); + $incomeCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '>', $drillParentId); + $expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<', $drillParentId); $totalIncome = $incomeCategories->sum('amount'); $totalExpense = $expenseCategories->sum('amount'); @@ -114,16 +116,18 @@ class CashflowAnalyticsController extends Controller 'from' => 'required|date', 'to' => 'required|date', 'type' => 'required|in:income,expense', + 'parent' => 'nullable|uuid', ]); $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); $user = $request->user(); + $drillParentId = $validated['parent'] ?? null; $categoryType = $validated['type'] === 'income' ? CategoryType::Income : CategoryType::Expense; - $current = $this->getCategoryBreakdown($user->id, $user->currency_code, $period->from, $period->to, $categoryType); - $previous = $this->getCategoryBreakdown($user->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType); + $current = $this->getCategoryBreakdown($user->id, $user->currency_code, $period->from, $period->to, $categoryType, $drillParentId); + $previous = $this->getCategoryBreakdown($user->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType, $drillParentId); $currentTotal = $current->sum('amount'); $previousTotal = $previous->sum('amount'); @@ -138,6 +142,8 @@ class CashflowAnalyticsController extends Controller 'amount' => $item['amount'], 'percentage' => $currentTotal > 0 ? round(($item['amount'] / $currentTotal) * 100, 1) : 0, 'previous_amount' => $previousAmount, + 'has_children' => $item['has_children'] ?? false, + 'is_direct' => $item['is_direct'] ?? false, ]; })->sortByDesc('amount')->values(); @@ -219,7 +225,7 @@ class CashflowAnalyticsController extends Controller ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency))); } - private function getSankeyBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, string $operator): Collection + private function getSankeyBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, string $operator, ?string $drillParentId = null): Collection { $isIncome = $operator === '>'; $type = $isIncome ? CategoryType::Income : CategoryType::Expense; @@ -284,7 +290,11 @@ class CashflowAnalyticsController extends Controller 'amount' => $item['amount'], ]); - $categorized = $regularCategories->concat($transferCategories)->values(); + $categorized = collect($this->rollUpByTree( + $regularCategories->concat($transferCategories)->values()->all(), + $userId, + $drillParentId, + )); $uncategorized = $transactions ->filter(function (Transaction $transaction) use ($operator): bool { @@ -293,7 +303,7 @@ class CashflowAnalyticsController extends Controller }) ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); - if ($uncategorized != 0) { + if ($drillParentId === null && $uncategorized != 0) { $categorized->push([ 'category_id' => null, 'category' => (new Category)->forceFill([ @@ -304,6 +314,8 @@ class CashflowAnalyticsController extends Controller 'icon' => 'HelpCircle', ]), 'amount' => abs($uncategorized), + 'has_children' => false, + 'is_direct' => false, ]); } @@ -368,7 +380,7 @@ class CashflowAnalyticsController extends Controller }); } - private function getCategoryBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type): Collection + private function getCategoryBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type, ?string $drillParentId = null): Collection { $transactions = Transaction::query() ->where('transactions.user_id', $userId) @@ -398,6 +410,8 @@ class CashflowAnalyticsController extends Controller 'amount' => $item['amount'], ]); + $categorized = collect($this->rollUpByTree($categorized->values()->all(), $userId, $drillParentId)); + $uncategorized = $transactions ->filter(function (Transaction $transaction) use ($type): bool { return $transaction->category_id === null @@ -406,7 +420,7 @@ class CashflowAnalyticsController extends Controller ->sum(fn (Transaction $transaction): int => $this->convertTransactionAmount($transaction, $userCurrency)); // Add uncategorized as a special category if there are any - if ($uncategorized != 0) { + if ($drillParentId === null && $uncategorized != 0) { $categorized->push([ 'category_id' => null, 'category' => (new Category)->forceFill([ @@ -417,6 +431,8 @@ class CashflowAnalyticsController extends Controller 'icon' => 'HelpCircle', ]), 'amount' => abs($uncategorized), + 'has_children' => false, + 'is_direct' => false, ]); } @@ -486,4 +502,123 @@ class CashflowAnalyticsController extends Controller { return $type === CategoryType::Income ? $amount > 0 : $amount < 0; } + + /** + * 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 "(direct)" node for transactions + * sitting on the parent itself. Items outside the drilled subtree drop out. + * + * @param array $categorized + * @return array + */ + private function rollUpByTree(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' => $displayCategory->name.' ('.__('direct').')', + '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); + } + + /** + * 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 = []; + $current = $categoryId; + $guard = 0; + + while ($current !== null && $guard++ < Category::MAX_DEPTH + 1) { + array_unshift($chain, $current); + $current = $parentMap[$current] ?? null; + } + + 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]; + } } diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 4295aa88..adfec712 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -7,14 +7,17 @@ use App\Enums\CategoryType; use App\Http\Controllers\Controller; use App\Models\Account; use App\Models\AccountBalance; +use App\Models\Category; use App\Models\Transaction; use App\Services\AccountMetricsService; use App\Services\BalanceLookup; +use App\Services\CategoryTree; use App\Services\ExchangeRateService; use App\Services\LoanAmortizationService; use App\Services\PeriodComparator; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; class DashboardAnalyticsController extends Controller @@ -23,6 +26,7 @@ class DashboardAnalyticsController extends Controller private ExchangeRateService $exchangeRateService, private AccountMetricsService $accountMetricsService, private LoanAmortizationService $loanAmortizationService, + private CategoryTree $tree, ) {} public function netWorth(Request $request) @@ -456,9 +460,17 @@ class DashboardAnalyticsController extends Controller return response()->json($top); } - private function getCategorySpending(string $userId, Carbon $from, Carbon $to) + /** + * Spending per top-level category: child category amounts roll up into + * their root ancestor so the dashboard only lists parents. + * + * @return Collection + */ + private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection { - return Transaction::query() + $rootMap = $this->tree->rootAncestorMap($userId); + + $rolledUp = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->join('categories', function ($join) { @@ -468,16 +480,23 @@ class DashboardAnalyticsController extends Controller }) ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) ->groupBy('transactions.category_id') - ->with('category') ->get() - ->filter(fn ($item): bool => (int) $item->total_amount < 0) - ->map(function ($item) { - return [ - 'category_id' => $item->category_id, - 'category' => $item->category, - 'amount' => (int) -$item->total_amount, - ]; - }); + ->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id) + ->map(fn (Collection $items, string $rootId): array => [ + 'category_id' => $rootId, + 'amount' => (int) -$items->sum('total_amount'), + ]) + ->filter(fn (array $item): bool => $item['amount'] > 0); + + $categories = Category::query() + ->whereIn('id', $rolledUp->keys()) + ->get() + ->keyBy('id'); + + return $rolledUp + ->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])]) + ->filter(fn (array $item): bool => $item['category'] !== null) + ->values(); } private function calculateNetWorthAt(Carbon $date, string $userCurrency): int diff --git a/app/Http/Controllers/Api/ImportDataController.php b/app/Http/Controllers/Api/ImportDataController.php index 3ec3599f..7be77384 100644 --- a/app/Http/Controllers/Api/ImportDataController.php +++ b/app/Http/Controllers/Api/ImportDataController.php @@ -20,8 +20,8 @@ class ImportDataController extends Controller ->orderBy('name') ->get(['id', 'name', 'name_iv', 'encrypted', 'bank_id', 'type', 'currency_code']), 'categories' => $user->categories() - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color']), + ->forDisplay() + ->get(), 'banks' => $user->banks() ->orderBy('name') ->get(['id', 'name', 'logo']), diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index 6d358b79..032af38a 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -85,8 +85,8 @@ class BudgetController extends Controller $categories = Category::query() ->where('user_id', $user->id) - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color']); + ->forDisplay() + ->get(); $accounts = Account::query() ->where('user_id', $user->id) diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index 50036a51..57d96b21 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -17,8 +17,8 @@ class CashflowController extends Controller $categories = Category::query() ->where('user_id', $user->id) - ->orderBy('name') - ->get(['id', 'name', 'type', 'icon', 'color', 'cashflow_direction']); + ->forDisplay() + ->get(); $accounts = Account::query() ->where('user_id', $user->id) diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 6bce5ece..d5807023 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -4,18 +4,24 @@ namespace App\Http\Controllers; use App\Enums\CategoryType; use App\Models\Account; +use App\Models\Category; use App\Models\Transaction; use App\Services\AccountMetricsService; +use App\Services\CategoryTree; use App\Services\PeriodComparator; use Carbon\Carbon; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Inertia\Inertia; use Inertia\Response; class DashboardController extends Controller { - public function __construct(private AccountMetricsService $accountMetricsService) {} + public function __construct( + private AccountMetricsService $accountMetricsService, + private CategoryTree $tree, + ) {} public function __invoke(Request $request): Response { @@ -90,9 +96,17 @@ class DashboardController extends Controller ]; } - private function getCategorySpending(string $userId, Carbon $from, Carbon $to) + /** + * Spending per top-level category: child category amounts roll up into + * their root ancestor so the dashboard only lists parents. + * + * @return Collection + */ + private function getCategorySpending(string $userId, Carbon $from, Carbon $to): Collection { - return Transaction::query() + $rootMap = $this->tree->rootAncestorMap($userId); + + $rolledUp = Transaction::query() ->where('transactions.user_id', $userId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->join('categories', function ($join) { @@ -102,16 +116,23 @@ class DashboardController extends Controller }) ->select('transactions.category_id', DB::raw('sum(transactions.amount) as total_amount')) ->groupBy('transactions.category_id') - ->with('category') ->get() - ->filter(fn ($item): bool => (int) $item->total_amount < 0) - ->map(function ($item) { - return [ - 'category_id' => $item->category_id, - 'category' => $item->category, - 'amount' => (int) -$item->total_amount, - ]; - }); + ->groupBy(fn ($item): string => $rootMap[$item->category_id] ?? $item->category_id) + ->map(fn (Collection $items, string $rootId): array => [ + 'category_id' => $rootId, + 'amount' => (int) -$items->sum('total_amount'), + ]) + ->filter(fn (array $item): bool => $item['amount'] > 0); + + $categories = Category::query() + ->whereIn('id', $rolledUp->keys()) + ->get() + ->keyBy('id'); + + return $rolledUp + ->map(fn (array $item): array => [...$item, 'category' => $categories->get($item['category_id'])]) + ->filter(fn (array $item): bool => $item['category'] !== null) + ->values(); } private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 3dac4e27..9b1e7f7b 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -29,8 +29,8 @@ class OnboardingController extends Controller $categories = Category::query() ->where('user_id', $user->id) - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color', 'type']); + ->forDisplay() + ->get(); $transactions = Transaction::query() ->where('user_id', $user->id) diff --git a/app/Http/Controllers/Settings/CategoryController.php b/app/Http/Controllers/Settings/CategoryController.php index 2bba4fbf..75637747 100644 --- a/app/Http/Controllers/Settings/CategoryController.php +++ b/app/Http/Controllers/Settings/CategoryController.php @@ -2,21 +2,28 @@ namespace App\Http\Controllers\Settings; +use App\Enums\CategoryDeletionStrategy; +use App\Features\CategoryTree as CategoryTreeFeature; use App\Http\Controllers\Controller; +use App\Http\Requests\Settings\DeleteCategoryRequest; use App\Http\Requests\Settings\StoreCategoryRequest; use App\Http\Requests\Settings\UpdateCategoryRequest; use App\Models\Category; +use App\Services\CategoryTree; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Validation\ValidationException; use Inertia\Inertia; use Inertia\Response; +use Laravel\Pennant\Feature; class CategoryController extends Controller { use AuthorizesRequests; + public function __construct(private readonly CategoryTree $tree = new CategoryTree) {} + /** * Show the user's categories settings page. */ @@ -24,11 +31,12 @@ class CategoryController extends Controller { $categories = auth()->user() ->categories() - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color', 'type', 'cashflow_direction']); + ->forDisplay() + ->get(); return Inertia::render('settings/categories', [ 'categories' => $categories, + 'categoryTreeEnabled' => Feature::for(auth()->user())->active(CategoryTreeFeature::class), ]); } @@ -59,25 +67,49 @@ class CategoryController extends Controller $this->throwDuplicateCategoryNameValidationException($exception); } + $this->tree->syncDescendantTypes($category); + return to_route('categories.index'); } /** - * Soft delete the specified category. + * Soft delete the specified category, handling its children according to + * the chosen strategy. */ - public function destroy(Category $category): RedirectResponse + public function destroy(DeleteCategoryRequest $request, Category $category): RedirectResponse { $this->authorize('delete', $category); - $category->delete(); + match ($request->strategy()) { + CategoryDeletionStrategy::Cascade => $this->tree->deleteSubtree($category), + CategoryDeletionStrategy::Promote => $this->detachChildrenAndDelete($category, null), + CategoryDeletionStrategy::Reparent => $this->detachChildrenAndDelete($category, $category->parent_id), + }; return to_route('categories.index'); } + /** + * Move the category's direct children to a new parent, then soft delete it. + */ + private function detachChildrenAndDelete(Category $category, ?string $newParentId): void + { + try { + $category->children()->update(['parent_id' => $newParentId]); + } catch (UniqueConstraintViolationException) { + throw ValidationException::withMessages([ + 'strategy' => __('A category with the same name already exists at the destination level. Rename it first.'), + ]); + } + + $category->delete(); + } + private function throwDuplicateCategoryNameValidationException(UniqueConstraintViolationException $exception): never { if (! str_contains($exception->getMessage(), 'categories_user_id_name_unique') - && ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique')) { + && ! str_contains($exception->getMessage(), 'categories_user_id_name_active_unique') + && ! str_contains($exception->getMessage(), 'categories_user_id_parent_name_active_unique')) { throw $exception; } diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 6f9e3fa5..29e2180d 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -72,8 +72,8 @@ class TransactionController extends Controller $categories = Category::query() ->where('user_id', $user->id) - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color']); + ->forDisplay() + ->get(); $accounts = Account::query() ->where('user_id', $user->id) @@ -114,8 +114,8 @@ class TransactionController extends Controller $categories = Category::query() ->where('user_id', $user->id) - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color', 'type']); + ->forDisplay() + ->get(); $accounts = Account::query() ->where('user_id', $user->id) diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 9abded9b..33fecc9a 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -144,8 +144,8 @@ class HandleInertiaRequests extends Middleware return $data; }) : [], 'categories' => fn () => $user ? $user->categories() - ->orderBy('name') - ->get(['id', 'name', 'icon', 'color']) : [], + ->forDisplay() + ->get() : [], 'banks' => fn () => $user ? $user->banks() ->orderBy('name') ->get(['id', 'name', 'logo']) : [], diff --git a/app/Http/Requests/Settings/Concerns/PreparesCategoryAttributes.php b/app/Http/Requests/Settings/Concerns/PreparesCategoryAttributes.php new file mode 100644 index 00000000..8917ceaa --- /dev/null +++ b/app/Http/Requests/Settings/Concerns/PreparesCategoryAttributes.php @@ -0,0 +1,81 @@ +parentCategory(); + + if ($parent !== null) { + $this->merge([ + 'type' => $parent->type->value, + 'cashflow_direction' => $parent->cashflow_direction->value, + ]); + + return; + } + + $this->resolveCashflowDirectionFromType(); + } + + /** + * The requested parent category, or null when creating/keeping a root. + */ + protected function parentCategory(): ?Category + { + if ($this->parentResolved) { + return $this->resolvedParent; + } + + $this->parentResolved = true; + + $parentId = $this->input('parent_id'); + + if (blank($parentId)) { + return $this->resolvedParent = null; + } + + return $this->resolvedParent = Category::query() + ->where('user_id', auth()->id()) + ->whereKey($parentId) + ->first(); + } + + /** + * Scope a unique-name check to the user and the category's siblings (same + * parent), treating a null parent as the root level. + */ + protected function scopeUniqueToSiblings(Builder $query): Builder + { + $query->where('user_id', auth()->id()); + + $parentId = $this->input('parent_id'); + + return blank($parentId) + ? $query->whereNull('parent_id') + : $query->where('parent_id', $parentId); + } +} diff --git a/app/Http/Requests/Settings/DeleteCategoryRequest.php b/app/Http/Requests/Settings/DeleteCategoryRequest.php new file mode 100644 index 00000000..110986e4 --- /dev/null +++ b/app/Http/Requests/Settings/DeleteCategoryRequest.php @@ -0,0 +1,41 @@ +|string> + */ + public function rules(): array + { + return [ + 'strategy' => ['nullable', 'string', Rule::enum(CategoryDeletionStrategy::class)], + ]; + } + + /** + * The chosen strategy for handling children, defaulting to lifting them up + * to the deleted category's own parent. + */ + public function strategy(): CategoryDeletionStrategy + { + return CategoryDeletionStrategy::tryFrom((string) $this->input('strategy')) + ?? CategoryDeletionStrategy::Reparent; + } +} diff --git a/app/Http/Requests/Settings/StoreCategoryRequest.php b/app/Http/Requests/Settings/StoreCategoryRequest.php index 921062f9..37067988 100644 --- a/app/Http/Requests/Settings/StoreCategoryRequest.php +++ b/app/Http/Requests/Settings/StoreCategoryRequest.php @@ -5,14 +5,15 @@ namespace App\Http\Requests\Settings; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryColor; use App\Enums\CategoryType; -use App\Http\Requests\Concerns\ResolvesCategoryCashflowDirection; +use App\Http\Requests\Settings\Concerns\PreparesCategoryAttributes; +use App\Rules\ValidCategoryParent; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class StoreCategoryRequest extends FormRequest { - use ResolvesCategoryCashflowDirection; + use PreparesCategoryAttributes; /** * Determine if the user is authorized to make this request. @@ -35,9 +36,15 @@ class StoreCategoryRequest extends FormRequest 'string', 'max:255', Rule::unique('categories', 'name') - ->where('user_id', auth()->id()) + ->where(fn ($query) => $this->scopeUniqueToSiblings($query)) ->withoutTrashed(), ], + 'parent_id' => [ + 'nullable', + 'string', + 'uuid', + new ValidCategoryParent(null), + ], 'icon' => ['required', 'string'], 'color' => [ 'required', diff --git a/app/Http/Requests/Settings/UpdateCategoryRequest.php b/app/Http/Requests/Settings/UpdateCategoryRequest.php index a9be4cd7..541aaf73 100644 --- a/app/Http/Requests/Settings/UpdateCategoryRequest.php +++ b/app/Http/Requests/Settings/UpdateCategoryRequest.php @@ -5,14 +5,16 @@ namespace App\Http\Requests\Settings; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryColor; use App\Enums\CategoryType; -use App\Http\Requests\Concerns\ResolvesCategoryCashflowDirection; +use App\Http\Requests\Settings\Concerns\PreparesCategoryAttributes; +use App\Models\Category; +use App\Rules\ValidCategoryParent; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Foundation\Http\FormRequest; use Illuminate\Validation\Rule; class UpdateCategoryRequest extends FormRequest { - use ResolvesCategoryCashflowDirection; + use PreparesCategoryAttributes; /** * Determine if the user is authorized to make this request. @@ -29,15 +31,23 @@ class UpdateCategoryRequest extends FormRequest */ public function rules(): array { + $category = $this->route('category'); + return [ 'name' => [ 'required', 'string', 'max:255', Rule::unique('categories', 'name') - ->where('user_id', auth()->id()) + ->where(fn ($query) => $this->scopeUniqueToSiblings($query)) ->withoutTrashed() - ->ignore($this->route('category')), + ->ignore($category), + ], + 'parent_id' => [ + 'nullable', + 'string', + 'uuid', + new ValidCategoryParent($category instanceof Category ? $category : null), ], 'icon' => ['required', 'string'], 'color' => [ diff --git a/app/Models/Category.php b/app/Models/Category.php index 108e3a05..36e3e84d 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -5,6 +5,7 @@ namespace App\Models; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; use Database\Factories\CategoryFactory; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -12,11 +13,42 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\SoftDeletes; +/** + * @property string $id + * @property string $name + * @property string $icon + * @property string $color + * @property CategoryType $type + * @property CategoryCashflowDirection $cashflow_direction + * @property string $user_id + * @property string|null $parent_id + */ class Category extends Model { /** @use HasFactory */ use HasFactory, HasUuids, SoftDeletes; + /** + * Maximum allowed nesting depth (a root counts as level 1). + */ + public const int MAX_DEPTH = 3; + + /** + * Columns sent to the frontend. Always returns the full Category shape so + * every selector receives the same object regardless of what it needs. + * + * @var list + */ + private const array FRONTEND_COLUMNS = [ + 'id', + 'name', + 'icon', + 'color', + 'type', + 'cashflow_direction', + 'parent_id', + ]; + protected $fillable = [ 'name', 'icon', @@ -24,6 +56,7 @@ class Category extends Model 'type', 'cashflow_direction', 'user_id', + 'parent_id', ]; protected function casts(): array @@ -45,4 +78,46 @@ class Category extends Model { return $this->hasMany(Transaction::class); } + + /** @return BelongsTo */ + public function parent(): BelongsTo + { + return $this->belongsTo(Category::class, 'parent_id'); + } + + /** @return HasMany */ + public function children(): HasMany + { + return $this->hasMany(Category::class, 'parent_id'); + } + + /** + * Recursively eager-load the whole subtree (bounded by MAX_DEPTH). + * + * @return HasMany + */ + public function descendants(): HasMany + { + return $this->children()->with('descendants'); + } + + /** + * Whether this category sits at the top of the tree. + */ + public function isRoot(): bool + { + return $this->parent_id === null; + } + + /** + * Scope for fetching categories to send to the frontend: the full, ordered + * Category shape used by every category selector. + * + * @param Builder $query + * @return Builder + */ + public function scopeForDisplay(Builder $query): Builder + { + return $query->orderBy('name')->select(self::FRONTEND_COLUMNS); + } } diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index eb99543f..d7aa0dea 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -6,6 +6,7 @@ use App\Enums\TransactionSource; use App\Events\TransactionCreated; use App\Events\TransactionDeleted; use App\Events\TransactionUpdated; +use App\Services\CategoryTree; use Carbon\Carbon; use Database\Factories\TransactionFactory; use Illuminate\Database\Eloquent\Builder; @@ -122,6 +123,14 @@ class Transaction extends Model $hasUncategorized = $ids->contains('uncategorized'); $realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all(); + if ($realIds !== []) { + $userId = $filters['user_id'] ?? Category::query()->whereIn('id', $realIds)->value('user_id'); + + if ($userId !== null) { + $realIds = app(CategoryTree::class)->expand($userId, $realIds); + } + } + $query->where(function (Builder $q) use ($realIds, $hasUncategorized) { if (! empty($realIds)) { $q->whereIn('category_id', $realIds); diff --git a/app/Rules/ValidCategoryParent.php b/app/Rules/ValidCategoryParent.php new file mode 100644 index 00000000..c8274722 --- /dev/null +++ b/app/Rules/ValidCategoryParent.php @@ -0,0 +1,55 @@ +where('user_id', auth()->id()) + ->whereKey($value) + ->first(); + + if ($parent === null) { + $fail(__('The selected parent category is invalid.')); + + return; + } + + if ($this->category !== null && $this->tree->wouldCreateCycle($this->category, $parent->id)) { + $fail(__('A category cannot be nested under itself or one of its children.')); + + return; + } + + $subtreeDepth = $this->category !== null + ? $this->tree->subtreeDepth($this->category) + : 1; + + if ($this->tree->depth($parent) + $subtreeDepth > Category::MAX_DEPTH) { + $fail(__('Categories can only be nested up to :max levels deep.', ['max' => Category::MAX_DEPTH])); + } + } +} diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index befc7f34..d9fb103d 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -10,6 +10,8 @@ use Illuminate\Support\Facades\Log; class BudgetTransactionService { + public function __construct(private readonly CategoryTree $tree = new CategoryTree) {} + public function assignTransaction(Transaction $transaction): void { $userId = $transaction->user_id; @@ -23,13 +25,20 @@ class BudgetTransactionService $transactionLabelIds = $transaction->labels->pluck('id'); + // A budget tracking a parent category also covers its children, so a + // transaction matches a budget when any of its category's ancestors + // (or itself) is attached to that budget. + $categoryMatchIds = $transaction->category_id + ? $this->tree->ancestorAndSelfIds($userId, $transaction->category_id) + : []; + // Find budget periods that potentially match this transaction. $budgetPeriods = BudgetPeriod::query() - ->whereHas('budget', function ($query) use ($transaction, $transactionLabelIds, $userId) { + ->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $userId) { $query->where('user_id', $userId) - ->where(function ($q) use ($transaction, $transactionLabelIds) { - $q->whereHas('categories', function ($cq) use ($transaction) { - $cq->whereKey($transaction->category_id); + ->where(function ($q) use ($categoryMatchIds, $transactionLabelIds) { + $q->whereHas('categories', function ($cq) use ($categoryMatchIds) { + $cq->whereIn('categories.id', $categoryMatchIds); }) ->orWhereHas('labels', function ($lq) use ($transactionLabelIds) { $lq->whereIn('labels.id', $transactionLabelIds); @@ -47,8 +56,8 @@ class BudgetTransactionService foreach ($budgetPeriods as $period) { $budget = $period->budget; - $matchesCategory = $transaction->category_id - && $budget->categories->contains('id', $transaction->category_id); + $matchesCategory = $categoryMatchIds !== [] + && $budget->categories->pluck('id')->intersect($categoryMatchIds)->isNotEmpty(); $matchesLabel = $budget->labels ->pluck('id') ->intersect($transactionLabelIds) @@ -105,7 +114,8 @@ class BudgetTransactionService $assignedCount = 0; - $categoryIds = $budget->categories->pluck('id'); + // Tracking a parent category also tracks its children's spending. + $categoryIds = collect($this->tree->expand($budget->user_id, $budget->categories->pluck('id')->all())); $labelIds = $budget->labels->pluck('id'); Log::info('Building query for historical transactions', [ diff --git a/app/Services/CategoryTree.php b/app/Services/CategoryTree.php new file mode 100644 index 00000000..6d537054 --- /dev/null +++ b/app/Services/CategoryTree.php @@ -0,0 +1,219 @@ + $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; + } + + /** + * Map every category id of a user to its top-level ancestor id (roots map + * to themselves). Used to roll child amounts up into their parent. + * + * @return array + */ + public function rootAncestorMap(string $userId): array + { + $parents = Category::query() + ->where('user_id', $userId) + ->pluck('parent_id', 'id') + ->all(); + + $map = []; + + foreach ($parents as $id => $parentId) { + $rootId = $id; + $guard = 0; + + while ($parentId !== null && array_key_exists($parentId, $parents) && $guard++ < Category::MAX_DEPTH) { + $rootId = $parentId; + $parentId = $parents[$parentId]; + } + + $map[$id] = $rootId; + } + + return $map; + } + + /** + * 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(); + } +} diff --git a/database/factories/CategoryFactory.php b/database/factories/CategoryFactory.php index e119de76..acfc12e2 100644 --- a/database/factories/CategoryFactory.php +++ b/database/factories/CategoryFactory.php @@ -27,6 +27,20 @@ class CategoryFactory extends Factory 'type' => fake()->randomElement(CategoryType::cases()), 'cashflow_direction' => CategoryCashflowDirection::Hidden, 'user_id' => User::factory(), + 'parent_id' => null, ]; } + + /** + * Nest the category under a parent, inheriting its owner and type. + */ + public function childOf(Category $parent): static + { + return $this->state(fn (): array => [ + 'parent_id' => $parent->id, + 'user_id' => $parent->user_id, + 'type' => $parent->type, + 'cashflow_direction' => $parent->cashflow_direction, + ]); + } } diff --git a/database/migrations/2026_06_02_090000_add_parent_id_to_categories_table.php b/database/migrations/2026_06_02_090000_add_parent_id_to_categories_table.php new file mode 100644 index 00000000..aada6eee --- /dev/null +++ b/database/migrations/2026_06_02_090000_add_parent_id_to_categories_table.php @@ -0,0 +1,60 @@ +uuid('parent_id')->nullable()->after('user_id'); + $table->foreign('parent_id')->references('id')->on('categories')->nullOnDelete(); + $table->index('parent_id'); + }); + + Schema::table('categories', function (Blueprint $table) { + $table->string('parent_unique_marker') + ->nullable() + ->virtualAs("coalesce(`parent_id`, 'root')"); + }); + + // Create the new uniqueness index before dropping the old one so the + // user_id foreign key always has a supporting (user_id-leading) index. + Schema::table('categories', function (Blueprint $table) { + $table->unique( + ['user_id', 'parent_unique_marker', 'name', 'active_unique_marker'], + 'categories_user_id_parent_name_active_unique' + ); + }); + + Schema::table('categories', function (Blueprint $table) { + $table->dropUnique('categories_user_id_name_active_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->unique(['user_id', 'name', 'active_unique_marker'], 'categories_user_id_name_active_unique'); + }); + + Schema::table('categories', function (Blueprint $table) { + $table->dropUnique('categories_user_id_parent_name_active_unique'); + $table->dropColumn('parent_unique_marker'); + }); + + Schema::table('categories', function (Blueprint $table) { + $table->dropForeign(['parent_id']); + $table->dropIndex(['parent_id']); + $table->dropColumn('parent_id'); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index aea2f74b..7a2b76e7 100644 --- a/lang/es.json +++ b/lang/es.json @@ -91,6 +91,7 @@ "@sarahm_finance": "@sarahm_finance", "A Few Quick Questions": "Unas Preguntas Rápidas", "A Special Offer for You": "Una Oferta Especial para Ti", + "A category with the same name already exists at the destination level. Rename it first.": "Ya existe una categoría con el mismo nombre en el nivel de destino. Renómbrala primero.", "A new verification link has been sent to the email address you provided during registration.": "Se ha enviado un nuevo enlace de verificación a la dirección de correo electrónico que proporcionaste durante el registro.", "A new verification link has been sent to your email address.": "Se ha enviado un nuevo enlace de verificación a tu dirección de correo electrónico.", "A new verification link has\\n been sent to your email\\n address.": "Se ha enviado un nuevo enlace de verificación a tu dirección de correo electrónico.", @@ -160,6 +161,7 @@ "All Set!": "¡Todo Listo!", "All Your Accounts": "Todas Tus Cuentas", "All associated accounts, transactions and balances will be permanently deleted.": "Todas las cuentas, transacciones y saldos asociados serán eliminados permanentemente.", + "All categories": "Todas las categorías", "All data is stored on secure servers with encryption at rest": "Todos los datos se almacenan en servidores seguros con encriptación en reposo", "All fees are non-refundable except as required by law or explicitly stated otherwise": "Todas las tarifas no son reembolsables excepto según lo requiera la ley o se indique explícitamente de otra manera", "All fees are non-refundable except as\\n required by law or explicitly stated\\n otherwise": "Todas las tarifas no son reembolsables excepto cuando lo exija la ley o se indique explícitamente lo contrario", @@ -205,6 +207,7 @@ "Applying rule…": "Aplicando regla…", "Applying rule… :processed of :total processed": "Aplicando regla… :processed de :total procesadas", "Are you overspending? Know exactly where you stand.": "¿Estás gastando de más? Conoce exactamente dónde estás parado.", + "Are you sure you want to delete": "¿Estás seguro de que deseas eliminar", "Are you sure you want to delete \"": "¿Estás seguro de que deseas eliminar \"", "Are you sure you want to delete this balance record? This action cannot be undone.": "¿Estás seguro de que deseas eliminar este registro de balance? Esta acción no se puede deshacer.", "Are you sure you want to delete this owed amount record? This action cannot be undone.": "¿Estás seguro de que deseas eliminar este registro de monto adeudado? Esta acción no se puede deshacer.", @@ -311,7 +314,6 @@ "Cashflow income vs expenses bar chart": "Gráfico de barras de ingresos frente a gastos", "Cashflow periods and analysis will start on this day.": "Los períodos y análisis de flujo de caja empezarán este día.", "Cashflow visualization": "Visualización del flujo de caja", - "Categories": "Categorías", "Categories below 5% of total": "Categorías por debajo del 5% del total", "Categories settings": "Configuración de categorías", "Categorize": "Categorizar", @@ -330,6 +332,7 @@ "Check that everything looks correct and click \"Import\". Your transactions will be encrypted and stored securely.": "Verifica que todo se vea correcto y haz clic en \"Importar\". Tus transacciones serán encriptadas y almacenadas de forma segura.", "Check your inbox — we've sent you an email.": "Revisa tu bandeja de entrada — te hemos enviado un correo.", "Checking": "Cuenta Corriente", + "Child categories inherit their parent’s type and cashflow settings.": "Las categorías hijas heredan el tipo y la configuración de flujo de caja de su categoría padre.", "Chilean Peso": "Peso chileno", "Chinese Yuan": "Yuan chino", "Choose Sankey chart visibility": "Elige la visibilidad en el gráfico Sankey", @@ -499,6 +502,7 @@ "Delete balance": "Eliminar balance", "Delete budget": "Eliminar presupuesto", "Delete owed amount": "Eliminar monto adeudado", + "Delete this category and all of its children": "Eliminar esta categoría y todas sus categorías hijas", "Delete your account and all of its resources": "Elimina tu cuenta y todos sus recursos", "Deleting...": "Eliminando...", "Demo encryption password:": "Contraseña de encriptación de demo:", @@ -780,6 +784,7 @@ "Indian Rupee": "Rupia india", "Information about how you use our service, including access times and features used": "Información sobre cómo usas nuestro servicio, incluyendo horarios de acceso y funciones utilizadas", "Information about how you use our service,\\n including access times and features used": "Información sobre cómo usas nuestro servicio, incluyendo tiempos de acceso y funciones utilizadas", + "Inherited from parent": "Heredado de la categoría padre", "Install App": "Instalar App", "Install Whisper Money": "Instalar Whisper Money", "Instant Application": "Aplicación Instantánea", @@ -818,7 +823,6 @@ "Key Storage": "Almacenamiento de Clave", "Label (Optional)": "Etiqueta (Opcional)", "Label name": "Nombre de etiqueta", - "Labels": "Etiquetas", "Labels settings": "Configuración de etiquetas", "Land": "Terreno", "Language": "Idioma", @@ -876,6 +880,7 @@ "Main Checking": "Cuenta Corriente Principal", "Maintain and promptly update your account information": "Mantén y actualiza prontamente tu información de cuenta", "Maintain and promptly update your account\\n information": "Mantén y actualiza puntualmente la información de tu cuenta", + "Make child categories top level": "Convertir las categorías hijas en categorías principales", "Making your money talk...": "Haciendo que tu dinero hable...", "Manage Plan": "Gestionar Plan", "Manage Subscription": "Gestionar Suscripción", @@ -931,6 +936,8 @@ "Mortgages and loans": "Hipotecas y préstamos", "Most Popular": "Más Popular", "Move Up — Share Your Personal Link": "Sube puestos — Comparte Tu Enlace Personal", + "Move child categories to the top level": "Mover las categorías hijas al nivel principal", + "Move child categories up to the parent": "Mover las categorías hijas a la categoría padre", "Move up 10 spots per referral": "Avanza 10 puestos por referido", "Movie Night": "Noche de Cine", "Moving money between accounts. It does not count in expenses or income charts.": "Mover dinero entre cuentas. No cuenta en los gráficos de gastos o ingresos.", @@ -998,6 +1005,7 @@ "No worries! Your encryption key is automatically generated and stored securely on your device. You don't need to remember anything - I've made it as simple as possible.": "¡No te preocupes! Tu clave de encriptación se genera automáticamente y se almacena de forma segura en tu dispositivo. No necesitas recordar nada, lo he hecho lo más simple posible.", "None": "Ninguno", "None (Remove)": "Ninguno (Eliminar)", + "None (top level)": "Ninguna (nivel principal)", "Not sure how to import transactions?": "¿No sabes cómo importar transacciones?", "Notes": "Notas", "Notifications": "Notificaciones", @@ -1034,6 +1042,7 @@ "Owed amount evolution": "Evolución del monto adeudado", "Page": "Página", "Paraguayan Guarani": "Guaraní paraguayo", + "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.", "Password changes are disabled on the demo\\n account.": "Los cambios de contraseña están desactivados en la cuenta de demostración.", @@ -1410,6 +1419,7 @@ "This account tracks balance changes over time instead of individual transactions.": "Esta cuenta rastrea cambios de balance a lo largo del tiempo en lugar de transacciones individuales.", "This account type is for balance tracking only and doesn't support transactions.": "Este tipo de cuenta es solo para rastreo de balance y no soporta transacciones.", "This account type is for balance tracking only and\\n doesn't support transactions.": "Este tipo de cuenta es solo para seguimiento de saldo y no admite transacciones.", + "This action cannot be undone.": "Esta acción no se puede deshacer.", "This action cannot be undone. To confirm, type": "Esta acción no se puede deshacer. Para confirmar, escribe", "This action is irreversible. All transactions in this account will also be permanently deleted.": "Esta acción es irreversible. Todas las transacciones en esta cuenta también serán eliminadas permanentemente.", "This code won't last forever, but more importantly, your support means the world to me. As a solo founder, every subscriber helps me continue building something I'm passionate about.": "Este código no durará para siempre, pero lo más importante es que tu apoyo significa mucho para mí. Como fundador en solitario, cada suscriptor me ayuda a seguir construyendo algo que me apasiona.", @@ -1489,6 +1499,7 @@ "Transaction\\n details, budgets, categories, and other\\n financial information you manually enter\\n into the application": "Detalles de transacciones, presupuestos, categorías y otra información financiera que introduces manualmente en la aplicación", "Transactions": "Transacciones", "Transactions + Balance": "Transacciones + Balance", + "Transactions in the deleted categories will become uncategorized.": "Las transacciones de las categorías eliminadas quedarán sin categoría.", "Transactions in this category will not be counted in top expenses or income. Transfer categories are mainly used for transactions between accounts.": "Las transacciones en esta categoría no se contarán entre los principales gastos o ingresos. Las categorías de transferencia se usan principalmente para transacciones entre cuentas.", "Transactions in this category will\\n not be counted in top expenses or\\n income. Transfer categories are\\n mainly used for transactions between\\n accounts.": "Las transacciones en esta categoría no se contarán entre los principales gastos o ingresos. Las categorías de transferencia se usan principalmente para transacciones entre cuentas.", "Transfer": "Transferencia", @@ -1669,6 +1680,7 @@ "What happens after you confirm?": "¿Qué pasa después de confirmar?", "What is Whisper Money?": "¿Qué es Whisper Money?", "What people are saying": "Lo que la gente dice", + "What should happen to its child categories?": "¿Qué debería pasar con sus categorías hijas?", "What you get": "Lo que obtienes", "What's more, even if I wanted to use it, I couldn't, since you're the only one who has the key to read the data.": "Es más, aunque quisiera usarlos, no podría, ya que tú eres el único que tiene la clave para leer los datos.", "When I started building Whisper Money, I was frustrated with finance apps that wanted to mine my data or use AI to analyze my spending. I just wanted a simple, private way to track my finances - and I figured others might too.": "Cuando empecé a construir Whisper Money, estaba frustrado con las apps de finanzas que querían minar mis datos o usar IA para analizar mis gastos. Solo quería una forma simple y privada de rastrear mis finanzas - y pensé que otros también.", @@ -1855,6 +1867,7 @@ "category(ies) total.": "categoría(s) en total.", "contains": "contiene", "cyan": "cian", + "direct": "directo", "e.g. 360 for a 30-year mortgage": "ej. 360 para una hipoteca a 30 años", "e.g., Monthly Budget": "ej., Presupuesto Mensual", "e.g., Padel Budget": "ej., Presupuesto de Pádel", diff --git a/resources/js/components/budgets/create-budget-dialog.tsx b/resources/js/components/budgets/create-budget-dialog.tsx index e60b1867..fdaf8696 100644 --- a/resources/js/components/budgets/create-budget-dialog.tsx +++ b/resources/js/components/budgets/create-budget-dialog.tsx @@ -20,6 +20,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { buildCategoryTree, flattenCategoryTree } from '@/lib/category-tree'; import { cn } from '@/lib/utils'; import { SharedData } from '@/types'; import { @@ -231,7 +232,9 @@ export function CreateBudgetDialog({ { + options={flattenCategoryTree( + buildCategoryTree(allCategories), + ).map((category) => { const colorClasses = getCategoryColorClasses( category.color, @@ -243,6 +246,8 @@ export function CreateBudgetDialog({ return { value: category.id, label: category.name, + depth: category.depth, + parentValue: category.parent_id, icon: IconComponent ? ( ) : undefined, diff --git a/resources/js/components/categories/create-category-dialog.tsx b/resources/js/components/categories/create-category-dialog.tsx index 92468d7d..bfcf7210 100644 --- a/resources/js/components/categories/create-category-dialog.tsx +++ b/resources/js/components/categories/create-category-dialog.tsx @@ -1,5 +1,6 @@ import { store } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { CategoryCashflowDirectionFields } from '@/components/categories/category-cashflow-direction-fields'; +import { ParentCategoryField } from '@/components/categories/parent-category-field'; import InputError from '@/components/input-error'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -27,6 +28,7 @@ import { CATEGORY_TYPES, getCategoryColorClasses, getCategoryTypeLabel, + type Category, type CategoryType, } from '@/types/category'; import { __ } from '@/utils/i18n'; @@ -35,12 +37,15 @@ import * as Icons from 'lucide-react'; import { useState } from 'react'; export function CreateCategoryDialog({ + categories, onSuccess, }: { + categories: Category[]; onSuccess?: () => void; }) { const [open, setOpen] = useState(false); const [selectedType, setSelectedType] = useState(''); + const [parent, setParent] = useState(null); return ( @@ -78,6 +83,18 @@ export function CreateCategoryDialog({ + { + setParent(next); + if (next) { + setSelectedType(next.type); + } + }} + error={errors.parent_id} + /> +
- setSelectedType(value as CategoryType) - } - > - - - - - {CATEGORY_TYPES.map((type) => ( - - {getCategoryTypeLabel(type)} - - ))} - - - -
+ {parent ? ( +
+ + +

+ {getCategoryTypeLabel(parent.type)} + {' · '} + {__('Inherited from parent')} +

+
+ ) : ( + <> +
+ + + +
- + + + )}
- - + {({ processing, errors }) => ( +
+ {hasChildren && ( +
+ + + + setStrategy( + value as DeletionStrategy, + ) + } + > + {options.map((option) => ( + + ))} + + {strategy === 'cascade' && ( +

+ {__( + 'Transactions in the deleted categories will become uncategorized.', + )} +

+ )} +
+ )} + {errors.strategy && ( +

+ {errors.strategy} +

+ )} + + + + +
)} diff --git a/resources/js/components/categories/edit-category-dialog.tsx b/resources/js/components/categories/edit-category-dialog.tsx index ff6688f9..d87cc18b 100644 --- a/resources/js/components/categories/edit-category-dialog.tsx +++ b/resources/js/components/categories/edit-category-dialog.tsx @@ -1,5 +1,6 @@ import { update } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { CategoryCashflowDirectionFields } from '@/components/categories/category-cashflow-direction-fields'; +import { ParentCategoryField } from '@/components/categories/parent-category-field'; import InputError from '@/components/input-error'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -35,6 +36,7 @@ import { useState } from 'react'; interface EditCategoryDialogProps { category: Category; + categories: Category[]; open: boolean; onOpenChange: (open: boolean) => void; onSuccess?: () => void; @@ -42,6 +44,7 @@ interface EditCategoryDialogProps { export function EditCategoryDialog({ category, + categories, open, onOpenChange, onSuccess, @@ -49,6 +52,11 @@ export function EditCategoryDialog({ const [selectedType, setSelectedType] = useState( category.type, ); + const [parent, setParent] = useState( + category.parent_id + ? (categories.find((c) => c.id === category.parent_id) ?? null) + : null, + ); return ( @@ -82,6 +90,19 @@ export function EditCategoryDialog({
+ { + setParent(next); + if (next) { + setSelectedType(next.type); + } + }} + error={errors.parent_id} + /> +
- setSelectedType(value as CategoryType) - } - > - - - - - {CATEGORY_TYPES.map((type) => ( - - {getCategoryTypeLabel(type)} - - ))} - - - -
+ {parent ? ( +
+ + +

+ {getCategoryTypeLabel(parent.type)} + {' · '} + {__('Inherited from parent')} +

+
+ ) : ( + <> +
+ + + +
- + + + )}
- {__('Uncategorized')} + {noneLabel} ) : ( @@ -133,7 +175,7 @@ export function CategoryCombobox({ - + - {__('No category found.')} - {showUncategorized && ( + {!hasResults && ( +
+ {__('No category found.')} +
+ )} + {showNone && ( { @@ -154,7 +200,7 @@ export function CategoryCombobox({
- {__('Uncategorized')} + {noneLabel}
)} - {sortedCategories.map((category) => ( + {visibleNodes.map((category) => ( { onValueChange(String(category.id)); setOpen(false); }} > -
+
{category.name} diff --git a/resources/js/components/transactions/categorizer-command.tsx b/resources/js/components/transactions/categorizer-command.tsx index 2654fedf..29c714f0 100644 --- a/resources/js/components/transactions/categorizer-command.tsx +++ b/resources/js/components/transactions/categorizer-command.tsx @@ -1,7 +1,6 @@ import { CategoryIcon } from '@/components/shared/category-combobox'; import { Command, - CommandEmpty, CommandGroup, CommandInput, CommandItem, @@ -9,12 +8,17 @@ import { } from '@/components/ui/command'; import { Kbd } from '@/components/ui/kbd'; import { type AnimationState } from '@/hooks/use-categorize-transactions'; +import { + buildCategoryTree, + flattenCategoryTree, + getCategoryPath, +} from '@/lib/category-tree'; import { cn } from '@/lib/utils'; import { type Category, getCategoryColorClasses } from '@/types/category'; import { type DecryptedTransaction } from '@/types/transaction'; import { __ } from '@/utils/i18n'; import { ArrowDown, ArrowUp } from 'lucide-react'; -import { type RefObject } from 'react'; +import { type RefObject, useMemo } from 'react'; interface CategorizerCommandProps { sortedCategories: Category[]; @@ -37,6 +41,39 @@ export function CategorizerCommand({ commandInputRef, disabled = false, }: CategorizerCommandProps) { + const treeCategories = useMemo( + () => flattenCategoryTree(buildCategoryTree(sortedCategories)), + [sortedCategories], + ); + + const query = searchValue.trim().toLowerCase(); + + // Tree-aware search: keep each match together with its ancestors so a + // matching child still shows its parent chain for context. + const visibleCategories = useMemo(() => { + if (!query) { + return treeCategories; + } + + const parentOf = new Map( + sortedCategories.map((c) => [c.id, c.parent_id]), + ); + const include = new Set(); + for (const category of sortedCategories) { + if (!category.name.toLowerCase().includes(query)) { + continue; + } + let id: string | null | undefined = category.id; + let guard = 0; + while (id != null && guard++ < 10) { + include.add(id); + id = parentOf.get(id); + } + } + + return treeCategories.filter((node) => include.has(node.id)); + }, [treeCategories, sortedCategories, query]); + if (animationState === 'success' || !currentTransaction) { return null; } @@ -68,7 +105,7 @@ export function CategorizerCommand({
- {__('No categories found.')} + {visibleCategories.length === 0 && ( +
+ {__('No categories found.')} +
+ )} - {sortedCategories.map((category) => { + {visibleCategories.map((category) => { const colorClasses = getCategoryColorClasses( category.color, ); return ( onCategorySelect(category)} disabled={ animationState !== 'idle' || disabled } className="group cursor-pointer gap-3 p-2" + style={{ + paddingLeft: `${0.5 + category.depth * 1.25}rem`, + }} >
id !== categoryId) - : [...filters.categoryIds, categoryId]; + // Snapshot of which categories were selected when the dropdown opened. + // Ordering uses this snapshot, so toggling while open never reshuffles the + // list under the cursor; it only re-sorts the next time it is opened. + const [categoryOrderSnapshot, setCategoryOrderSnapshot] = useState< + Set + >(new Set()); - onFiltersChange({ ...filters, categoryIds: newCategoryIds }); + const categoryTree = useMemo(() => { + // A branch sorts to the top when it (or a descendant) was selected. + const branchSelected = new Set(); + if (categoryOrderSnapshot.size > 0) { + const parentOf = new Map( + categories.map((c) => [c.id, c.parent_id]), + ); + for (const id of categoryOrderSnapshot) { + let current: string | null | undefined = id; + let guard = 0; + while (current != null && guard++ < 10) { + branchSelected.add(current); + current = parentOf.get(current); + } + } + } + + const compare = (a: Category, b: Category) => { + const aSelected = branchSelected.has(a.id); + const bSelected = branchSelected.has(b.id); + if (aSelected !== bSelected) { + return aSelected ? -1 : 1; + } + return a.name.localeCompare(b.name); + }; + + return flattenCategoryTree(buildCategoryTree(categories, compare)); + }, [categories, categoryOrderSnapshot]); + + const selectedCategorySet = useMemo( + () => new Set(filters.categoryIds), + [filters.categoryIds], + ); + + // Tree-aware search: keep each match together with its ancestors so a + // matching child still shows its parent chain for context. + const visibleCategoryTree = useMemo(() => { + const query = categorySearch.trim().toLowerCase(); + if (!query) { + return categoryTree; + } + + const parentOf = new Map(categories.map((c) => [c.id, c.parent_id])); + const include = new Set(); + for (const category of categories) { + if (!category.name.toLowerCase().includes(query)) { + continue; + } + let id: string | null | undefined = category.id; + let guard = 0; + while (id != null && guard++ < 10) { + include.add(id); + id = parentOf.get(id); + } + } + + return categoryTree.filter((node) => include.has(node.id)); + }, [categoryTree, categories, categorySearch]); + + const showUncategorizedRow = + categorySearch.trim() === '' || + 'uncategorized'.includes(categorySearch.trim().toLowerCase()); + const hasCategoryResults = + visibleCategoryTree.length > 0 || showUncategorizedRow; + + function handleCategoryToggle(categoryId: string) { + onFiltersChange({ + ...filters, + categoryIds: toggleCategorySelection( + filters.categoryIds, + categoryId, + categories, + ), + }); } function handleAccountToggle(accountId: number) { @@ -300,9 +383,18 @@ export function TransactionFilters({
{ + if (open) { + setCategoryOrderSnapshot( + new Set( + filters.categoryIds, + ), + ); + } else { + setCategorySearch(''); + } + setCategoryDropdownOpen(open); + }} > + + › + + + {sankeyDrill.label} + + + )} +
{isLoading ? ( @@ -215,6 +248,9 @@ export default function CashflowPage() { height={400} currency={auth.user.currency_code} period={period} + onDrill={(id, label) => + setSankeyDrill({ id, label }) + } /> )} diff --git a/resources/js/pages/settings/categories.tsx b/resources/js/pages/settings/categories.tsx index 9c700515..6730f09a 100644 --- a/resources/js/pages/settings/categories.tsx +++ b/resources/js/pages/settings/categories.tsx @@ -14,8 +14,8 @@ import { VisibilityState, } from '@tanstack/react-table'; import * as Icons from 'lucide-react'; -import { ArrowUpDown, MoreHorizontal } from 'lucide-react'; -import { useState } from 'react'; +import { ArrowDown, ArrowUp, ArrowUpDown, MoreHorizontal } from 'lucide-react'; +import { useMemo, useState } from 'react'; import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { CreateCategoryDialog } from '@/components/categories/create-category-dialog'; @@ -54,9 +54,16 @@ import { } from '@/components/ui/tooltip'; import AppLayout from '@/layouts/app-layout'; import SettingsLayout from '@/layouts/settings/layout'; +import { + buildCategoryTree, + type CategoryNode, + flattenCategoryTree, +} from '@/lib/category-tree'; import { type BreadcrumbItem } from '@/types'; import { type Category, getCategoryColorClasses } from '@/types/category'; +type SortField = 'name' | 'color' | 'type'; + const breadcrumbs: BreadcrumbItem[] = [ { title: 'Categories settings', @@ -64,7 +71,13 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -function CategoryActions({ category }: { category: Category }) { +function CategoryActions({ + category, + categories, +}: { + category: Category; + categories: Category[]; +}) { const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -93,6 +106,7 @@ function CategoryActions({ category }: { category: Category }) { {}} @@ -100,6 +114,7 @@ function CategoryActions({ category }: { category: Category }) { {}} @@ -108,7 +123,13 @@ function CategoryActions({ category }: { category: Category }) { ); } -function CategoryRow({ row }: { row: Row }) { +function CategoryRow({ + row, + categories, +}: { + row: Row; + categories: Category[]; +}) { const category = row.original; const [editOpen, setEditOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); @@ -155,6 +176,7 @@ function CategoryRow({ row }: { row: Row }) { {}} @@ -162,6 +184,7 @@ function CategoryRow({ row }: { row: Row }) { {}} @@ -172,9 +195,54 @@ function CategoryRow({ row }: { row: Row }) { export default function Categories() { const { categories } = usePage<{ categories: Category[] }>().props; - const [sorting, setSorting] = useState([ - { id: 'name', desc: false }, - ]); + + const [sortField, setSortField] = useState('name'); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + + const toggleSort = (field: SortField) => { + if (sortField === field) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')); + return; + } + + setSortField(field); + setSortDirection('asc'); + }; + + // Sort siblings by the chosen column at every level so subcategories stay + // grouped under their parent, then flatten to depth-first display order. + const orderedCategories = useMemo(() => { + const compare = (a: Category, b: Category) => { + const result = String(a[sortField] ?? '').localeCompare( + String(b[sortField] ?? ''), + ); + + return sortDirection === 'asc' ? result : -result; + }; + + return flattenCategoryTree(buildCategoryTree(categories, compare)); + }, [categories, sortField, sortDirection]); + + const sortHeader = (field: SortField, label: string) => ( + + ); + + const [sorting, setSorting] = useState([]); const [columnFilters, setColumnFilters] = useState([]); const [columnVisibility, setColumnVisibility] = useState( {}, @@ -183,20 +251,7 @@ export default function Categories() { const columns: ColumnDef[] = [ { accessorKey: 'name', - header: ({ column }) => { - return ( - - ); - }, + header: () => sortHeader('name', __('Name')), cell: ({ row }) => { const iconName = row.original.icon; const IconComponent = Icons[iconName as keyof typeof Icons] as @@ -204,19 +259,32 @@ export default function Categories() { | undefined; const Icon = IconComponent || Icons.Tag; + const depth = (row.original as CategoryNode).depth ?? 0; + return ( -
+
+ {depth > 0 && ( + + └ + + )} -
+ {row.getValue('name')} -
+
); }, }, { accessorKey: 'color', - header: __('Color'), + header: () => sortHeader('color', __('Color')), cell: ({ row }) => { const color = row.getValue('color') as Category['color']; if (!color) { @@ -237,7 +305,7 @@ export default function Categories() { }, { accessorKey: 'type', - header: __('Type'), + header: () => sortHeader('type', __('Type')), cell: ({ row }) => { const type = row.getValue('type') as Category['type']; const cashflowDirection = row.original.cashflow_direction; @@ -335,12 +403,17 @@ export default function Categories() { { id: 'actions', enableHiding: false, - cell: ({ row }) => , + cell: ({ row }) => ( + + ), }, ]; const table = useReactTable({ - data: categories, + data: orderedCategories, columns, onSortingChange: setSorting, onColumnFiltersChange: setColumnFilters, @@ -383,7 +456,10 @@ export default function Categories() { className="max-w-sm" /> - {}} /> + {}} + />
@@ -423,6 +499,7 @@ export default function Categories() { )) ) : ( diff --git a/resources/js/types/category.ts b/resources/js/types/category.ts index 13effda0..3347e3d8 100644 --- a/resources/js/types/category.ts +++ b/resources/js/types/category.ts @@ -136,6 +136,8 @@ export const CATEGORY_CASHFLOW_DIRECTIONS = [ export type CategoryCashflowDirection = (typeof CATEGORY_CASHFLOW_DIRECTIONS)[number]; +export const CATEGORY_MAX_DEPTH = 3; + export interface Category { id: UUID; name: string; @@ -143,6 +145,7 @@ export interface Category { color: CategoryColor; type: CategoryType; cashflow_direction: CategoryCashflowDirection; + parent_id: UUID | null; } export function getCategoryColorClasses(color: CategoryColor): { diff --git a/tests/Browser/AutomationRuleBuilderTest.php b/tests/Browser/AutomationRuleBuilderTest.php index d01ad5d3..afab73e1 100644 --- a/tests/Browser/AutomationRuleBuilderTest.php +++ b/tests/Browser/AutomationRuleBuilderTest.php @@ -285,13 +285,13 @@ it('can use is empty operator for nullable fields', function () { ->wait(1) ->click('button:has-text("Create Rule")') ->wait(0.5) - ->fill('title', 'Empty Category Rule') + ->fill('title', 'Empty Creditor Rule') ->click('button:has-text("Description")') ->wait(0.5) - ->click('[role="option"]:has-text("Category")') + ->click('[role="option"]:has-text("Creditor Name")') ->wait(1) - // Click the operator dropdown - it shows "equals" by default for Category field - ->click('button[role="combobox"]:has-text("equals")') + // Click the operator dropdown - it shows "contains" by default for Creditor Name + ->click('button[role="combobox"]:has-text("contains")') ->wait(0.5) ->click('[role="option"]:has-text("is empty")') ->wait(0.5) @@ -303,11 +303,11 @@ it('can use is empty operator for nullable fields', function () { $page->navigate('/settings/automation-rules')->wait(1); - $page->assertSee('Empty Category Rule') + $page->assertSee('Empty Creditor Rule') ->assertNoJavascriptErrors(); $this->assertDatabaseHas('automation_rules', [ 'user_id' => $user->id, - 'title' => 'Empty Category Rule', + 'title' => 'Empty Creditor Rule', ]); }); diff --git a/tests/Feature/BudgetTransactionServiceTest.php b/tests/Feature/BudgetTransactionServiceTest.php index 58209d74..351def3a 100644 --- a/tests/Feature/BudgetTransactionServiceTest.php +++ b/tests/Feature/BudgetTransactionServiceTest.php @@ -703,3 +703,58 @@ test('assignTransaction matches a budget tracking multiple categories', function ->where('budget_period_id', $period->id) ->exists())->toBeTrue(); }); + +test('a budget tracking a parent category includes child category transactions historically', function () { + $parent = Category::factory()->create(['user_id' => $this->user->id]); + $child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $parent->id, + 'transaction_date' => now()->subDay(), + 'amount' => -1000, + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $child->id, + 'transaction_date' => now()->subDay(), + 'amount' => -2000, + ]); + + $budget = Budget::factory()->forCategories($parent)->create(['user_id' => $this->user->id]); + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $count = $this->service->assignHistoricalTransactionsToPeriod($period); + + expect($count)->toBe(2); + expect((int) $period->budgetTransactions()->sum('amount'))->toBe(3000); +}); + +test('assigning a child category transaction matches a budget tracking the parent', function () { + $parent = Category::factory()->create(['user_id' => $this->user->id]); + $child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]); + + $budget = Budget::factory()->forCategories($parent)->create(['user_id' => $this->user->id]); + $period = BudgetPeriod::factory()->create([ + 'budget_id' => $budget->id, + 'start_date' => now()->subDays(30), + 'end_date' => now()->addDays(30), + ]); + + $transaction = Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $child->id, + 'transaction_date' => now(), + 'amount' => -1500, + ]); + + $this->service->assignTransaction($transaction); + + expect(BudgetTransaction::where('transaction_id', $transaction->id) + ->where('budget_period_id', $period->id) + ->exists())->toBeTrue(); +}); diff --git a/tests/Feature/CashflowAnalyticsTest.php b/tests/Feature/CashflowAnalyticsTest.php index 4e9e0c23..42bd965e 100644 --- a/tests/Feature/CashflowAnalyticsTest.php +++ b/tests/Feature/CashflowAnalyticsTest.php @@ -1380,3 +1380,93 @@ test('breakdown includes unknown income category', function () { expect($unknownCategory['amount'])->toBe(50000); expect($unknownCategory['percentage'])->toEqual(20.0); // 50k / 250k = 20% }); + +test('sankey rolls child category amounts up to their top-level parent by default', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + $parent = Category::factory()->create([ + 'user_id' => $this->user->id, + 'name' => 'Food', + 'type' => CategoryType::Expense, + ]); + $child = Category::factory()->childOf($parent)->create([ + 'user_id' => $this->user->id, + 'name' => 'Groceries', + ]); + + // $100 directly on the parent, $50 on the child. + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $parent->id, + 'amount' => -10000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $child->id, + 'amount' => -5000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $expense = collect($response->json('expense_categories')); + + expect($expense)->toHaveCount(1); + $node = $expense->first(); + expect($node['category_id'])->toBe($parent->id) + ->and($node['amount'])->toBe(15000) + ->and($node['has_children'])->toBeTrue(); +}); + +test('drilling into a parent splits it into children plus a direct node', function () { + $account = Account::factory()->create(['user_id' => $this->user->id]); + $parent = Category::factory()->create([ + 'user_id' => $this->user->id, + 'name' => 'Food', + 'type' => CategoryType::Expense, + ]); + $child = Category::factory()->childOf($parent)->create([ + 'user_id' => $this->user->id, + 'name' => 'Groceries', + ]); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $parent->id, + 'amount' => -10000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'account_id' => $account->id, + 'category_id' => $child->id, + 'amount' => -5000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/cashflow/sankey?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + 'parent' => $parent->id, + ])); + + $response->assertOk(); + $expense = collect($response->json('expense_categories')); + + expect($expense)->toHaveCount(2); + + $childNode = $expense->firstWhere('is_direct', false); + expect($childNode['category_id'])->toBe($child->id) + ->and($childNode['amount'])->toBe(5000); + + $directNode = $expense->firstWhere('is_direct', true); + expect($directNode['category_id'])->toBe($parent->id) + ->and($directNode['amount'])->toBe(10000); +}); diff --git a/tests/Feature/DashboardAnalyticsTest.php b/tests/Feature/DashboardAnalyticsTest.php index 3bb5fd61..f9d31069 100644 --- a/tests/Feature/DashboardAnalyticsTest.php +++ b/tests/Feature/DashboardAnalyticsTest.php @@ -333,6 +333,44 @@ test('top categories excludes soft deleted categories', function () { ->and($response->json('0.category.id'))->toBe($activeCategory->id); }); +test('top categories rolls child spending up into the top-level parent', function () { + $food = Category::factory()->create(['user_id' => $this->user->id, 'type' => CategoryType::Expense, 'name' => 'Food']); + $groceries = Category::factory()->childOf($food)->create(['user_id' => $this->user->id, 'name' => 'Groceries']); + $restaurants = Category::factory()->childOf($food)->create(['user_id' => $this->user->id, 'name' => 'Restaurants']); + + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $food->id, + 'amount' => -1000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $groceries->id, + 'amount' => -2000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $this->user->id, + 'category_id' => $restaurants->id, + 'amount' => -3000, + 'transaction_date' => now(), + ]); + + $response = $this->getJson('/api/dashboard/top-categories?'.http_build_query([ + 'from' => now()->startOfMonth()->toDateString(), + 'to' => now()->endOfMonth()->toDateString(), + ])); + + $response->assertOk(); + $data = $response->json(); + + expect($data)->toHaveCount(1); + expect($data[0]['category']['id'])->toBe($food->id); + expect($data[0]['amount'])->toBe(6000); + expect($data[0]['total_amount'])->toBe(6000); +}); + test('net worth evolution returns monthly data points with per-account balances', function () { $account1 = Account::factory()->create([ 'user_id' => $this->user->id, diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 6473161e..58481dda 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -1,5 +1,8 @@ get(route('dashboard'))->assertOk(); }); + +test('dashboard top categories roll child spending up into the parent', function () { + $user = User::factory()->onboarded()->create(); + $food = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense, 'name' => 'Food']); + $groceries = Category::factory()->childOf($food)->create(['user_id' => $user->id, 'name' => 'Groceries']); + + Transaction::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $food->id, + 'amount' => -1000, + 'transaction_date' => now(), + ]); + Transaction::factory()->create([ + 'user_id' => $user->id, + 'category_id' => $groceries->id, + 'amount' => -2000, + 'transaction_date' => now(), + ]); + + $response = $this->actingAs($user)->withoutVite()->get(route('dashboard'), [ + 'X-Inertia' => 'true', + 'X-Inertia-Partial-Component' => 'dashboard', + 'X-Inertia-Partial-Data' => 'topCategories', + ]); + + $response->assertOk() + ->assertJsonCount(1, 'props.topCategories') + ->assertJsonPath('props.topCategories.0.category.id', $food->id) + ->assertJsonPath('props.topCategories.0.amount', 3000); +}); diff --git a/tests/Feature/Settings/CategoryTest.php b/tests/Feature/Settings/CategoryTest.php index 841481ef..80c3190b 100644 --- a/tests/Feature/Settings/CategoryTest.php +++ b/tests/Feature/Settings/CategoryTest.php @@ -550,6 +550,34 @@ test('default categories are created when user registers', function () { ->cashflow_direction->toBe(CategoryCashflowDirection::Inflow); }); +test('default categories nest children under their configured parent', function () { + $user = User::factory()->create(); + + (new CreateDefaultCategories)->handle($user); + + $categories = $user->categories()->get()->keyBy('name'); + + expect($categories->get('Food')->parent_id)->toBeNull(); + expect($categories->get('Groceries')->parent_id)->toBe($categories->get('Food')->id); + expect($categories->get('Electricity')->parent_id)->toBe($categories->get('Utility services')->id); + expect($categories->get('Fuel')->parent_id)->toBe($categories->get('Transportation')->id); + expect($categories->get('Other investments')->parent_id)->toBe($categories->get('Investments')->id); + expect($categories->get('Salary')->parent_id)->toBeNull(); +}); + +test('default child categories attach to an already existing parent', function () { + $user = User::factory()->create(); + $food = Category::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Food', + 'type' => CategoryType::Expense, + ]); + + (new CreateDefaultCategories)->handle($user); + + expect($user->categories()->firstWhere('name', 'Groceries')->parent_id)->toBe($food->id); +}); + test('default categories are not created twice for the same user', function () { $user = User::factory()->create(); @@ -569,7 +597,7 @@ test('default categories are created without repeated category lookups', functio $categorySelects = 0; DB::listen(function ($query) use (&$categorySelects) { - if (str_starts_with($query->sql, 'select `name` from `categories`')) { + if (str_starts_with($query->sql, 'select `id`, `name` from `categories`')) { $categorySelects++; } }); diff --git a/tests/Feature/Settings/CategoryTreeTest.php b/tests/Feature/Settings/CategoryTreeTest.php new file mode 100644 index 00000000..37c3a31d --- /dev/null +++ b/tests/Feature/Settings/CategoryTreeTest.php @@ -0,0 +1,217 @@ +create(); + $parent = Category::factory()->create([ + 'user_id' => $user->id, + 'name' => 'Transfers', + 'type' => CategoryType::Transfer, + 'cashflow_direction' => CategoryCashflowDirection::Inflow, + ]); + + $this->actingAs($user)->post(route('categories.store'), [ + 'name' => 'Family transfers', + 'parent_id' => $parent->id, + 'icon' => 'Users', + 'color' => 'blue', + 'type' => CategoryType::Expense->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertRedirect(route('categories.index')); + + $this->assertDatabaseHas('categories', [ + 'user_id' => $user->id, + 'name' => 'Family transfers', + 'parent_id' => $parent->id, + 'type' => CategoryType::Transfer->value, + 'cashflow_direction' => CategoryCashflowDirection::Inflow->value, + ]); +}); + +test('categories cannot be nested deeper than the max depth', function () { + $user = User::factory()->create(); + $root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]); + $level2 = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + + // Third level is allowed. + $this->actingAs($user)->post(route('categories.store'), [ + 'name' => 'Level three', + 'parent_id' => $level2->id, + 'icon' => 'Tag', + 'color' => 'blue', + 'type' => CategoryType::Expense->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertRedirect(route('categories.index')); + + $level3 = $user->categories()->where('name', 'Level three')->firstOrFail(); + + // Fourth level is rejected. + $this->actingAs($user)->post(route('categories.store'), [ + 'name' => 'Level four', + 'parent_id' => $level3->id, + 'icon' => 'Tag', + 'color' => 'blue', + 'type' => CategoryType::Expense->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertSessionHasErrors(['parent_id']); +}); + +test('a category cannot be moved under one of its own descendants', function () { + $user = User::factory()->create(); + $root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]); + $child = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + + $this->actingAs($user)->patch(route('categories.update', $root), [ + 'name' => $root->name, + 'parent_id' => $child->id, + 'icon' => 'Tag', + 'color' => 'blue', + 'type' => CategoryType::Expense->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertSessionHasErrors(['parent_id']); +}); + +test('the same name is allowed under different parents but blocked under the same parent', function () { + $user = User::factory()->create(); + $food = Category::factory()->create(['user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense]); + $drinks = Category::factory()->create(['user_id' => $user->id, 'name' => 'Drinks', 'type' => CategoryType::Expense]); + + Category::factory()->childOf($food)->create(['user_id' => $user->id, 'name' => 'Coffee']); + + // Same name under a different parent: allowed. + $this->actingAs($user)->post(route('categories.store'), [ + 'name' => 'Coffee', + 'parent_id' => $drinks->id, + 'icon' => 'Tag', + 'color' => 'blue', + 'type' => CategoryType::Expense->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertRedirect(route('categories.index')); + + // Same name under the same parent: blocked. + $this->actingAs($user)->post(route('categories.store'), [ + 'name' => 'Coffee', + 'parent_id' => $food->id, + 'icon' => 'Tag', + 'color' => 'blue', + 'type' => CategoryType::Expense->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertSessionHasErrors(['name']); + + expect($user->categories()->where('name', 'Coffee')->count())->toBe(2); +}); + +test('deleting a parent lifts its children up to the grandparent', function () { + $user = User::factory()->create(); + $root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]); + $parent = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + $child = Category::factory()->childOf($parent)->create(['user_id' => $user->id]); + + $this->actingAs($user)->delete(route('categories.destroy', $parent)) + ->assertRedirect(route('categories.index')); + + $this->assertSoftDeleted('categories', ['id' => $parent->id]); + expect($child->refresh()->parent_id)->toBe($root->id); +}); + +test('deleting a parent with the promote strategy turns its children into roots', function () { + $user = User::factory()->create(); + $root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]); + $child = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + + $this->actingAs($user)->delete(route('categories.destroy', $root), ['strategy' => 'promote']) + ->assertRedirect(route('categories.index')); + + $this->assertSoftDeleted('categories', ['id' => $root->id]); + expect($child->refresh()->parent_id)->toBeNull(); +}); + +test('deleting a parent with the cascade strategy removes the subtree and uncategorizes transactions', function () { + $user = User::factory()->create(); + $account = Account::factory()->create(['user_id' => $user->id]); + $root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]); + $child = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + + $transaction = Transaction::factory()->plaintext()->create([ + 'user_id' => $user->id, + 'account_id' => $account->id, + 'category_id' => $child->id, + ]); + + $this->actingAs($user)->delete(route('categories.destroy', $root), ['strategy' => 'cascade']) + ->assertRedirect(route('categories.index')); + + $this->assertSoftDeleted('categories', ['id' => $root->id]); + $this->assertSoftDeleted('categories', ['id' => $child->id]); + expect($transaction->refresh()->category_id)->toBeNull(); +}); + +test('deleting a parent with the promote strategy rejects child name collisions at the top level', function () { + $user = User::factory()->create(); + Category::factory()->create(['user_id' => $user->id, 'name' => 'Other', 'type' => CategoryType::Expense]); + $root = Category::factory()->create(['user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense]); + $child = Category::factory()->childOf($root)->create(['user_id' => $user->id, 'name' => 'Other']); + + $this->actingAs($user)->delete(route('categories.destroy', $root), ['strategy' => 'promote']) + ->assertSessionHasErrors(['strategy']); + + $this->assertNotSoftDeleted('categories', ['id' => $root->id]); + expect($child->refresh()->parent_id)->toBe($root->id); +}); + +test('deleting a parent with the reparent strategy rejects child name collisions at the destination level', function () { + $user = User::factory()->create(); + $root = Category::factory()->create(['user_id' => $user->id, 'name' => 'Food', 'type' => CategoryType::Expense]); + Category::factory()->childOf($root)->create(['user_id' => $user->id, 'name' => 'Other']); + $parent = Category::factory()->childOf($root)->create(['user_id' => $user->id, 'name' => 'Eating out']); + $child = Category::factory()->childOf($parent)->create(['user_id' => $user->id, 'name' => 'Other']); + + $this->actingAs($user)->delete(route('categories.destroy', $parent)) + ->assertSessionHasErrors(['strategy']); + + $this->assertNotSoftDeleted('categories', ['id' => $parent->id]); + expect($child->refresh()->parent_id)->toBe($parent->id); +}); + +test('updating a parent type cascades to all descendants', function () { + $user = User::factory()->create(); + $root = Category::factory()->create([ + 'user_id' => $user->id, + 'type' => CategoryType::Expense, + 'cashflow_direction' => CategoryCashflowDirection::Hidden, + ]); + $child = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + $grandchild = Category::factory()->childOf($child)->create(['user_id' => $user->id]); + + $this->actingAs($user)->patch(route('categories.update', $root), [ + 'name' => $root->name, + 'parent_id' => null, + 'icon' => 'Tag', + 'color' => 'blue', + 'type' => CategoryType::Income->value, + 'cashflow_direction' => CategoryCashflowDirection::Hidden->value, + ])->assertRedirect(route('categories.index')); + + expect($child->refresh()->type)->toBe(CategoryType::Income) + ->and($grandchild->refresh()->type)->toBe(CategoryType::Income); +}); + +test('the tree service expands a parent id to include all descendants', function () { + $user = User::factory()->create(); + $root = Category::factory()->create(['user_id' => $user->id, 'type' => CategoryType::Expense]); + $child = Category::factory()->childOf($root)->create(['user_id' => $user->id]); + $grandchild = Category::factory()->childOf($child)->create(['user_id' => $user->id]); + $unrelated = Category::factory()->create(['user_id' => $user->id]); + + $expanded = (new CategoryTree)->expand($user->id, [$root->id]); + + expect($expanded)->toContain($root->id, $child->id, $grandchild->id) + ->not->toContain($unrelated->id); +}); diff --git a/tests/Feature/TransactionFilterTest.php b/tests/Feature/TransactionFilterTest.php index d3bf6c16..3b2bcf6f 100644 --- a/tests/Feature/TransactionFilterTest.php +++ b/tests/Feature/TransactionFilterTest.php @@ -121,6 +121,30 @@ test('filter by category', function () { ); }); +test('filtering by a parent category includes transactions from its descendants', function () { + $parent = Category::factory()->create(['user_id' => $this->user->id]); + $child = Category::factory()->childOf($parent)->create(['user_id' => $this->user->id]); + $grandchild = Category::factory()->childOf($child)->create(['user_id' => $this->user->id]); + $unrelated = Category::factory()->create(['user_id' => $this->user->id]); + + foreach ([$parent, $child, $grandchild, $unrelated] as $category) { + Transaction::factory()->plaintext()->create([ + 'user_id' => $this->user->id, + 'account_id' => $this->account->id, + 'category_id' => $category->id, + ]); + } + + $response = actingAs($this->user)->get(route('transactions.index', [ + 'category_ids' => $parent->id, + ])); + + // Parent + child + grandchild, but not the unrelated category. + $response->assertInertia(fn ($page) => $page + ->has('transactions.data', 3) + ); +}); + test('filter by uncategorized', function () { $category = Category::factory()->create(['user_id' => $this->user->id]); diff --git a/tests/Performance/PageQueryCountTest.php b/tests/Performance/PageQueryCountTest.php index 12f221cb..f31f8d7b 100644 --- a/tests/Performance/PageQueryCountTest.php +++ b/tests/Performance/PageQueryCountTest.php @@ -58,7 +58,7 @@ test('transactions index page does not exceed query threshold', function () { }); test('budgets index page does not exceed query threshold', function () { - assertMaxQueries(18, function () { + assertMaxQueries(21, function () { $this->get(route('budgets.index'))->assertOk(); }, 'Budgets Index'); }); @@ -66,7 +66,7 @@ test('budgets index page does not exceed query threshold', function () { test('budget show page does not exceed query threshold', function () { $budget = $this->user->budgets()->first(); - assertMaxQueries(20, function () use ($budget) { + assertMaxQueries(22, function () use ($budget) { $this->get(route('budgets.show', $budget))->assertOk(); }, 'Budget Show'); }); @@ -82,7 +82,7 @@ test('cashflow page does not exceed query threshold', function () { // ────────────────────────────────────────────────────────────────────────── test('settings accounts page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('accounts.index'))->assertOk(); }, 'Settings Accounts'); });