From 2bfb569a2226df44b71363e731889ab07a2a41c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sun, 14 Jun 2026 20:46:44 +0200 Subject: [PATCH] fix(account): block deletion while subscription or trial is active (#531) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem Self-deleting an account via **Settings → Delete account** only called `markAsDeleted()`. It never checked or cancelled the Stripe subscription, so a user could delete their account while still subscribed and **keep getting billed** afterwards. The `user:delete` CLI command already cancelled the subscription, but the web path never got that logic. ## Fix Block account deletion while the user is **on a trial** or holds a **valid, uncancelled subscription**, and direct them to cancel via the billing portal first. - `User::hasActiveSubscriptionOrTrial()` — true on a trial or a `valid()` subscription; **grace-period** (already-cancelled) subscriptions are excluded since they won't re-bill, so deletion is still allowed. - `ProfileController::destroy()` — rejects with a `subscription` error before deleting (server-side guard, also covers non-UI calls). - Delete-account page now receives `hasActiveSubscriptionOrTrial`; when set, the UI shows a warning + **Manage billing** link and hides the delete dialog. - Spanish translations added for the two new strings. The `user:delete` CLI is unchanged — it keeps its auto-cancel-with-confirmation flow for admin use. ## Tests - Active subscription → deletion blocked - Trialing subscription → deletion blocked - Grace-period (cancelled) subscription → deletion allowed - Delete-account page exposes the flag (true/false) All passing locally, alongside Pint / Prettier / ESLint. --- .../Settings/ProfileController.php | 10 ++- app/Models/User.php | 22 ++++++ lang/es.json | 2 + resources/js/components/delete-user.tsx | 38 ++++++++- .../js/pages/settings/delete-account.tsx | 10 ++- routes/settings.php | 7 +- .../Settings/DeleteAccountPageTest.php | 25 ++++++ tests/Feature/Settings/ProfileUpdateTest.php | 79 +++++++++++++++++++ 8 files changed, 185 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index 9126c5e0..5b4405b4 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -65,12 +65,18 @@ class ProfileController extends Controller */ public function destroy(Request $request): RedirectResponse { + $user = $request->user(); + + if ($user->hasActiveSubscriptionOrTrial()) { + return back()->withErrors([ + 'subscription' => __('Please cancel your subscription before deleting your account.'), + ]); + } + $request->validate([ 'password' => ['required', 'current_password'], ]); - $user = $request->user(); - Auth::logout(); $user->markAsDeleted(); diff --git a/app/Models/User.php b/app/Models/User.php index a54a3204..98b9914c 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -255,6 +255,28 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma && $subscription->ended(); } + /** + * Whether the user is still being billed: on a trial or holding an + * active subscription that has not been cancelled (grace period excluded, + * as it will not renew). Such users must cancel before deleting their account. + */ + public function hasActiveSubscriptionOrTrial(): bool + { + if (! config('subscriptions.enabled')) { + return false; + } + + if ($this->onGenericTrial()) { + return true; + } + + $subscription = $this->subscription('default'); + + return $subscription !== null + && $subscription->valid() + && ! $subscription->onGracePeriod(); + } + /** * The tax rates that should apply to the customer's subscriptions. * diff --git a/lang/es.json b/lang/es.json index 70c3ae53..0fe93a97 100644 --- a/lang/es.json +++ b/lang/es.json @@ -948,6 +948,7 @@ "Making your money talk...": "Haciendo que tu dinero hable...", "Manage Plan": "Gestionar Plan", "Manage Subscription": "Gestionar Suscripción", + "Manage billing": "Gestionar facturación", "Manage rules that categorize transactions and add labels automatically": "Gestiona reglas que categorizan transacciones y agregan etiquetas automáticamente", "Manage the automatic notifications you receive": "Gestiona las notificaciones automáticas que recibes", "Manage your bank accounts": "Gestiona tus cuentas bancarias", @@ -1144,6 +1145,7 @@ "Please enter an account name.": "Por favor, ingresa un nombre de cuenta.", "Please enter your new password below": "Por favor ingresa tu nueva contraseña a continuación", "Please fill in all required fields.": "Por favor, completa todos los campos obligatorios.", + "Please cancel your subscription before deleting your account.": "Por favor cancela tu suscripción antes de eliminar tu cuenta.", "Please proceed with caution, this cannot be undone.": "Por favor procede con precaución, esto no se puede deshacer.", "Please select a bank.": "Por favor, selecciona un banco.", "Please unlock your encryption key to add transactions": "Por favor desbloquea tu clave de encriptación para agregar transacciones", diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 0f484ec5..9e8127c1 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -14,13 +14,18 @@ import { } from '@/components/ui/dialog'; import { Label } from '@/components/ui/label'; import { PasswordInput } from '@/components/ui/password-input'; +import { billing } from '@/routes/settings'; import { type SharedData } from '@/types'; import { __ } from '@/utils/i18n'; -import { Form, usePage } from '@inertiajs/react'; +import { Form, Link, usePage } from '@inertiajs/react'; import { InfoIcon } from 'lucide-react'; import { useRef } from 'react'; -export default function DeleteUser() { +export default function DeleteUser({ + hasActiveSubscriptionOrTrial, +}: { + hasActiveSubscriptionOrTrial: boolean; +}) { const { auth } = usePage().props; const isDemoAccount = auth?.isDemoAccount ?? false; const passwordInput = useRef(null); @@ -45,6 +50,35 @@ export default function DeleteUser() { ); } + if (hasActiveSubscriptionOrTrial) { + return ( +
+ + + + + + + {__( + 'Please cancel your subscription before deleting your account.', + )} + + + + +
+ ); + } + return (
- + ); diff --git a/routes/settings.php b/routes/settings.php index 16c78045..9efff3f1 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -16,6 +16,7 @@ use App\Http\Controllers\Settings\ProfileController; use App\Http\Controllers\Settings\TimezoneController; use App\Http\Controllers\Settings\TwoFactorAuthenticationController; use App\Http\Controllers\SubscriptionController; +use Illuminate\Http\Request; use Illuminate\Support\Facades\Route; use Inertia\Inertia; @@ -87,8 +88,10 @@ Route::middleware('auth')->group(function () { Route::get('settings/billing', [SubscriptionController::class, 'billing'])->name('settings.billing'); Route::get('settings/billing/portal', [SubscriptionController::class, 'billingPortal'])->name('settings.billing.portal'); - Route::get('settings/delete-account', function () { - return Inertia::render('settings/delete-account'); + Route::get('settings/delete-account', function (Request $request) { + return Inertia::render('settings/delete-account', [ + 'hasActiveSubscriptionOrTrial' => $request->user()->hasActiveSubscriptionOrTrial(), + ]); })->name('delete-account.edit'); Route::get('settings/two-factor', [TwoFactorAuthenticationController::class, 'show']) diff --git a/tests/Feature/Settings/DeleteAccountPageTest.php b/tests/Feature/Settings/DeleteAccountPageTest.php index 7cb083e7..0614fd19 100644 --- a/tests/Feature/Settings/DeleteAccountPageTest.php +++ b/tests/Feature/Settings/DeleteAccountPageTest.php @@ -23,5 +23,30 @@ test('delete account page displays delete account component', function () { ->assertOk() ->assertInertia(fn ($page) => $page ->component('settings/delete-account') + ->where('hasActiveSubscriptionOrTrial', false) + ); +}); + +test('delete account page flags users with an active subscription', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->create(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_active_delete_page_test', + 'stripe_status' => 'active', + 'stripe_price' => 'price_delete_page_test', + ]); + + $response = $this + ->actingAs($user) + ->get(route('delete-account.edit')); + + $response + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('settings/delete-account') + ->where('hasActiveSubscriptionOrTrial', true) ); }); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 479c7c35..9a919050 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -183,3 +183,82 @@ test('correct password must be provided to delete account', function () { expect($user->fresh())->not->toBeNull(); }); + +test('user with an active subscription cannot delete their account', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->create(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_active_delete_test', + 'stripe_status' => 'active', + 'stripe_price' => 'price_delete_test', + ]); + + $response = $this + ->actingAs($user) + ->from(route('delete-account.edit')) + ->delete(route('profile.destroy'), [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasErrors('subscription') + ->assertRedirect(route('delete-account.edit')); + + expect(User::query()->find($user->id))->not->toBeNull(); +}); + +test('user on a trial cannot delete their account', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->create(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_trialing_delete_test', + 'stripe_status' => 'trialing', + 'stripe_price' => 'price_delete_test', + 'trial_ends_at' => now()->addDays(7), + ]); + + $response = $this + ->actingAs($user) + ->from(route('delete-account.edit')) + ->delete(route('profile.destroy'), [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasErrors('subscription') + ->assertRedirect(route('delete-account.edit')); + + expect(User::query()->find($user->id))->not->toBeNull(); +}); + +test('user with a cancelled subscription on grace period can delete their account', function () { + config(['subscriptions.enabled' => true]); + + $user = User::factory()->create(); + + $user->subscriptions()->create([ + 'type' => 'default', + 'stripe_id' => 'sub_grace_delete_test', + 'stripe_status' => 'active', + 'stripe_price' => 'price_delete_test', + 'ends_at' => now()->addDays(7), + ]); + + $response = $this + ->actingAs($user) + ->delete(route('profile.destroy'), [ + 'password' => 'password', + ]); + + $response + ->assertSessionHasNoErrors() + ->assertRedirect(route('home')); + + expect(User::query()->find($user->id))->toBeNull(); +});