fix(portal): enforce customer lifecycle per Codex review

- EnsureCustomerActive middleware: suspended/closed customers lose portal
  access (admins + active impersonation exempt)
- cancellation: reject non-active instances; service-end anchored on the
  subscription start date, not calendar month-end

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
feat/portal-design
nexxo 2026-07-25 14:45:03 +02:00
parent 3b288486cd
commit 074b0c041b
7 changed files with 92 additions and 3 deletions

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Middleware;
use App\Models\Customer;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
/**
* Enforce customer account lifecycle on portal requests: a suspended or closed
* customer must lose access. Admins are exempt, and an active impersonation
* session is exempt so operators can still inspect a suspended customer's portal.
*/
class EnsureCustomerActive
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if ($user !== null && ! $user->is_admin && ! $request->session()->has('impersonator_id')) {
$customer = Customer::query()->where('user_id', $user->id)->first()
?? Customer::query()->where('email', $user->email)->first();
if ($customer !== null && ($customer->status === 'suspended' || $customer->status === 'closed' || $customer->closed_at !== null)) {
Auth::logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
$key = $customer->closed_at !== null || $customer->status === 'closed' ? 'auth.account_closed' : 'auth.account_suspended';
return redirect()->route('login')->withErrors(['email' => __($key)]);
}
}
return $next($request);
}
}

View File

@ -21,7 +21,9 @@ class ConfirmCancelPackage extends ModalComponent
$customer = $this->customer();
$instance = $customer?->instances()->latest('id')->first();
if ($instance === null || $instance->status === 'cancellation_scheduled') {
// Only an active package can be cancelled — never resurrect an already
// cancelled/deprovisioned/scheduled instance.
if ($instance === null || $instance->status !== 'active') {
return $this->redirectRoute('settings', navigate: true);
}
@ -35,12 +37,27 @@ class ConfirmCancelPackage extends ModalComponent
$instance->update([
'status' => 'cancellation_scheduled',
'cancel_requested_at' => now(),
'service_ends_at' => now()->endOfMonth(), // end of the billing term
'service_ends_at' => $this->currentPeriodEnd($instance),
]);
return $this->redirectRoute('settings', navigate: true);
}
/**
* End of the current monthly billing period, anchored on the subscription
* start (the order date) rather than assuming calendar-month billing.
*/
private function currentPeriodEnd(Instance $instance): \Illuminate\Support\Carbon
{
$start = $instance->order?->created_at ?? $instance->created_at ?? now();
$end = $start->copy();
while ($end->lessThanOrEqualTo(now())) {
$end->addMonthNoOverflow();
}
return $end;
}
private function customer(): ?Customer
{
$user = auth()->user();

View File

@ -15,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
->withMiddleware(function (Middleware $middleware): void {
$middleware->alias([
'admin' => \App\Http\Middleware\EnsureAdmin::class,
'customer.active' => \App\Http\Middleware\EnsureCustomerActive::class,
]);
// Stripe posts server-to-server with its own signature (no CSRF token).

View File

@ -23,4 +23,6 @@ return [
'verify' => 'Bestätigen',
'use_recovery' => 'Wiederherstellungscode verwenden',
'use_otp' => 'Authenticator-Code verwenden',
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
'account_closed' => 'Dieses Konto wurde geschlossen.',
];

View File

@ -23,4 +23,6 @@ return [
'verify' => 'Verify',
'use_recovery' => 'Use a recovery code',
'use_otp' => 'Use authenticator code',
'account_suspended' => 'Your account is suspended. Please contact support.',
'account_closed' => 'This account has been closed.',
];

View File

@ -36,7 +36,7 @@ Route::middleware('guest')->group(function () {
// Customer portal — each sidebar tab is a full-page class-based Livewire
// component (R1/R2); paths are English (R13).
Route::middleware('auth')->group(function () {
Route::middleware(['auth', 'customer.active'])->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/cloud', Cloud::class)->name('cloud');
Route::get('/users', Users::class)->name('users');

View File

@ -25,6 +25,34 @@ it('gates settings to authenticated users', function () {
$this->get(route('settings'))->assertRedirect('/login');
});
it('blocks a suspended customer from the portal', function () {
$user = User::factory()->create(['email' => 'sus@set.test', 'is_admin' => false]);
Customer::factory()->create(['email' => 'sus@set.test', 'user_id' => $user->id, 'status' => 'suspended']);
$this->actingAs($user)->get(route('dashboard'))->assertRedirect('/login');
expect(auth()->check())->toBeFalse();
});
it('blocks a closed customer from the portal', function () {
$user = User::factory()->create(['email' => 'cl@set.test', 'is_admin' => false]);
Customer::factory()->create(['email' => 'cl@set.test', 'user_id' => $user->id, 'status' => 'closed', 'closed_at' => now()]);
$this->actingAs($user)->get(route('dashboard'))->assertRedirect('/login');
});
it('rejects cancelling a package that is not active', function () {
['user' => $user, 'customer' => $customer] = settingsSetup();
$instance = $customer->instances()->first();
$instance->update(['status' => 'cancellation_scheduled']);
Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
->set('confirmName', 'acme')
->call('cancelPackage');
// Still scheduled — not re-transitioned or resurrected.
expect($instance->fresh()->status)->toBe('cancellation_scheduled');
});
it('saves the company profile', function () {
['user' => $user, 'customer' => $customer] = settingsSetup();