From 074b0c041bf0b28f3a16260d5e3a9f1160ec145a Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 25 Jul 2026 14:45:03 +0200 Subject: [PATCH] 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 --- app/Http/Middleware/EnsureCustomerActive.php | 39 ++++++++++++++++++++ app/Livewire/ConfirmCancelPackage.php | 21 ++++++++++- bootstrap/app.php | 1 + lang/de/auth.php | 2 + lang/en/auth.php | 2 + routes/web.php | 2 +- tests/Feature/SettingsTest.php | 28 ++++++++++++++ 7 files changed, 92 insertions(+), 3 deletions(-) create mode 100644 app/Http/Middleware/EnsureCustomerActive.php diff --git a/app/Http/Middleware/EnsureCustomerActive.php b/app/Http/Middleware/EnsureCustomerActive.php new file mode 100644 index 0000000..ebb3b9b --- /dev/null +++ b/app/Http/Middleware/EnsureCustomerActive.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php index 55eaace..82cd8d9 100644 --- a/app/Livewire/ConfirmCancelPackage.php +++ b/app/Livewire/ConfirmCancelPackage.php @@ -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(); diff --git a/bootstrap/app.php b/bootstrap/app.php index c9f032f..a0be5d8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -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). diff --git a/lang/de/auth.php b/lang/de/auth.php index ab64d80..42895e7 100644 --- a/lang/de/auth.php +++ b/lang/de/auth.php @@ -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.', ]; diff --git a/lang/en/auth.php b/lang/en/auth.php index 97a603f..a5737ee 100644 --- a/lang/en/auth.php +++ b/lang/en/auth.php @@ -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.', ]; diff --git a/routes/web.php b/routes/web.php index 055c323..187d689 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index f535d62..a96d285 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -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();