diff --git a/app/Actions/CreateDefaultCategories.php b/app/Actions/CreateDefaultCategories.php index c1dff91a..eead2ea5 100644 --- a/app/Actions/CreateDefaultCategories.php +++ b/app/Actions/CreateDefaultCategories.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; use App\Models\Category; +use App\Models\Space; use App\Models\User; use Illuminate\Support\Arr; use Illuminate\Support\Str; @@ -13,14 +14,16 @@ class CreateDefaultCategories { /** * Create default categories for a newly registered user, nesting child - * categories under their configured parent. + * categories under their configured parent. Categories live in a space, so + * seeding targets the given space (defaulting to the user's active one). */ - public function handle(User $user): void + public function handle(User $user, ?Space $space = null): void { + $space ??= $user->activeSpace(); $locale = $user->locale ?? app()->getLocale(); $defaultCategories = self::getDefaultCategories($locale); - $existingCategories = $user->categories() + $existingCategories = $space->categories() ->whereIn('name', array_column($defaultCategories, 'name')) ->pluck('id', 'name'); @@ -32,6 +35,7 @@ class CreateDefaultCategories 'cashflow_direction' => $category['cashflow_direction'] ?? CategoryCashflowDirection::Hidden->value, 'id' => (string) Str::uuid(), 'user_id' => $user->id, + 'space_id' => $space->id, 'created_at' => $now, 'updated_at' => $now, ]); diff --git a/app/Console/Commands/BackfillSpaces.php b/app/Console/Commands/BackfillSpaces.php new file mode 100644 index 00000000..8392f40c --- /dev/null +++ b/app/Console/Commands/BackfillSpaces.php @@ -0,0 +1,82 @@ +> + */ + private array $models = [ + Account::class, + BankingConnection::class, + Transaction::class, + Category::class, + Label::class, + Budget::class, + AutomationRule::class, + SavedFilter::class, + ]; + + public function handle(): int + { + $chunk = (int) $this->option('chunk'); + + // Soft-deleted users are included: their accounts/transactions still + // exist and must be stamped too, so a restored account keeps its data + // and the column can eventually go NOT NULL. + $this->info('Provisioning personal spaces…'); + User::withTrashed()->whereNull('current_space_id')->chunkById($chunk, function ($users): void { + foreach ($users as $user) { + $user->provisionPersonalSpace(); + } + }); + + $this->info('Backfilling space_id on owned rows…'); + Space::query()->where('personal', true)->chunkById($chunk, function ($spaces): void { + foreach ($spaces as $space) { + foreach ($this->models as $model) { + $this->stamp($model, $space->owner_id, $space->id); + } + } + }); + + $this->info('Done.'); + + return self::SUCCESS; + } + + /** + * @param class-string $model + */ + private function stamp(string $model, string $ownerId, string $spaceId): void + { + // Go through the query builder rather than Eloquent so soft-deleted rows + // are stamped too (no soft-delete global scope to work around). + DB::table((new $model)->getTable()) + ->whereNull('space_id') + ->where('user_id', $ownerId) + ->update(['space_id' => $spaceId]); + } +} diff --git a/app/Models/Account.php b/app/Models/Account.php index fbc7536f..3d96c7ca 100644 --- a/app/Models/Account.php +++ b/app/Models/Account.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\AccountType; +use App\Models\Concerns\BelongsToSpace; use Database\Factories\AccountFactory; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Account extends Model { /** @use HasFactory */ - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; protected $fillable = [ 'user_id', + 'space_id', 'name', 'name_iv', 'bank_id', @@ -40,6 +42,7 @@ class Account extends Model /** @var list */ protected $hidden = [ 'user_id', + 'space_id', 'bank_id', 'iban', 'position', diff --git a/app/Models/AutomationRule.php b/app/Models/AutomationRule.php index c9abc248..08153311 100644 --- a/app/Models/AutomationRule.php +++ b/app/Models/AutomationRule.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\RuleOrigin; +use App\Models\Concerns\BelongsToSpace; use Database\Factories\AutomationRuleFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -19,10 +20,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; class AutomationRule extends Model { /** @use HasFactory */ - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; protected $fillable = [ 'user_id', + 'space_id', 'title', 'priority', 'origin', @@ -32,6 +34,11 @@ class AutomationRule extends Model 'action_note_iv', ]; + /** @var list */ + protected $hidden = [ + 'space_id', + ]; + protected function casts(): array { return [ diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index d390cd8b..eea236cb 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; +use App\Models\Concerns\BelongsToSpace; use Carbon\Carbon; use Database\Factories\BankingConnectionFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -28,10 +29,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; class BankingConnection extends Model { /** @use HasFactory */ - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; protected $fillable = [ 'user_id', + 'space_id', 'provider', 'authorization_id', 'state_token', @@ -52,6 +54,7 @@ class BankingConnection extends Model ]; protected $hidden = [ + 'space_id', 'api_token', 'api_secret', 'pending_accounts_data', diff --git a/app/Models/Budget.php b/app/Models/Budget.php index 13f07b7e..cad0a537 100644 --- a/app/Models/Budget.php +++ b/app/Models/Budget.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\BudgetPeriodType; use App\Enums\RolloverType; +use App\Models\Concerns\BelongsToSpace; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; @@ -18,10 +19,11 @@ use Illuminate\Database\Eloquent\SoftDeletes; */ class Budget extends Model { - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; protected $fillable = [ 'user_id', + 'space_id', 'name', 'period_type', 'period_start_day', @@ -32,6 +34,7 @@ class Budget extends Model /** @var list */ protected $hidden = [ 'period_duration', + 'space_id', ]; protected function casts(): array diff --git a/app/Models/Category.php b/app/Models/Category.php index d735df4b..7326d3c6 100644 --- a/app/Models/Category.php +++ b/app/Models/Category.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\CategoryCashflowDirection; use App\Enums\CategoryType; +use App\Models\Concerns\BelongsToSpace; use Database\Factories\CategoryFactory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Concerns\HasUuids; @@ -26,7 +27,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Category extends Model { /** @use HasFactory */ - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; /** * Maximum allowed nesting depth (a root counts as level 1). @@ -40,12 +41,14 @@ class Category extends Model 'type', 'cashflow_direction', 'user_id', + 'space_id', 'parent_id', ]; /** @var list */ protected $hidden = [ 'user_id', + 'space_id', 'created_at', 'updated_at', 'deleted_at', diff --git a/app/Models/Concerns/BelongsToSpace.php b/app/Models/Concerns/BelongsToSpace.php new file mode 100644 index 00000000..2345453f --- /dev/null +++ b/app/Models/Concerns/BelongsToSpace.php @@ -0,0 +1,69 @@ +space_id === null) { + $model->space_id = $model->resolveDefaultSpaceId(); + } + }); + } + + /** @return BelongsTo */ + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + /** + * Restrict a query to a single space. + * + * @param Builder $query + * @return Builder + */ + public function scopeForSpace(Builder $query, Space|string $space): Builder + { + return $query->where($this->getTable().'.space_id', $space instanceof Space ? $space->id : $space); + } + + /** + * The space a new row defaults to when none was set explicitly: the current + * space of the user that owns the row. Models with a stronger anchor (e.g. a + * transaction inheriting its account's space) override this. + */ + protected function resolveDefaultSpaceId(): ?string + { + return $this->spaceIdFromUser(); + } + + /** + * The current space of the user that owns this row, if any. + */ + protected function spaceIdFromUser(): ?string + { + $userId = $this->getAttribute('user_id'); + + if ($userId === null) { + return null; + } + + return User::query()->whereKey($userId)->value('current_space_id'); + } +} diff --git a/app/Models/Label.php b/app/Models/Label.php index da3f425c..92282596 100644 --- a/app/Models/Label.php +++ b/app/Models/Label.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Models\Concerns\BelongsToSpace; use Database\Factories\LabelFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -13,12 +14,13 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Label extends Model { /** @use HasFactory */ - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; protected $fillable = [ 'name', 'color', 'user_id', + 'space_id', ]; /** @@ -29,6 +31,7 @@ class Label extends Model */ protected $hidden = [ 'pivot', + 'space_id', ]; /** @return BelongsTo */ diff --git a/app/Models/SavedFilter.php b/app/Models/SavedFilter.php index 261dae15..9719e9db 100644 --- a/app/Models/SavedFilter.php +++ b/app/Models/SavedFilter.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Enums\AnalysisMode; +use App\Models\Concerns\BelongsToSpace; use Database\Factories\SavedFilterFactory; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; @@ -12,10 +13,11 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo; class SavedFilter extends Model { /** @use HasFactory */ - use HasFactory, HasUuids; + use BelongsToSpace, HasFactory, HasUuids; protected $fillable = [ 'user_id', + 'space_id', 'name', 'filters', 'analysis_days', @@ -25,6 +27,7 @@ class SavedFilter extends Model /** @var list */ protected $hidden = [ 'user_id', + 'space_id', 'created_at', 'updated_at', ]; diff --git a/app/Models/Space.php b/app/Models/Space.php new file mode 100644 index 00000000..f2533874 --- /dev/null +++ b/app/Models/Space.php @@ -0,0 +1,134 @@ + */ + use HasFactory, HasUuids; + + protected $fillable = [ + 'owner_id', + 'name', + 'personal', + ]; + + protected function casts(): array + { + return [ + 'personal' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function owner(): BelongsTo + { + return $this->belongsTo(User::class, 'owner_id'); + } + + /** + * Members invited into the space (excludes the owner, who is implicit). + * + * @return BelongsToMany + */ + public function members(): BelongsToMany + { + return $this->belongsToMany(User::class, 'space_user') + ->withPivot('role') + ->withTimestamps(); + } + + /** @return HasMany */ + public function invitations(): HasMany + { + return $this->hasMany(SpaceInvitation::class); + } + + /** @return HasMany */ + public function accounts(): HasMany + { + return $this->hasMany(Account::class); + } + + /** @return HasMany */ + public function bankingConnections(): HasMany + { + return $this->hasMany(BankingConnection::class); + } + + /** @return HasMany */ + public function transactions(): HasMany + { + return $this->hasMany(Transaction::class); + } + + /** @return HasMany */ + public function categories(): HasMany + { + return $this->hasMany(Category::class); + } + + /** @return HasMany */ + public function labels(): HasMany + { + return $this->hasMany(Label::class); + } + + /** @return HasMany */ + public function budgets(): HasMany + { + return $this->hasMany(Budget::class); + } + + /** @return HasMany */ + public function automationRules(): HasMany + { + return $this->hasMany(AutomationRule::class); + } + + /** @return HasMany */ + public function savedFilters(): HasMany + { + return $this->hasMany(SavedFilter::class); + } + + /** + * Whether the given user owns or is a member of this space. The owner check + * short-circuits without a query for the common (personal-space) case. + */ + public function hasMember(User $user): bool + { + if ($this->owner_id === $user->id) { + return true; + } + + return $this->members()->whereKey($user->id)->exists(); + } + + /** + * The role the user holds in this space: owner, the pivot role, or null when + * they have no access. + */ + public function roleFor(User $user): ?string + { + if ($this->owner_id === $user->id) { + return 'owner'; + } + + return $this->members()->whereKey($user->id)->value('role'); + } +} diff --git a/app/Models/SpaceInvitation.php b/app/Models/SpaceInvitation.php new file mode 100644 index 00000000..71b41027 --- /dev/null +++ b/app/Models/SpaceInvitation.php @@ -0,0 +1,63 @@ + 'datetime', + 'accepted_at' => 'datetime', + ]; + } + + /** @return BelongsTo */ + public function space(): BelongsTo + { + return $this->belongsTo(Space::class); + } + + /** @return BelongsTo */ + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by_id'); + } + + public function isExpired(): bool + { + return $this->expires_at !== null && $this->expires_at->isPast(); + } + + public function isAccepted(): bool + { + return $this->accepted_at !== null; + } +} diff --git a/app/Models/Transaction.php b/app/Models/Transaction.php index f937ebb1..fb9dccdd 100644 --- a/app/Models/Transaction.php +++ b/app/Models/Transaction.php @@ -9,6 +9,7 @@ use App\Enums\TransactionSource; use App\Events\TransactionCreated; use App\Events\TransactionDeleted; use App\Events\TransactionUpdated; +use App\Models\Concerns\BelongsToSpace; use App\Services\CategoryTree; use Carbon\Carbon; use Database\Factories\TransactionFactory; @@ -35,7 +36,7 @@ use Illuminate\Database\Eloquent\SoftDeletes; class Transaction extends Model { /** @use HasFactory */ - use HasFactory, HasUuids, SoftDeletes; + use BelongsToSpace, HasFactory, HasUuids, SoftDeletes; /** @var array */ protected $dispatchesEvents = [ @@ -46,6 +47,7 @@ class Transaction extends Model protected $fillable = [ 'user_id', + 'space_id', 'account_id', 'category_id', 'category_source', @@ -77,6 +79,7 @@ class Transaction extends Model * @var list */ protected $hidden = [ + 'space_id', 'original_description', 'external_transaction_id', 'dedup_fingerprint', @@ -111,6 +114,26 @@ class Transaction extends Model return $this->belongsTo(Account::class); } + /** + * A transaction always lives in its account's space (the account is the + * tenant anchor), so bank-sync inserts land in the right space regardless of + * whichever space the syncing user is currently viewing. + */ + protected function resolveDefaultSpaceId(): ?string + { + $accountId = $this->getAttribute('account_id'); + + if ($accountId !== null) { + $spaceId = Account::query()->whereKey($accountId)->value('space_id'); + + if ($spaceId !== null) { + return $spaceId; + } + } + + return $this->spaceIdFromUser(); + } + /** @return BelongsTo */ public function category(): BelongsTo { diff --git a/app/Models/User.php b/app/Models/User.php index e128f2af..27d4a66b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -10,8 +10,11 @@ use Database\Factories\UserFactory; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Concerns\HasUuids; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\SoftDeletes; @@ -50,6 +53,7 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma 'currency_code', 'locale', 'timezone', + 'current_space_id', ]; /** @@ -89,6 +93,18 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma ]; } + /** + * Memoized active space for the current request lifecycle. + */ + protected ?Space $resolvedActiveSpace = null; + + protected static function booted(): void + { + static::created(function (User $user): void { + $user->provisionPersonalSpace(); + }); + } + public function isOnboarded(): bool { return $this->onboarded_at !== null; @@ -139,6 +155,97 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma }); } + /** @return BelongsTo */ + public function currentSpace(): BelongsTo + { + return $this->belongsTo(Space::class, 'current_space_id'); + } + + /** @return HasMany */ + public function ownedSpaces(): HasMany + { + return $this->hasMany(Space::class, 'owner_id'); + } + + /** + * The one personal space every user owns (created on registration). + * + * @return HasOne + */ + public function personalSpace(): HasOne + { + return $this->hasOne(Space::class, 'owner_id')->where('personal', true); + } + + /** + * Spaces the user was invited into (excludes the ones they own). + * + * @return BelongsToMany + */ + public function memberSpaces(): BelongsToMany + { + return $this->belongsToMany(Space::class, 'space_user') + ->withPivot('role') + ->withTimestamps(); + } + + /** + * Every space the user can access: the ones they own plus the ones they were + * invited into, ordered with the personal space first. + * + * @return Collection + */ + public function accessibleSpaces(): Collection + { + return Space::query() + ->where('owner_id', $this->id) + ->orWhereHas('members', fn (Builder $query) => $query->whereKey($this->id)) + ->orderByDesc('personal') + ->orderBy('name') + ->get(); + } + + /** + * Idempotently ensure the user has a personal space and points at it. + */ + public function provisionPersonalSpace(): Space + { + $space = $this->ownedSpaces()->firstOrCreate( + ['personal' => true], + ['name' => 'Personal'], + ); + + if ($this->current_space_id === null) { + $this->forceFill(['current_space_id' => $space->id])->saveQuietly(); + } + + return $space; + } + + /** + * The space the user is currently working in. Falls back to (and repairs + * towards) the personal space when the pointer is missing or points at a + * space the user can no longer access — e.g. after a membership is revoked or + * a Business subscription lapses. + */ + public function activeSpace(): Space + { + if ($this->resolvedActiveSpace !== null) { + return $this->resolvedActiveSpace; + } + + $space = $this->current_space_id !== null + ? Space::query()->find($this->current_space_id) + : null; + + if ($space === null || ! $space->hasMember($this)) { + $space = $this->provisionPersonalSpace(); + $this->forceFill(['current_space_id' => $space->id])->saveQuietly(); + } + + return $this->resolvedActiveSpace = $space; + } + /** @return HasMany */ public function automationRules(): HasMany { diff --git a/app/Services/Banking/TransactionSyncService.php b/app/Services/Banking/TransactionSyncService.php index ef05684f..6a7c85f6 100644 --- a/app/Services/Banking/TransactionSyncService.php +++ b/app/Services/Banking/TransactionSyncService.php @@ -181,6 +181,7 @@ class TransactionSyncService try { $account->transactions()->create([ 'user_id' => $account->user_id, + 'space_id' => $account->space_id, 'description' => $formatted['description'], 'description_iv' => null, 'original_description' => $formatted['original_description'], diff --git a/app/Services/Banking/WiseTransactionSyncService.php b/app/Services/Banking/WiseTransactionSyncService.php index a04c449d..a280ccc3 100644 --- a/app/Services/Banking/WiseTransactionSyncService.php +++ b/app/Services/Banking/WiseTransactionSyncService.php @@ -166,6 +166,7 @@ class WiseTransactionSyncService try { $account->transactions()->create([ 'user_id' => $account->user_id, + 'space_id' => $account->space_id, 'description' => $parsed['description'], 'description_iv' => null, 'original_description' => $parsed['description'], diff --git a/database/factories/SpaceFactory.php b/database/factories/SpaceFactory.php new file mode 100644 index 00000000..b48b372e --- /dev/null +++ b/database/factories/SpaceFactory.php @@ -0,0 +1,33 @@ + + */ +class SpaceFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'owner_id' => User::factory(), + 'name' => fake()->company(), + 'personal' => false, + ]; + } + + public function personal(): static + { + return $this->state(fn (array $attributes): array => [ + 'personal' => true, + 'name' => 'Personal', + ]); + } +} diff --git a/database/migrations/2026_07_06_120000_create_spaces_table.php b/database/migrations/2026_07_06_120000_create_spaces_table.php new file mode 100644 index 00000000..32694dc6 --- /dev/null +++ b/database/migrations/2026_07_06_120000_create_spaces_table.php @@ -0,0 +1,26 @@ +uuid('id')->primary(); + $table->foreignUuid('owner_id')->constrained('users')->cascadeOnDelete(); + $table->string('name'); + $table->boolean('personal')->default(false); + $table->timestamps(); + + $table->index('owner_id'); + }); + } + + public function down(): void + { + Schema::dropIfExists('spaces'); + } +}; diff --git a/database/migrations/2026_07_06_120001_create_space_user_table.php b/database/migrations/2026_07_06_120001_create_space_user_table.php new file mode 100644 index 00000000..1dc3907d --- /dev/null +++ b/database/migrations/2026_07_06_120001_create_space_user_table.php @@ -0,0 +1,26 @@ +uuid('id')->primary(); + $table->foreignUuid('space_id')->constrained('spaces')->cascadeOnDelete(); + $table->foreignUuid('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('role')->default('member'); + $table->timestamps(); + + $table->unique(['space_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('space_user'); + } +}; diff --git a/database/migrations/2026_07_06_120002_create_space_invitations_table.php b/database/migrations/2026_07_06_120002_create_space_invitations_table.php new file mode 100644 index 00000000..108cf32f --- /dev/null +++ b/database/migrations/2026_07_06_120002_create_space_invitations_table.php @@ -0,0 +1,30 @@ +uuid('id')->primary(); + $table->foreignUuid('space_id')->constrained('spaces')->cascadeOnDelete(); + $table->foreignUuid('invited_by_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('email'); + $table->string('role')->default('member'); + $table->string('token', 64)->unique(); + $table->timestamp('expires_at')->nullable(); + $table->timestamp('accepted_at')->nullable(); + $table->timestamps(); + + $table->index(['space_id', 'email']); + }); + } + + public function down(): void + { + Schema::dropIfExists('space_invitations'); + } +}; diff --git a/database/migrations/2026_07_06_120003_add_current_space_id_to_users_table.php b/database/migrations/2026_07_06_120003_add_current_space_id_to_users_table.php new file mode 100644 index 00000000..1e1d01f6 --- /dev/null +++ b/database/migrations/2026_07_06_120003_add_current_space_id_to_users_table.php @@ -0,0 +1,22 @@ +foreignUuid('current_space_id')->nullable()->constrained('spaces')->nullOnDelete(); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('current_space_id'); + }); + } +}; diff --git a/database/migrations/2026_07_06_120004_add_space_id_to_owned_tables.php b/database/migrations/2026_07_06_120004_add_space_id_to_owned_tables.php new file mode 100644 index 00000000..e1367c46 --- /dev/null +++ b/database/migrations/2026_07_06_120004_add_space_id_to_owned_tables.php @@ -0,0 +1,49 @@ + + */ + private array $tables = [ + 'accounts', + 'banking_connections', + 'transactions', + 'categories', + 'labels', + 'budgets', + 'automation_rules', + 'saved_filters', + ]; + + public function up(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->uuid('space_id')->nullable()->index(); + }); + } + } + + public function down(): void + { + foreach ($this->tables as $table) { + Schema::table($table, function (Blueprint $blueprint) { + $blueprint->dropColumn('space_id'); + }); + } + } +}; diff --git a/database/migrations/2026_07_06_120005_backfill_spaces.php b/database/migrations/2026_07_06_120005_backfill_spaces.php new file mode 100644 index 00000000..a1333236 --- /dev/null +++ b/database/migrations/2026_07_06_120005_backfill_spaces.php @@ -0,0 +1,24 @@ +create(); + + expect($user->personalSpace)->not->toBeNull() + ->and($user->personalSpace->personal)->toBeTrue() + ->and($user->current_space_id)->toBe($user->personalSpace->id) + ->and($user->activeSpace()->id)->toBe($user->personalSpace->id); +}); + +it('stamps owned rows with the owner\'s current space by default', function () { + $user = User::factory()->create(); + + $account = Account::factory()->for($user)->create(); + + expect($account->space_id)->toBe($user->current_space_id); +}); + +it('inherits a transaction\'s space from its account, not the acting user', 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]); + + expect($transaction->space_id)->toBe($account->space_id); +}); + +it('seeds default categories into the given space', function () { + $user = User::factory()->create(); + + app(CreateDefaultCategories::class)->handle($user); + + expect(Category::where('space_id', $user->current_space_id)->count())->toBeGreaterThan(0) + ->and(Category::whereNull('space_id')->count())->toBe(0); +}); + +it('resolves active space back to personal when the pointer is stale', function () { + $user = User::factory()->create(); + $foreign = Space::factory()->create(); + + // Point the user at a space they cannot access. + $user->forceFill(['current_space_id' => $foreign->id])->saveQuietly(); + $user->refresh(); + + expect($user->activeSpace()->personal)->toBeTrue() + ->and($user->fresh()->current_space_id)->toBe($user->personalSpace->id); +}); + +it('backfills legacy rows that predate spaces', function () { + $user = User::factory()->create(); + $account = Account::factory()->for($user)->create(); + + // Simulate a pre-migration state: no space, dangling pointers. + Account::query()->where('id', $account->id)->update(['space_id' => null]); + User::query()->where('id', $user->id)->update(['current_space_id' => null]); + Space::query()->where('owner_id', $user->id)->delete(); + + $this->artisan('spaces:backfill')->assertSuccessful(); + + $user->refresh(); + expect($user->current_space_id)->not->toBeNull() + ->and($account->fresh()->space_id)->toBe($user->current_space_id); +}); + +it('backfills soft-deleted users and their rows', function () { + $user = User::factory()->create(); + $account = Account::factory()->for($user)->create(); + + // Simulate a pre-migration, soft-deleted user with dangling data. + Account::query()->where('id', $account->id)->update(['space_id' => null]); + User::query()->where('id', $user->id)->update(['current_space_id' => null]); + Space::query()->where('owner_id', $user->id)->delete(); + $user->delete(); + + $this->artisan('spaces:backfill')->assertSuccessful(); + + $trashed = User::withTrashed()->find($user->id); + expect($trashed->trashed())->toBeTrue() + ->and($trashed->current_space_id)->not->toBeNull() + ->and($account->fresh()->space_id)->toBe($trashed->current_space_id); +});