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 b80a9772..bfec5391 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'); @@ -505,8 +505,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') @@ -520,8 +522,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') @@ -531,7 +535,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..aa96248a 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(); @@ -106,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/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 da086fc8..b607fcc7 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') @@ -304,10 +295,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); @@ -357,7 +353,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 84d3b169..f3329076 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -282,10 +282,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/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/lang/es.json b/lang/es.json index 8facad96..4ffe8c74 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", @@ -2217,5 +2216,23 @@ "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.", + "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/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..f6dab15a 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,7 +35,18 @@ import { type PropsWithChildren } from 'react'; const getNavItems = ( subscriptionsEnabled: boolean, 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', @@ -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/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); + + 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 confirmDelete = () => { + if (!deleting) { + return; + } + setProcessing(true); + router.delete(destroySpace(deleting.id).url, { + preserveScroll: true, + onFinish: () => setProcessing(false), + onSuccess: () => setDeleting(null), + }); + }; + + 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 + /> +
+ + + + +
+
+
+ + !open && setDeleting(null)} + > + + + + {__('Delete space')} + + + {__('Delete this space? This cannot be undone.')} + {deleting ? ` (${deleting.name})` : ''} + + + + + {__('Cancel')} + + + {__('Delete')} + + + + +
+ ); +} 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..b9749fd3 --- /dev/null +++ b/tests/Feature/Spaces/SpaceManagementTest.php @@ -0,0 +1,99 @@ +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(); + + $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) + ->and(Category::where('space_id', $user->personalSpace->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'); });