From d470ee9646c6d60bccae39e729437beb457cbbf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Mon, 6 Jul 2026 17:26:11 +0200 Subject: [PATCH 1/4] feat(spaces): scope reads by active space and add the space switcher MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flip every user-facing read (dashboard, cashflow, transactions, budgets, accounts, settings, analytics, sync, shared props) from user-scoped to active-space-scoped via explicit $space->… relations, and re-key the category tree, spending, and budget-assignment services by space_id. Authorization moves from creator-ownership to space membership. Add the visible spaces layer behind a Pennant 'spaces' flag: a sidebar space switcher, a settings page to create/rename/delete/switch spaces, per-space offline-cache flushing, and the currentSpace/spaces shared props. Data creation is unchanged — the BelongsToSpace trait already stamps space_id from the active space. Isolation tests prove no cross-space leakage. --- app/Features/Spaces.php | 24 ++ app/Http/Controllers/AccountController.php | 10 +- .../Controllers/Api/AccountController.php | 4 +- .../Api/CashflowAnalyticsController.php | 36 ++- .../CategoryMonthlyBreakdownController.php | 13 +- .../Api/DashboardAnalyticsController.php | 32 +- .../Controllers/Api/ImportDataController.php | 7 +- .../Controllers/Api/SavedFilterController.php | 12 +- .../Api/TransactionAnalysisController.php | 10 +- .../Controllers/Api/TransactionController.php | 11 +- app/Http/Controllers/BudgetController.php | 16 +- app/Http/Controllers/CashflowController.php | 9 +- app/Http/Controllers/DashboardController.php | 25 +- app/Http/Controllers/OnboardingController.php | 15 +- .../Settings/AccountController.php | 6 +- .../AutomationRuleApplicationController.php | 4 +- .../Settings/CategoryController.php | 1 + .../Controllers/Settings/LabelController.php | 1 + app/Http/Controllers/SpaceController.php | 104 +++++++ .../Controllers/SubscriptionController.php | 12 +- .../Sync/TransactionSyncController.php | 4 +- .../Controllers/TransactionController.php | 44 ++- app/Http/Middleware/HandleInertiaRequests.php | 38 ++- app/Http/Middleware/ResolveActiveSpace.php | 23 ++ app/Http/Requests/StoreSpaceRequest.php | 26 ++ app/Http/Requests/UpdateSpaceRequest.php | 31 ++ app/Models/Transaction.php | 6 +- app/Models/User.php | 3 +- .../Concerns/HandlesUserOwnership.php | 21 +- app/Services/BudgetTransactionService.php | 38 +-- app/Services/CategorySpendingService.php | 6 +- app/Services/CategoryTree.php | 30 +- bootstrap/app.php | 2 + database/factories/TransactionFactory.php | 27 ++ lang/es.json | 18 +- resources/js/components/app-sidebar.tsx | 2 + resources/js/components/space-switcher.tsx | 210 +++++++++++++ resources/js/contexts/sync-context.tsx | 15 + resources/js/layouts/settings/layout.tsx | 21 +- resources/js/pages/settings/spaces.tsx | 275 ++++++++++++++++++ resources/js/types/index.d.ts | 10 + routes/settings.php | 7 + tests/Feature/InertiaSharedDataTest.php | 1 + tests/Feature/Settings/CategoryTreeTest.php | 2 +- tests/Feature/Spaces/SpaceFoundationTest.php | 18 +- tests/Feature/Spaces/SpaceManagementTest.php | 92 ++++++ tests/Feature/Spaces/SpaceScopingTest.php | 109 +++++++ tests/Performance/PageQueryCountTest.php | 30 +- 48 files changed, 1251 insertions(+), 210 deletions(-) create mode 100644 app/Features/Spaces.php create mode 100644 app/Http/Controllers/SpaceController.php create mode 100644 app/Http/Middleware/ResolveActiveSpace.php create mode 100644 app/Http/Requests/StoreSpaceRequest.php create mode 100644 app/Http/Requests/UpdateSpaceRequest.php create mode 100644 resources/js/components/space-switcher.tsx create mode 100644 resources/js/pages/settings/spaces.tsx create mode 100644 tests/Feature/Spaces/SpaceManagementTest.php create mode 100644 tests/Feature/Spaces/SpaceScopingTest.php diff --git a/app/Features/Spaces.php b/app/Features/Spaces.php new file mode 100644 index 00000000..23322b0c --- /dev/null +++ b/app/Features/Spaces.php @@ -0,0 +1,24 @@ +isAdmin() ?? false; + } +} diff --git a/app/Http/Controllers/AccountController.php b/app/Http/Controllers/AccountController.php index 9e42d3f1..ab6b6a4e 100644 --- a/app/Http/Controllers/AccountController.php +++ b/app/Http/Controllers/AccountController.php @@ -29,8 +29,7 @@ class AccountController extends Controller { $user = $request->user(); - $accounts = Account::query() - ->where('user_id', $user->id) + $accounts = $user->activeSpace()->accounts() ->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id']) ->orderBy('position') ->orderBy('name') @@ -50,10 +49,11 @@ class AccountController extends Controller { // ponytail: one update per account; fine for the handful of accounts a // user has. Switch to a single CASE update if that ever grows large. + $space = $request->user()->activeSpace(); + foreach (array_values($request->validated('ids')) as $position => $id) { - Account::query() + $space->accounts() ->whereKey($id) - ->where('user_id', $request->user()->id) ->update(['position' => $position]); } @@ -105,7 +105,7 @@ class AccountController extends Controller } // Provide available loan accounts for linking - $data['available_loan_accounts'] = $request->user() + $data['available_loan_accounts'] = $request->user()->activeSpace() ->accounts() ->where('type', AccountType::Loan->value) ->with('bank') diff --git a/app/Http/Controllers/Api/AccountController.php b/app/Http/Controllers/Api/AccountController.php index 9bcd7950..dded9ff6 100644 --- a/app/Http/Controllers/Api/AccountController.php +++ b/app/Http/Controllers/Api/AccountController.php @@ -20,7 +20,9 @@ class AccountController extends Controller { // The decryption-migration flow needs bank_id, which Account hides by // default; opt it back in explicitly here. - $accounts = $request->user() + $space = $request->user()->activeSpace(); + + $accounts = $space ->accounts() ->get() ->makeVisible('bank_id'); diff --git a/app/Http/Controllers/Api/CashflowAnalyticsController.php b/app/Http/Controllers/Api/CashflowAnalyticsController.php index d6543ab0..4c69555e 100644 --- a/app/Http/Controllers/Api/CashflowAnalyticsController.php +++ b/app/Http/Controllers/Api/CashflowAnalyticsController.php @@ -39,9 +39,10 @@ class CashflowAnalyticsController extends Controller $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); $user = $request->user(); + $space = $user->activeSpace(); return $this->cashflowJson( - $this->calculateCashflowSummaries($user->id, $user->currency_code, $period, $previousPeriod) + $this->calculateCashflowSummaries($space->id, $user->currency_code, $period, $previousPeriod) ); } @@ -56,12 +57,13 @@ class CashflowAnalyticsController extends Controller $from = Carbon::parse($validated['from']); $to = Carbon::parse($validated['to']); $user = $request->user(); + $space = $user->activeSpace(); $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, '>', $drillParentId); - $expenseCategories = $this->getSankeyBreakdown($user->id, $user->currency_code, $from, $to, '<', $drillParentId); + $incomeCategories = $this->getSankeyBreakdown($space->id, $user->currency_code, $from, $to, '>', $drillParentId); + $expenseCategories = $this->getSankeyBreakdown($space->id, $user->currency_code, $from, $to, '<', $drillParentId); $totalIncome = $incomeCategories->sum('amount'); $totalExpense = $expenseCategories->sum('amount'); @@ -83,6 +85,7 @@ class CashflowAnalyticsController extends Controller ]); $user = $request->user(); + $space = $user->activeSpace(); if (isset($validated['from'], $validated['to'])) { $start = Carbon::parse($validated['from'])->startOfMonth(); @@ -104,7 +107,7 @@ class CashflowAnalyticsController extends Controller $start = $earliestStart; } - $monthlyTotals = $this->getMonthlyTrendTotals($user->id, $user->currency_code, $start, $end); + $monthlyTotals = $this->getMonthlyTrendTotals($space->id, $user->currency_code, $start, $end); $data = []; $current = $start->copy(); @@ -142,12 +145,13 @@ class CashflowAnalyticsController extends Controller $period = PeriodComparator::fromRequest($validated); $previousPeriod = $period->previous(); $user = $request->user(); + $space = $user->activeSpace(); $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, $drillParentId); - $previous = $this->getCategoryBreakdown($user->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType, $drillParentId); + $current = $this->getCategoryBreakdown($space->id, $user->currency_code, $period->from, $period->to, $categoryType, $drillParentId); + $previous = $this->getCategoryBreakdown($space->id, $user->currency_code, $previousPeriod->from, $previousPeriod->to, $categoryType, $drillParentId); $currentTotal = $current->sum('amount'); $previousTotal = $previous->sum('amount'); @@ -181,10 +185,10 @@ class CashflowAnalyticsController extends Controller ->header('Cache-Control', 'no-store, private'); } - private function calculateCashflowSummaries(string $userId, string $userCurrency, PeriodComparator $period, PeriodComparator $previousPeriod): array + private function calculateCashflowSummaries(string $spaceId, string $userCurrency, PeriodComparator $period, PeriodComparator $previousPeriod): array { $transactions = Transaction::query() - ->where('transactions.user_id', $userId) + ->where('transactions.space_id', $spaceId) ->whereBetween('transactions.transaction_date', [$previousPeriod->from, $period->to]) ->with(['account', 'category']) ->get(); @@ -238,12 +242,12 @@ 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, ?string $drillParentId = null): Collection + private function getSankeyBreakdown(string $spaceId, string $userCurrency, Carbon $from, Carbon $to, string $operator, ?string $drillParentId = null): Collection { $isIncome = $operator === '>'; $type = $isIncome ? CategoryType::Income : CategoryType::Expense; $transactions = Transaction::query() - ->where('transactions.user_id', $userId) + ->where('transactions.space_id', $spaceId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->with(['account', 'category']) ->get(); @@ -305,7 +309,7 @@ class CashflowAnalyticsController extends Controller $categorized = collect($this->tree->rollUp( $regularCategories->concat($transferCategories)->values()->all(), - $userId, + $spaceId, $drillParentId, )); @@ -335,10 +339,10 @@ class CashflowAnalyticsController extends Controller return $categorized; } - private function getMonthlyTrendTotals(string $userId, string $userCurrency, Carbon $from, Carbon $to): Collection + private function getMonthlyTrendTotals(string $spaceId, string $userCurrency, Carbon $from, Carbon $to): Collection { $transactions = Transaction::query() - ->where('transactions.user_id', $userId) + ->where('transactions.space_id', $spaceId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->with(['account', 'category']) ->get(); @@ -393,10 +397,10 @@ class CashflowAnalyticsController extends Controller }); } - private function getCategoryBreakdown(string $userId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type, ?string $drillParentId = null): Collection + private function getCategoryBreakdown(string $spaceId, string $userCurrency, Carbon $from, Carbon $to, CategoryType $type, ?string $drillParentId = null): Collection { $transactions = Transaction::query() - ->where('transactions.user_id', $userId) + ->where('transactions.space_id', $spaceId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->with(['account', 'category']) ->get(); @@ -423,7 +427,7 @@ class CashflowAnalyticsController extends Controller 'amount' => $item['amount'], ]); - $categorized = collect($this->tree->rollUp($categorized->values()->all(), $userId, $drillParentId)); + $categorized = collect($this->tree->rollUp($categorized->values()->all(), $spaceId, $drillParentId)); $uncategorized = $transactions ->filter(function (Transaction $transaction) use ($type): bool { diff --git a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php index 7e005a83..27d57a83 100644 --- a/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php +++ b/app/Http/Controllers/Api/CategoryMonthlyBreakdownController.php @@ -6,7 +6,6 @@ use App\Enums\CategoryType; use App\Http\Controllers\Api\Concerns\ConvertsTransactionCurrency; use App\Http\Controllers\Controller; use App\Models\Category; -use App\Models\Transaction; use App\Services\ExchangeRateService; use Carbon\Carbon; use Illuminate\Http\JsonResponse; @@ -35,19 +34,18 @@ class CategoryMonthlyBreakdownController extends Controller public function __invoke(Request $request, Category $category): JsonResponse { $user = $request->user(); + $space = $user->activeSpace(); - abort_unless($category->user_id === $user->id, 403); + abort_unless($category->space_id === $space->id, 403); $currency = $user->currency_code; $start = Carbon::now()->startOfMonth()->subMonths(self::MONTHS - 1); - $parentMap = Category::query() - ->where('user_id', $user->id) + $parentMap = $space->categories() ->pluck('parent_id', 'id') ->all(); - $children = Category::query() - ->where('user_id', $user->id) + $children = $space->categories() ->where('parent_id', $category->id) ->orderBy('name') ->get() @@ -58,8 +56,7 @@ class CategoryMonthlyBreakdownController extends Controller fn (string $id): bool => $this->belongsToSubtree($id, $category->id, $parentMap), )); - $transactions = Transaction::query() - ->where('user_id', $user->id) + $transactions = $space->transactions() ->whereIn('category_id', $subtreeIds) ->where('transaction_date', '>=', $start->toDateString()) ->with('account') diff --git a/app/Http/Controllers/Api/DashboardAnalyticsController.php b/app/Http/Controllers/Api/DashboardAnalyticsController.php index 5659d931..df91eec6 100644 --- a/app/Http/Controllers/Api/DashboardAnalyticsController.php +++ b/app/Http/Controllers/Api/DashboardAnalyticsController.php @@ -38,9 +38,8 @@ class DashboardAnalyticsController extends Controller $userCurrency = $request->user()->currency_code; - $accounts = Account::query() - ->where('user_id', $request->user()->id) - ->get(); + $space = $request->user()->activeSpace(); + $accounts = $space->accounts()->get(); // Load every account's balance history for the compared range in a // fixed number of queries instead of one balance lookup per account. @@ -101,8 +100,8 @@ class DashboardAnalyticsController extends Controller $userCurrency = $request->user()->currency_code; - $accounts = Account::query() - ->where('user_id', $request->user()->id) + $space = $request->user()->activeSpace(); + $accounts = $space->accounts() ->with(['bank:id,name,logo']) ->get(); @@ -113,7 +112,7 @@ class DashboardAnalyticsController extends Controller public function accountBalanceEvolution(Request $request, Account $account) { - if ($account->user_id !== $request->user()->id) { + if ($account->space_id !== $request->user()->activeSpace()->id) { abort(403); } @@ -319,7 +318,7 @@ class DashboardAnalyticsController extends Controller public function accountDailyBalanceEvolution(Request $request, Account $account) { - if ($account->user_id !== $request->user()->id) { + if ($account->space_id !== $request->user()->activeSpace()->id) { abort(403); } @@ -426,8 +425,8 @@ class DashboardAnalyticsController extends Controller $userCurrency = $request->user()->currency_code; - $accounts = Account::query() - ->where('user_id', $request->user()->id) + $space = $request->user()->activeSpace(); + $accounts = $space->accounts() ->with(['bank:id,name,logo']) ->get(); @@ -448,8 +447,9 @@ class DashboardAnalyticsController extends Controller $previousPeriod = $period->previous(); $drillParentId = $validated['parent'] ?? null; - $currentSpending = $this->categorySpendingService->forPeriod($request->user()->id, $period->from, $period->to, $drillParentId); - $previousSpending = $this->categorySpendingService->forPeriod($request->user()->id, $previousPeriod->from, $previousPeriod->to, $drillParentId); + $space = $request->user()->activeSpace(); + $currentSpending = $this->categorySpendingService->forPeriod($space->id, $period->from, $period->to, $drillParentId); + $previousSpending = $this->categorySpendingService->forPeriod($space->id, $previousPeriod->from, $previousPeriod->to, $drillParentId); $totalAmount = $currentSpending->sum('amount'); @@ -501,8 +501,10 @@ class DashboardAnalyticsController extends Controller private function calculateSpending(Carbon $from, Carbon $to): int { + $space = request()->user()->activeSpace(); + $spending = Transaction::query() - ->where('transactions.user_id', request()->user()->id) + ->where('transactions.space_id', $space->id) ->whereBetween('transactions.transaction_date', [$from, $to]) ->join('categories', function ($join) { $join->on('transactions.category_id', '=', 'categories.id') @@ -516,8 +518,10 @@ class DashboardAnalyticsController extends Controller private function calculateCashFlow(Carbon $from, Carbon $to): array { + $space = request()->user()->activeSpace(); + $income = Transaction::query() - ->where('transactions.user_id', request()->user()->id) + ->where('transactions.space_id', $space->id) ->whereBetween('transactions.transaction_date', [$from, $to]) ->join('categories', function ($join) { $join->on('transactions.category_id', '=', 'categories.id') @@ -527,7 +531,7 @@ class DashboardAnalyticsController extends Controller ->sum('transactions.amount'); $expense = Transaction::query() - ->where('transactions.user_id', request()->user()->id) + ->where('transactions.space_id', $space->id) ->whereBetween('transactions.transaction_date', [$from, $to]) ->join('categories', function ($join) { $join->on('transactions.category_id', '=', 'categories.id') diff --git a/app/Http/Controllers/Api/ImportDataController.php b/app/Http/Controllers/Api/ImportDataController.php index bf4f582b..8333a642 100644 --- a/app/Http/Controllers/Api/ImportDataController.php +++ b/app/Http/Controllers/Api/ImportDataController.php @@ -13,19 +13,20 @@ class ImportDataController extends Controller public function index(): JsonResponse { $user = auth()->user(); + $space = $user->activeSpace(); return response()->json([ - 'accounts' => $user->accounts() + 'accounts' => $space->accounts() ->with('bank') ->orderBy('name') ->get(), - 'categories' => $user->categories() + 'categories' => $space->categories() ->forDisplay() ->get(), 'banks' => $user->banks() ->orderBy('name') ->get(), - 'automationRules' => $user->automationRules() + 'automationRules' => $space->automationRules() ->with(['category', 'labels']) ->orderBy('priority') ->get(), diff --git a/app/Http/Controllers/Api/SavedFilterController.php b/app/Http/Controllers/Api/SavedFilterController.php index 56575109..a1eef0ae 100644 --- a/app/Http/Controllers/Api/SavedFilterController.php +++ b/app/Http/Controllers/Api/SavedFilterController.php @@ -15,8 +15,8 @@ class SavedFilterController extends Controller { public function index(Request $request): JsonResponse { - $savedFilters = SavedFilter::query() - ->where('user_id', $request->user()->id) + $savedFilters = $request->user()->activeSpace() + ->savedFilters() ->orderBy('name') ->get(); @@ -38,7 +38,7 @@ class SavedFilterController extends Controller public function update(UpdateSavedFilterRequest $request, SavedFilter $savedFilter): JsonResponse { - abort_unless($savedFilter->user_id === $request->user()->id, 403); + abort_unless($savedFilter->space_id === $request->user()->activeSpace()->id, 403); $savedFilter->update(['filters' => $request->validated('filters')]); @@ -49,7 +49,7 @@ class SavedFilterController extends Controller public function destroy(Request $request, SavedFilter $savedFilter): JsonResponse { - abort_unless($savedFilter->user_id === $request->user()->id, 403); + abort_unless($savedFilter->space_id === $request->user()->activeSpace()->id, 403); $savedFilter->delete(); @@ -58,7 +58,7 @@ class SavedFilterController extends Controller public function updateAnalysisDays(Request $request, SavedFilter $savedFilter): JsonResponse { - abort_unless($savedFilter->user_id === $request->user()->id, 403); + abort_unless($savedFilter->space_id === $request->user()->activeSpace()->id, 403); $validated = $request->validate([ 'analysis_days' => ['nullable', 'integer', 'min:1', 'max:36500'], @@ -73,7 +73,7 @@ class SavedFilterController extends Controller public function updateAnalysisMode(Request $request, SavedFilter $savedFilter): JsonResponse { - abort_unless($savedFilter->user_id === $request->user()->id, 403); + abort_unless($savedFilter->space_id === $request->user()->activeSpace()->id, 403); $validated = $request->validate([ 'analysis_mode' => ['nullable', Rule::enum(AnalysisMode::class)], diff --git a/app/Http/Controllers/Api/TransactionAnalysisController.php b/app/Http/Controllers/Api/TransactionAnalysisController.php index ed325060..295e0efb 100644 --- a/app/Http/Controllers/Api/TransactionAnalysisController.php +++ b/app/Http/Controllers/Api/TransactionAnalysisController.php @@ -37,6 +37,7 @@ class TransactionAnalysisController extends Controller public function summary(IndexTransactionRequest $request): JsonResponse { $user = $request->user(); + $space = $user->activeSpace(); $validated = $request->validated(); $currency = $user->currency_code; @@ -54,15 +55,14 @@ class TransactionAnalysisController extends Controller 'search' => $validated['search'] ?? null, ], fn ($value) => $value !== null); - $transactions = Transaction::query() - ->where('user_id', $user->id) + $transactions = $space->transactions() ->with(['account.bank', 'category', 'labels']) ->applyFilters($filters) ->get(); $this->preloadExchangeRates($transactions, $currency); - $byCategory = $this->categoryBreakdown($transactions, $currency, $user->id); + $byCategory = $this->categoryBreakdown($transactions, $currency, $space->id); $byTag = $this->tagBreakdown($transactions, $currency); $byPayee = $this->payeeBreakdown($transactions, $currency); $byAccount = $this->accountBreakdown($transactions, $currency); @@ -124,7 +124,7 @@ class TransactionAnalysisController extends Controller * that carry spending nested beneath each parent so the split is visible * instead of folded into the parent total. */ - private function categoryBreakdown(Collection $transactions, string $currency, string $userId): Collection + private function categoryBreakdown(Collection $transactions, string $currency, string $spaceId): Collection { $expenses = $transactions->filter( fn (Transaction $transaction): bool => $transaction->isExpenseSide(), @@ -154,7 +154,7 @@ class TransactionAnalysisController extends Controller 'icon' => $child['category']->icon, 'amount' => $child['amount'], ], $node['children']), - ], $this->tree->spendingBreakdown($grouped, $userId)); + ], $this->tree->spendingBreakdown($grouped, $spaceId)); $uncategorized = -$expenses ->filter(fn (Transaction $transaction): bool => $transaction->category_id === null) diff --git a/app/Http/Controllers/Api/TransactionController.php b/app/Http/Controllers/Api/TransactionController.php index 0c1723ae..f9bb9d15 100644 --- a/app/Http/Controllers/Api/TransactionController.php +++ b/app/Http/Controllers/Api/TransactionController.php @@ -16,7 +16,9 @@ class TransactionController extends Controller */ public function index(Request $request): JsonResponse { - $query = $request->user() + $space = $request->user()->activeSpace(); + + $query = $space ->transactions() ->with('labels'); @@ -48,7 +50,8 @@ class TransactionController extends Controller public function checkDuplicates(CheckDuplicateTransactionsRequest $request): JsonResponse { $validated = $request->validated(); - $account = $request->user()->accounts()->findOrFail($validated['account_id']); + $space = $request->user()->activeSpace(); + $account = $space->accounts()->findOrFail($validated['account_id']); $incoming = $validated['transactions']; $dates = array_map(fn (array $t): string => substr($t['transaction_date'], 0, 10), $incoming); @@ -103,7 +106,9 @@ class TransactionController extends Controller $transactionIds = collect($validated['transactions'])->pluck('id'); - $userTransactionIds = $request->user() + $space = $request->user()->activeSpace(); + + $userTransactionIds = $space ->transactions() ->whereIn('id', $transactionIds) ->pluck('id'); diff --git a/app/Http/Controllers/BudgetController.php b/app/Http/Controllers/BudgetController.php index c42457ba..2a2f7dcd 100644 --- a/app/Http/Controllers/BudgetController.php +++ b/app/Http/Controllers/BudgetController.php @@ -5,11 +5,8 @@ namespace App\Http\Controllers; use App\Http\Requests\StoreBudgetRequest; use App\Http\Requests\UpdateBudgetRequest; use App\Jobs\AssignHistoricalTransactionsToBudget; -use App\Models\Account; use App\Models\Bank; use App\Models\Budget; -use App\Models\Category; -use App\Models\Label; use App\Services\BudgetPeriodService; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; @@ -27,7 +24,7 @@ class BudgetController extends Controller public function index(Request $request): Response { $user = $request->user(); - $budgets = $user + $budgets = $user->activeSpace() ->budgets() ->with(['categories', 'labels', 'periods' => function ($query) { $query->where('start_date', '<=', today()) @@ -83,13 +80,13 @@ class BudgetController extends Controller $budget->load(['categories', 'labels']); - $categories = Category::query() - ->where('user_id', $user->id) + $space = $user->activeSpace(); + + $categories = $space->categories() ->forDisplay() ->get(); - $accounts = Account::query() - ->where('user_id', $user->id) + $accounts = $space->accounts() ->with('bank') ->orderBy('name') ->get(); @@ -99,8 +96,7 @@ class BudgetController extends Controller ->orderBy('name') ->get(); - $labels = Label::query() - ->where('user_id', $user->id) + $labels = $space->labels() ->orderBy('name') ->get(); diff --git a/app/Http/Controllers/CashflowController.php b/app/Http/Controllers/CashflowController.php index 67dd0e28..9fce3cc8 100644 --- a/app/Http/Controllers/CashflowController.php +++ b/app/Http/Controllers/CashflowController.php @@ -2,9 +2,7 @@ namespace App\Http\Controllers; -use App\Models\Account; use App\Models\Bank; -use App\Models\Category; use Illuminate\Http\Request; use Inertia\Inertia; use Inertia\Response; @@ -14,14 +12,13 @@ class CashflowController extends Controller public function __invoke(Request $request): Response { $user = $request->user(); + $space = $user->activeSpace(); - $categories = Category::query() - ->where('user_id', $user->id) + $categories = $space->categories() ->forDisplay() ->get(); - $accounts = Account::query() - ->where('user_id', $user->id) + $accounts = $space->accounts() ->with('bank') ->orderBy('name') ->get(); diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index f85ba628..b3771399 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers; use App\Enums\CategoryType; -use App\Models\Account; use App\Models\Transaction; use App\Services\AccountMetricsService; use App\Services\CashflowSummaryService; @@ -39,8 +38,7 @@ class DashboardController extends Controller $start = $now->copy()->subMonths(12); $end = $now->copy(); - $accounts = Account::query() - ->where('user_id', $user->id) + $accounts = $user->activeSpace()->accounts() ->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id']) ->orderBy('position') ->orderBy('name') @@ -59,8 +57,9 @@ class DashboardController extends Controller $period = new PeriodComparator($from, $to); $previousPeriod = $period->previous(); - $currentSpending = $this->categorySpendingService->forPeriod($user->id, $period->from, $period->to); - $previousSpending = $this->categorySpendingService->forPeriod($user->id, $previousPeriod->from, $previousPeriod->to); + $spaceId = $user->activeSpace()->id; + $currentSpending = $this->categorySpendingService->forPeriod($spaceId, $period->from, $period->to); + $previousSpending = $this->categorySpendingService->forPeriod($spaceId, $previousPeriod->from, $previousPeriod->to); $totalAmount = $currentSpending->sum('amount'); @@ -94,24 +93,26 @@ class DashboardController extends Controller $period = new PeriodComparator($from, $to); $previousPeriod = $period->previous(); + $spaceId = $user->activeSpace()->id; + return [ - 'current' => $this->calculateCashflowSummary($user->id, $period->from, $period->to), - 'previous' => $this->calculateCashflowSummary($user->id, $previousPeriod->from, $previousPeriod->to), + 'current' => $this->calculateCashflowSummary($spaceId, $period->from, $period->to), + 'previous' => $this->calculateCashflowSummary($spaceId, $previousPeriod->from, $previousPeriod->to), ]; } - private function calculateCashflowSummary(string $userId, Carbon $from, Carbon $to): array + private function calculateCashflowSummary(string $spaceId, Carbon $from, Carbon $to): array { - $income = max(0, $this->getTransactionSum($userId, $from, $to, CategoryType::Income)); - $expense = max(0, -$this->getTransactionSum($userId, $from, $to, CategoryType::Expense)); + $income = max(0, $this->getTransactionSum($spaceId, $from, $to, CategoryType::Income)); + $expense = max(0, -$this->getTransactionSum($spaceId, $from, $to, CategoryType::Expense)); return CashflowSummaryService::summarize($income, $expense); } - private function getTransactionSum(string $userId, Carbon $from, Carbon $to, CategoryType $type): int + private function getTransactionSum(string $spaceId, Carbon $from, Carbon $to, CategoryType $type): int { return Transaction::query() - ->where('transactions.user_id', $userId) + ->where('transactions.space_id', $spaceId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->where(function ($q) use ($type) { $q->whereExists(function ($sub) use ($type) { diff --git a/app/Http/Controllers/OnboardingController.php b/app/Http/Controllers/OnboardingController.php index 7bb3a62a..93f835e5 100644 --- a/app/Http/Controllers/OnboardingController.php +++ b/app/Http/Controllers/OnboardingController.php @@ -5,8 +5,6 @@ namespace App\Http\Controllers; use App\Enums\BankingConnectionStatus; use App\Jobs\CategorizeOnboardingTransactionsJob; use App\Models\Bank; -use App\Models\Category; -use App\Models\Transaction; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -38,23 +36,22 @@ class OnboardingController extends Controller public function index(Request $request): Response { $user = $request->user(); + $space = $user->activeSpace(); $banks = Bank::query() ->availableForUser($user) ->orderBy('name') ->get(); - $accounts = $user->accounts() + $accounts = $space->accounts() ->with('bank') ->get(); - $categories = Category::query() - ->where('user_id', $user->id) + $categories = $space->categories() ->forDisplay() ->get(); - $transactions = Transaction::query() - ->where('user_id', $user->id) + $transactions = $space->transactions() ->whereNull('category_id') ->with(['account.bank', 'labels']) ->orderBy('transaction_date', 'desc') @@ -77,7 +74,9 @@ class OnboardingController extends Controller public function syncStatus(Request $request): JsonResponse { - $pending = $request->user() + $space = $request->user()->activeSpace(); + + $pending = $space ->bankingConnections() ->where('status', BankingConnectionStatus::Active) ->whereNull('last_synced_at') diff --git a/app/Http/Controllers/Settings/AccountController.php b/app/Http/Controllers/Settings/AccountController.php index f1fd7a7c..6a4c6d2c 100644 --- a/app/Http/Controllers/Settings/AccountController.php +++ b/app/Http/Controllers/Settings/AccountController.php @@ -32,8 +32,9 @@ class AccountController extends Controller { /** @var User $user */ $user = Auth::user(); + $space = $user->activeSpace(); - $accounts = $user + $accounts = $space ->accounts() ->with(['bank', 'loanDetail', 'realEstateDetail']) ->orderBy('name') @@ -51,6 +52,7 @@ class AccountController extends Controller { /** @var User $user */ $user = Auth::user(); + $space = $user->activeSpace(); $validated = $request->validated(); $balance = $validated['balance'] ?? null; @@ -157,7 +159,7 @@ class AccountController extends Controller $linkedRealEstateAccountId = $validated['linked_real_estate_account_id'] ?? null; if ($linkedRealEstateAccountId !== null) { - $realEstateAccount = $user->accounts() + $realEstateAccount = $space->accounts() ->whereKey($linkedRealEstateAccountId) ->where('type', AccountType::RealEstate->value) ->with('realEstateDetail') diff --git a/app/Http/Controllers/Settings/AutomationRuleApplicationController.php b/app/Http/Controllers/Settings/AutomationRuleApplicationController.php index a72ec5d0..c8ad0487 100644 --- a/app/Http/Controllers/Settings/AutomationRuleApplicationController.php +++ b/app/Http/Controllers/Settings/AutomationRuleApplicationController.php @@ -97,7 +97,7 @@ class AutomationRuleApplicationController extends Controller if ($total <= self::SYNC_THRESHOLD) { $transactions = Transaction::query() - ->where('user_id', $automationRule->user_id) + ->where('space_id', $automationRule->space_id) ->whereIn('id', $matchingIds) ->whereNull('description_iv') ->with(['account.bank', 'category', 'labels']) @@ -177,7 +177,7 @@ class AutomationRuleApplicationController extends Controller } Transaction::query() - ->where('user_id', $rule->user_id) + ->where('space_id', $rule->space_id) ->whereNull('description_iv') ->with(array_values(array_unique($eagerLoads))) ->orderByDesc('transaction_date') diff --git a/app/Http/Controllers/Settings/CategoryController.php b/app/Http/Controllers/Settings/CategoryController.php index ba072bc4..8b92ea00 100644 --- a/app/Http/Controllers/Settings/CategoryController.php +++ b/app/Http/Controllers/Settings/CategoryController.php @@ -28,6 +28,7 @@ class CategoryController extends Controller public function index(): Response { $categories = auth()->user() + ->activeSpace() ->categories() ->forDisplay() ->get(); diff --git a/app/Http/Controllers/Settings/LabelController.php b/app/Http/Controllers/Settings/LabelController.php index b02cec41..dd7f26f7 100644 --- a/app/Http/Controllers/Settings/LabelController.php +++ b/app/Http/Controllers/Settings/LabelController.php @@ -22,6 +22,7 @@ class LabelController extends Controller public function index(): Response { $labels = auth()->user() + ->activeSpace() ->labels() ->orderBy('name') ->get(); diff --git a/app/Http/Controllers/SpaceController.php b/app/Http/Controllers/SpaceController.php new file mode 100644 index 00000000..02505a57 --- /dev/null +++ b/app/Http/Controllers/SpaceController.php @@ -0,0 +1,104 @@ +user(); + + $space = DB::transaction(function () use ($user, $request): Space { + $space = $user->ownedSpaces()->create([ + 'name' => $request->validated('name'), + 'personal' => false, + ]); + + app(CreateDefaultCategories::class)->handle($user, $space); + + return $space; + }); + + $user->forceFill(['current_space_id' => $space->id])->save(); + + return back(); + } + + /** + * Rename a space (owner only; the personal space name is fixed). + */ + public function update(UpdateSpaceRequest $request, Space $space): RedirectResponse + { + $space->update(['name' => $request->validated('name')]); + + return back(); + } + + /** + * Delete a space the user owns. The personal space is permanent, and a space + * that still holds accounts must be emptied first — financial data is never + * deleted implicitly. + */ + public function destroy(Request $request, Space $space): RedirectResponse + { + $user = $request->user(); + + abort_unless( + ! $space->personal + && $space->owner_id === $user->id + && Feature::for($user)->active(Spaces::class), + 403, + ); + + if ($space->accounts()->exists()) { + return back()->with('error', __('Remove or move this space\'s accounts before deleting it.')); + } + + if ($user->current_space_id === $space->id) { + $user->forceFill(['current_space_id' => $user->personalSpace->id])->save(); + } + + $space->delete(); + + return back(); + } + + /** + * Switch the active space. Any space the user owns or belongs to is allowed; + * the personal space is always reachable so this never requires the flag. + */ + public function select(Request $request, Space $space): RedirectResponse + { + $user = $request->user(); + + abort_unless($space->hasMember($user), 403); + + $user->forceFill(['current_space_id' => $space->id])->save(); + + return back(); + } +} diff --git a/app/Http/Controllers/SubscriptionController.php b/app/Http/Controllers/SubscriptionController.php index 39e6db5f..64422fd7 100644 --- a/app/Http/Controllers/SubscriptionController.php +++ b/app/Http/Controllers/SubscriptionController.php @@ -33,7 +33,8 @@ class SubscriptionController extends Controller return redirect()->route('dashboard'); } - $hasBankConnections = $user->bankingConnections()->exists(); + $space = $user->activeSpace(); + $hasBankConnections = $space->bankingConnections()->exists(); $canUseFreePlan = ! $hasBankConnections && ! $user->hasActiveAiConsent(); // Mark the paywall as seen so the middleware stops redirecting here. @@ -56,7 +57,8 @@ class SubscriptionController extends Controller */ private function getUserStats(User $user): array { - $accounts = $user->accounts()->get(); + $space = $user->activeSpace(); + $accounts = $space->accounts()->get(); $balancesByCurrency = []; foreach ($accounts as $account) { @@ -74,9 +76,9 @@ class SubscriptionController extends Controller return [ 'accountsCount' => $accounts->count(), - 'transactionsCount' => $user->transactions()->count(), - 'categoriesCount' => $user->categories()->count(), - 'automationRulesCount' => $user->automationRules()->count(), + 'transactionsCount' => $space->transactions()->count(), + 'categoriesCount' => $space->categories()->count(), + 'automationRulesCount' => $space->automationRules()->count(), 'balancesByCurrency' => $balancesByCurrency, ]; } diff --git a/app/Http/Controllers/Sync/TransactionSyncController.php b/app/Http/Controllers/Sync/TransactionSyncController.php index 448fe8eb..cb0bd84a 100644 --- a/app/Http/Controllers/Sync/TransactionSyncController.php +++ b/app/Http/Controllers/Sync/TransactionSyncController.php @@ -3,7 +3,6 @@ namespace App\Http\Controllers\Sync; use App\Http\Controllers\Controller; -use App\Models\Transaction; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -15,8 +14,7 @@ class TransactionSyncController extends Controller */ public function index(Request $request): JsonResponse { - $query = Transaction::query() - ->where('user_id', $request->user()->id); + $query = $request->user()->activeSpace()->transactions(); if ($request->has('since')) { $query->where('updated_at', '>', $request->input('since')); diff --git a/app/Http/Controllers/TransactionController.php b/app/Http/Controllers/TransactionController.php index 3df66dec..2d525f21 100644 --- a/app/Http/Controllers/TransactionController.php +++ b/app/Http/Controllers/TransactionController.php @@ -7,11 +7,7 @@ use App\Http\Requests\BulkUpdateTransactionsRequest; use App\Http\Requests\IndexTransactionRequest; use App\Http\Requests\StoreTransactionRequest; use App\Http\Requests\UpdateTransactionRequest; -use App\Models\Account; -use App\Models\AutomationRule; use App\Models\Bank; -use App\Models\Category; -use App\Models\Label; use App\Models\Transaction; use App\Services\Ai\CategoryOverrideHandler; use App\Services\ManualBalanceAdjuster; @@ -28,6 +24,7 @@ class TransactionController extends Controller public function index(IndexTransactionRequest $request): Response { $user = $request->user(); + $space = $user->activeSpace(); $validated = $request->validated(); $lastVisitAt = $user->transactions_last_visited_at; @@ -53,8 +50,9 @@ class TransactionController extends Controller 'search' => $validated['search'] ?? null, ], fn ($value) => $value !== null); - $query = Transaction::query() - ->where('user_id', $user->id) + $filters['space_id'] = $space->id; + + $query = $space->transactions() ->with(['account.bank', 'category', 'labels', 'categorizedByRule:id,origin']) ->applyFilters($filters); @@ -99,13 +97,11 @@ class TransactionController extends Controller 'sort' => $sortParam, ]; - $categories = Category::query() - ->where('user_id', $user->id) + $categories = $space->categories() ->forDisplay() ->get(); - $accounts = Account::query() - ->where('user_id', $user->id) + $accounts = $space->accounts() ->with('bank') ->orderBy('name') ->get(); @@ -115,13 +111,11 @@ class TransactionController extends Controller ->orderBy('name') ->get(); - $labels = Label::query() - ->where('user_id', $user->id) + $labels = $space->labels() ->orderBy('name') ->get(); - $automationRules = AutomationRule::query() - ->where('user_id', $user->id) + $automationRules = $space->automationRules() ->with(['category', 'labels']) ->orderBy('priority') ->get(); @@ -143,14 +137,13 @@ class TransactionController extends Controller public function categorize(Request $request): Response { $user = $request->user(); + $space = $user->activeSpace(); - $categories = Category::query() - ->where('user_id', $user->id) + $categories = $space->categories() ->forDisplay() ->get(); - $accounts = Account::query() - ->where('user_id', $user->id) + $accounts = $space->accounts() ->with('bank') ->orderBy('name') ->get(); @@ -160,13 +153,11 @@ class TransactionController extends Controller ->orderBy('name') ->get(); - $labels = Label::query() - ->where('user_id', $user->id) + $labels = $space->labels() ->orderBy('name') ->get(); - $transactions = Transaction::query() - ->where('user_id', $user->id) + $transactions = $space->transactions() ->whereNull('category_id') ->with(['account.bank', 'labels']) ->orderBy('transaction_date', 'desc') @@ -288,10 +279,15 @@ class TransactionController extends Controller public function bulkUpdate(BulkUpdateTransactionsRequest $request): JsonResponse { $user = $request->user(); + $space = $user->activeSpace(); $transactionIds = $request->input('transaction_ids'); $filters = $request->input('filters'); - $query = Transaction::query()->where('user_id', $user->id); + if (is_array($filters)) { + $filters['space_id'] = $space->id; + } + + $query = $space->transactions(); if ($transactionIds && count($transactionIds) > 0) { $query->whereIn('id', $transactionIds); @@ -341,7 +337,7 @@ class TransactionController extends Controller } if (! empty($updateData)) { - $updateQuery = Transaction::query()->where('user_id', $user->id); + $updateQuery = $space->transactions(); if ($transactionIds && count($transactionIds) > 0) { $updateQuery->whereIn('id', $transactionIds); } elseif ($filters !== null) { diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index a7991136..bca2ac17 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; use App\Features\CalculateBalancesOnImport; +use App\Features\Spaces; use App\Jobs\PurgeResidualEncryptionArtifactsJob; use App\Models\BankingConnection; use App\Services\CurrencyOptions; @@ -48,6 +49,7 @@ class HandleInertiaRequests extends Middleware [$message, $author] = str(Inspiring::quotes()->random())->explode('-'); $user = $request->user(); + $space = $user?->activeSpace(); $isDemoAccount = $user?->isDemoAccount() && ! app()->environment('local'); $isDemoQuery = $request->query('demo') === '1'; @@ -105,7 +107,24 @@ class HandleInertiaRequests extends Middleware 'includeRealEstateInNetWorthChart' => $user?->setting->include_real_estate_in_net_worth_chart ?? true, 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'features' => $this->resolveFeatureFlags(), - 'expiredBankingConnections' => fn () => $user ? $user->bankingConnections() + 'currentSpace' => $space ? [ + 'id' => $space->id, + 'name' => $space->personal ? __('Personal') : $space->name, + 'personal' => $space->personal, + 'is_owner' => $space->owner_id === $user->id, + ] : null, + // Only the spaces UI needs the full list; skip the query entirely for + // everyone still on their single, invisible personal space. + 'spaces' => fn () => $user && Feature::for($user)->active(Spaces::class) + ? $user->accessibleSpaces() + ->map(fn ($accessibleSpace): array => [ + 'id' => $accessibleSpace->id, + 'name' => $accessibleSpace->personal ? __('Personal') : $accessibleSpace->name, + 'personal' => $accessibleSpace->personal, + 'is_owner' => $accessibleSpace->owner_id === $user->id, + ])->all() + : [], + 'expiredBankingConnections' => fn () => $space ? $space->bankingConnections() ->where('provider', BankingProvider::EnableBanking) ->where(function ($query) { $query->where('status', BankingConnectionStatus::Expired) @@ -124,7 +143,7 @@ class HandleInertiaRequests extends Middleware 'valid_until' => $connection->valid_until?->toIso8601String(), 'reconnect_url' => route('open-banking.reconnect', $connection), ]) : [], - 'bankingConnections' => fn () => $user ? $user->bankingConnections() + 'bankingConnections' => fn () => $space ? $space->bankingConnections() ->get(['id', 'aspsp_name', 'provider', 'status']) ->map(fn (BankingConnection $connection): array => [ 'id' => $connection->id, @@ -132,28 +151,28 @@ class HandleInertiaRequests extends Middleware 'provider' => $connection->provider->value, 'status' => $connection->status->value, ]) : [], - 'accounts' => fn () => $user ? $user->accounts() + 'accounts' => fn () => $space ? $space->accounts() ->with(['bank', 'realEstateDetail:id,account_id,linked_loan_account_id']) ->orderBy('name') ->get() ->makeHidden('realEstateDetail') : [], - 'categories' => fn () => $user ? $user->categories() + 'categories' => fn () => $space ? $space->categories() ->forDisplay() ->get() : [], 'banks' => fn () => $user ? $user->banks() ->orderBy('name') ->get() : [], - 'automationRules' => function () use ($user) { - if (! $user) { + 'automationRules' => function () use ($space) { + if (! $space) { return []; } - return $user->automationRules() + return $space->automationRules() ->with(['category', 'labels']) ->orderBy('priority') ->get(); }, - 'labels' => fn () => $user ? $user->labels() + 'labels' => fn () => $space ? $space->labels() ->orderBy('name') ->get() : [], 'hasEncryptedAccounts' => $hasEncryptedAccounts, @@ -179,16 +198,19 @@ class HandleInertiaRequests extends Middleware return [ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'spaces' => false, ]; } $features = Feature::for($user)->values([ CalculateBalancesOnImport::class, + Spaces::class, ]); return [ 'cashflow' => true, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, + 'spaces' => $features[Spaces::class] !== false, ]; } diff --git a/app/Http/Middleware/ResolveActiveSpace.php b/app/Http/Middleware/ResolveActiveSpace.php new file mode 100644 index 00000000..4f1f7471 --- /dev/null +++ b/app/Http/Middleware/ResolveActiveSpace.php @@ -0,0 +1,23 @@ +activeSpace()` and every write that fills + * `space_id` from `current_space_id` sees a valid pointer — even right after a + * space was deleted or a membership was revoked. + */ +class ResolveActiveSpace +{ + public function handle(Request $request, Closure $next): Response + { + $request->user()?->activeSpace(); + + return $next($request); + } +} diff --git a/app/Http/Requests/StoreSpaceRequest.php b/app/Http/Requests/StoreSpaceRequest.php new file mode 100644 index 00000000..5809b7df --- /dev/null +++ b/app/Http/Requests/StoreSpaceRequest.php @@ -0,0 +1,26 @@ +user() !== null + && Feature::for($this->user())->active(Spaces::class); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Http/Requests/UpdateSpaceRequest.php b/app/Http/Requests/UpdateSpaceRequest.php new file mode 100644 index 00000000..59fd9218 --- /dev/null +++ b/app/Http/Requests/UpdateSpaceRequest.php @@ -0,0 +1,31 @@ +route('space'); + + return $space instanceof Space + && ! $space->personal + && $space->owner_id === $this->user()?->id + && Feature::for($this->user())->active(Spaces::class); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + ]; + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index fb9dccdd..2f07036f 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -281,10 +281,10 @@ class Transaction extends Model $realIds = $ids->reject(fn ($id) => $id === 'uncategorized')->values()->all(); if ($realIds !== []) { - $userId = $filters['user_id'] ?? Category::query()->whereIn('id', $realIds)->value('user_id'); + $spaceId = $filters['space_id'] ?? Category::query()->whereIn('id', $realIds)->value('space_id'); - if ($userId !== null) { - $realIds = app(CategoryTree::class)->expand($userId, $realIds); + if ($spaceId !== null) { + $realIds = app(CategoryTree::class)->expand($spaceId, $realIds); } } } diff --git a/app/Models/User.php b/app/Models/User.php index 27d4a66b..c9c236ed 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -230,7 +230,8 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma */ public function activeSpace(): Space { - if ($this->resolvedActiveSpace !== null) { + if ($this->resolvedActiveSpace !== null + && $this->resolvedActiveSpace->id === $this->current_space_id) { return $this->resolvedActiveSpace; } diff --git a/app/Policies/Concerns/HandlesUserOwnership.php b/app/Policies/Concerns/HandlesUserOwnership.php index 3a4b2a8e..805a32b6 100644 --- a/app/Policies/Concerns/HandlesUserOwnership.php +++ b/app/Policies/Concerns/HandlesUserOwnership.php @@ -36,7 +36,7 @@ trait HandlesUserOwnership */ public function update(User $user, Model $model): bool { - return $user->id === $model->getAttribute('user_id'); + return $this->userCanAccess($user, $model); } /** @@ -44,7 +44,24 @@ trait HandlesUserOwnership */ public function delete(User $user, Model $model): bool { - return $user->id === $model->getAttribute('user_id'); + return $this->userCanAccess($user, $model); + } + + /** + * A space-owned model is accessible to any member of its space; models that + * predate spaces (or child records without a space_id) fall back to the + * legacy creator check. + */ + protected function userCanAccess(User $user, Model $model): bool + { + $spaceId = $model->getAttribute('space_id'); + + if ($spaceId === null) { + return $user->id === $model->getAttribute('user_id'); + } + + return $user->current_space_id === $spaceId + || $user->accessibleSpaces()->contains('id', $spaceId); } /** diff --git a/app/Services/BudgetTransactionService.php b/app/Services/BudgetTransactionService.php index adf39b80..a8807dbd 100644 --- a/app/Services/BudgetTransactionService.php +++ b/app/Services/BudgetTransactionService.php @@ -16,9 +16,9 @@ class BudgetTransactionService public function assignTransaction(Transaction $transaction): void { - $userId = $transaction->user_id; + $spaceId = $transaction->space_id; - if (! $userId) { + if (! $spaceId) { return; } @@ -31,13 +31,13 @@ class BudgetTransactionService // 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) + ? $this->tree->ancestorAndSelfIds($spaceId, $transaction->category_id) : []; // Find budget periods that potentially match this transaction. $budgetPeriods = BudgetPeriod::query() - ->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $userId) { - $query->where('user_id', $userId) + ->whereHas('budget', function ($query) use ($categoryMatchIds, $transactionLabelIds, $spaceId) { + $query->where('space_id', $spaceId) ->where(function ($q) use ($categoryMatchIds, $transactionLabelIds) { $q->whereHas('categories', function ($cq) use ($categoryMatchIds) { $cq->whereIn('categories.id', $categoryMatchIds); @@ -72,7 +72,7 @@ class BudgetTransactionService $matchingPeriodIds = array_merge( $matchingPeriodIds, - $this->catchAllPeriodIds($transaction, $userId, $categoryMatchIds), + $this->catchAllPeriodIds($transaction, $spaceId, $categoryMatchIds), ); // Apply changes atomically so concurrent workers cannot leave the @@ -122,11 +122,11 @@ class BudgetTransactionService $assignedCount = 0; // Tracking a parent category also tracks its children's spending. - $categoryIds = collect($this->tree->expand($budget->user_id, $budget->categories->pluck('id')->all())); + $categoryIds = collect($this->tree->expand($budget->space_id, $budget->categories->pluck('id')->all())); $labelIds = $budget->labels->pluck('id'); Log::info('Building query for historical transactions', [ - 'user_id' => $budget->user_id, + 'space_id' => $budget->space_id, 'category_ids' => $categoryIds->all(), 'label_ids' => $labelIds->all(), 'start_date' => $period->start_date->toDateString(), @@ -135,16 +135,16 @@ class BudgetTransactionService // Build the query for matching transactions $query = Transaction::query() - ->where('user_id', $budget->user_id) + ->where('space_id', $budget->space_id) ->whereBetween('transaction_date', [$period->start_date, $period->end_date]) ->withoutTrashed(); if ($budget->is_catch_all) { // A catch-all budget absorbs every expense whose category is not - // already tracked by one of the user's other budgets. + // already tracked by one of the space's other budgets. $claimedCategoryIds = $this->tree->expand( - $budget->user_id, - $this->claimedCategoryIds($budget->user_id), + $budget->space_id, + $this->claimedCategoryIds($budget->space_id), ); $query->whereNotNull('category_id') @@ -200,7 +200,7 @@ class BudgetTransactionService * @param array $categoryMatchIds the transaction category and its ancestors * @return array */ - private function catchAllPeriodIds(Transaction $transaction, string $userId, array $categoryMatchIds): array + private function catchAllPeriodIds(Transaction $transaction, string $spaceId, array $categoryMatchIds): array { if ($transaction->category_id === null) { return []; @@ -212,13 +212,13 @@ class BudgetTransactionService return []; } - if (array_intersect($categoryMatchIds, $this->claimedCategoryIds($userId)) !== []) { + if (array_intersect($categoryMatchIds, $this->claimedCategoryIds($spaceId)) !== []) { return []; } return BudgetPeriod::query() - ->whereHas('budget', function ($query) use ($userId) { - $query->where('user_id', $userId)->where('is_catch_all', true); + ->whereHas('budget', function ($query) use ($spaceId) { + $query->where('space_id', $spaceId)->where('is_catch_all', true); }) ->where('start_date', '<=', $transaction->transaction_date) ->where('end_date', '>=', $transaction->transaction_date) @@ -227,14 +227,14 @@ class BudgetTransactionService } /** - * Category ids directly tracked by the user's non-catch-all budgets. + * Category ids directly tracked by the space's non-catch-all budgets. * * @return array */ - private function claimedCategoryIds(string $userId): array + private function claimedCategoryIds(string $spaceId): array { return Budget::query() - ->where('user_id', $userId) + ->where('space_id', $spaceId) ->where('is_catch_all', false) ->with('categories:id') ->get() diff --git a/app/Services/CategorySpendingService.php b/app/Services/CategorySpendingService.php index 3e41836d..e3e64439 100644 --- a/app/Services/CategorySpendingService.php +++ b/app/Services/CategorySpendingService.php @@ -20,10 +20,10 @@ class CategorySpendingService * become the rows (plus a direct node for transactions sitting on the * parent itself). Soft-deleted categories are excluded. */ - public function forPeriod(string $userId, Carbon $from, Carbon $to, ?string $drillParentId = null): Collection + public function forPeriod(string $spaceId, Carbon $from, Carbon $to, ?string $drillParentId = null): Collection { $perCategory = Transaction::query() - ->where('transactions.user_id', $userId) + ->where('transactions.space_id', $spaceId) ->whereBetween('transactions.transaction_date', [$from, $to]) ->join('categories', function ($join) { $join->on('transactions.category_id', '=', 'categories.id') @@ -41,7 +41,7 @@ class CategorySpendingService ->values() ->all(); - return collect($this->tree->rollUp($perCategory, $userId, $drillParentId)) + return collect($this->tree->rollUp($perCategory, $spaceId, $drillParentId)) ->filter(fn (array $item): bool => $item['amount'] > 0) ->values(); } diff --git a/app/Services/CategoryTree.php b/app/Services/CategoryTree.php index cd427776..fba4e57b 100644 --- a/app/Services/CategoryTree.php +++ b/app/Services/CategoryTree.php @@ -17,7 +17,7 @@ class CategoryTree * @param array $ids * @return array */ - public function expand(string $userId, array $ids): array + public function expand(string $spaceId, array $ids): array { $ids = array_values(array_unique(array_filter($ids))); @@ -30,7 +30,7 @@ class CategoryTree for ($level = 1; $level < Category::MAX_DEPTH; $level++) { $childIds = Category::query() - ->where('user_id', $userId) + ->where('space_id', $spaceId) ->whereIn('parent_id', $frontier) ->pluck('id') ->all(); @@ -56,7 +56,7 @@ class CategoryTree public function descendantIds(Category $category): array { return array_values(array_diff( - $this->expand($category->user_id, [$category->id]), + $this->expand($category->space_id, [$category->id]), [$category->id], )); } @@ -66,11 +66,11 @@ class CategoryTree * * @return array */ - public function ancestorAndSelfIds(string $userId, string $categoryId): array + public function ancestorAndSelfIds(string $spaceId, string $categoryId): array { $ids = [$categoryId]; $parentId = Category::query() - ->where('user_id', $userId) + ->where('space_id', $spaceId) ->whereKey($categoryId) ->value('parent_id'); @@ -79,7 +79,7 @@ class CategoryTree while ($parentId !== null && $guard++ < Category::MAX_DEPTH) { $ids[] = $parentId; $parentId = Category::query() - ->where('user_id', $userId) + ->where('space_id', $spaceId) ->whereKey($parentId) ->value('parent_id'); } @@ -97,7 +97,7 @@ class CategoryTree while ($parentId !== null && $depth <= Category::MAX_DEPTH) { $parentId = Category::query() - ->where('user_id', $category->user_id) + ->where('space_id', $category->space_id) ->where('id', $parentId) ->value('parent_id'); @@ -118,7 +118,7 @@ class CategoryTree for ($level = 1; $level < Category::MAX_DEPTH; $level++) { $childIds = Category::query() - ->where('user_id', $category->user_id) + ->where('space_id', $category->space_id) ->whereIn('parent_id', $frontier) ->pluck('id') ->all(); @@ -145,10 +145,10 @@ class CategoryTree * @param array $categorized * @return array */ - public function rollUp(array $categorized, string $userId, ?string $drillParentId): array + public function rollUp(array $categorized, string $spaceId, ?string $drillParentId): array { $categories = Category::query() - ->where('user_id', $userId) + ->where('space_id', $spaceId) ->forDisplay() ->get() ->keyBy('id'); @@ -230,10 +230,10 @@ class CategoryTree * @param array $categorized * @return array}> */ - public function spendingBreakdown(array $categorized, string $userId): array + public function spendingBreakdown(array $categorized, string $spaceId): array { $categories = Category::query() - ->where('user_id', $userId) + ->where('space_id', $spaceId) ->forDisplay() ->get() ->keyBy('id'); @@ -380,7 +380,7 @@ class CategoryTree } Category::query() - ->where('user_id', $category->user_id) + ->where('space_id', $category->space_id) ->whereIn('id', $descendantIds) ->update([ 'type' => $category->type, @@ -397,12 +397,12 @@ class CategoryTree $ids = [$category->id, ...$this->descendantIds($category)]; Transaction::query() - ->where('user_id', $category->user_id) + ->where('space_id', $category->space_id) ->whereIn('category_id', $ids) ->update(['category_id' => null]); Category::query() - ->where('user_id', $category->user_id) + ->where('space_id', $category->space_id) ->whereIn('id', $ids) ->delete(); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 9a402ed2..6d9fc68b 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -5,6 +5,7 @@ use App\Http\Middleware\EnsureOnboardingComplete; use App\Http\Middleware\EnsureUserIsSubscribed; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; +use App\Http\Middleware\ResolveActiveSpace; use App\Http\Middleware\SetLocale; use App\Http\Middleware\SetSentryUser; use App\Http\Middleware\TrackLastActiveAt; @@ -44,6 +45,7 @@ return Application::configure(basePath: dirname(__DIR__)) HandleAppearance::class, SetLocale::class, SetSentryUser::class, + ResolveActiveSpace::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, TrackLastActiveAt::class, diff --git a/database/factories/TransactionFactory.php b/database/factories/TransactionFactory.php index 772c1cc6..f1317f9e 100644 --- a/database/factories/TransactionFactory.php +++ b/database/factories/TransactionFactory.php @@ -36,6 +36,33 @@ class TransactionFactory extends Factory ]; } + /** + * Keep fixtures coherent with the multi-tenant model: a transaction, its + * account and its space share one owner. When a caller sets a user_id but + * lets the account default (or wires an account owned by someone else), we + * realign the account to that user so space scoping in tests mirrors + * production, where transaction.space_id always equals account.space_id. + */ + public function configure(): static + { + return $this->afterMaking(function (Transaction $transaction): void { + if ($transaction->user_id === null) { + return; + } + + $account = $transaction->account; + + if ($account !== null && $account->user_id !== $transaction->user_id) { + $account->forceFill([ + 'user_id' => $transaction->user_id, + 'space_id' => User::query()->whereKey($transaction->user_id)->value('current_space_id'), + ])->save(); + + $transaction->setRelation('account', $account); + } + }); + } + public function imported(): static { return $this->state(fn (array $attributes) => [ diff --git a/lang/es.json b/lang/es.json index 9d71af0c..3f03695b 100644 --- a/lang/es.json +++ b/lang/es.json @@ -68,7 +68,6 @@ "You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.", "Remove one vote": "Quitar un voto", "Categorized by AI": "Categorizado por IA", - "Let AI categorize your transactions": "Deja que la IA categorice tus transacciones", "Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza", "Only show AI guesses": "Mostrar solo sugerencias de IA", "Created by AI": "Creado por IA", @@ -2215,5 +2214,20 @@ "See your categorised transactions": "Ver tus transacciones categorizadas", "Private by design, always": "Privado por diseño, siempre", "Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.", - "If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje." + "If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje.", + "Personal": "Personal", + "Personal space": "Espacio personal", + "Space": "Espacio", + "Spaces": "Espacios", + "Create space": "Crear espacio", + "Manage spaces": "Gestionar espacios", + "A space groups its own accounts, transactions, categories and budgets, kept separate from your other spaces.": "Un espacio agrupa sus propias cuentas, transacciones, categorías y presupuestos, separados de tus otros espacios.", + "e.g. My Company": "p. ej. Mi Empresa", + "Delete this space? This cannot be undone.": "¿Eliminar este espacio? Esta acción no se puede deshacer.", + "Each space keeps its own accounts, transactions, categories and budgets separate.": "Cada espacio mantiene separadas sus propias cuentas, transacciones, categorías y presupuestos.", + "Active": "Activo", + "Switch": "Cambiar", + "Rename": "Renombrar", + "Rename space": "Renombrar espacio", + "Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo." } diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index 3521a4a3..43e50c48 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,6 +1,7 @@ import { NavFooter } from '@/components/nav-footer'; import { NavMain } from '@/components/nav-main'; import { NavUser } from '@/components/nav-user'; +import { SpaceSwitcher } from '@/components/space-switcher'; import { Sidebar, SidebarContent, @@ -71,6 +72,7 @@ export function AppSidebar() { + diff --git a/resources/js/components/space-switcher.tsx b/resources/js/components/space-switcher.tsx new file mode 100644 index 00000000..03a3ecaf --- /dev/null +++ b/resources/js/components/space-switcher.tsx @@ -0,0 +1,210 @@ +import { + index as manageSpaces, + select as selectSpace, + store as storeSpace, +} from '@/actions/App/Http/Controllers/SpaceController'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + useSidebar, +} from '@/components/ui/sidebar'; +import { useIsMobile } from '@/hooks/use-mobile'; +import { SharedData } from '@/types'; +import { __ } from '@/utils/i18n'; +import { Link, router, usePage } from '@inertiajs/react'; +import { + Building2, + Check, + ChevronsUpDown, + Plus, + Settings2, + User, +} from 'lucide-react'; +import { FormEvent, useState } from 'react'; + +export function SpaceSwitcher() { + const { currentSpace, spaces, features } = usePage().props; + const { state } = useSidebar(); + const isMobile = useIsMobile(); + const [createOpen, setCreateOpen] = useState(false); + const [name, setName] = useState(''); + const [processing, setProcessing] = useState(false); + + // The switcher only exists for accounts with the spaces feature; everyone + // else keeps their single, invisible personal space. + if (!features.spaces || !currentSpace) { + return null; + } + + const switchTo = (spaceId: string) => { + if (spaceId === currentSpace.id) { + return; + } + + router.post(selectSpace(spaceId).url, {}, { preserveScroll: false }); + }; + + const createSpace = (event: FormEvent) => { + event.preventDefault(); + setProcessing(true); + + router.post( + storeSpace().url, + { name }, + { + preserveScroll: true, + onFinish: () => setProcessing(false), + onSuccess: () => { + setCreateOpen(false); + setName(''); + }, + }, + ); + }; + + return ( + + + + + +
+ {currentSpace.personal ? ( + + ) : ( + + )} +
+
+ + {currentSpace.name} + + + {currentSpace.personal + ? __('Personal space') + : __('Space')} + +
+ +
+
+ + + {__('Spaces')} + + {spaces.map((space) => ( + switchTo(space.id)} + className="gap-2" + data-test="space-option" + > + {space.personal ? ( + + ) : ( + + )} + {space.name} + {space.id === currentSpace.id && ( + + )} + + ))} + + setCreateOpen(true)} + className="gap-2" + data-test="space-create" + > + + {__('Create space')} + + + + + {__('Manage spaces')} + + + +
+
+ + + +
+ + {__('Create space')} + + {__( + 'A space groups its own accounts, transactions, categories and budgets, kept separate from your other spaces.', + )} + + +
+ + + setName(event.target.value) + } + placeholder={__('e.g. My Company')} + autoFocus + required + /> +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/contexts/sync-context.tsx b/resources/js/contexts/sync-context.tsx index cf71e319..2f98bb42 100644 --- a/resources/js/contexts/sync-context.tsx +++ b/resources/js/contexts/sync-context.tsx @@ -1,5 +1,6 @@ import { useOnlineStatus } from '@/hooks/use-online-status'; import { identifyUser, resetPostHog } from '@/lib/posthog'; +import { transactionSyncService } from '@/services/transaction-sync'; import type { User } from '@/types/index.d'; import type { Page } from '@inertiajs/core'; import { router } from '@inertiajs/react'; @@ -47,16 +48,30 @@ export function SyncProvider({ const [error, setError] = useState(null); const [wasOffline, setWasOffline] = useState(!isOnline); const lastUserIdRef = useRef(null); + const lastSpaceIdRef = useRef(null); useEffect(() => { const unsubscribe = router.on('navigate', (event) => { const page = event.detail.page as Page<{ auth?: { user?: User }; + currentSpace?: { id: string } | null; }>; const user = page.props?.auth?.user ?? null; setIsAuthenticated(Boolean(user)); setCurrentUser(user); + + // The offline cache is a per-space mirror. When the active space + // changes (switch or logout), drop it so stale rows from the + // previous space never surface and the next sync starts clean. + const spaceId = page.props?.currentSpace?.id ?? null; + if ( + lastSpaceIdRef.current !== null && + lastSpaceIdRef.current !== spaceId + ) { + void transactionSyncService.clearAll(); + } + lastSpaceIdRef.current = spaceId; }); return () => { diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 26943f82..579919d9 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -2,6 +2,7 @@ import { index as accountsIndex } from '@/actions/App/Http/Controllers/Settings/ import { index as automationRulesIndex } from '@/actions/App/Http/Controllers/Settings/AutomationRuleController'; import { index as categoriesIndex } from '@/actions/App/Http/Controllers/Settings/CategoryController'; import { index as labelsIndex } from '@/actions/App/Http/Controllers/Settings/LabelController'; +import { index as spacesIndex } from '@/actions/App/Http/Controllers/SpaceController'; import Heading from '@/components/heading'; import { Button } from '@/components/ui/button'; import { @@ -34,6 +35,7 @@ import { type PropsWithChildren } from 'react'; const getNavItems = ( subscriptionsEnabled: boolean, isDemoAccount: boolean, + spacesEnabled: boolean, ): (NavItem | NavSectionHeader | NavDivider)[] => [ { type: 'nav-item' as const, @@ -65,6 +67,16 @@ const getNavItems = ( href: labelsIndex(), icon: null, }, + ...(spacesEnabled + ? [ + { + type: 'nav-item' as const, + title: 'Spaces', + href: spacesIndex(), + icon: null, + }, + ] + : []), { type: 'divider' }, { type: 'section-header', @@ -172,7 +184,8 @@ function renderMobileNavGroups( } export default function SettingsLayout({ children }: PropsWithChildren) { - const { subscriptionsEnabled, auth } = usePage().props; + const { subscriptionsEnabled, auth, features } = + usePage().props; const isDemoAccount = auth?.isDemoAccount ?? false; // When server-side rendering, we only render the layout on the client... @@ -181,7 +194,11 @@ export default function SettingsLayout({ children }: PropsWithChildren) { } const currentPath = window.location.pathname; - const sidebarNavItems = getNavItems(subscriptionsEnabled, isDemoAccount); + const sidebarNavItems = getNavItems( + subscriptionsEnabled, + isDemoAccount, + features?.spaces ?? false, + ); const activeNavItem = sidebarNavItems.find( (item): item is NavItem => diff --git a/resources/js/pages/settings/spaces.tsx b/resources/js/pages/settings/spaces.tsx new file mode 100644 index 00000000..7200a036 --- /dev/null +++ b/resources/js/pages/settings/spaces.tsx @@ -0,0 +1,275 @@ +import { + destroy as destroySpace, + select as selectSpace, + index as spacesIndex, + store as storeSpace, + update as updateSpace, +} from '@/actions/App/Http/Controllers/SpaceController'; +import HeadingSmall from '@/components/heading-small'; +import { Button } from '@/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import AppLayout from '@/layouts/app-layout'; +import SettingsLayout from '@/layouts/settings/layout'; +import { type BreadcrumbItem, type SharedData, type Space } from '@/types'; +import { __ } from '@/utils/i18n'; +import { Head, router, usePage } from '@inertiajs/react'; +import { Building2, Check, User } from 'lucide-react'; +import { FormEvent, useState } from 'react'; + +const breadcrumbs: BreadcrumbItem[] = [ + { + title: 'Spaces settings', + href: spacesIndex().url, + }, +]; + +export default function Spaces() { + const { spaces, currentSpace } = usePage().props; + const [createOpen, setCreateOpen] = useState(false); + const [renaming, setRenaming] = useState(null); + const [name, setName] = useState(''); + const [processing, setProcessing] = useState(false); + + const submitCreate = (event: FormEvent) => { + event.preventDefault(); + setProcessing(true); + router.post( + storeSpace().url, + { name }, + { + preserveScroll: true, + onFinish: () => setProcessing(false), + onSuccess: () => { + setCreateOpen(false); + setName(''); + }, + }, + ); + }; + + const submitRename = (event: FormEvent) => { + event.preventDefault(); + if (!renaming) { + return; + } + setProcessing(true); + router.patch( + updateSpace(renaming.id).url, + { name }, + { + preserveScroll: true, + onFinish: () => setProcessing(false), + onSuccess: () => setRenaming(null), + }, + ); + }; + + const remove = (space: Space) => { + if ( + !window.confirm( + __('Delete this space? This cannot be undone.') + + ` (${space.name})`, + ) + ) { + return; + } + router.delete(destroySpace(space.id).url, { preserveScroll: true }); + }; + + return ( + + + + +
+
+ + +
+ +
    + {spaces.map((space) => { + const isCurrent = space.id === currentSpace?.id; + + return ( +
  • +
    + {space.personal ? ( + + ) : ( + + )} +
    +
    +
    + {space.name} + {isCurrent && ( + + + {__('Active')} + + )} +
    +
    + {space.personal + ? __('Personal space') + : __('Space')} +
    +
    + +
    + {!isCurrent && ( + + )} + {!space.personal && space.is_owner && ( + <> + + + + )} +
    +
  • + ); + })} +
+
+
+ + + +
+ + {__('Create space')} + + {__( + 'A space groups its own accounts, transactions, categories and budgets, kept separate from your other spaces.', + )} + + +
+ + + setName(event.target.value) + } + placeholder={__('e.g. My Company')} + autoFocus + required + /> +
+ + + + +
+
+
+ + !open && setRenaming(null)} + > + +
+ + {__('Rename space')} + +
+ + + setName(event.target.value) + } + autoFocus + required + /> +
+ + + + +
+
+
+
+ ); +} diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 3ca7b705..088018e5 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -42,6 +42,14 @@ export interface NavDivider { export interface Features { cashflow: boolean; calculateBalancesOnImport: boolean; + spaces: boolean; +} + +export interface Space { + id: UUID; + name: string; + personal: boolean; + is_owner: boolean; } export interface ExpiredBankingConnectionNotification { @@ -82,6 +90,8 @@ export interface SharedData { pricing: PricingConfig; sidebarOpen: boolean; features: Features; + currentSpace: Space | null; + spaces: Space[]; expiredBankingConnections: ExpiredBankingConnectionNotification[]; hasEncryptedAccounts: boolean; hasEncryptedTransactions: boolean; diff --git a/routes/settings.php b/routes/settings.php index 2071c630..241964b6 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -15,6 +15,7 @@ use App\Http\Controllers\Settings\PasswordController; use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\TimezoneController; use App\Http\Controllers\Settings\TwoFactorAuthenticationController; +use App\Http\Controllers\SpaceController; use App\Http\Controllers\SubscriptionController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -60,6 +61,12 @@ Route::middleware('auth')->group(function () { Route::redirect('settings/budgets', '/budgets')->name('budgets.settings'); + Route::get('settings/spaces', [SpaceController::class, 'index'])->name('spaces.index'); + Route::post('settings/spaces', [SpaceController::class, 'store'])->name('spaces.store'); + Route::patch('settings/spaces/{space}', [SpaceController::class, 'update'])->name('spaces.update'); + Route::delete('settings/spaces/{space}', [SpaceController::class, 'destroy'])->name('spaces.destroy'); + Route::post('spaces/{space}/switch', [SpaceController::class, 'select'])->name('spaces.switch'); + Route::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index'); Route::post('settings/automation-rules', [AutomationRuleController::class, 'store'])->name('automation-rules.store'); Route::patch('settings/automation-rules/{automationRule}', [AutomationRuleController::class, 'update'])->name('automation-rules.update'); diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index f86c4ec1..71a740df 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -99,6 +99,7 @@ test('shared feature flags do not include coinbase flag', function () { expect($props['features'])->toBe([ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'spaces' => false, ]); }); diff --git a/tests/Feature/Settings/CategoryTreeTest.php b/tests/Feature/Settings/CategoryTreeTest.php index 37c3a31d..9bf5ef84 100644 --- a/tests/Feature/Settings/CategoryTreeTest.php +++ b/tests/Feature/Settings/CategoryTreeTest.php @@ -210,7 +210,7 @@ test('the tree service expands a parent id to include all descendants', function $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]); + $expanded = (new CategoryTree)->expand($user->current_space_id, [$root->id]); expect($expanded)->toContain($root->id, $child->id, $grandchild->id) ->not->toContain($unrelated->id); diff --git a/tests/Feature/Spaces/SpaceFoundationTest.php b/tests/Feature/Spaces/SpaceFoundationTest.php index c06f5b6a..62d9cd61 100644 --- a/tests/Feature/Spaces/SpaceFoundationTest.php +++ b/tests/Feature/Spaces/SpaceFoundationTest.php @@ -1,10 +1,10 @@ space_id)->toBe($user->current_space_id); }); -it('inherits a transaction\'s space from its account, not the acting user', function () { +it('inherits a transaction\'s space from its account (the tenant anchor)', function () { $owner = User::factory()->create(); $account = Account::factory()->for($owner)->create(); - // A transaction created with a different acting user still lands in the - // account's space (the account is the tenant anchor). - $other = User::factory()->create(); - $transaction = Transaction::factory()->for($account)->create(['user_id' => $other->id]); + // Mirrors bank sync: the row is created straight off the account with no + // explicit space_id, so the model resolves it from the account. + $transaction = $account->transactions()->create([ + 'user_id' => $owner->id, + 'description' => 'Coffee', + 'transaction_date' => now(), + 'amount' => -350, + 'currency_code' => 'EUR', + 'source' => TransactionSource::EnableBanking, + ]); expect($transaction->space_id)->toBe($account->space_id); }); diff --git a/tests/Feature/Spaces/SpaceManagementTest.php b/tests/Feature/Spaces/SpaceManagementTest.php new file mode 100644 index 00000000..b114d63f --- /dev/null +++ b/tests/Feature/Spaces/SpaceManagementTest.php @@ -0,0 +1,92 @@ +onboarded()->create(); + Feature::for($user)->activate(Spaces::class); + + $this->actingAs($user)->post('/settings/spaces', ['name' => 'Acme']) + ->assertRedirect(); + + $space = Space::query()->where('owner_id', $user->id)->where('personal', false)->first(); + + expect($space)->not->toBeNull() + ->and($user->fresh()->current_space_id)->toBe($space->id) + ->and(Category::where('space_id', $space->id)->count())->toBeGreaterThan(0); +}); + +it('forbids creating a space without the spaces feature', function () { + $user = User::factory()->onboarded()->create(); + + $this->actingAs($user)->post('/settings/spaces', ['name' => 'Acme']) + ->assertForbidden(); +}); + +it('lets the owner rename a non-personal space', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate(Spaces::class); + $space = $user->ownedSpaces()->create(['name' => 'Old', 'personal' => false]); + + $this->actingAs($user)->patch("/settings/spaces/{$space->id}", ['name' => 'New']) + ->assertRedirect(); + + expect($space->fresh()->name)->toBe('New'); +}); + +it('never lets the personal space be renamed or deleted', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate(Spaces::class); + $personal = $user->personalSpace; + + $this->actingAs($user)->patch("/settings/spaces/{$personal->id}", ['name' => 'Nope']) + ->assertForbidden(); + + $this->actingAs($user)->delete("/settings/spaces/{$personal->id}") + ->assertForbidden(); +}); + +it('refuses to delete a space that still has accounts', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate(Spaces::class); + $space = $user->ownedSpaces()->create(['name' => 'Acme', 'personal' => false]); + Account::factory()->for($user)->create(['space_id' => $space->id]); + + $this->actingAs($user)->delete("/settings/spaces/{$space->id}") + ->assertSessionHas('error'); + + expect($space->fresh())->not->toBeNull(); +}); + +it('deletes an empty space and falls back to the personal space', function () { + $user = User::factory()->onboarded()->create(); + Feature::for($user)->activate(Spaces::class); + $space = $user->ownedSpaces()->create(['name' => 'Acme', 'personal' => false]); + $user->forceFill(['current_space_id' => $space->id])->save(); + + $this->actingAs($user)->delete("/settings/spaces/{$space->id}") + ->assertRedirect(); + + expect(Space::find($space->id))->toBeNull() + ->and($user->fresh()->current_space_id)->toBe($user->personalSpace->id); +}); + +it('switches the active space and rejects spaces the user cannot access', function () { + $user = User::factory()->onboarded()->create(); + $space = $user->ownedSpaces()->create(['name' => 'Acme', 'personal' => false]); + + $this->actingAs($user)->post("/spaces/{$space->id}/switch") + ->assertRedirect(); + expect($user->fresh()->current_space_id)->toBe($space->id); + + $stranger = User::factory()->create(); + $strangerSpace = $stranger->personalSpace; + + $this->actingAs($user)->post("/spaces/{$strangerSpace->id}/switch") + ->assertForbidden(); +}); diff --git a/tests/Feature/Spaces/SpaceScopingTest.php b/tests/Feature/Spaces/SpaceScopingTest.php new file mode 100644 index 00000000..ca16fdf6 --- /dev/null +++ b/tests/Feature/Spaces/SpaceScopingTest.php @@ -0,0 +1,109 @@ +onboarded()->create(); + $personal = $user->personalSpace; + $business = $user->ownedSpaces()->create(['name' => 'Acme', 'personal' => false]); + + return [$user, $personal, $business]; +} + +it('only lists the active space accounts', function () { + [$user, $personal, $business] = userWithTwoSpaces(); + + Account::factory()->for($user)->create(['space_id' => $personal->id, 'name' => 'Personal Checking']); + Account::factory()->for($user)->create(['space_id' => $business->id, 'name' => 'Acme Payroll']); + + $user->forceFill(['current_space_id' => $business->id])->save(); + + $this->actingAs($user)->get('/accounts') + ->assertInertia(fn (AssertableInertia $page) => $page + ->component('Accounts/Index') + ->has('accounts', 1) + ->where('accounts.0.name', 'Acme Payroll')); + + $user->forceFill(['current_space_id' => $personal->id])->save(); + + $this->actingAs($user)->get('/accounts') + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('accounts', 1) + ->where('accounts.0.name', 'Personal Checking')); +}); + +it('only syncs the active space transactions', function () { + [$user, $personal, $business] = userWithTwoSpaces(); + + $personalAccount = Account::factory()->for($user)->create(['space_id' => $personal->id]); + $businessAccount = Account::factory()->for($user)->create(['space_id' => $business->id]); + + Transaction::factory()->for($personalAccount)->create(['user_id' => $user->id]); + $businessTx = Transaction::factory()->for($businessAccount)->create(['user_id' => $user->id]); + + $user->forceFill(['current_space_id' => $business->id])->save(); + + $response = $this->actingAs($user)->getJson('/api/sync/transactions'); + $response->assertOk(); + + $ids = collect($response->json('data'))->pluck('id'); + expect($ids)->toContain($businessTx->id) + ->and($ids)->toHaveCount(1); +}); + +it('scopes categories on the settings page to the active space', function () { + [$user, $personal, $business] = userWithTwoSpaces(); + + Category::factory()->for($user)->create(['space_id' => $personal->id, 'name' => 'Personal Only']); + Category::factory()->for($user)->create(['space_id' => $business->id, 'name' => 'Business Only']); + + $user->forceFill(['current_space_id' => $business->id])->save(); + + $this->actingAs($user)->get('/settings/categories') + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('categories', 1) + ->where('categories.0.name', 'Business Only')); +}); + +it('cannot update a transaction that lives in another space', function () { + [$user, $personal, $business] = userWithTwoSpaces(); + + // A transaction created by someone else in a space this user cannot access. + $stranger = User::factory()->create(); + $strangerAccount = Account::factory()->for($stranger)->create(); + $strangerSpace = $stranger->personalSpace; + $foreignTx = Transaction::factory()->for($strangerAccount)->create([ + 'user_id' => $stranger->id, + 'space_id' => $strangerSpace->id, + ]); + + $this->actingAs($user) + ->patchJson("/transactions/{$foreignTx->id}", ['description' => 'hacked']) + ->assertForbidden(); +}); + +it('exposes the accessible spaces and current space as shared props', function () { + [$user, $personal, $business] = userWithTwoSpaces(); + Feature::for($user)->activate(Spaces::class); + + $user->forceFill(['current_space_id' => $business->id])->save(); + + $this->actingAs($user)->get('/accounts') + ->assertInertia(fn (AssertableInertia $page) => $page + ->where('currentSpace.id', $business->id) + ->where('currentSpace.name', 'Acme') + ->where('features.spaces', true) + ->has('spaces', 2)); +}); diff --git a/tests/Performance/PageQueryCountTest.php b/tests/Performance/PageQueryCountTest.php index abc16d39..127072e1 100644 --- a/tests/Performance/PageQueryCountTest.php +++ b/tests/Performance/PageQueryCountTest.php @@ -33,13 +33,13 @@ beforeEach(function () { // ────────────────────────────────────────────────────────────────────────── test('dashboard page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('dashboard'))->assertOk(); }, 'Dashboard'); }); test('accounts index page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('accounts.list'))->assertOk(); }, 'Accounts Index'); }); @@ -51,19 +51,19 @@ test('account show page does not exceed query threshold', function () { 'type' => AccountType::Checking, ]); - assertMaxQueries(18, function () use ($account) { + assertMaxQueries(20, function () use ($account) { $this->get(route('accounts.show', $account))->assertOk(); }, 'Account Show'); }); test('transactions index page does not exceed query threshold', function () { - assertMaxQueries(21, function () { + assertMaxQueries(23, function () { $this->get(route('transactions.index'))->assertOk(); }, 'Transactions Index'); }); test('budgets index page does not exceed query threshold', function () { - assertMaxQueries(21, function () { + assertMaxQueries(23, function () { $this->get(route('budgets.index'))->assertOk(); }, 'Budgets Index'); }); @@ -71,13 +71,13 @@ 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(22, function () use ($budget) { + assertMaxQueries(24, function () use ($budget) { $this->get(route('budgets.show', $budget))->assertOk(); }, 'Budget Show'); }); test('cashflow page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('cashflow'))->assertOk(); }, 'Cashflow'); }); @@ -87,37 +87,37 @@ test('cashflow page does not exceed query threshold', function () { // ────────────────────────────────────────────────────────────────────────── test('settings accounts page does not exceed query threshold', function () { - assertMaxQueries(17, function () { + assertMaxQueries(19, function () { $this->get(route('accounts.index'))->assertOk(); }, 'Settings Accounts'); }); test('settings categories page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('categories.index'))->assertOk(); }, 'Settings Categories'); }); test('settings labels page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('labels.index'))->assertOk(); }, 'Settings Labels'); }); test('settings automation rules page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('automation-rules.index'))->assertOk(); }, 'Settings Automation Rules'); }); test('settings account/profile page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('account.edit'))->assertOk(); }, 'Settings Account/Profile'); }); test('settings appearance page does not exceed query threshold', function () { - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('appearance.edit'))->assertOk(); }, 'Settings Appearance'); }); @@ -147,7 +147,7 @@ test('dashboard query count does not scale with number of accounts', function () } // Same threshold as 3 accounts — query count must not grow with data - assertMaxQueries(15, function () { + assertMaxQueries(17, function () { $this->get(route('dashboard'))->assertOk(); }, 'Dashboard with 10 accounts'); }); @@ -164,7 +164,7 @@ test('transactions page query count does not scale with number of transactions', ]); // Same threshold — paginated queries should not scale - assertMaxQueries(21, function () { + assertMaxQueries(23, function () { $this->get(route('transactions.index'))->assertOk(); }, 'Transactions with 120 records'); }); From 5dca04dbba61c095e40bd36024ae55581969652c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Mon, 6 Jul 2026 18:10:25 +0200 Subject: [PATCH 2/4] fix(spaces): scope category/label/saved-filter uniqueness per space A user's spaces are each seeded with the same default category names, so the per-user unique indexes on categories, labels and saved_filters collided the moment a second space was created (500 on space creation). Rescope those uniques to space_id (adding a plain user_id index first so the FK keeps its index), teach the category duplicate-name handler the new index name, and harden the creation test to seed the personal space first so it reproduces the collision. --- .../Settings/CategoryController.php | 3 +- ...130000_rescope_unique_indexes_to_space.php | 67 +++++++++++++++++++ tests/Feature/Spaces/SpaceManagementTest.php | 9 ++- 3 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php diff --git a/app/Http/Controllers/Settings/CategoryController.php b/app/Http/Controllers/Settings/CategoryController.php index 8b92ea00..aa96248a 100644 --- a/app/Http/Controllers/Settings/CategoryController.php +++ b/app/Http/Controllers/Settings/CategoryController.php @@ -107,7 +107,8 @@ class CategoryController extends Controller { 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_parent_name_active_unique')) { + && ! str_contains($exception->getMessage(), 'categories_user_id_parent_name_active_unique') + && ! str_contains($exception->getMessage(), 'categories_space_id_parent_name_active_unique')) { throw $exception; } diff --git a/database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php b/database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php new file mode 100644 index 00000000..81f72a1a --- /dev/null +++ b/database/migrations/2026_07_06_130000_rescope_unique_indexes_to_space.php @@ -0,0 +1,67 @@ +index('user_id', 'categories_user_id_index'); + $table->dropUnique('categories_user_id_parent_name_active_unique'); + $table->unique( + ['space_id', 'parent_unique_marker', 'name', 'active_unique_marker'], + 'categories_space_id_parent_name_active_unique', + ); + }); + + Schema::table('labels', function (Blueprint $table) { + $table->index('user_id', 'labels_user_id_index'); + $table->dropUnique('labels_user_id_name_deleted_at_unique'); + $table->unique(['space_id', 'name', 'deleted_at'], 'labels_space_id_name_deleted_at_unique'); + }); + + Schema::table('saved_filters', function (Blueprint $table) { + $table->index('user_id', 'saved_filters_user_id_index'); + $table->dropUnique('saved_filters_user_id_name_unique'); + $table->unique(['space_id', 'name'], 'saved_filters_space_id_name_unique'); + }); + } + + public function down(): void + { + Schema::table('categories', function (Blueprint $table) { + $table->dropUnique('categories_space_id_parent_name_active_unique'); + $table->unique( + ['user_id', 'parent_unique_marker', 'name', 'active_unique_marker'], + 'categories_user_id_parent_name_active_unique', + ); + $table->dropIndex('categories_user_id_index'); + }); + + Schema::table('labels', function (Blueprint $table) { + $table->dropUnique('labels_space_id_name_deleted_at_unique'); + $table->unique(['user_id', 'name', 'deleted_at'], 'labels_user_id_name_deleted_at_unique'); + $table->dropIndex('labels_user_id_index'); + }); + + Schema::table('saved_filters', function (Blueprint $table) { + $table->dropUnique('saved_filters_space_id_name_unique'); + $table->unique(['user_id', 'name'], 'saved_filters_user_id_name_unique'); + $table->dropIndex('saved_filters_user_id_index'); + }); + } +}; diff --git a/tests/Feature/Spaces/SpaceManagementTest.php b/tests/Feature/Spaces/SpaceManagementTest.php index b114d63f..b9749fd3 100644 --- a/tests/Feature/Spaces/SpaceManagementTest.php +++ b/tests/Feature/Spaces/SpaceManagementTest.php @@ -1,5 +1,6 @@ onboarded()->create(); Feature::for($user)->activate(Spaces::class); + // The personal space already carries the default categories (as it would + // after registration), so seeding the same names into a second space must + // NOT collide — category name uniqueness is per space, not per user. + app(CreateDefaultCategories::class)->handle($user, $user->personalSpace); + $this->actingAs($user)->post('/settings/spaces', ['name' => 'Acme']) ->assertRedirect(); @@ -18,7 +24,8 @@ it('creates a space, seeds its categories and switches to it', function () { expect($space)->not->toBeNull() ->and($user->fresh()->current_space_id)->toBe($space->id) - ->and(Category::where('space_id', $space->id)->count())->toBeGreaterThan(0); + ->and(Category::where('space_id', $space->id)->count())->toBeGreaterThan(0) + ->and(Category::where('space_id', $user->personalSpace->id)->count())->toBeGreaterThan(0); }); it('forbids creating a space without the spaces feature', function () { From c163e592d9df29acb6a8439781c52acb101a7958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Thu, 9 Jul 2026 15:33:04 +0200 Subject: [PATCH 3/4] fix(spaces): move Spaces to the top of the settings navigation Surface the space management page as the first settings entry, above Bank accounts, so it is the primary option in the menu. --- resources/js/layouts/settings/layout.tsx | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/resources/js/layouts/settings/layout.tsx b/resources/js/layouts/settings/layout.tsx index 579919d9..f6dab15a 100644 --- a/resources/js/layouts/settings/layout.tsx +++ b/resources/js/layouts/settings/layout.tsx @@ -37,6 +37,16 @@ const getNavItems = ( isDemoAccount: boolean, spacesEnabled: boolean, ): (NavItem | NavSectionHeader | NavDivider)[] => [ + ...(spacesEnabled + ? [ + { + type: 'nav-item' as const, + title: 'Spaces', + href: spacesIndex(), + icon: null, + }, + ] + : []), { type: 'nav-item' as const, title: 'Bank accounts', @@ -67,16 +77,6 @@ const getNavItems = ( href: labelsIndex(), icon: null, }, - ...(spacesEnabled - ? [ - { - type: 'nav-item' as const, - title: 'Spaces', - href: spacesIndex(), - icon: null, - }, - ] - : []), { type: 'divider' }, { type: 'section-header', From d76b98c837fcedb605e42fd85e1d95d12aeb7d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Thu, 9 Jul 2026 15:33:04 +0200 Subject: [PATCH 4/4] fix(spaces): address QA review nits - Replace the native window.confirm() on space deletion with the app's AlertDialog, matching how every other destructive action confirms. - Translate the 'Spaces settings' breadcrumb (add the es.json key). - Show an empty state on the dashboard when the active space has no accounts, instead of a bare 'Accounts' heading. --- lang/es.json | 5 ++- resources/js/pages/dashboard.tsx | 36 +++++++++------- resources/js/pages/settings/spaces.tsx | 58 ++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 25 deletions(-) diff --git a/lang/es.json b/lang/es.json index 3f03695b..c49c1a1f 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2229,5 +2229,8 @@ "Switch": "Cambiar", "Rename": "Renombrar", "Rename space": "Renombrar espacio", - "Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo." + "Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo.", + "Spaces settings": "Configuración de espacios", + "Delete space": "Eliminar espacio", + "This space has no accounts yet.": "Este espacio aún no tiene cuentas." } diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index 50942758..a528fe8a 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -311,21 +311,27 @@ export default function Dashboard() { )} -
- {gridAccounts.map((account) => ( - - ))} -
+ {gridAccounts.length === 0 ? ( +

+ {__('This space has no accounts yet.')} +

+ ) : ( +
+ {gridAccounts.map((account) => ( + + ))} +
+ )} ().props; const [createOpen, setCreateOpen] = useState(false); const [renaming, setRenaming] = useState(null); + const [deleting, setDeleting] = useState(null); const [name, setName] = useState(''); const [processing, setProcessing] = useState(false); @@ -73,16 +84,16 @@ export default function Spaces() { ); }; - const remove = (space: Space) => { - if ( - !window.confirm( - __('Delete this space? This cannot be undone.') + - ` (${space.name})`, - ) - ) { + const confirmDelete = () => { + if (!deleting) { return; } - router.delete(destroySpace(space.id).url, { preserveScroll: true }); + setProcessing(true); + router.delete(destroySpace(deleting.id).url, { + preserveScroll: true, + onFinish: () => setProcessing(false), + onSuccess: () => setDeleting(null), + }); }; return ( @@ -169,7 +180,7 @@ export default function Spaces() { size="sm" className="text-destructive hover:text-destructive" onClick={() => - remove(space) + setDeleting(space) } > {__('Delete')} @@ -270,6 +281,35 @@ export default function Spaces() { + + !open && setDeleting(null)} + > + + + + {__('Delete space')} + + + {__('Delete this space? This cannot be undone.')} + {deleting ? ` (${deleting.name})` : ''} + + + + + {__('Cancel')} + + + {__('Delete')} + + + + ); }