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:
parent
da3c570913
commit
c43bfd5439
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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)],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ class CategoryController extends Controller
|
|||
public function index(): Response
|
||||
{
|
||||
$categories = auth()->user()
|
||||
->activeSpace()
|
||||
->categories()
|
||||
->forDisplay()
|
||||
->get();
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ class LabelController extends Controller
|
|||
public function index(): Response
|
||||
{
|
||||
$labels = auth()->user()
|
||||
->activeSpace()
|
||||
->labels()
|
||||
->orderBy('name')
|
||||
->get();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
18
lang/es.json
18
lang/es.json
|
|
@ -68,7 +68,6 @@
|
|||
"You have reached your monthly limit of :count integration actions. Try again next month.": "Has alcanzado tu límite mensual de :count acciones de integración. Inténtalo de nuevo el mes que viene.",
|
||||
"Remove one vote": "Quitar un voto",
|
||||
"Categorized by AI": "Categorizado por IA",
|
||||
"Let AI categorize your transactions": "Deja que la IA categorice tus transacciones",
|
||||
"Categorized by AI with :confidence% confident": "Categorizado por IA con un :confidence % de confianza",
|
||||
"Only show AI guesses": "Mostrar solo sugerencias de IA",
|
||||
"Created by AI": "Creado por IA",
|
||||
|
|
@ -2215,5 +2214,20 @@
|
|||
"See your categorised transactions": "Ver tus transacciones categorizadas",
|
||||
"Private by design, always": "Privado por diseño, siempre",
|
||||
"Turning on AI never changes who owns your data — you do. We only use it to help you understand your own finances, and you can revoke this consent at any time from your settings.": "Activar la IA nunca cambia quién es el dueño de tus datos: lo eres tú. Solo la usamos para ayudarte a entender tus propias finanzas, y puedes revocar este consentimiento cuando quieras desde tus ajustes.",
|
||||
"If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje."
|
||||
"If you have any questions, **just reply to this email**. We personally read every message.": "Si tienes cualquier duda, **responde a este correo**. Leemos personalmente cada mensaje.",
|
||||
"Personal": "Personal",
|
||||
"Personal space": "Espacio personal",
|
||||
"Space": "Espacio",
|
||||
"Spaces": "Espacios",
|
||||
"Create space": "Crear espacio",
|
||||
"Manage spaces": "Gestionar espacios",
|
||||
"A space groups its own accounts, transactions, categories and budgets, kept separate from your other spaces.": "Un espacio agrupa sus propias cuentas, transacciones, categorías y presupuestos, separados de tus otros espacios.",
|
||||
"e.g. My Company": "p. ej. Mi Empresa",
|
||||
"Delete this space? This cannot be undone.": "¿Eliminar este espacio? Esta acción no se puede deshacer.",
|
||||
"Each space keeps its own accounts, transactions, categories and budgets separate.": "Cada espacio mantiene separadas sus propias cuentas, transacciones, categorías y presupuestos.",
|
||||
"Active": "Activo",
|
||||
"Switch": "Cambiar",
|
||||
"Rename": "Renombrar",
|
||||
"Rename space": "Renombrar espacio",
|
||||
"Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 =>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
expect($props['features'])->toBe([
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
'spaces' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue