feat: parent/child category tree (#474)

## Summary

Adds nested categories (parent → child, up to **3 levels**) across the
app. Children inherit their parent's type and cashflow direction, and
every category selector now renders the hierarchy as an indented tree.
Gated behind the `CategoryTree` Pennant flag (off by default).

## Backend

- **Migration**: nullable self-referencing `parent_id`; uniqueness
scoped per-parent via a `parent_unique_marker` virtual column (root
names stay unique). New composite unique created before dropping the old
one so the `user_id` FK keeps a supporting index.
- **`CategoryTree` service**: descendant/ancestor resolution, depth &
cycle checks, type cascade, subtree deletion.
- **Validation**: depth limit, cycle prevention, inherited + locked
child type.
- **Delete strategies**: reparent (default), promote to root, or cascade
(uncategorizes affected transactions).
- **Transaction filter** expands a selected parent to its descendants.
- **Cashflow Sankey & breakdown** roll up to top-level parents with
click-to-drill (children + a parent "direct" node).
- **Budgets** tracking a parent also count their children's
transactions.
- **Unified** the frontend category query behind
`Category::forDisplay()` / `FRONTEND_COLUMNS` (8 call sites) so every
selector receives the full Category shape, including `parent_id`.

## Frontend

- New `category-tree.ts` helpers (build/flatten/descendants/path,
tree-aware selection toggle + tri-state).
- **Settings page**: indented tree, sortable by name/color/type
(siblings sorted, hierarchy preserved), parent picker, delete-strategy
dialog.
- **Combobox** (transaction table cell + edit/create modal + parent
picker): indented tree, search keeps matches with their ancestors.
- **Transaction filter**: indented tree, tri-state cascading selection
(parent ↔ children), themed checkboxes, selected branches float to top
on open, ancestor-aware search.
- **Categorizer palette** and **budget multi-select**: same indented
tree + ancestor-aware search.
- **Sankey**: click a parent node to drill into its children, with
breadcrumb.
- Pennant `CategoryTree` flag gates the parent UI.

## Tests

- Pest: model/validation, delete strategies, filter expansion, cashflow
rollup/drill, budget child inclusion.
- Vitest: tree-aware selection logic.
- All green; Pint / ESLint / Prettier clean.

## Rollout

Flag is off by default — enable per user with:
\`\`\`
php artisan feature:enable "App\\Features\\CategoryTree" you@example.com
\`\`\`
This commit is contained in:
Víctor Falcón 2026-06-03 19:30:12 +02:00 committed by GitHub
parent faccbc3c5a
commit 1cc10566a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 2695 additions and 316 deletions

View File

@ -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<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
*/
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<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string}>
* @return array<int, array{name: string, icon: string, color: string, type: string, cashflow_direction?: string, parent?: string}>
*/
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',

View File

@ -0,0 +1,10 @@
<?php
namespace App\Enums;
enum CategoryDeletionStrategy: string
{
case Reparent = 'reparent';
case Promote = 'promote';
case Cascade = 'cascade';
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* Gates the parent/child category tree UI for gradual rollout. The backend
* always understands nesting; this flag only controls whether users can create
* and manage nested categories from the interface.
*
* @api
*/
class CategoryTree
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -39,16 +39,18 @@ class CashflowAnalyticsController extends Controller
$validated = $request->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<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
*/
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<string, ?string> $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];
}
}

View File

@ -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<int, array{category_id: string, amount: int, category: Category}>
*/
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

View File

@ -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']),

View File

@ -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)

View File

@ -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)

View File

@ -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<int, array{category_id: string, amount: int, category: Category}>
*/
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

View File

@ -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)

View File

@ -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;
}

View File

@ -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)

View File

@ -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']) : [],

View File

@ -0,0 +1,81 @@
<?php
namespace App\Http\Requests\Settings\Concerns;
use App\Http\Requests\Concerns\ResolvesCategoryCashflowDirection;
use App\Models\Category;
use Illuminate\Contracts\Database\Query\Builder;
trait PreparesCategoryAttributes
{
use ResolvesCategoryCashflowDirection {
prepareForValidation as resolveCashflowDirectionFromType;
}
/**
* Resolve and cache the requested parent category (owned by the user).
*/
private ?Category $resolvedParent = null;
private bool $parentResolved = false;
/**
* Derive type and cashflow direction.
*
* Children inherit both from their parent so a whole subtree stays on a
* single type. Roots keep the existing type-driven cashflow rules.
*/
protected function prepareForValidation(): void
{
$parent = $this->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);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Http\Requests\Settings;
use App\Enums\CategoryDeletionStrategy;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class DeleteCategoryRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
* @return array<string, ValidationRule|array<mixed>|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;
}
}

View File

@ -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',

View File

@ -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' => [

View File

@ -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<CategoryFactory> */
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<string>
*/
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<Category, $this> */
public function parent(): BelongsTo
{
return $this->belongsTo(Category::class, 'parent_id');
}
/** @return HasMany<Category, $this> */
public function children(): HasMany
{
return $this->hasMany(Category::class, 'parent_id');
}
/**
* Recursively eager-load the whole subtree (bounded by MAX_DEPTH).
*
* @return HasMany<Category, $this>
*/
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<Category> $query
* @return Builder<Category>
*/
public function scopeForDisplay(Builder $query): Builder
{
return $query->orderBy('name')->select(self::FRONTEND_COLUMNS);
}
}

View File

@ -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);

View File

@ -0,0 +1,55 @@
<?php
namespace App\Rules;
use App\Models\Category;
use App\Services\CategoryTree;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class ValidCategoryParent implements ValidationRule
{
/**
* @param Category|null $category The category being moved (null when creating).
*/
public function __construct(
private readonly ?Category $category,
private readonly CategoryTree $tree = new CategoryTree,
) {}
/**
* @param Closure(string): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if ($value === null || $value === '') {
return;
}
$parent = Category::query()
->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]));
}
}
}

View File

@ -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', [

View File

@ -0,0 +1,219 @@
<?php
namespace App\Services;
use App\Models\Category;
use App\Models\Transaction;
class CategoryTree
{
/**
* Expand a set of category ids to also include every descendant id.
*
* Used when filtering transactions: selecting a parent must also match
* transactions assigned to any of its children. Bounded by MAX_DEPTH, so
* this resolves in at most a couple of cheap queries.
*
* @param array<int, string> $ids
* @return array<int, string>
*/
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<int, string>
*/
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<int, string>
*/
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<string, string>
*/
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();
}
}

View File

@ -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,
]);
}
}

View File

@ -0,0 +1,60 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('categories', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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 parents 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",

View File

@ -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({
</UILabel>
<MultiSelect
id="categories"
options={allCategories.map((category) => {
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 ? (
<IconComponent className="h-3 w-3 opacity-80" />
) : undefined,

View File

@ -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<CategoryType | ''>('');
const [parent, setParent] = useState<Category | null>(null);
return (
<Dialog open={open} onOpenChange={setOpen}>
@ -78,6 +83,18 @@ export function CreateCategoryDialog({
<InputError message={errors.name} />
</div>
<ParentCategoryField
categories={categories}
value={parent?.id ?? null}
onChange={(next) => {
setParent(next);
if (next) {
setSelectedType(next.type);
}
}}
error={errors.parent_id}
/>
<div className="space-y-2">
<Label htmlFor="icon">{__('Icon')}</Label>
<Select name="icon" required>
@ -140,24 +157,51 @@ export function CreateCategoryDialog({
<InputError message={errors.color} />
</div>
{parent ? (
<div className="space-y-2">
<Label htmlFor="type">{__('Type')}</Label>
<Label>{__('Type')}</Label>
<input
type="hidden"
name="type"
value={parent.type}
/>
<p className="text-sm text-muted-foreground">
{getCategoryTypeLabel(parent.type)}
{' · '}
{__('Inherited from parent')}
</p>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="type">
{__('Type')}
</Label>
<Select
name="type"
required
onValueChange={(value) =>
setSelectedType(value as CategoryType)
setSelectedType(
value as CategoryType,
)
}
>
<SelectTrigger>
<SelectValue
placeholder={__('Select a type')}
placeholder={__(
'Select a type',
)}
/>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{getCategoryTypeLabel(type)}
<SelectItem
key={type}
value={type}
>
{getCategoryTypeLabel(
type,
)}
</SelectItem>
))}
</SelectContent>
@ -169,6 +213,8 @@ export function CreateCategoryDialog({
selectedType={selectedType}
defaultValue="hidden"
/>
</>
)}
<div className="flex justify-end gap-2">
<Button

View File

@ -8,12 +8,18 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { type Category } from '@/types/category';
import { __ } from '@/utils/i18n';
import { Form } from '@inertiajs/react';
import { useMemo, useState } from 'react';
type DeletionStrategy = 'reparent' | 'promote' | 'cascade';
interface DeleteCategoryDialogProps {
category: Category;
categories: Category[];
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess?: () => void;
@ -21,21 +27,40 @@ interface DeleteCategoryDialogProps {
export function DeleteCategoryDialog({
category,
categories,
open,
onOpenChange,
onSuccess,
}: DeleteCategoryDialogProps) {
const hasChildren = useMemo(
() => categories.some((c) => c.parent_id === category.id),
[categories, category.id],
);
const isRoot = category.parent_id === null;
const [strategy, setStrategy] = useState<DeletionStrategy>('reparent');
const options: { value: DeletionStrategy; label: string }[] = [
{
value: 'reparent',
label: isRoot
? __('Move child categories to the top level')
: __('Move child categories up to the parent'),
},
{ value: 'promote', label: __('Make child categories top level') },
{
value: 'cascade',
label: __('Delete this category and all of its children'),
},
];
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{__('Delete Category')}</DialogTitle>
<DialogDescription>
{__('Are you sure you want to delete "')}
{category.name}
{__(
'"? This\n action cannot be undone.',
)}
{__('Are you sure you want to delete')} {category.name}
? {__('This action cannot be undone.')}
</DialogDescription>
</DialogHeader>
<Form
@ -45,7 +70,54 @@ export function DeleteCategoryDialog({
onSuccess?.();
}}
>
{({ processing }) => (
{({ processing, errors }) => (
<div className="space-y-4">
{hasChildren && (
<div className="space-y-2">
<input
type="hidden"
name="strategy"
value={strategy}
/>
<Label>
{__(
'What should happen to its child categories?',
)}
</Label>
<RadioGroup
value={strategy}
onValueChange={(value) =>
setStrategy(
value as DeletionStrategy,
)
}
>
{options.map((option) => (
<Label
key={option.value}
className="flex items-center gap-2 text-sm font-normal"
>
<RadioGroupItem
value={option.value}
/>
{option.label}
</Label>
))}
</RadioGroup>
{strategy === 'cascade' && (
<p className="text-sm text-red-500">
{__(
'Transactions in the deleted categories will become uncategorized.',
)}
</p>
)}
</div>
)}
{errors.strategy && (
<p className="text-sm text-red-500">
{errors.strategy}
</p>
)}
<DialogFooter>
<Button
type="button"
@ -60,9 +132,12 @@ export function DeleteCategoryDialog({
variant="destructive"
disabled={processing}
>
{processing ? 'Deleting...' : 'Delete'}
{processing
? __('Deleting...')
: __('Delete')}
</Button>
</DialogFooter>
</div>
)}
</Form>
</DialogContent>

View File

@ -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<CategoryType>(
category.type,
);
const [parent, setParent] = useState<Category | null>(
category.parent_id
? (categories.find((c) => c.id === category.parent_id) ?? null)
: null,
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@ -82,6 +90,19 @@ export function EditCategoryDialog({
<InputError message={errors.name} />
</div>
<ParentCategoryField
categories={categories}
value={parent?.id ?? null}
excludeId={category.id}
onChange={(next) => {
setParent(next);
if (next) {
setSelectedType(next.type);
}
}}
error={errors.parent_id}
/>
<div className="space-y-2">
<Label htmlFor="icon">{__('Icon')}</Label>
<Select
@ -152,25 +173,52 @@ export function EditCategoryDialog({
<InputError message={errors.color} />
</div>
{parent ? (
<div className="space-y-2">
<Label htmlFor="type">{__('Type')}</Label>
<Label>{__('Type')}</Label>
<input
type="hidden"
name="type"
value={parent.type}
/>
<p className="text-sm text-muted-foreground">
{getCategoryTypeLabel(parent.type)}
{' · '}
{__('Inherited from parent')}
</p>
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="type">
{__('Type')}
</Label>
<Select
name="type"
defaultValue={category.type}
required
onValueChange={(value) =>
setSelectedType(value as CategoryType)
setSelectedType(
value as CategoryType,
)
}
>
<SelectTrigger>
<SelectValue
placeholder={__('Select a type')}
placeholder={__(
'Select a type',
)}
/>
</SelectTrigger>
<SelectContent>
{CATEGORY_TYPES.map((type) => (
<SelectItem key={type} value={type}>
{getCategoryTypeLabel(type)}
<SelectItem
key={type}
value={type}
>
{getCategoryTypeLabel(
type,
)}
</SelectItem>
))}
</SelectContent>
@ -180,8 +228,12 @@ export function EditCategoryDialog({
<CategoryCashflowDirectionFields
selectedType={selectedType}
defaultValue={category.cashflow_direction}
defaultValue={
category.cashflow_direction
}
/>
</>
)}
<div className="flex justify-end gap-2">
<Button

View File

@ -0,0 +1,87 @@
import InputError from '@/components/input-error';
import { CategoryCombobox } from '@/components/shared/category-combobox';
import { Label } from '@/components/ui/label';
import { getDescendantIds } from '@/lib/category-tree';
import { type Category } from '@/types/category';
import { UUID } from '@/types/uuid';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { useMemo } from 'react';
interface ParentCategoryFieldProps {
categories: Category[];
value: UUID | null;
onChange: (parent: Category | null) => void;
/** Category being edited — itself and its descendants can't be a parent. */
excludeId?: UUID;
error?: string;
}
export function ParentCategoryField({
categories,
value,
onChange,
excludeId,
error,
}: ParentCategoryFieldProps) {
const enabled =
usePage<{ categoryTreeEnabled?: boolean }>().props
.categoryTreeEnabled ?? false;
const byId = useMemo(
() => new Map(categories.map((c) => [c.id, c])),
[categories],
);
// Eligible parents: anything that isn't this category or one of its
// descendants (no cycles) and that still leaves room for a level below it.
const eligibleParents = useMemo(() => {
const excluded = new Set<UUID>();
if (excludeId) {
excluded.add(excludeId);
for (const id of getDescendantIds(excludeId, categories)) {
excluded.add(id);
}
}
const depthOf = (category: Category): number => {
let depth = 0;
let current: Category | undefined = category;
while (current?.parent_id != null && depth < 5) {
current = byId.get(current.parent_id);
depth += 1;
}
return depth;
};
return categories.filter(
(category) => !excluded.has(category.id) && depthOf(category) < 2,
);
}, [categories, excludeId, byId]);
if (!enabled) {
return null;
}
return (
<div className="space-y-2">
<Label htmlFor="parent_id">{__('Parent category')}</Label>
<input type="hidden" name="parent_id" value={value ?? ''} />
<CategoryCombobox
value={value ?? 'null'}
onValueChange={(next) =>
onChange(next === 'null' ? null : (byId.get(next) ?? null))
}
categories={eligibleParents}
emptyOptionLabel={__('None (top level)')}
placeholder={__('None (top level)')}
/>
<p className="text-xs text-muted-foreground">
{__(
'Child categories inherit their parents type and cashflow settings.',
)}
</p>
<InputError message={error} />
</div>
);
}

View File

@ -28,6 +28,8 @@ interface SankeyChartProps {
currency?: string;
groupingThreshold?: number;
period?: { from: Date; to: Date };
/** Drill into a parent category node instead of navigating to it. */
onDrill?: (categoryId: string, label: string) => void;
}
interface NodeData {
@ -39,6 +41,7 @@ interface NodeData {
height: number;
column: 0 | 1 | 2;
category?: Category;
hasChildren?: boolean;
}
interface LinkData {
@ -149,6 +152,7 @@ export function SankeyChart({
currency = 'USD',
groupingThreshold = 0.03,
period,
onDrill,
}: SankeyChartProps) {
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
const [hoveredLink, setHoveredLink] = useState<string | null>(null);
@ -242,6 +246,7 @@ export function SankeyChart({
height: nodeHeight,
column: 0,
category: item.category,
hasChildren: item.has_children,
};
incomeY += nodeHeight + NODE_PADDING;
return node;
@ -308,6 +313,7 @@ export function SankeyChart({
height: nodeHeight,
column: 2,
category: item.category,
hasChildren: item.has_children,
};
expenseY += nodeHeight + NODE_PADDING;
return node;
@ -488,8 +494,25 @@ export function SankeyChart({
},
}).url
: null;
const canDrill =
!isOtherNode &&
!!node.hasChildren &&
!!node.category &&
!!onDrill;
const isNavigable =
!isOtherNode && categoryUrl !== null;
!isOtherNode && !canDrill && categoryUrl !== null;
const isInteractive = canDrill || isNavigable;
const activate = () => {
if (canDrill && node.category) {
onDrill?.(node.category.id, node.label);
return;
}
if (categoryUrl) {
router.visit(categoryUrl);
}
};
const nodeContent = (
<g
@ -497,12 +520,12 @@ export function SankeyChart({
onMouseEnter={() => setHoveredNode(node.id)}
onMouseLeave={() => setHoveredNode(null)}
onClick={() => {
if (categoryUrl) {
router.visit(categoryUrl);
if (isInteractive) {
activate();
}
}}
onKeyDown={(event) => {
if (!categoryUrl) {
if (!isInteractive) {
return;
}
@ -511,22 +534,30 @@ export function SankeyChart({
event.key === ' '
) {
event.preventDefault();
router.visit(categoryUrl);
activate();
}
}}
role={isNavigable ? 'link' : undefined}
tabIndex={isNavigable ? 0 : undefined}
role={
canDrill
? 'button'
: isNavigable
? 'link'
: undefined
}
tabIndex={isInteractive ? 0 : undefined}
aria-label={
isNavigable
canDrill
? `Drill into ${node.label}`
: isNavigable
? `View ${node.label} transactions`
: undefined
}
className={cn(
'transition-all duration-200',
isOtherNode && 'cursor-pointer',
isNavigable && 'cursor-pointer',
isInteractive && 'cursor-pointer',
!isOtherNode &&
!isNavigable &&
!isInteractive &&
'cursor-default',
)}
>

View File

@ -2,7 +2,6 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Command,
CommandEmpty,
CommandInput,
CommandItem,
CommandList,
@ -12,6 +11,11 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
buildCategoryTree,
flattenCategoryTree,
getCategoryPath,
} from '@/lib/category-tree';
import { cn } from '@/lib/utils';
import { type Category, getCategoryColorClasses } from '@/types/category';
import { __ } from '@/utils/i18n';
@ -22,7 +26,7 @@ import {
HelpCircle,
type LucideIcon,
} from 'lucide-react';
import { memo, useEffect, useRef, useState } from 'react';
import { memo, useEffect, useMemo, useRef, useState } from 'react';
const iconCache = new Map<string, LucideIcon>();
@ -49,6 +53,8 @@ interface CategoryComboboxProps {
triggerClassName?: string;
showUncategorized?: boolean;
withoutChevronIcon?: boolean;
/** Label for the empty / "no category" option (defaults to Uncategorized). */
emptyOptionLabel?: string;
'data-testid'?: string;
}
@ -61,8 +67,10 @@ export function CategoryCombobox({
triggerClassName,
showUncategorized = true,
withoutChevronIcon = false,
emptyOptionLabel,
'data-testid': dataTestId,
}: CategoryComboboxProps) {
const noneLabel = emptyOptionLabel ?? __('Uncategorized');
const [open, setOpen] = useState(false);
const [filterValue, setFilterValue] = useState('');
const listRef = useRef<HTMLDivElement>(null);
@ -72,10 +80,44 @@ export function CategoryCombobox({
? categories.find((c) => c.id === value)
: null;
const sortedCategories = [...categories].sort((a, b) =>
a.name.localeCompare(b.name),
const orderedNodes = useMemo(
() => flattenCategoryTree(buildCategoryTree(categories)),
[categories],
);
const query = filterValue.trim().toLowerCase();
// Tree-aware search: keep every match plus its ancestors, so a matching
// child is always shown under its parent for context.
const visibleNodes = useMemo(() => {
if (!query) {
return orderedNodes;
}
const parentOf = new Map(categories.map((c) => [c.id, c.parent_id]));
const include = new Set<string>();
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 orderedNodes.filter((node) => include.has(node.id));
}, [orderedNodes, categories, query]);
const showNone =
showUncategorized &&
(query === '' ||
noneLabel.toLowerCase().includes(query) ||
'uncategorized'.includes(query));
const hasResults = visibleNodes.length > 0 || showNone;
useEffect(() => {
if (filterValue && listRef.current) {
listRef.current.scrollTop = 0;
@ -119,7 +161,7 @@ export function CategoryCombobox({
<HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
<span className="truncate text-zinc-500">
{__('Uncategorized')}
{noneLabel}
</span>
</div>
) : (
@ -133,7 +175,7 @@ export function CategoryCombobox({
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start">
<Command>
<Command shouldFilter={false}>
<CommandInput
placeholder={__('Search categories...')}
value={filterValue}
@ -141,8 +183,12 @@ export function CategoryCombobox({
/>
<CommandList ref={listRef}>
<CommandEmpty>{__('No category found.')}</CommandEmpty>
{showUncategorized && (
{!hasResults && (
<div className="py-6 text-center text-sm text-muted-foreground">
{__('No category found.')}
</div>
)}
{showNone && (
<CommandItem
value="uncategorized"
onSelect={() => {
@ -154,7 +200,7 @@ export function CategoryCombobox({
<div className="flex h-6 w-6 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
<span>{__('Uncategorized')}</span>
<span>{noneLabel}</span>
</div>
<Check
className={cn(
@ -166,16 +212,21 @@ export function CategoryCombobox({
/>
</CommandItem>
)}
{sortedCategories.map((category) => (
{visibleNodes.map((category) => (
<CommandItem
key={category.id}
value={category.name}
value={getCategoryPath(category.id, categories)}
onSelect={() => {
onValueChange(String(category.id));
setOpen(false);
}}
>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2"
style={{
paddingLeft: `${category.depth * 1.25}rem`,
}}
>
<CategoryIcon category={category} />
<span className="truncate">
{category.name}

View File

@ -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<string>();
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({
</div>
<Command
className="rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-800 dark:bg-zinc-900"
shouldFilter={true}
shouldFilter={false}
>
<CommandInput
ref={commandInputRef}
@ -79,21 +116,31 @@ export function CategorizerCommand({
/>
<CommandList className="max-h-64">
<CommandEmpty>{__('No categories found.')}</CommandEmpty>
{visibleCategories.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
{__('No categories found.')}
</div>
)}
<CommandGroup>
{sortedCategories.map((category) => {
{visibleCategories.map((category) => {
const colorClasses = getCategoryColorClasses(
category.color,
);
return (
<CommandItem
key={category.id}
value={category.name}
value={getCategoryPath(
category.id,
sortedCategories,
)}
onSelect={() => onCategorySelect(category)}
disabled={
animationState !== 'idle' || disabled
}
className="group cursor-pointer gap-3 p-2"
style={{
paddingLeft: `${0.5 + category.depth * 1.25}rem`,
}}
>
<div
className={cn(

View File

@ -1,12 +1,13 @@
import { __ } from '@/utils/i18n';
import { format } from 'date-fns';
import * as Icons from 'lucide-react';
import { Check, ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useState } from 'react';
import { ChevronsUpDown, Tag, X } from 'lucide-react';
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { AccountName } from '@/components/accounts/account-name';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Command,
CommandEmpty,
@ -21,6 +22,12 @@ import {
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import {
buildCategoryTree,
categorySelectionState,
flattenCategoryTree,
toggleCategorySelection,
} from '@/lib/category-tree';
import { cn } from '@/lib/utils';
import { type Account } from '@/types/account';
import { type Category, getCategoryColorClasses } from '@/types/category';
@ -50,6 +57,7 @@ export function TransactionFilters({
}: TransactionFiltersProps) {
const [isOpen, setIsOpen] = useState(false);
const [categoryDropdownOpen, setCategoryDropdownOpen] = useState(false);
const [categorySearch, setCategorySearch] = useState('');
const [labelDropdownOpen, setLabelDropdownOpen] = useState(false);
const [searchText, setSearchText] = useState(filters.searchText);
const [creditorName, setCreditorName] = useState(filters.creditorName);
@ -93,12 +101,87 @@ export function TransactionFilters({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchText, filters.creditorName, filters.debtorName]);
function handleCategoryToggle(categoryId: string) {
const newCategoryIds = filters.categoryIds.includes(categoryId)
? filters.categoryIds.filter((id) => 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<string>
>(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<string>();
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<string>();
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({
<div className="pt-2">
<Popover
open={categoryDropdownOpen}
onOpenChange={
setCategoryDropdownOpen
onOpenChange={(open) => {
if (open) {
setCategoryOrderSnapshot(
new Set(
filters.categoryIds,
),
);
} else {
setCategorySearch('');
}
setCategoryDropdownOpen(open);
}}
>
<PopoverTrigger asChild>
<Button
@ -334,18 +426,25 @@ export function TransactionFilters({
className="w-full p-0"
align="start"
>
<Command>
<Command shouldFilter={false}>
<CommandInput
placeholder={__(
'Search categories...',
)}
value={categorySearch}
onValueChange={
setCategorySearch
}
/>
<CommandList>
<CommandEmpty>
{!hasCategoryResults && (
<div className="py-6 text-center text-sm text-muted-foreground">
{__(
'No category found.',
)}
</CommandEmpty>
</div>
)}
{showUncategorizedRow && (
<CommandItem
onSelect={() =>
handleCategoryToggle(
@ -353,18 +452,14 @@ export function TransactionFilters({
)
}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
<Checkbox
checked={
isUncategorizedSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
}
className="pointer-events-none mr-1"
/>
<div className="flex items-center gap-2">
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<div className="flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-zinc-100 dark:bg-zinc-800">
<Icons.HelpCircle className="h-3 w-3 text-zinc-500" />
</div>
{__(
@ -372,11 +467,14 @@ export function TransactionFilters({
)}
</div>
</CommandItem>
{categories.map(
)}
{visibleCategoryTree.map(
(category) => {
const isSelected =
filters.categoryIds.includes(
const state =
categorySelectionState(
category.id,
selectedCategorySet,
categories,
);
const colorClasses =
getCategoryColorClasses(
@ -387,26 +485,32 @@ export function TransactionFilters({
key={
category.id
}
value={
category.name
}
onSelect={() =>
handleCategoryToggle(
category.id,
)
}
style={{
paddingLeft: `${0.5 + category.depth * 1.25}rem`,
}}
>
<Checkbox
checked={
state ===
'indeterminate'
? 'indeterminate'
: state ===
'checked'
}
className="pointer-events-none mr-1"
/>
<div className="flex min-w-0 items-center gap-2">
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
<div className="flex items-center gap-2">
<div
className={cn(
'flex h-5 w-5 items-center justify-center rounded-full',
'flex h-5 w-5 shrink-0 items-center justify-center rounded-full',
colorClasses.bg,
)}
>
@ -419,7 +523,7 @@ export function TransactionFilters({
}
/>
</div>
<span>
<span className="truncate">
{
category.name
}
@ -504,16 +608,12 @@ export function TransactionFilters({
)
}
>
<div
className={cn(
'mr-1 flex size-4 items-center justify-center rounded-sm border border-primary p-1',
<Checkbox
checked={
isSelected
? 'bg-primary/10 text-primary-foreground'
: 'opacity-50 [&_svg]:invisible',
)}
>
<Check className="size-3" />
</div>
}
className="pointer-events-none mr-1"
/>
<div className="flex items-center gap-2">
<div
className={cn(

View File

@ -1,6 +1,6 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { CheckIcon, MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
@ -12,7 +12,7 @@ function Checkbox({
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
"peer group border-input data-[state=checked]:bg-primary/5 dark:data-[state=checked]:bg-primary/20 data-[state=checked]:text-primary data-[state=checked]:border-primary/25 data-[state=indeterminate]:bg-primary/5 dark:data-[state=indeterminate]:bg-primary/20 data-[state=indeterminate]:text-primary-foreground data-[state=indeterminate]:border-primary/25 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
@ -21,7 +21,8 @@ function Checkbox({
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
<CheckIcon className="size-3.5 stroke-primary group-data-[state=indeterminate]:hidden" />
<MinusIcon className="hidden stroke-primary size-3.5 group-data-[state=indeterminate]:block" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)

View File

@ -16,13 +16,16 @@ import {
import { cn } from '@/lib/utils';
import { __ } from '@/utils/i18n';
import { Check, ChevronsUpDown, X } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
export interface MultiSelectOption {
value: string;
label: string;
icon?: React.ReactNode;
badgeClassName?: string;
/** Optional hierarchy: indentation level and parent for tree-aware search. */
depth?: number;
parentValue?: string | null;
}
interface Props {
@ -47,6 +50,38 @@ export function MultiSelect({
className,
}: Props) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const isTree = options.some(
(option) => option.depth != null || option.parentValue != null,
);
// Tree-aware search: keep each match together with its ancestors so a
// matching child still shows its parent chain for context.
const visibleOptions = useMemo(() => {
const query = search.trim().toLowerCase();
if (!isTree || !query) {
return options;
}
const parentOf = new Map(
options.map((option) => [option.value, option.parentValue ?? null]),
);
const include = new Set<string>();
for (const option of options) {
if (!option.label.toLowerCase().includes(query)) {
continue;
}
let value: string | null | undefined = option.value;
let guard = 0;
while (value != null && guard++ < 10) {
include.add(value);
value = parentOf.get(value);
}
}
return options.filter((option) => include.has(option.value));
}, [options, isTree, search]);
const toggle = (value: string) => {
if (selected.includes(value)) {
@ -90,12 +125,24 @@ export function MultiSelect({
className="w-[--radix-popover-trigger-width] p-0"
align="start"
>
<Command>
<CommandInput placeholder={searchPlaceholder} />
<Command shouldFilter={!isTree}>
<CommandInput
placeholder={searchPlaceholder}
value={search}
onValueChange={setSearch}
/>
<CommandList>
{isTree ? (
visibleOptions.length === 0 && (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
)
) : (
<CommandEmpty>{emptyText}</CommandEmpty>
)}
<CommandGroup>
{options.map((option) => {
{visibleOptions.map((option) => {
const isSelected = selected.includes(
option.value,
);
@ -104,10 +151,17 @@ export function MultiSelect({
key={option.value}
value={option.label}
onSelect={() => toggle(option.value)}
style={
option.depth
? {
paddingLeft: `${option.depth * 1.25}rem`,
}
: undefined
}
>
<Check
className={cn(
'mr-2 h-4 w-4',
'mr-2 h-4 w-4 shrink-0',
isSelected
? 'opacity-100'
: 'opacity-0',

View File

@ -17,6 +17,8 @@ export interface SankeyCategory {
category: Category;
category_id: string;
amount: number;
has_children?: boolean;
is_direct?: boolean;
}
export interface SankeyData {
@ -39,6 +41,8 @@ export interface BreakdownItem {
amount: number;
percentage: number;
previous_amount: number;
has_children?: boolean;
is_direct?: boolean;
}
export interface BreakdownData {
@ -63,6 +67,8 @@ interface UseCashflowDataOptions {
from: Date;
to: Date;
periodType: CashflowPeriodType;
/** When set, the Sankey diagram drills into this parent category. */
sankeyParent?: string | null;
}
const emptyBreakdown: BreakdownData = {
@ -84,6 +90,7 @@ export function useCashflowData({
from,
to,
periodType,
sankeyParent = null,
}: UseCashflowDataOptions): CashflowData & { refetch: () => void } {
const [data, setData] = useState<Omit<CashflowData, 'isLoading'>>({
summary: { current: emptySummary, previous: emptySummary },
@ -110,6 +117,9 @@ export function useCashflowData({
to: toStr,
});
const periodQuery = `?${periodParams.toString()}`;
const sankeyQuery = sankeyParent
? `${periodQuery}&parent=${sankeyParent}`
: periodQuery;
const trendQuery =
periodType === 'month' ? `?months=12&to=${toStr}` : periodQuery;
@ -118,7 +128,7 @@ export function useCashflowData({
fetch(`/api/cashflow/summary${periodQuery}`).then((r) =>
r.json(),
),
fetch(`/api/cashflow/sankey${periodQuery}`).then((r) =>
fetch(`/api/cashflow/sankey${sankeyQuery}`).then((r) =>
r.json(),
),
fetch(`/api/cashflow/trend${trendQuery}`).then((r) =>
@ -144,7 +154,7 @@ export function useCashflowData({
} finally {
setIsLoading(false);
}
}, [from, periodType, to]);
}, [from, periodType, to, sankeyParent]);
useEffect(() => {
fetchData();

View File

@ -0,0 +1,101 @@
import { Category } from '@/types/category';
import { describe, expect, it } from 'vitest';
import {
categorySelectionState,
toggleCategorySelection,
} from './category-tree';
function category(id: string, parent_id: string | null): Category {
return {
id,
name: id,
icon: 'Tag',
color: 'gray',
type: 'expense',
cashflow_direction: 'hidden',
parent_id,
} as Category;
}
// food ──┬─ groceries ── coffee
// └─ restaurants
// drinks (root)
const categories: Category[] = [
category('food', null),
category('groceries', 'food'),
category('restaurants', 'food'),
category('coffee', 'groceries'),
category('drinks', null),
];
const stateOf = (id: string, selected: string[]) =>
categorySelectionState(id, new Set(selected), categories);
describe('categorySelectionState', () => {
it('marks a category and its descendants checked when the parent is selected', () => {
expect(stateOf('food', ['food'])).toBe('checked');
expect(stateOf('groceries', ['food'])).toBe('checked');
expect(stateOf('coffee', ['food'])).toBe('checked');
expect(stateOf('drinks', ['food'])).toBe('unchecked');
});
it('marks ancestors indeterminate when only a descendant is selected', () => {
expect(stateOf('food', ['coffee'])).toBe('indeterminate');
expect(stateOf('groceries', ['coffee'])).toBe('indeterminate');
expect(stateOf('coffee', ['coffee'])).toBe('checked');
});
});
describe('toggleCategorySelection', () => {
it('selecting a parent collapses to a single id covering the subtree', () => {
expect(toggleCategorySelection([], 'food', categories)).toEqual([
'food',
]);
});
it('unselecting one child keeps its siblings selected', () => {
const result = toggleCategorySelection(
['food'],
'restaurants',
categories,
);
expect(result).not.toContain('food');
expect(result).not.toContain('restaurants');
expect(stateOf('groceries', result)).toBe('checked');
expect(stateOf('restaurants', result)).toBe('unchecked');
expect(stateOf('food', result)).toBe('indeterminate');
});
it('re-selecting a sibling does not auto-collapse to the parent, but clicking the parent does', () => {
const partial = toggleCategorySelection(
['food'],
'restaurants',
categories,
);
const reselected = toggleCategorySelection(
partial,
'restaurants',
categories,
);
// Both children selected individually; the parent is not auto-added.
expect(reselected).not.toContain('food');
expect(stateOf('groceries', reselected)).toBe('checked');
expect(stateOf('restaurants', reselected)).toBe('checked');
// Clicking the parent collapses the whole subtree to a single id.
expect(toggleCategorySelection(reselected, 'food', categories)).toEqual(
['food'],
);
});
it('selecting a single child never pulls in its parent', () => {
const result = toggleCategorySelection([], 'coffee', categories);
expect(result).toEqual(['coffee']);
expect(stateOf('groceries', result)).toBe('indeterminate');
expect(stateOf('food', result)).toBe('indeterminate');
});
});

View File

@ -0,0 +1,235 @@
import { Category } from '@/types/category';
import { UUID } from '@/types/uuid';
export interface CategoryNode extends Category {
depth: number;
children: CategoryNode[];
}
const compareByName = (a: Category, b: Category): number =>
a.name.localeCompare(b.name);
/**
* Build a nested tree from a flat list of categories. Siblings are sorted with
* `compare` at every level (defaults to name), so children always stay grouped
* under their parent regardless of the chosen order. Orphans (a parent that is
* missing from the list) are treated as roots so nothing silently disappears.
*/
export function buildCategoryTree(
categories: Category[],
compare: (a: Category, b: Category) => number = compareByName,
): CategoryNode[] {
const byId = new Map<UUID, CategoryNode>();
for (const category of categories) {
byId.set(category.id, { ...category, depth: 0, children: [] });
}
const roots: CategoryNode[] = [];
for (const node of byId.values()) {
const parent =
node.parent_id != null ? byId.get(node.parent_id) : undefined;
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
}
const sortAndDepth = (nodes: CategoryNode[], depth: number) => {
nodes.sort(compare);
for (const node of nodes) {
node.depth = depth;
sortAndDepth(node.children, depth + 1);
}
};
sortAndDepth(roots, 0);
return roots;
}
/**
* Flatten a tree back into a list in depth-first display order, carrying the
* computed depth so callers can indent rows.
*/
export function flattenCategoryTree(nodes: CategoryNode[]): CategoryNode[] {
const flat: CategoryNode[] = [];
const walk = (list: CategoryNode[]) => {
for (const node of list) {
flat.push(node);
walk(node.children);
}
};
walk(nodes);
return flat;
}
/**
* All descendant ids of a category (excluding itself), resolved from a flat
* list via a parent index.
*/
export function getDescendantIds(
categoryId: UUID,
categories: Category[],
): UUID[] {
const childrenByParent = new Map<UUID, UUID[]>();
for (const category of categories) {
if (category.parent_id == null) {
continue;
}
const siblings = childrenByParent.get(category.parent_id) ?? [];
siblings.push(category.id);
childrenByParent.set(category.parent_id, siblings);
}
const result: UUID[] = [];
const stack = [...(childrenByParent.get(categoryId) ?? [])];
while (stack.length > 0) {
const id = stack.pop()!;
result.push(id);
stack.push(...(childrenByParent.get(id) ?? []));
}
return result;
}
export type CategorySelectionState = 'checked' | 'indeterminate' | 'unchecked';
interface TreeIndex {
parentOf: Map<UUID, UUID | null>;
childrenOf: Map<UUID, UUID[]>;
}
function indexTree(categories: Category[]): TreeIndex {
const parentOf = new Map<UUID, UUID | null>();
const childrenOf = new Map<UUID, UUID[]>();
for (const category of categories) {
parentOf.set(category.id, category.parent_id);
if (category.parent_id != null) {
const siblings = childrenOf.get(category.parent_id) ?? [];
siblings.push(category.id);
childrenOf.set(category.parent_id, siblings);
}
}
return { parentOf, childrenOf };
}
function collectDescendants(id: UUID, childrenOf: Map<UUID, UUID[]>): UUID[] {
const result: UUID[] = [];
const stack = [...(childrenOf.get(id) ?? [])];
while (stack.length > 0) {
const current = stack.pop()!;
result.push(current);
stack.push(...(childrenOf.get(current) ?? []));
}
return result;
}
/**
* Tri-state for a category in a tree multi-select: a category is checked when
* it (or an ancestor) is in the selected set, indeterminate when only some
* descendants are, otherwise unchecked.
*/
export function categorySelectionState(
id: UUID,
selected: Set<UUID>,
categories: Category[],
): CategorySelectionState {
const { parentOf, childrenOf } = indexTree(categories);
let current: UUID | null | undefined = id;
let guard = 0;
while (current != null && guard++ < 10) {
if (selected.has(current)) {
return 'checked';
}
current = parentOf.get(current);
}
if (collectDescendants(id, childrenOf).some((d) => selected.has(d))) {
return 'indeterminate';
}
return 'unchecked';
}
/**
* Toggle a category in a tree multi-select selection.
*
* Selecting a category selects its whole subtree (one id covers it; the backend
* expands it when filtering). Unselecting a covered child first pushes the
* covering ancestor down into its children, so siblings stay selected while the
* one child is removed. Selecting a child never auto-selects its parent, so
* picking a child won't pull in the parent's directly-assigned transactions
* click the parent row to select the whole subtree at once.
*/
export function toggleCategorySelection(
selected: UUID[],
id: UUID,
categories: Category[],
): UUID[] {
const set = new Set(selected);
const { parentOf, childrenOf } = indexTree(categories);
const isChecked = categorySelectionState(id, set, categories) === 'checked';
if (isChecked) {
// Push every covering ancestor down to its direct children so removing
// a single node leaves its siblings selected.
const chain: UUID[] = [];
let ancestor = parentOf.get(id);
let guard = 0;
while (ancestor != null && guard++ < 10) {
chain.unshift(ancestor);
ancestor = parentOf.get(ancestor);
}
for (const node of chain) {
if (set.has(node)) {
set.delete(node);
for (const child of childrenOf.get(node) ?? []) {
set.add(child);
}
}
}
set.delete(id);
for (const descendant of collectDescendants(id, childrenOf)) {
set.delete(descendant);
}
return [...set];
}
// Select the whole subtree: one id covers it, so drop any now-redundant
// descendants. Ancestors are left untouched so selecting a child never
// pulls in a parent (and its directly-assigned transactions).
for (const descendant of collectDescendants(id, childrenOf)) {
set.delete(descendant);
}
set.add(id);
return [...set];
}
/**
* Build the "Parent > Child" display path for a category.
*/
export function getCategoryPath(
categoryId: UUID,
categories: Category[],
separator = ' ',
): string {
const byId = new Map<UUID, Category>(categories.map((c) => [c.id, c]));
const names: string[] = [];
let current: Category | undefined = byId.get(categoryId);
let guard = 0;
while (current && guard++ < 10) {
names.unshift(current.name);
current =
current.parent_id != null ? byId.get(current.parent_id) : undefined;
}
return names.join(separator);
}

View File

@ -60,11 +60,6 @@ export const FIELD_CONFIG: Record<
type: 'string',
operators: ['contains', 'equals'],
},
category: {
label: 'Category',
type: 'string',
operators: ['equals', 'is_empty', 'is_not_empty'],
},
};
export const OPERATOR_LABELS: Record<Operator, string> = {
@ -85,7 +80,7 @@ function buildConditionJsonLogic(condition: Condition): JsonLogicRule {
case 'contains':
return { in: [value, { var: field }] };
case 'equals':
if (FIELD_CONFIG[field].type === 'number') {
if (FIELD_CONFIG[field]?.type === 'number') {
return { '==': [{ var: field }, parseFloat(value)] };
}
return { '==': [{ var: field }, value] };

View File

@ -125,6 +125,11 @@ export default function CashflowPage() {
parsePeriodParam(initialPeriod, initialPeriodType),
);
const [sankeyDrill, setSankeyDrill] = useState<{
id: string;
label: string;
} | null>(null);
const period = getPeriodRange(currentDate, periodType);
const {
@ -134,7 +139,16 @@ export default function CashflowPage() {
incomeBreakdown,
expenseBreakdown,
isLoading,
} = useCashflowData({ ...period, periodType });
} = useCashflowData({
...period,
periodType,
sankeyParent: sankeyDrill?.id ?? null,
});
// Leaving the period resets any Sankey drill-down.
useEffect(() => {
setSankeyDrill(null);
}, [currentDate, periodType]);
useEffect(() => {
const periodParam = formatPeriodParam(currentDate, periodType);
@ -202,9 +216,28 @@ export default function CashflowPage() {
{/* Sankey Diagram */}
<Card>
<CardHeader className="pb-4">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-base">
{__('Money Flow')}
</CardTitle>
{sankeyDrill && (
<nav className="flex items-center gap-1 text-sm">
<button
type="button"
onClick={() => setSankeyDrill(null)}
className="text-muted-foreground hover:text-foreground hover:underline"
>
{__('All categories')}
</button>
<span className="text-muted-foreground">
</span>
<span className="font-medium">
{sankeyDrill.label}
</span>
</nav>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
@ -215,6 +248,9 @@ export default function CashflowPage() {
height={400}
currency={auth.user.currency_code}
period={period}
onDrill={(id, label) =>
setSankeyDrill({ id, label })
}
/>
)}
</CardContent>

View File

@ -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 }) {
<EditCategoryDialog
category={category}
categories={categories}
open={editOpen}
onOpenChange={setEditOpen}
onSuccess={() => {}}
@ -100,6 +114,7 @@ function CategoryActions({ category }: { category: Category }) {
<DeleteCategoryDialog
category={category}
categories={categories}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={() => {}}
@ -108,7 +123,13 @@ function CategoryActions({ category }: { category: Category }) {
);
}
function CategoryRow({ row }: { row: Row<Category> }) {
function CategoryRow({
row,
categories,
}: {
row: Row<Category>;
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<Category> }) {
<EditCategoryDialog
category={category}
categories={categories}
open={editOpen}
onOpenChange={setEditOpen}
onSuccess={() => {}}
@ -162,6 +184,7 @@ function CategoryRow({ row }: { row: Row<Category> }) {
<DeleteCategoryDialog
category={category}
categories={categories}
open={deleteOpen}
onOpenChange={setDeleteOpen}
onSuccess={() => {}}
@ -172,9 +195,54 @@ function CategoryRow({ row }: { row: Row<Category> }) {
export default function Categories() {
const { categories } = usePage<{ categories: Category[] }>().props;
const [sorting, setSorting] = useState<SortingState>([
{ id: 'name', desc: false },
]);
const [sortField, setSortField] = useState<SortField>('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<CategoryNode[]>(() => {
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) => (
<Button
variant="ghost"
className="px-3"
onClick={() => toggleSort(field)}
>
{label}
{sortField === field ? (
sortDirection === 'asc' ? (
<ArrowUp className="ml-2 h-4 w-4" />
) : (
<ArrowDown className="ml-2 h-4 w-4" />
)
) : (
<ArrowUpDown className="ml-2 h-4 w-4 opacity-40" />
)}
</Button>
);
const [sorting, setSorting] = useState<SortingState>([]);
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
{},
@ -183,20 +251,7 @@ export default function Categories() {
const columns: ColumnDef<Category>[] = [
{
accessorKey: 'name',
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === 'asc')
}
>
{__('Name')}
<ArrowUpDown className="ml-2 h-4 w-4" />
</Button>
);
},
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 (
<div className="flex items-center gap-3 pl-3">
<div
className="flex items-center gap-3 pl-3"
style={{ paddingLeft: `${0.75 + depth * 1.5}rem` }}
>
{depth > 0 && (
<span
aria-hidden
className="text-muted-foreground select-none"
>
</span>
)}
<Icon className="h-4 w-4 opacity-80" />
<div className="font-medium">
<span className="font-medium">
{row.getValue('name')}
</div>
</span>
</div>
);
},
},
{
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 }) => <CategoryActions category={row.original} />,
cell: ({ row }) => (
<CategoryActions
category={row.original}
categories={categories}
/>
),
},
];
const table = useReactTable({
data: categories,
data: orderedCategories,
columns,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
@ -383,7 +456,10 @@ export default function Categories() {
className="max-w-sm"
/>
<CreateCategoryDialog onSuccess={() => {}} />
<CreateCategoryDialog
categories={categories}
onSuccess={() => {}}
/>
</div>
<div className="overflow-hidden rounded-md border">
@ -423,6 +499,7 @@ export default function Categories() {
<CategoryRow
key={row.id}
row={row}
categories={categories}
/>
))
) : (

View File

@ -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): {

View File

@ -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',
]);
});

View File

@ -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();
});

View File

@ -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);
});

View File

@ -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,

View File

@ -1,5 +1,8 @@
<?php
use App\Enums\CategoryType;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use Laravel\Fortify\Features;
@ -49,3 +52,33 @@ test('authenticated users can visit the dashboard', function () {
$this->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);
});

View File

@ -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++;
}
});

View File

@ -0,0 +1,217 @@
<?php
use App\Enums\CategoryCashflowDirection;
use App\Enums\CategoryType;
use App\Models\Account;
use App\Models\Category;
use App\Models\Transaction;
use App\Models\User;
use App\Services\CategoryTree;
test('a child category inherits its parent type and cashflow direction', function () {
$user = User::factory()->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);
});

View File

@ -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]);

View File

@ -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');
});