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;
}
// 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([
'status' => $customer->status === 'suspended' ? 'active' : 'suspended',
]);
@ -46,8 +52,13 @@ class Customers extends Component
'plan' => $planKey !== null ? __('billing.plan.'.$planKey) : '—',
'mrr' => Number::currency($priceCents / 100, in: 'EUR', locale: $locale),
'instance' => $instance->subdomain ?? '—',
'closed' => $c->closed_at !== null || $c->status === 'closed',
'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();

View File

@ -143,7 +143,10 @@ class Users extends Component
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';
return (int) config("provisioning.plans.$plan.seats", 5);

View File

@ -14,7 +14,9 @@ class CustomerFactory extends Factory
{
return [
'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',
'status' => 'active',
];

View File

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

View File

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

View File

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

View File

@ -87,6 +87,7 @@ return [
'active' => 'Active',
'provisioning' => 'Provisioning',
'suspended' => 'Suspended',
'closed' => 'Closed',
'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">
<div class="flex items-center justify-end gap-1.5">
@unless ($r['closed'])
<button type="button" wire:click="toggleSuspend('{{ $r['uuid'] }}')"
class="inline-flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-semibold
{{ $r['suspended'] ? 'border-success-border text-success hover:bg-success-bg' : 'border-line text-muted hover:border-warning hover:text-warning' }}">
{{ $r['suspended'] ? __('admin.reactivate') : __('admin.suspend') }}
</button>
@endunless
<form method="POST" action="{{ route('admin.impersonate', $r['uuid']) }}" class="inline">
@csrf
<button type="submit"

View File

@ -84,6 +84,16 @@ it('suspends and reactivates a customer', function () {
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 () {
$dc = Datacenter::factory()->create(['active' => true]);