fix: seat limit + suspend guard per Codex; harden factory email uniqueness

- seat limit follows the active/cancelling package, not a newer inactive record
- suspend/reactivate toggle refuses closed accounts (terminal); closed shown in list
- factory emails use a large unique numeric space (safeEmail pool could collide
  late in a full suite run)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:53:15 +02:00
parent cbbb523f9c
commit 3bf0dfb548
9 changed files with 40 additions and 10 deletions

View File

@ -18,6 +18,12 @@ class Customers extends Component
return; return;
} }
// A closed account is terminal — the suspend/reactivate toggle must not
// resurrect it to active while closed_at is still set.
if ($customer->closed_at !== null || $customer->status === 'closed') {
return;
}
$customer->update([ $customer->update([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended', 'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]); ]);
@ -46,8 +52,13 @@ class Customers extends Component
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—', 'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale), 'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—', 'instance' => $instance->subdomain ?? '—',
'closed' => $c->closed_at !== null || $c->status === 'closed',
'suspended' => $c->status === 'suspended', 'suspended' => $c->status === 'suspended',
'status' => $c->status === 'suspended' ? 'suspended' : ($instance->status ?? $c->status ?? 'provisioning'), 'status' => match (true) {
$c->closed_at !== null || $c->status === 'closed' => 'closed',
$c->status === 'suspended' => 'suspended',
default => $instance->status ?? $c->status ?? 'provisioning',
},
]; ];
})->all(); })->all();

View File

@ -143,7 +143,10 @@ class Users extends Component
private function seatLimit(Customer $customer): int private function seatLimit(Customer $customer): int
{ {
$instance = $customer->instances()->latest('id')->first(); // The entitlement follows the active (or cancelling) package, not a newer
// failed/deprovisioned record.
$instance = $customer->instances()->whereIn('status', ['active', 'cancellation_scheduled'])->latest('id')->first()
?? $customer->instances()->latest('id')->first();
$plan = $instance->plan ?? 'start'; $plan = $instance->plan ?? 'start';
return (int) config("provisioning.plans.$plan.seats", 5); return (int) config("provisioning.plans.$plan.seats", 5);

View File

@ -14,7 +14,9 @@ class CustomerFactory extends Factory
{ {
return [ return [
'name' => $this->faker->company(), 'name' => $this->faker->company(),
'email' => $this->faker->unique()->safeEmail(), // Large unique space — safeEmail()'s pool can collide late in a full
// suite run (process-wide unique() state), tripping customers.email.
'email' => 'customer-'.$this->faker->unique()->numerify('########').'@example.test',
'locale' => 'de', 'locale' => 'de',
'status' => 'active', 'status' => 'active',
]; ];

View File

@ -15,7 +15,7 @@ class SeatFactory extends Factory
{ {
return [ return [
'customer_id' => Customer::factory(), 'customer_id' => Customer::factory(),
'email' => $this->faker->unique()->safeEmail(), 'email' => 'seat-'.$this->faker->unique()->numerify('########').'@example.test',
'name' => $this->faker->name(), 'name' => $this->faker->name(),
'role' => 'member', 'role' => 'member',
'status' => 'active', 'status' => 'active',

View File

@ -26,7 +26,7 @@ class UserFactory extends Factory
{ {
return [ return [
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => 'user-'.fake()->unique()->numerify('########').'@example.test',
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'remember_token' => Str::random(10), 'remember_token' => Str::random(10),

View File

@ -87,6 +87,7 @@ return [
'active' => 'Aktiv', 'active' => 'Aktiv',
'provisioning' => 'Bereitstellung', 'provisioning' => 'Bereitstellung',
'suspended' => 'Ausgesetzt', 'suspended' => 'Ausgesetzt',
'closed' => 'Geschlossen',
'warning' => 'Warnung', 'warning' => 'Warnung',
], ],

View File

@ -87,6 +87,7 @@ return [
'active' => 'Active', 'active' => 'Active',
'provisioning' => 'Provisioning', 'provisioning' => 'Provisioning',
'suspended' => 'Suspended', 'suspended' => 'Suspended',
'closed' => 'Closed',
'warning' => 'Warning', 'warning' => 'Warning',
], ],

View File

@ -29,11 +29,13 @@
<td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td> <td class="px-4 py-3"><x-ui.badge :status="$r['status']">{{ __('admin.status.'.$r['status']) }}</x-ui.badge></td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center justify-end gap-1.5"> <div class="flex items-center justify-end gap-1.5">
<button type="button" wire:click="toggleSuspend('{{ $r['uuid'] }}')" @unless ($r['closed'])
class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-semibold <button type="button" wire:click="toggleSuspend('{{ $r['uuid'] }}')"
{{ $r['suspended'] ? 'border-success-border text-success hover:bg-success-bg' : 'border-line text-muted hover:border-warning hover:text-warning' }}"> class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-semibold
{{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }} {{ $r['suspended'] ? 'border-success-border text-success hover:bg-success-bg' : 'border-line text-muted hover:border-warning hover:text-warning' }}">
</button> {{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }}
</button>
@endunless
<form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline"> <form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline">
@csrf @csrf
<button type="submit" <button type="submit"

View File

@ -84,6 +84,16 @@ it('suspends and reactivates a customer', function () {
expect($customer->fresh()->status)->toBe('active'); expect($customer->fresh()->status)->toBe('active');
}); });
it('does not let the suspend toggle resurrect a closed customer', function () {
$customer = \App\Models\Customer::factory()->create(['status' => 'closed', 'closed_at' => now()]);
Livewire::actingAs(admin())->test(\App\Livewire\Admin\Customers::class)
->call('toggleSuspend', $customer->uuid);
$customer->refresh();
expect($customer->status)->toBe('closed')->and($customer->closed_at)->not->toBeNull();
});
it('toggles a datacenter active flag', function () { it('toggles a datacenter active flag', function () {
$dc = Datacenter::factory()->create(['active' => true]); $dc = Datacenter::factory()->create(['active' => true]);