diff --git a/app/Livewire/ConfirmCloseAccount.php b/app/Livewire/ConfirmCloseAccount.php index 79a4c33..7100c13 100644 --- a/app/Livewire/ConfirmCloseAccount.php +++ b/app/Livewire/ConfirmCloseAccount.php @@ -40,8 +40,10 @@ class ConfirmCloseAccount extends ModalComponent private function hasActivePackage(Customer $customer): bool { + // Only genuinely-live packages block closure — a failed/cancelled/ + // deprovisioned instance does not retain a service. return $customer->instances() - ->whereNotIn('status', ['cancelled', 'deprovisioned']) + ->whereIn('status', ['active', 'provisioning', 'cancellation_scheduled']) ->exists(); } diff --git a/app/Livewire/Users.php b/app/Livewire/Users.php index dc1bea3..ccbf91c 100644 --- a/app/Livewire/Users.php +++ b/app/Livewire/Users.php @@ -93,36 +93,57 @@ class Users extends Component if (! in_array($role, Seat::ROLES, true)) { return; } - $seat = $this->seat($uuid); - if ($seat === null) { + $customer = $this->customer(); + if ($customer === null) { return; } - // Never leave the account without an owner. - if ($seat->role === 'owner' && $role !== 'owner' && $this->ownerCount() <= 1) { + // Lock the customer so a concurrent owner change can't race past the guard. + $ok = DB::transaction(function () use ($customer, $uuid, $role) { + Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); + $seat = $customer->seats()->where('uuid', $uuid)->first(); + if ($seat === null) { + return true; + } + if ($seat->role === 'owner' && $role !== 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) { + return false; // would remove the last owner + } + $seat->update(['role' => $role]); + + return true; + }); + + if (! $ok) { $this->dispatch('notify', message: __('users.last_owner')); - - return; } - - $seat->update(['role' => $role]); } public function revoke(string $uuid): void { - $seat = $this->seat($uuid); - if ($seat === null) { + $customer = $this->customer(); + if ($customer === null) { return; } - if ($seat->role === 'owner' && $this->ownerCount() <= 1) { + $result = DB::transaction(function () use ($customer, $uuid) { + Customer::query()->whereKey($customer->id)->lockForUpdate()->first(); + $seat = $customer->seats()->where('uuid', $uuid)->first(); + if ($seat === null) { + return 'gone'; + } + if ($seat->role === 'owner' && $customer->seats()->where('role', 'owner')->count() <= 1) { + return 'last_owner'; + } + $seat->delete(); + + return 'ok'; + }); + + if ($result === 'last_owner') { $this->dispatch('notify', message: __('users.last_owner')); - - return; + } elseif ($result === 'ok') { + $this->dispatch('notify', message: __('users.revoked')); } - - $seat->delete(); - $this->dispatch('notify', message: __('users.revoked')); } public function resend(string $uuid): void