From 7df967c487575e649cccea6a4e20f2343eb71e16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vi=CC=81ctor=20Falco=CC=81n?= Date: Mon, 6 Jul 2026 17:41:33 +0200 Subject: [PATCH] feat(spaces): invitations, members and owner-scoped feature gates Add the collaboration layer for Business spaces: owners invite people by email (seat-capped per subscription), invitees accept via a tokened link to join as members, and owners can remove members while members can leave. Paid-feature gates (subscription middleware, open-banking) now resolve against the active space's owner, so a member of a Business space gets its features without a plan of their own. Members management and invitations live on the spaces settings page. --- .../Concerns/HandlesSubscriptionGate.php | 2 +- app/Http/Controllers/SpaceController.php | 69 +++++- .../Controllers/SpaceInvitationController.php | 91 ++++++++ .../Middleware/EnsureUserIsSubscribed.php | 7 +- .../Requests/StoreSpaceInvitationRequest.php | 31 +++ app/Mail/SpaceInvitationMail.php | 59 ++++++ app/Models/User.php | 41 ++++ config/spaces.php | 27 +++ ...6_07_06_120001_create_space_user_table.php | 3 +- lang/es.json | 33 ++- .../js/components/spaces/manage-members.tsx | 197 ++++++++++++++++++ resources/js/pages/settings/spaces.tsx | 35 ++++ .../views/mail/space-invitation.blade.php | 22 ++ routes/settings.php | 7 + tests/Feature/Spaces/SpaceInvitationTest.php | 173 +++++++++++++++ 15 files changed, 787 insertions(+), 10 deletions(-) create mode 100644 app/Http/Controllers/SpaceInvitationController.php create mode 100644 app/Http/Requests/StoreSpaceInvitationRequest.php create mode 100644 app/Mail/SpaceInvitationMail.php create mode 100644 config/spaces.php create mode 100644 resources/js/components/spaces/manage-members.tsx create mode 100644 resources/views/mail/space-invitation.blade.php create mode 100644 tests/Feature/Spaces/SpaceInvitationTest.php diff --git a/app/Http/Controllers/OpenBanking/Concerns/HandlesSubscriptionGate.php b/app/Http/Controllers/OpenBanking/Concerns/HandlesSubscriptionGate.php index 99a807c6..de6e2370 100644 --- a/app/Http/Controllers/OpenBanking/Concerns/HandlesSubscriptionGate.php +++ b/app/Http/Controllers/OpenBanking/Concerns/HandlesSubscriptionGate.php @@ -19,7 +19,7 @@ trait HandlesSubscriptionGate return false; } - return ! $user->canUseFeature(PlanFeature::ConnectedAccounts); + return ! $user->canUseFeatureInSpace(PlanFeature::ConnectedAccounts, $user->activeSpace()); } private function subscribeJsonResponse(): JsonResponse diff --git a/app/Http/Controllers/SpaceController.php b/app/Http/Controllers/SpaceController.php index 02505a57..e0517448 100644 --- a/app/Http/Controllers/SpaceController.php +++ b/app/Http/Controllers/SpaceController.php @@ -7,6 +7,7 @@ use App\Features\Spaces; use App\Http\Requests\StoreSpaceRequest; use App\Http\Requests\UpdateSpaceRequest; use App\Models\Space; +use App\Models\User; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -17,12 +18,39 @@ 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. + * The spaces management settings page. The space list is shared globally + * (see HandleInertiaRequests); here we add the members and pending + * invitations of the spaces the current user owns, plus the seat usage. */ - public function index(): Response + public function index(Request $request): Response { - return Inertia::render('settings/spaces'); + $user = $request->user(); + + $managed = $user->ownedSpaces() + ->where('personal', false) + ->with(['members:id,name,email', 'invitations' => fn ($query) => $query->whereNull('accepted_at')]) + ->get() + ->map(fn (Space $space): array => [ + 'id' => $space->id, + 'name' => $space->name, + 'members' => $space->members->map(fn (User $member): array => [ + 'id' => $member->id, + 'name' => $member->name, + 'email' => $member->email, + ])->all(), + 'invitations' => $space->invitations + ->reject(fn ($invitation): bool => $invitation->isExpired()) + ->map(fn ($invitation): array => [ + 'id' => $invitation->id, + 'email' => $invitation->email, + ])->values()->all(), + ]); + + return Inertia::render('settings/spaces', [ + 'managedSpaces' => $managed, + 'seatsInUse' => $user->seatsInUse(), + 'maxSeats' => (int) config('spaces.max_seats'), + ]); } /** @@ -101,4 +129,37 @@ class SpaceController extends Controller return back(); } + + /** + * Remove a member from a space (owner only). Their pointer self-heals back to + * their personal space on their next request. + */ + public function removeMember(Request $request, Space $space, User $member): RedirectResponse + { + abort_unless($space->owner_id === $request->user()->id, 403); + + $space->members()->detach($member->id); + + return back()->with('success', __('Member removed.')); + } + + /** + * Leave a space you were invited to (members only; an owner cannot leave + * their own space). + */ + public function leave(Request $request, Space $space): RedirectResponse + { + $user = $request->user(); + + abort_if($space->owner_id === $user->id, 403); + abort_unless($space->members()->whereKey($user->id)->exists(), 403); + + $space->members()->detach($user->id); + + if ($user->current_space_id === $space->id) { + $user->forceFill(['current_space_id' => $user->personalSpace->id])->save(); + } + + return back()->with('success', __('You\'ve left :space.', ['space' => $space->name])); + } } diff --git a/app/Http/Controllers/SpaceInvitationController.php b/app/Http/Controllers/SpaceInvitationController.php new file mode 100644 index 00000000..6cd63f83 --- /dev/null +++ b/app/Http/Controllers/SpaceInvitationController.php @@ -0,0 +1,91 @@ +user(); + $email = strtolower($request->validated('email')); + + if ($space->members()->where('email', $email)->exists() || strcasecmp($email, $user->email) === 0) { + return back()->with('error', __('That person is already a member of this space.')); + } + + if ($user->seatsInUse() >= config('spaces.max_seats')) { + return back()->with('error', __('You\'ve reached the :count-user limit for your plan.', [ + 'count' => (int) config('spaces.max_seats'), + ])); + } + + $invitation = $space->invitations()->updateOrCreate( + ['email' => $email, 'accepted_at' => null], + [ + 'invited_by_id' => $user->id, + 'role' => 'member', + 'token' => Str::random(48), + 'expires_at' => now()->addDays((int) config('spaces.invitation_expiry_days')), + ], + ); + + Mail::to($email)->send(new SpaceInvitationMail($invitation)); + + return back()->with('success', __('Invitation sent to :email.', ['email' => $email])); + } + + /** + * Accept an invitation. The route sits behind auth, so a logged-out invitee + * is sent to log in (or register with the same email) and returns here. + */ + public function accept(Request $request, string $token): RedirectResponse + { + $invitation = SpaceInvitation::query()->where('token', $token)->first(); + + if ($invitation === null || $invitation->isAccepted() || $invitation->isExpired()) { + return redirect()->route('dashboard')->with('error', __('This invitation is no longer valid.')); + } + + $user = $request->user(); + + if (strcasecmp($user->email, $invitation->email) !== 0) { + return redirect()->route('dashboard')->with('error', __('This invitation was sent to a different email address.')); + } + + $invitation->space->members()->syncWithoutDetaching([ + $user->id => ['role' => $invitation->role], + ]); + + $invitation->forceFill(['accepted_at' => now()])->save(); + $user->forceFill(['current_space_id' => $invitation->space_id])->save(); + + return redirect()->route('dashboard')->with('success', __('You\'ve joined :space.', [ + 'space' => $invitation->space->name, + ])); + } + + /** + * Revoke a pending invitation (owner only). + */ + public function destroy(Request $request, Space $space, SpaceInvitation $invitation): RedirectResponse + { + abort_unless($space->owner_id === $request->user()->id && $invitation->space_id === $space->id, 403); + + $invitation->delete(); + + return back()->with('success', __('Invitation revoked.')); + } +} diff --git a/app/Http/Middleware/EnsureUserIsSubscribed.php b/app/Http/Middleware/EnsureUserIsSubscribed.php index e3d308ba..681fdd14 100644 --- a/app/Http/Middleware/EnsureUserIsSubscribed.php +++ b/app/Http/Middleware/EnsureUserIsSubscribed.php @@ -18,12 +18,15 @@ class EnsureUserIsSubscribed } $user = $request->user(); + $space = $user?->activeSpace(); - if ($user?->hasProPlan()) { + // The active space's owner plan governs access: a member of a Business + // space gets in even without a plan of their own. + if ($space?->owner?->hasProPlan()) { return $next($request); } - if ($user && ! $user->bankingConnections()->exists() && ! $user->hasActiveAiConsent()) { + if ($space && ! $space->bankingConnections()->exists() && ! $user->hasActiveAiConsent()) { if (! $user->hasSeenPaywall()) { return redirect()->route('subscribe'); } diff --git a/app/Http/Requests/StoreSpaceInvitationRequest.php b/app/Http/Requests/StoreSpaceInvitationRequest.php new file mode 100644 index 00000000..35ca21f1 --- /dev/null +++ b/app/Http/Requests/StoreSpaceInvitationRequest.php @@ -0,0 +1,31 @@ +route('space'); + + return $space instanceof Space + && ! $space->personal + && $space->owner_id === $this->user()?->id + && Feature::for($this->user())->active(Spaces::class); + } + + /** + * @return array + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + ]; + } +} diff --git a/app/Mail/SpaceInvitationMail.php b/app/Mail/SpaceInvitationMail.php new file mode 100644 index 00000000..50fab075 --- /dev/null +++ b/app/Mail/SpaceInvitationMail.php @@ -0,0 +1,59 @@ + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct(public SpaceInvitation $invitation) + { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: __(':inviter invited you to :space on Whisper Money', [ + 'inviter' => $this->invitation->invitedBy?->name ?? __('Someone'), + 'space' => $this->invitation->space->name, + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.space-invitation', + with: [ + 'invitation' => $this->invitation, + 'spaceName' => $this->invitation->space->name, + 'inviterName' => $this->invitation->invitedBy?->name ?? __('A Whisper Money user'), + 'acceptUrl' => route('spaces.invitations.accept', $this->invitation->token), + ], + ); + } + + /** + * @return array + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index c9c236ed..6ed2b405 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -247,6 +247,47 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma return $this->resolvedActiveSpace = $space; } + /** + * Whether a paid feature is available while acting in the given space. The + * space owner's plan governs the space, so a member of a Business space gets + * its paid features without a plan of their own. + */ + public function canUseFeatureInSpace(PlanFeature $feature, ?Space $space = null): bool + { + if (! $feature->requiresProPlan()) { + return true; + } + + $space ??= $this->activeSpace(); + $owner = $space->owner_id === $this->id ? $this : $space->owner; + + return $owner->hasProPlan(); + } + + /** + * Unique users counted against the owner's subscription: the owner, every + * member across their spaces, and still-pending invitations. This is the + * seat count the Business plan caps. + */ + public function seatsInUse(): int + { + $ownedSpaceIds = $this->ownedSpaces()->pluck('id'); + + $members = static::query() + ->whereHas('memberSpaces', fn (Builder $query) => $query->whereIn('spaces.id', $ownedSpaceIds)) + ->get(['id', 'email']); + + $pendingEmails = SpaceInvitation::query() + ->whereIn('space_id', $ownedSpaceIds) + ->whereNull('accepted_at') + ->where(fn (Builder $query) => $query->whereNull('expires_at')->orWhere('expires_at', '>', now())) + ->pluck('email') + ->unique() + ->reject(fn (string $email): bool => $members->pluck('email')->contains($email)); + + return 1 + $members->count() + $pendingEmails->count(); + } + /** @return HasMany */ public function automationRules(): HasMany { diff --git a/config/spaces.php b/config/spaces.php new file mode 100644 index 00000000..fabffed3 --- /dev/null +++ b/config/spaces.php @@ -0,0 +1,27 @@ + (int) env('SPACES_MAX_SEATS', 5), + + /* + |-------------------------------------------------------------------------- + | Invitation expiry + |-------------------------------------------------------------------------- + */ + + 'invitation_expiry_days' => (int) env('SPACES_INVITATION_EXPIRY_DAYS', 14), + +]; 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 index 1dc3907d..e0fd6f0e 100644 --- a/database/migrations/2026_07_06_120001_create_space_user_table.php +++ b/database/migrations/2026_07_06_120001_create_space_user_table.php @@ -9,13 +9,12 @@ return new class extends Migration public function up(): void { Schema::create('space_user', function (Blueprint $table) { - $table->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']); + $table->primary(['space_id', 'user_id']); }); } diff --git a/lang/es.json b/lang/es.json index 3f03695b..1dfa2741 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2229,5 +2229,36 @@ "Switch": "Cambiar", "Rename": "Renombrar", "Rename space": "Renombrar espacio", - "Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo." + "Remove or move this space's accounts before deleting it.": "Elimina o mueve las cuentas de este espacio antes de borrarlo.", + "Members": "Miembros", + "Invite people to your spaces. They can see and work with everything in the spaces they join.": "Invita a personas a tus espacios. Podrán ver y trabajar con todo lo que haya en los espacios a los que se unan.", + ":used of :max seats used": ":used de :max asientos usados", + "Pending": "Pendiente", + "Revoke": "Revocar", + "No members yet.": "Aún no hay miembros.", + "name@example.com": "nombre@ejemplo.com", + "Invite": "Invitar", + "You've reached your plan's seat limit.": "Has alcanzado el límite de asientos de tu plan.", + "Leave": "Abandonar", + "That person is already a member of this space.": "Esa persona ya es miembro de este espacio.", + "You've reached the :count-user limit for your plan.": "Has alcanzado el límite de :count usuarios de tu plan.", + "Invitation sent to :email.": "Invitación enviada a :email.", + "This invitation is no longer valid.": "Esta invitación ya no es válida.", + "This invitation was sent to a different email address.": "Esta invitación se envió a una dirección de correo distinta.", + "You've joined :space.": "Te has unido a :space.", + "Invitation revoked.": "Invitación revocada.", + "Member removed.": "Miembro eliminado.", + "You've left :space.": "Has abandonado :space.", + ":inviter invited you to :space on Whisper Money": ":inviter te ha invitado a :space en Whisper Money", + "Someone": "Alguien", + "A Whisper Money user": "Un usuario de Whisper Money", + "You've been invited to :space": "Te han invitado a :space", + ":inviter has invited you to collaborate in the \":space\" space on Whisper Money.": ":inviter te ha invitado a colaborar en el espacio \":space\" en Whisper Money.", + "Joining gives you access to that space's accounts, transactions, budgets and reports.": "Unirte te da acceso a las cuentas, transacciones, presupuestos e informes de ese espacio.", + "Accept invitation": "Aceptar invitación", + "If you don't have a Whisper Money account yet, you'll be able to create one first — just use this same email address.": "Si aún no tienes una cuenta de Whisper Money, podrás crear una primero: usa esta misma dirección de correo.", + "If you weren't expecting this invitation, you can safely ignore this email.": "Si no esperabas esta invitación, puedes ignorar este correo con tranquilidad.", + "The Whisper Money team": "El equipo de Whisper Money", + "If you're having trouble clicking the \"Accept invitation\" button, copy and paste the URL below into your web browser:": "Si tienes problemas para hacer clic en el botón \"Aceptar invitación\", copia y pega la URL de abajo en tu navegador:", + "Remove": "Eliminar" } diff --git a/resources/js/components/spaces/manage-members.tsx b/resources/js/components/spaces/manage-members.tsx new file mode 100644 index 00000000..f71d142e --- /dev/null +++ b/resources/js/components/spaces/manage-members.tsx @@ -0,0 +1,197 @@ +import { removeMember } from '@/actions/App/Http/Controllers/SpaceController'; +import { + store as inviteMember, + destroy as revokeInvitation, +} from '@/actions/App/Http/Controllers/SpaceInvitationController'; +import HeadingSmall from '@/components/heading-small'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { __ } from '@/utils/i18n'; +import { router } from '@inertiajs/react'; +import { Mail, UserMinus } from 'lucide-react'; +import { FormEvent, useState } from 'react'; + +interface Member { + id: string; + name: string; + email: string; +} + +interface PendingInvitation { + id: string; + email: string; +} + +export interface ManagedSpace { + id: string; + name: string; + members: Member[]; + invitations: PendingInvitation[]; +} + +export function ManageMembers({ + managedSpaces, + seatsInUse, + maxSeats, +}: { + managedSpaces: ManagedSpace[]; + seatsInUse: number; + maxSeats: number; +}) { + if (managedSpaces.length === 0) { + return null; + } + + const atCapacity = seatsInUse >= maxSeats; + + return ( +
+ + +

+ {__(':used of :max seats used', { + used: String(seatsInUse), + max: String(maxSeats), + })} +

+ + {managedSpaces.map((space) => ( + + ))} +
+ ); +} + +function SpaceMemberCard({ + space, + atCapacity, +}: { + space: ManagedSpace; + atCapacity: boolean; +}) { + const [email, setEmail] = useState(''); + const [processing, setProcessing] = useState(false); + + const invite = (event: FormEvent) => { + event.preventDefault(); + setProcessing(true); + router.post( + inviteMember(space.id).url, + { email }, + { + preserveScroll: true, + onFinish: () => setProcessing(false), + onSuccess: () => setEmail(''), + }, + ); + }; + + return ( +
+
{space.name}
+ +
    + {space.members.map((member) => ( +
  • + + {member.name}{' '} + + {member.email} + + + +
  • + ))} + + {space.invitations.map((invitation) => ( +
  • + + + {invitation.email} · {__('Pending')} + + +
  • + ))} + + {space.members.length === 0 && + space.invitations.length === 0 && ( +
  • + {__('No members yet.')} +
  • + )} +
+ +
+ setEmail(event.target.value)} + placeholder={__('name@example.com')} + disabled={atCapacity} + required + /> + +
+ {atCapacity && ( +

+ {__("You've reached your plan's seat limit.")} +

+ )} +
+ ); +} diff --git a/resources/js/pages/settings/spaces.tsx b/resources/js/pages/settings/spaces.tsx index 7200a036..954be43e 100644 --- a/resources/js/pages/settings/spaces.tsx +++ b/resources/js/pages/settings/spaces.tsx @@ -1,11 +1,16 @@ import { destroy as destroySpace, + leave as leaveSpace, 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 { + ManageMembers, + type ManagedSpace, +} from '@/components/spaces/manage-members'; import { Button } from '@/components/ui/button'; import { Dialog, @@ -34,6 +39,11 @@ const breadcrumbs: BreadcrumbItem[] = [ export default function Spaces() { const { spaces, currentSpace } = usePage().props; + const { managedSpaces, seatsInUse, maxSeats } = usePage<{ + managedSpaces: ManagedSpace[]; + seatsInUse: number; + maxSeats: number; + }>().props; const [createOpen, setCreateOpen] = useState(false); const [renaming, setRenaming] = useState(null); const [name, setName] = useState(''); @@ -176,11 +186,36 @@ export default function Spaces() { )} + {!space.personal && !space.is_owner && ( + + )} ); })} + + diff --git a/resources/views/mail/space-invitation.blade.php b/resources/views/mail/space-invitation.blade.php new file mode 100644 index 00000000..21dd4fed --- /dev/null +++ b/resources/views/mail/space-invitation.blade.php @@ -0,0 +1,22 @@ + +# {{ __('You\'ve been invited to :space', ['space' => $spaceName]) }} + +{{ __(':inviter has invited you to collaborate in the ":space" space on Whisper Money.', ['inviter' => $inviterName, 'space' => $spaceName]) }} + +{{ __('Joining gives you access to that space\'s accounts, transactions, budgets and reports.') }} + + +{{ __('Accept invitation') }} + + +{{ __('If you don\'t have a Whisper Money account yet, you\'ll be able to create one first — just use this same email address.') }} + +{{ __('If you weren\'t expecting this invitation, you can safely ignore this email.') }} + +{{ __('Best,') }}
+{{ __('The Whisper Money team') }} + + +{{ __('If you\'re having trouble clicking the "Accept invitation" button, copy and paste the URL below into your web browser:') }} [{{ $acceptUrl }}]({{ $acceptUrl }}) + +
diff --git a/routes/settings.php b/routes/settings.php index 241964b6..4e29a37e 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,6 +16,7 @@ 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\SpaceInvitationController; use App\Http\Controllers\SubscriptionController; use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; @@ -66,6 +67,12 @@ Route::middleware('auth')->group(function () { 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::post('spaces/{space}/leave', [SpaceController::class, 'leave'])->name('spaces.leave'); + Route::delete('spaces/{space}/members/{member}', [SpaceController::class, 'removeMember'])->name('spaces.members.destroy'); + + Route::post('spaces/{space}/invitations', [SpaceInvitationController::class, 'store'])->name('spaces.invitations.store'); + Route::delete('spaces/{space}/invitations/{invitation}', [SpaceInvitationController::class, 'destroy'])->name('spaces.invitations.destroy'); + Route::get('spaces/invitations/{token}/accept', [SpaceInvitationController::class, 'accept'])->name('spaces.invitations.accept'); Route::get('settings/automation-rules', [AutomationRuleController::class, 'index'])->name('automation-rules.index'); Route::post('settings/automation-rules', [AutomationRuleController::class, 'store'])->name('automation-rules.store'); diff --git a/tests/Feature/Spaces/SpaceInvitationTest.php b/tests/Feature/Spaces/SpaceInvitationTest.php new file mode 100644 index 00000000..e397459e --- /dev/null +++ b/tests/Feature/Spaces/SpaceInvitationTest.php @@ -0,0 +1,173 @@ +onboarded()->create(); + Feature::for($owner)->activate(Spaces::class); + $space = $owner->ownedSpaces()->create(['name' => 'Acme', 'personal' => false]); + + return [$owner, $space]; +} + +it('sends an invitation and records it as pending', function () { + Mail::fake(); + [$owner, $space] = ownerWithBusinessSpace(); + + $this->actingAs($owner) + ->post("/spaces/{$space->id}/invitations", ['email' => 'partner@example.com']) + ->assertRedirect(); + + Mail::assertQueued(SpaceInvitationMail::class); + expect($space->invitations()->where('email', 'partner@example.com')->whereNull('accepted_at')->exists())->toBeTrue(); +}); + +it('enforces the seat cap', function () { + Mail::fake(); + [$owner, $space] = ownerWithBusinessSpace(); + + config(['spaces.max_seats' => 2]); // owner + 1 + + $this->actingAs($owner) + ->post("/spaces/{$space->id}/invitations", ['email' => 'first@example.com']) + ->assertRedirect(); + + // Second invitation would be the 3rd seat (owner + 2) — over the cap of 2. + $this->actingAs($owner) + ->post("/spaces/{$space->id}/invitations", ['email' => 'second@example.com']) + ->assertSessionHas('error'); + + expect($space->invitations()->count())->toBe(1); +}); + +it('rejects inviting someone who cannot be invited', function () { + Mail::fake(); + [$owner, $space] = ownerWithBusinessSpace(); + + $this->actingAs($owner) + ->post("/spaces/{$space->id}/invitations", ['email' => $owner->email]) + ->assertSessionHas('error'); + + expect($space->invitations()->count())->toBe(0); +}); + +it('only lets the space owner invite', function () { + [$owner, $space] = ownerWithBusinessSpace(); + $stranger = User::factory()->create(); + + $this->actingAs($stranger) + ->post("/spaces/{$space->id}/invitations", ['email' => 'x@example.com']) + ->assertForbidden(); +}); + +it('lets an invitee with a matching email accept and join', function () { + [$owner, $space] = ownerWithBusinessSpace(); + $invitee = User::factory()->create(['email' => 'partner@example.com']); + + $invitation = SpaceInvitation::create([ + 'space_id' => $space->id, + 'invited_by_id' => $owner->id, + 'email' => 'partner@example.com', + 'role' => 'member', + 'token' => 'tok_valid', + 'expires_at' => now()->addDays(7), + ]); + + $this->actingAs($invitee)->get('/spaces/invitations/tok_valid/accept') + ->assertRedirect(route('dashboard')); + + expect($space->fresh()->hasMember($invitee))->toBeTrue() + ->and($invitation->fresh()->accepted_at)->not->toBeNull() + ->and($invitee->fresh()->current_space_id)->toBe($space->id); +}); + +it('refuses to accept an invitation for a different email', function () { + [$owner, $space] = ownerWithBusinessSpace(); + $other = User::factory()->create(['email' => 'someone-else@example.com']); + + SpaceInvitation::create([ + 'space_id' => $space->id, + 'email' => 'partner@example.com', + 'role' => 'member', + 'token' => 'tok_mismatch', + 'expires_at' => now()->addDays(7), + ]); + + $this->actingAs($other)->get('/spaces/invitations/tok_mismatch/accept') + ->assertRedirect(route('dashboard')); + + expect($space->fresh()->hasMember($other))->toBeFalse(); +}); + +it('rejects an expired invitation token', function () { + [$owner, $space] = ownerWithBusinessSpace(); + $invitee = User::factory()->create(['email' => 'late@example.com']); + + SpaceInvitation::create([ + 'space_id' => $space->id, + 'email' => 'late@example.com', + 'role' => 'member', + 'token' => 'tok_expired', + 'expires_at' => now()->subDay(), + ]); + + $this->actingAs($invitee)->get('/spaces/invitations/tok_expired/accept') + ->assertRedirect(route('dashboard')); + + expect($space->fresh()->hasMember($invitee))->toBeFalse(); +}); + +it('lets a member see the owner\'s space data once they join', function () { + [$owner, $space] = ownerWithBusinessSpace(); + Account::factory()->for($owner)->create(['space_id' => $space->id, 'name' => 'Acme Bank']); + + $member = User::factory()->onboarded()->create(); + $space->members()->attach($member->id, ['role' => 'member']); + $member->forceFill(['current_space_id' => $space->id])->save(); + + $this->actingAs($member)->get('/accounts') + ->assertInertia(fn (AssertableInertia $page) => $page + ->has('accounts', 1) + ->where('accounts.0.name', 'Acme Bank')); +}); + +it('removes a member and lets a member leave', function () { + [$owner, $space] = ownerWithBusinessSpace(); + $member = User::factory()->create(); + $space->members()->attach($member->id, ['role' => 'member']); + + // Owner removes the member. + $this->actingAs($owner)->delete("/spaces/{$space->id}/members/{$member->id}") + ->assertRedirect(); + expect($space->fresh()->hasMember($member))->toBeFalse(); + + // A member can leave on their own. + $space->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member)->post("/spaces/{$space->id}/leave") + ->assertRedirect(); + expect($space->fresh()->hasMember($member))->toBeFalse(); +}); + +it('counts owner, members and pending invitations as seats', function () { + [$owner, $space] = ownerWithBusinessSpace(); + $member = User::factory()->create(); + $space->members()->attach($member->id, ['role' => 'member']); + SpaceInvitation::create([ + 'space_id' => $space->id, + 'email' => 'pending@example.com', + 'role' => 'member', + 'token' => 'tok_pending', + 'expires_at' => now()->addDays(7), + ]); + + // owner + 1 member + 1 pending invitation = 3 + expect($owner->seatsInUse())->toBe(3); +});