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
parent
cbbb523f9c
commit
3bf0dfb548
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ return [
|
|||
'active' => 'Aktiv',
|
||||
'provisioning' => 'Bereitstellung',
|
||||
'suspended' => 'Ausgesetzt',
|
||||
'closed' => 'Geschlossen',
|
||||
'warning' => 'Warnung',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ return [
|
|||
'active' => 'Active',
|
||||
'provisioning' => 'Provisioning',
|
||||
'suspended' => 'Suspended',
|
||||
'closed' => 'Closed',
|
||||
'warning' => 'Warning',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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>
|
||||
@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"
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue