feat(spaces): scope reads by active space and add the space switcher

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.
This commit is contained in:
Víctor Falcón 2026-07-06 17:26:11 +02:00
parent 6f72c43cce
commit d470ee9646
48 changed files with 1251 additions and 210 deletions

24
app/Features/Spaces.php Normal file
View File

@ -0,0 +1,24 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* Gates the visible spaces UI (the switcher and space management). The
* space-scoping of data is always on and safe; this flag only controls whether
* a user can see and manage more than their invisible personal space. Defaults
* to admins for internal dogfooding; flipped to the Business plan at launch.
*
* @api
*/
class Spaces
{
/**
* Resolve the feature's initial value.
*/
public function resolve(?User $user): bool
{
return $user?->isAdmin() ?? false;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -28,6 +28,7 @@ class CategoryController extends Controller
public function index(): Response
{
$categories = auth()->user()
->activeSpace()
->categories()
->forDisplay()
->get();

View File

@ -22,6 +22,7 @@ class LabelController extends Controller
public function index(): Response
{
$labels = auth()->user()
->activeSpace()
->labels()
->orderBy('name')
->get();

View File

@ -0,0 +1,104 @@
<?php
namespace App\Http\Controllers;
use App\Actions\CreateDefaultCategories;
use App\Features\Spaces;
use App\Http\Requests\StoreSpaceRequest;
use App\Http\Requests\UpdateSpaceRequest;
use App\Models\Space;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Inertia\Inertia;
use Inertia\Response;
use Laravel\Pennant\Feature;
class SpaceController extends Controller
{
/**
* The spaces management settings page. The list itself is shared globally
* (see HandleInertiaRequests), so this only needs to render the page.
*/
public function index(): Response
{
return Inertia::render('settings/spaces');
}
/**
* Create a new space, seed it with the default categories and switch to it.
*/
public function store(StoreSpaceRequest $request): RedirectResponse
{
$user = $request->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();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,23 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Resolve (and repair) the authenticated user's active space before controllers
* run, so every read scoped to `$user->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);
}
}

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests;
use App\Features\Spaces;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class StoreSpaceRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null
&& Feature::for($this->user())->active(Spaces::class);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use App\Features\Spaces;
use App\Models\Space;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class UpdateSpaceRequest extends FormRequest
{
public function authorize(): bool
{
$space = $this->route('space');
return $space instanceof Space
&& ! $space->personal
&& $space->owner_id === $this->user()?->id
&& Feature::for($this->user())->active(Spaces::class);
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:255'],
];
}
}

View File

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

View File

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

View File

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

View File

@ -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<int, string> $categoryMatchIds the transaction category and its ancestors
* @return array<int, string>
*/
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<int, string>
*/
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()

View File

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

View File

@ -17,7 +17,7 @@ class CategoryTree
* @param array<int, string> $ids
* @return array<int, string>
*/
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<int, string>
*/
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<int, array{category_id: ?string, category: Category|null, amount: int}> $categorized
* @return array<int, array{category_id: ?string, category: Category|null, amount: int, has_children: bool, is_direct: bool}>
*/
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<int, array{category_id: ?string, amount: int}> $categorized
* @return array<int, array{category_id: string, category: Category, amount: int, children: array<int, array{category_id: string, category: Category, amount: int}>}>
*/
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();
}

View File

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

View File

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

View File

@ -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."
}

View File

@ -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() {
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SpaceSwitcher />
</SidebarHeader>
<SidebarContent>

View File

@ -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<SharedData>().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 (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent"
data-test="space-switcher"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary/10 text-sidebar-primary">
{currentSpace.personal ? (
<User className="size-4" />
) : (
<Building2 className="size-4" />
)}
</div>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">
{currentSpace.name}
</span>
<span className="truncate text-xs text-muted-foreground">
{currentSpace.personal
? __('Personal space')
: __('Space')}
</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-60 rounded-lg"
align="start"
side={
isMobile
? 'bottom'
: state === 'collapsed'
? 'right'
: 'bottom'
}
>
<DropdownMenuLabel className="text-xs text-muted-foreground">
{__('Spaces')}
</DropdownMenuLabel>
{spaces.map((space) => (
<DropdownMenuItem
key={space.id}
onClick={() => switchTo(space.id)}
className="gap-2"
data-test="space-option"
>
{space.personal ? (
<User className="size-4 text-muted-foreground" />
) : (
<Building2 className="size-4 text-muted-foreground" />
)}
<span className="truncate">{space.name}</span>
{space.id === currentSpace.id && (
<Check className="ml-auto size-4" />
)}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => setCreateOpen(true)}
className="gap-2"
data-test="space-create"
>
<Plus className="size-4" />
{__('Create space')}
</DropdownMenuItem>
<DropdownMenuItem asChild className="gap-2">
<Link href={manageSpaces().url}>
<Settings2 className="size-4" />
{__('Manage spaces')}
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<form onSubmit={createSpace}>
<DialogHeader>
<DialogTitle>{__('Create space')}</DialogTitle>
<DialogDescription>
{__(
'A space groups its own accounts, transactions, categories and budgets, kept separate from your other spaces.',
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="space-name">{__('Name')}</Label>
<Input
id="space-name"
value={name}
onChange={(event) =>
setName(event.target.value)
}
placeholder={__('e.g. My Company')}
autoFocus
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={processing || name.trim() === ''}
>
{__('Create space')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</SidebarMenu>
);
}

View File

@ -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<string | null>(null);
const [wasOffline, setWasOffline] = useState(!isOnline);
const lastUserIdRef = useRef<string | null>(null);
const lastSpaceIdRef = useRef<string | null>(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 () => {

View File

@ -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<SharedData>().props;
const { subscriptionsEnabled, auth, features } =
usePage<SharedData>().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 =>

View File

@ -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<SharedData>().props;
const [createOpen, setCreateOpen] = useState(false);
const [renaming, setRenaming] = useState<Space | null>(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 (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={__('Spaces')} />
<SettingsLayout>
<div className="space-y-6">
<div className="flex items-start justify-between gap-4">
<HeadingSmall
title={__('Spaces')}
description={__(
'Each space keeps its own accounts, transactions, categories and budgets separate.',
)}
/>
<Button onClick={() => setCreateOpen(true)}>
{__('Create space')}
</Button>
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{spaces.map((space) => {
const isCurrent = space.id === currentSpace?.id;
return (
<li
key={space.id}
className="flex items-center gap-3 px-4 py-3"
data-test="space-row"
>
<div className="flex size-9 items-center justify-center rounded-lg bg-muted text-muted-foreground">
{space.personal ? (
<User className="size-4" />
) : (
<Building2 className="size-4" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-2 font-medium">
{space.name}
{isCurrent && (
<span className="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
<Check className="size-3" />
{__('Active')}
</span>
)}
</div>
<div className="text-xs text-muted-foreground">
{space.personal
? __('Personal space')
: __('Space')}
</div>
</div>
<div className="flex items-center gap-2">
{!isCurrent && (
<Button
variant="outline"
size="sm"
onClick={() =>
router.post(
selectSpace(space.id)
.url,
)
}
>
{__('Switch')}
</Button>
)}
{!space.personal && space.is_owner && (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setName(space.name);
setRenaming(space);
}}
>
{__('Rename')}
</Button>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() =>
remove(space)
}
>
{__('Delete')}
</Button>
</>
)}
</div>
</li>
);
})}
</ul>
</div>
</SettingsLayout>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<form onSubmit={submitCreate}>
<DialogHeader>
<DialogTitle>{__('Create space')}</DialogTitle>
<DialogDescription>
{__(
'A space groups its own accounts, transactions, categories and budgets, kept separate from your other spaces.',
)}
</DialogDescription>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="create-space-name">
{__('Name')}
</Label>
<Input
id="create-space-name"
value={name}
onChange={(event) =>
setName(event.target.value)
}
placeholder={__('e.g. My Company')}
autoFocus
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setCreateOpen(false)}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={processing || name.trim() === ''}
>
{__('Create space')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<Dialog
open={renaming !== null}
onOpenChange={(open) => !open && setRenaming(null)}
>
<DialogContent>
<form onSubmit={submitRename}>
<DialogHeader>
<DialogTitle>{__('Rename space')}</DialogTitle>
</DialogHeader>
<div className="grid gap-2 py-4">
<Label htmlFor="rename-space-name">
{__('Name')}
</Label>
<Input
id="rename-space-name"
value={name}
onChange={(event) =>
setName(event.target.value)
}
autoFocus
required
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setRenaming(null)}
>
{__('Cancel')}
</Button>
<Button
type="submit"
disabled={processing || name.trim() === ''}
>
{__('Save')}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</AppLayout>
);
}

View File

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

View File

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

View File

@ -99,6 +99,7 @@ test('shared feature flags do not include coinbase flag', function () {
expect($props['features'])->toBe([
'cashflow' => true,
'calculateBalancesOnImport' => false,
'spaces' => false,
]);
});

View File

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

View File

@ -1,10 +1,10 @@
<?php
use App\Actions\CreateDefaultCategories;
use App\Enums\TransactionSource;
use App\Models\Account;
use App\Models\Category;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
it('provisions a personal space when a user is created', function () {
@ -24,14 +24,20 @@ it('stamps owned rows with the owner\'s current space by default', function () {
expect($account->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);
});

View File

@ -0,0 +1,92 @@
<?php
use App\Features\Spaces;
use App\Models\Account;
use App\Models\Category;
use App\Models\Space;
use App\Models\User;
use Laravel\Pennant\Feature;
it('creates a space, seeds its categories and switches to it', function () {
$user = User::factory()->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();
});

View File

@ -0,0 +1,109 @@
<?php
use App\Features\Spaces;
use App\Models\Account;
use App\Models\Category;
use App\Models\Space;
use App\Models\Transaction;
use App\Models\User;
use Inertia\Testing\AssertableInertia;
use Laravel\Pennant\Feature;
/**
* A user with two spaces must only ever see the active space's data. These are
* the anti-leak guardrails for the whole multi-tenant feature.
*/
function userWithTwoSpaces(): array
{
$user = User::factory()->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));
});

View File

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