fix(account): block deletion while subscription or trial is active (#531)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-14 20:46:44 +02:00 committed by GitHub
parent 906e3cc2b4
commit 2bfb569a22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 185 additions and 8 deletions

View File

@ -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();

View File

@ -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.
*

View File

@ -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",

View File

@ -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<SharedData>().props;
const isDemoAccount = auth?.isDemoAccount ?? false;
const passwordInput = useRef<HTMLInputElement>(null);
@ -45,6 +50,35 @@ export default function DeleteUser() {
);
}
if (hasActiveSubscriptionOrTrial) {
return (
<div className="space-y-6">
<HeadingSmall
title={__('Delete account')}
description={__(
'Mark your account as deleted and disable access',
)}
/>
<Alert>
<InfoIcon className="h-4 w-4" />
<AlertDescription className="flex flex-col items-start gap-3">
<span>
{__(
'Please cancel your subscription before deleting your account.',
)}
</span>
<Button variant="secondary" asChild>
<Link href={billing.url()}>
{__('Manage billing')}
</Link>
</Button>
</AlertDescription>
</Alert>
</div>
);
}
return (
<div className="space-y-6">
<HeadingSmall

View File

@ -13,13 +13,19 @@ const breadcrumbs: BreadcrumbItem[] = [
},
];
export default function DeleteAccount() {
export default function DeleteAccount({
hasActiveSubscriptionOrTrial,
}: {
hasActiveSubscriptionOrTrial: boolean;
}) {
return (
<AppLayout breadcrumbs={breadcrumbs}>
<Head title={__('Delete account')} />
<SettingsLayout>
<DeleteUser />
<DeleteUser
hasActiveSubscriptionOrTrial={hasActiveSubscriptionOrTrial}
/>
</SettingsLayout>
</AppLayout>
);

View File

@ -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'])

View File

@ -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)
);
});

View File

@ -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();
});