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:
parent
906e3cc2b4
commit
2bfb569a22
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue