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
parent
3b288486cd
commit
074b0c041b
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,7 +21,9 @@ class ConfirmCancelPackage extends ModalComponent
|
||||||
$customer = $this->customer();
|
$customer = $this->customer();
|
||||||
$instance = $customer?->instances()->latest('id')->first();
|
$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);
|
return $this->redirectRoute('settings', navigate: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -35,12 +37,27 @@ class ConfirmCancelPackage extends ModalComponent
|
||||||
$instance->update([
|
$instance->update([
|
||||||
'status' => 'cancellation_scheduled',
|
'status' => 'cancellation_scheduled',
|
||||||
'cancel_requested_at' => now(),
|
'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);
|
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
|
private function customer(): ?Customer
|
||||||
{
|
{
|
||||||
$user = auth()->user();
|
$user = auth()->user();
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withMiddleware(function (Middleware $middleware): void {
|
->withMiddleware(function (Middleware $middleware): void {
|
||||||
$middleware->alias([
|
$middleware->alias([
|
||||||
'admin' => \App\Http\Middleware\EnsureAdmin::class,
|
'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).
|
// Stripe posts server-to-server with its own signature (no CSRF token).
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,6 @@ return [
|
||||||
'verify' => 'Bestätigen',
|
'verify' => 'Bestätigen',
|
||||||
'use_recovery' => 'Wiederherstellungscode verwenden',
|
'use_recovery' => 'Wiederherstellungscode verwenden',
|
||||||
'use_otp' => 'Authenticator-Code verwenden',
|
'use_otp' => 'Authenticator-Code verwenden',
|
||||||
|
'account_suspended' => 'Ihr Konto ist gesperrt. Bitte kontaktieren Sie den Support.',
|
||||||
|
'account_closed' => 'Dieses Konto wurde geschlossen.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,6 @@ return [
|
||||||
'verify' => 'Verify',
|
'verify' => 'Verify',
|
||||||
'use_recovery' => 'Use a recovery code',
|
'use_recovery' => 'Use a recovery code',
|
||||||
'use_otp' => 'Use authenticator code',
|
'use_otp' => 'Use authenticator code',
|
||||||
|
'account_suspended' => 'Your account is suspended. Please contact support.',
|
||||||
|
'account_closed' => 'This account has been closed.',
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ Route::middleware('guest')->group(function () {
|
||||||
|
|
||||||
// Customer portal — each sidebar tab is a full-page class-based Livewire
|
// Customer portal — each sidebar tab is a full-page class-based Livewire
|
||||||
// component (R1/R2); paths are English (R13).
|
// 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('/dashboard', Dashboard::class)->name('dashboard');
|
||||||
Route::get('/cloud', Cloud::class)->name('cloud');
|
Route::get('/cloud', Cloud::class)->name('cloud');
|
||||||
Route::get('/users', Users::class)->name('users');
|
Route::get('/users', Users::class)->name('users');
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,34 @@ it('gates settings to authenticated users', function () {
|
||||||
$this->get(route('settings'))->assertRedirect('/login');
|
$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 () {
|
it('saves the company profile', function () {
|
||||||
['user' => $user, 'customer' => $customer] = settingsSetup();
|
['user' => $user, 'customer' => $customer] = settingsSetup();
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue