Support soft-deleted users with reusable emails (#316)
## Summary - soft-delete users by adding `deleted_at` to `users` - rename deleted user emails with a timestamp prefix so original email can be reused - block email sends and banking follow-up work for deleted users while preserving data ## Testing - php artisan test --compact tests/Feature/DeleteUserCommandTest.php tests/Feature/Settings/ProfileUpdateTest.php tests/Feature/Auth/RegistrationTest.php tests/Feature/Auth/AuthenticationTest.php tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php tests/Feature/Console/SendUpdateEmailCommandTest.php - vendor/bin/pint --dirty --format agent
This commit is contained in:
parent
c7cfa10117
commit
2604f1158c
|
|
@ -21,6 +21,7 @@ class CancelFreeEnableBankingConnectionsCommand extends Command
|
|||
|
||||
$connections = BankingConnection::query()
|
||||
->with(['user', 'accounts'])
|
||||
->whereHas('user')
|
||||
->where('provider', 'enablebanking')
|
||||
->where('status', '!=', BankingConnectionStatus::Revoked)
|
||||
->where('created_at', '<=', $cutoff)
|
||||
|
|
@ -57,10 +58,12 @@ class CancelFreeEnableBankingConnectionsCommand extends Command
|
|||
$revoked++;
|
||||
}
|
||||
|
||||
Mail::to($user)->send(new EnableBankingConnectionsCancelledEmail(
|
||||
$user,
|
||||
$userConnections->count(),
|
||||
));
|
||||
if ($user->canReceiveEmails()) {
|
||||
Mail::to($user)->send(new EnableBankingConnectionsCancelledEmail(
|
||||
$user,
|
||||
$userConnections->count(),
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
$this->info("Revoked {$revoked} Enable Banking connection(s). Skipped paid users: {$skipped}.");
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Actions\OpenBanking\DisconnectBankingConnection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
class DeleteUserCommand extends Command
|
||||
{
|
||||
|
|
@ -20,16 +21,16 @@ class DeleteUserCommand extends Command
|
|||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $description = 'Delete a user and all their associated data';
|
||||
protected $description = 'Mark a user as deleted while preserving their data';
|
||||
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(): int
|
||||
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
|
||||
{
|
||||
$email = $this->argument('email');
|
||||
|
||||
$user = User::query()->where('email', $email)->first();
|
||||
$user = User::withTrashed()->where('email', $email)->first();
|
||||
|
||||
if (! $user) {
|
||||
$this->error("User with email '{$email}' not found.");
|
||||
|
|
@ -37,48 +38,75 @@ class DeleteUserCommand extends Command
|
|||
return self::FAILURE;
|
||||
}
|
||||
|
||||
// Check for active subscriptions
|
||||
if ($user->subscribed('default')) {
|
||||
$this->error('Cannot delete user with an active subscription. Please cancel the subscription first.');
|
||||
if ($user->trashed()) {
|
||||
$this->info("User '{$email}' is already marked as deleted.");
|
||||
|
||||
return self::FAILURE;
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
if (! $this->confirm("Are you sure you want to delete user '{$user->name}' ({$user->email}) and all their data?")) {
|
||||
if (! $this->confirm("Are you sure you want to mark user '{$user->name}' ({$user->email}) as deleted? Their data will be preserved.")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($user) {
|
||||
// Delete account balances through accounts
|
||||
foreach ($user->accounts as $account) {
|
||||
$account->balances()->delete();
|
||||
}
|
||||
$subscription = $this->activeSubscription($user);
|
||||
$enableBankingConnections = $user->bankingConnections()
|
||||
->with('accounts')
|
||||
->where('provider', 'enablebanking')
|
||||
->get();
|
||||
|
||||
// Delete all related data
|
||||
$user->encryptedMessage()->delete();
|
||||
$user->transactions()->delete();
|
||||
$user->accounts()->delete();
|
||||
$user->categories()->delete();
|
||||
$user->automationRules()->delete();
|
||||
$user->labels()->delete();
|
||||
$user->mailLogs()->delete();
|
||||
if ($subscription && ! $this->confirm("User '{$user->email}' has an active Stripe subscription. Cancel it before deleting the user?")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
|
||||
// Delete user's banks
|
||||
DB::table('banks')->where('user_id', $user->id)->delete();
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
// Delete Cashier subscription data if exists
|
||||
if ($user->subscriptions()->exists()) {
|
||||
$user->subscriptions()->delete();
|
||||
}
|
||||
if ($enableBankingConnections->isNotEmpty() && ! $this->confirm("User '{$user->email}' has {$enableBankingConnections->count()} Enable Banking connection(s). Revoke them and keep linked accounts as manual accounts?")) {
|
||||
$this->info('Deletion cancelled.');
|
||||
|
||||
// Delete the user
|
||||
$user->delete();
|
||||
});
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
$this->info("User '{$email}' and all associated data have been deleted successfully.");
|
||||
if ($subscription) {
|
||||
$this->cancelSubscription($user, $subscription);
|
||||
$this->info("Cancelled active Stripe subscription for '{$user->email}'.");
|
||||
}
|
||||
|
||||
foreach ($enableBankingConnections as $connection) {
|
||||
$disconnectBankingConnection->handle($connection, deleteAccounts: false);
|
||||
}
|
||||
|
||||
if ($enableBankingConnections->isNotEmpty()) {
|
||||
$this->info("Revoked {$enableBankingConnections->count()} Enable Banking connection(s) for '{$user->email}'.");
|
||||
}
|
||||
|
||||
$user->markAsDeleted();
|
||||
|
||||
$this->info("User '{$email}' has been marked as deleted. Their data remains in the database.");
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
|
||||
private function activeSubscription(User $user): ?Subscription
|
||||
{
|
||||
$subscription = $user->subscription('default');
|
||||
|
||||
if (! $subscription || ! $subscription->valid()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $subscription;
|
||||
}
|
||||
|
||||
private function cancelSubscription(User $user, Subscription $subscription): void
|
||||
{
|
||||
if ($user->hasStripeId()) {
|
||||
$subscription->cancelNow();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$subscription->markAsCanceled();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class SyncBankingConnections extends Command
|
|||
}
|
||||
|
||||
$query = BankingConnection::query()
|
||||
->whereHas('user')
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ class ProfileController extends Controller
|
|||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
$user->markAsDeleted();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class SendFeedbackEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::Feedback)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class SendImportHelpEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::ImportHelp)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class SendOnboardingReminderEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::OnboardingReminder)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class SendPromoCodeEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::PromoCode)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class SendSubscriptionCancelledEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::SubscriptionCancelled)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ class SendWelcomeEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->user->hasReceivedEmail(DripEmailType::Welcome)) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@ class SendDailyBankTransactionsSyncedEmailJob implements ShouldBeUnique, ShouldQ
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$localReportDate = $this->localReportDate();
|
||||
$lastSentMailLog = UserMailLog::query()
|
||||
->where('user_id', $this->user->id)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ class SendUpdateEmailJob implements ShouldQueue
|
|||
|
||||
public function handle(): void
|
||||
{
|
||||
if (! $this->user->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->hasReceivedUpdate()) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class SyncAllBankingConnectionsJob implements ShouldQueue
|
|||
public function handle(): void
|
||||
{
|
||||
BankingConnection::query()
|
||||
->whereHas('user')
|
||||
->where(function ($query) {
|
||||
$query->where('status', BankingConnectionStatus::Active)
|
||||
->orWhere(function ($query) {
|
||||
|
|
|
|||
|
|
@ -55,6 +55,16 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
$startTime = microtime(true);
|
||||
$syncedAt = now();
|
||||
|
||||
$connection->loadMissing('user');
|
||||
|
||||
if (! $connection->user) {
|
||||
Log::info('Banking connection belongs to deleted user, skipping sync', ['connection_id' => $connection->id]);
|
||||
|
||||
$this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'deleted_user']);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($connection->isEnableBanking() && $connection->isExpired()) {
|
||||
$connection->update(['status' => BankingConnectionStatus::Expired]);
|
||||
Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]);
|
||||
|
|
@ -83,7 +93,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
} else {
|
||||
$metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync);
|
||||
|
||||
if (! $isFirstSync) {
|
||||
if (! $isFirstSync && $connection->user->canReceiveEmails()) {
|
||||
SendDailyBankTransactionsSyncedEmailJob::dispatch(
|
||||
$connection->user,
|
||||
$syncedAt->toDateString(),
|
||||
|
|
@ -141,7 +151,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
|
|||
'consecutive_sync_failures' => self::MAX_SCHEDULED_RETRIES + 1,
|
||||
]);
|
||||
|
||||
if ($this->isApiKeyProvider($connection)) {
|
||||
if ($this->isApiKeyProvider($connection) && $connection->user?->canReceiveEmails()) {
|
||||
Mail::to($connection->user)->send(new BankingConnectionAuthFailedEmail(
|
||||
$connection->user,
|
||||
$connection,
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ use Illuminate\Database\Eloquent\Concerns\HasUuids;
|
|||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Laravel\Cashier\Billable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Pennant\Concerns\HasFeatures;
|
||||
|
|
@ -21,7 +24,7 @@ use Laravel\Pennant\Concerns\HasFeatures;
|
|||
class User extends Authenticatable implements HasLocalePreference, MustVerifyEmail
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, TwoFactorAuthenticatable;
|
||||
use Billable, HasFactory, HasFeatures, HasUuids, Notifiable, SoftDeletes, TwoFactorAuthenticatable;
|
||||
|
||||
/**
|
||||
* The attributes that are mass assignable.
|
||||
|
|
@ -172,8 +175,59 @@ class User extends Authenticatable implements HasLocalePreference, MustVerifyEma
|
|||
return $this->locale ?? 'en';
|
||||
}
|
||||
|
||||
public function isDeleted(): bool
|
||||
{
|
||||
return $this->trashed();
|
||||
}
|
||||
|
||||
public function markAsDeleted(): void
|
||||
{
|
||||
if ($this->trashed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () {
|
||||
$this->forceFill([
|
||||
'email' => $this->deletedEmail(),
|
||||
])->saveQuietly();
|
||||
|
||||
$this->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function canReceiveEmails(): bool
|
||||
{
|
||||
return ! $this->isDeleted();
|
||||
}
|
||||
|
||||
public function routeNotificationForMail(?Notification $notification = null): ?string
|
||||
{
|
||||
if (! $this->canReceiveEmails()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->email;
|
||||
}
|
||||
|
||||
public function sendEmailVerificationNotification(): void
|
||||
{
|
||||
if (! $this->canReceiveEmails()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->notify(new VerifyEmailNotification);
|
||||
}
|
||||
|
||||
private function deletedEmail(): string
|
||||
{
|
||||
$timestamp = $this->freshTimestamp()->format('YmdHis');
|
||||
$originalEmail = $this->getOriginal('email');
|
||||
$candidate = "{$timestamp}_{$originalEmail}";
|
||||
|
||||
if (! static::withTrashed()->where('email', $candidate)->exists()) {
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
return "{$timestamp}_{$this->getKey()}_{$originalEmail}";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->softDeletes();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropSoftDeletes();
|
||||
});
|
||||
}
|
||||
};
|
||||
22
lang/es.json
22
lang/es.json
|
|
@ -27,7 +27,7 @@
|
|||
"...and 40+ more": "...y m\u00e1s de 40",
|
||||
"/12 min": "/12 min",
|
||||
"/month": "/mes",
|
||||
"/year": "/año",
|
||||
"/year": "/a\u00f1o",
|
||||
"1,200+ users": "1.200+ usuarios",
|
||||
"1. Data Controller": "1. Controlador de Datos",
|
||||
"10. Children's Privacy": "10. Privacidad de los Ni\u00f1os",
|
||||
|
|
@ -155,11 +155,11 @@
|
|||
"Amount is required": "Se requiere un valor",
|
||||
"An unexpected error occurred during sync.": "Se produjo un error inesperado durante la sincronizaci\u00f3n.",
|
||||
"Annual": "Anual",
|
||||
"Annual Interest Rate": "Tasa de Interés Anual",
|
||||
"Annual Interest Rate (%)": "Tasa de Interés Anual (%)",
|
||||
"Annual Revaluation": "Revalorización anual",
|
||||
"Annual Revaluation (%)": "Revalorización anual (%)",
|
||||
"Annual percentage applied monthly. Use negative values for depreciation.": "Porcentaje anual aplicado mensualmente. Usa valores negativos para depreciación.",
|
||||
"Annual Interest Rate": "Tasa de Inter\u00e9s Anual",
|
||||
"Annual Interest Rate (%)": "Tasa de Inter\u00e9s Anual (%)",
|
||||
"Annual Revaluation": "Revalorizaci\u00f3n anual",
|
||||
"Annual Revaluation (%)": "Revalorizaci\u00f3n anual (%)",
|
||||
"Annual percentage applied monthly. Use negative values for depreciation.": "Porcentaje anual aplicado mensualmente. Usa valores negativos para depreciaci\u00f3n.",
|
||||
"Any bugs, viruses, or similar harmful components transmitted through the service": "Cualquier error, virus o componente da\u00f1ino similar transmitido a trav\u00e9s del servicio",
|
||||
"Any bugs, viruses, or similar harmful\\n components transmitted through the service": "Cualquier error, virus u otros componentes da\u00f1inos transmitidos a trav\u00e9s del servicio",
|
||||
"Any errors or omissions in content or any loss or damage incurred from using content": "Cualquier error u omisi\u00f3n en el contenido o cualquier p\u00e9rdida o da\u00f1o incurrido por usar el contenido",
|
||||
|
|
@ -465,8 +465,8 @@
|
|||
"Edit Budget": "Editar Presupuesto",
|
||||
"Edit Category": "Editar Categor\u00eda",
|
||||
"Edit Label": "Editar Etiqueta",
|
||||
"Edit Loan Details": "Editar Detalles del Préstamo",
|
||||
"Edit loan details": "Editar detalles del préstamo",
|
||||
"Edit Loan Details": "Editar Detalles del Pr\u00e9stamo",
|
||||
"Edit loan details": "Editar detalles del pr\u00e9stamo",
|
||||
"Edit Owed Amount": "Editar Monto Adeudado",
|
||||
"Edit Property Details": "Editar Detalles de Propiedad",
|
||||
"Edit Transaction": "Editar transacci\u00f3n",
|
||||
|
|
@ -804,7 +804,7 @@
|
|||
"Market Value": "Valor de Mercado",
|
||||
"Market Values": "Valores de Mercado",
|
||||
"Market value": "Valor de mercado",
|
||||
"Market value evolution": "Evolución del valor de mercado",
|
||||
"Market value evolution": "Evoluci\u00f3n del valor de mercado",
|
||||
"Match your file columns to transaction fields": "Asocia las columnas de tu archivo con los campos de transacci\u00f3n",
|
||||
"Max": "M\u00e1x",
|
||||
"Maybe later": "Quiz\u00e1s m\u00e1s tarde",
|
||||
|
|
@ -1726,5 +1726,7 @@
|
|||
"yellow": "amarillo",
|
||||
"\u2014 $9/month": "\u2014 $9/mes",
|
||||
"\u2190 Back to home": "\u2190 Volver al inicio",
|
||||
"\ud83c\udf89 Get a founder discount \u2022": "\ud83c\udf89 Obt\u00e9n un descuento de fundador \u2022"
|
||||
"\ud83c\udf89 Get a founder discount \u2022": "\ud83c\udf89 Obt\u00e9n un descuento de fundador \u2022",
|
||||
"Mark your account as deleted and disable access": "Marca tu cuenta como eliminada y desactiva el acceso",
|
||||
"Once your account is deleted, you will be signed out\\n and your access will be disabled. Your data will\\n remain in the database. Please enter your password\\n to confirm you would like to mark your account as\\n deleted.": "Una vez que tu cuenta se marque como eliminada, se cerrar\u00e1 tu sesi\u00f3n y tu acceso quedar\u00e1 desactivado. Tus datos permanecer\u00e1n en la base de datos. Por favor, introduce tu contrase\u00f1a para confirmar que deseas marcar tu cuenta como eliminada."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export default function DeleteUser() {
|
|||
<HeadingSmall
|
||||
title={__('Delete account')}
|
||||
description={__(
|
||||
'Delete your account and all of its resources',
|
||||
'Mark your account as deleted and disable access',
|
||||
)}
|
||||
/>
|
||||
|
||||
|
|
@ -49,7 +49,7 @@ export default function DeleteUser() {
|
|||
<div className="space-y-6">
|
||||
<HeadingSmall
|
||||
title={__('Delete account')}
|
||||
description={__('Delete your account and all of its resources')}
|
||||
description={__('Mark your account as deleted and disable access')}
|
||||
/>
|
||||
|
||||
<div className="space-y-4 rounded-lg border border-red-100 bg-red-50 p-4 dark:border-red-200/10 dark:bg-red-700/10">
|
||||
|
|
@ -79,7 +79,7 @@ export default function DeleteUser() {
|
|||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{__(
|
||||
'Once your account is deleted, all of its resources\n and data will also be permanently deleted. Please\n enter your password to confirm you would like to\n permanently delete your account.',
|
||||
'Once your account is deleted, you will be signed out\n and your access will be disabled. Your data will\n remain in the database. Please enter your password\n to confirm you would like to mark your account as\n deleted.',
|
||||
)}
|
||||
</DialogDescription>
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,10 @@ export default function Login({
|
|||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const forceRegistration =
|
||||
typeof window !== 'undefined' &&
|
||||
new URLSearchParams(window.location.search).get('force') === '1';
|
||||
|
||||
useEffect(() => {
|
||||
clearKey();
|
||||
|
||||
|
|
@ -135,7 +139,14 @@ export default function Login({
|
|||
{canRegister && (
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
{__("Don't have an account?")}{' '}
|
||||
<TextLink href={register()} tabIndex={5}>
|
||||
<TextLink
|
||||
href={
|
||||
forceRegistration
|
||||
? register({ query: { force: 1 } })
|
||||
: register()
|
||||
}
|
||||
tabIndex={5}
|
||||
>
|
||||
{__('Sign up')}
|
||||
</TextLink>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
use App\Models\User;
|
||||
|
||||
it('can register a new user', function () {
|
||||
$page = visit('/register');
|
||||
$page = visit('/register?force=1');
|
||||
|
||||
$page->assertSee('Create an account')
|
||||
->fill('name', 'Test User')
|
||||
|
|
@ -22,7 +22,7 @@ it('can register a new user', function () {
|
|||
});
|
||||
|
||||
it('shows validation errors for invalid registration', function () {
|
||||
$page = visit('/register');
|
||||
$page = visit('/register?force=1');
|
||||
|
||||
$page->assertSee('Create an account')
|
||||
->fill('name', 'Test User')
|
||||
|
|
@ -73,19 +73,16 @@ it('shows validation error for invalid login credentials', function () {
|
|||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
it('can navigate from login to register', function () {
|
||||
$page = visit('/login');
|
||||
it('can open the register page with the force query', function () {
|
||||
$page = visit('/register?force=1');
|
||||
|
||||
$page->assertSee('Log in to your account')
|
||||
->click('Sign up')
|
||||
->wait(1)
|
||||
->assertSee('Create an account')
|
||||
$page->assertSee('Create an account')
|
||||
->assertPathIs('/register')
|
||||
->assertNoJavascriptErrors();
|
||||
});
|
||||
|
||||
it('can navigate from register to login', function () {
|
||||
$page = visit('/register');
|
||||
$page = visit('/register?force=1');
|
||||
|
||||
$page->assertSee('Create an account')
|
||||
->click('Log in')
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use App\Models\User;
|
|||
// =============================================================================
|
||||
|
||||
it('redirects new registration to email verification', function () {
|
||||
$page = visit('/register');
|
||||
$page = visit('/register?force=1');
|
||||
|
||||
$page->assertSee('Create an account')
|
||||
->fill('name', 'Test Onboarding User')
|
||||
|
|
|
|||
|
|
@ -80,6 +80,19 @@ test('users can not authenticate with invalid password', function () {
|
|||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('deleted users can not authenticate', function () {
|
||||
$user = User::factory()->withoutTwoFactor()->create();
|
||||
$user->delete();
|
||||
|
||||
$response = $this->post(route('login.store'), [
|
||||
'email' => $user->email,
|
||||
'password' => 'password',
|
||||
]);
|
||||
|
||||
$response->assertSessionHasErrors('email');
|
||||
$this->assertGuest();
|
||||
});
|
||||
|
||||
test('users can logout', function () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
|
|
|
|||
|
|
@ -154,3 +154,25 @@ test('new users are auto-verified when email verification is disabled', function
|
|||
|
||||
expect($user->hasVerifiedEmail())->toBeTrue();
|
||||
});
|
||||
|
||||
test('new users can register with the email of a deleted user', function () {
|
||||
Queue::fake();
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 9, 56));
|
||||
|
||||
$deletedUser = User::factory()->create(['email' => 'test@example.com']);
|
||||
$deletedUser->markAsDeleted();
|
||||
|
||||
$response = $this->post(route('register.store'), [
|
||||
'name' => 'New User',
|
||||
'email' => 'test@example.com',
|
||||
'password' => 'password',
|
||||
'password_confirmation' => 'password',
|
||||
]);
|
||||
|
||||
$this->assertAuthenticated();
|
||||
$response->assertRedirect(route('onboarding', absolute: false));
|
||||
|
||||
expect(User::query()->where('email', 'test@example.com')->count())->toBe(1)
|
||||
->and(User::withTrashed()->find($deletedUser->id)?->email)
|
||||
->toBe('20260422100956_test@example.com');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -64,6 +64,28 @@ test('command dispatches jobs for all users', function () {
|
|||
}
|
||||
});
|
||||
|
||||
test('command skips deleted users', function () {
|
||||
$activeUser = User::factory()->create();
|
||||
$deletedUser = User::factory()->create();
|
||||
$deletedUser->delete();
|
||||
|
||||
artisan('email:update', [
|
||||
'view' => 'test-update',
|
||||
'identifier' => 'test-2026',
|
||||
'--force' => true,
|
||||
])->assertSuccessful();
|
||||
|
||||
Queue::assertPushed(SendUpdateEmailJob::class, function ($job) use ($activeUser) {
|
||||
return $job->user->id === $activeUser->id;
|
||||
});
|
||||
|
||||
Queue::assertNotPushed(SendUpdateEmailJob::class, function ($job) use ($deletedUser) {
|
||||
return $job->user->id === $deletedUser->id;
|
||||
});
|
||||
|
||||
Queue::assertCount(1);
|
||||
});
|
||||
|
||||
test('command excludes demo account when flag is set', function () {
|
||||
$regularUser = User::factory()->create();
|
||||
$demoUser = User::factory()->create(['email' => config('app.demo.email')]);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
<?php
|
||||
|
||||
use App\Contracts\BankingProviderInterface;
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Models\Account;
|
||||
use App\Models\AccountBalance;
|
||||
use App\Models\AutomationRule;
|
||||
use App\Models\Bank;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\Category;
|
||||
use App\Models\EncryptedMessage;
|
||||
use App\Models\Label;
|
||||
|
|
@ -12,12 +15,18 @@ use App\Models\User;
|
|||
use App\Models\UserMailLog;
|
||||
use Laravel\Cashier\Subscription;
|
||||
|
||||
test('deletes user and all associated data when confirmed', function () {
|
||||
test('marks user as deleted, preserves data, and prefixes email with timestamp when confirmed', function () {
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 51, 24));
|
||||
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
]);
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('revokeSession');
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
// Create associated data
|
||||
EncryptedMessage::query()->create([
|
||||
'user_id' => $user->id,
|
||||
|
|
@ -36,23 +45,25 @@ test('deletes user and all associated data when confirmed', function () {
|
|||
|
||||
// Confirm deletion
|
||||
$this->artisan('user:delete', ['email' => 'test@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'yes')
|
||||
->expectsOutput("User 'test@example.com' and all associated data have been deleted successfully.")
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Test User' (test@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->expectsOutput("User 'test@example.com' has been marked as deleted. Their data remains in the database.")
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify user is deleted
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
|
||||
$deletedUser = User::withTrashed()->find($user->id);
|
||||
|
||||
// Verify all associated data is deleted
|
||||
expect(EncryptedMessage::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Transaction::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Account::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(AccountBalance::query()->where('account_id', $account->id)->exists())->toBeFalse();
|
||||
expect(Category::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(AutomationRule::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Label::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(UserMailLog::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(Bank::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
|
||||
expect($deletedUser?->deleted_at)->not->toBeNull();
|
||||
expect($deletedUser?->email)->toBe('20260422105124_test@example.com');
|
||||
|
||||
expect(EncryptedMessage::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(Transaction::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(Account::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(AccountBalance::query()->where('account_id', $account->id)->exists())->toBeTrue();
|
||||
expect(Category::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(AutomationRule::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(Label::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(UserMailLog::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
expect(Bank::query()->where('user_id', $user->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('shows error when user not found', function () {
|
||||
|
|
@ -68,7 +79,7 @@ test('cancels deletion when not confirmed', function () {
|
|||
]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'test@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'no')
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Test User' (test@example.com) as deleted? Their data will be preserved.", 'no')
|
||||
->expectsOutput('Deletion cancelled.')
|
||||
->assertSuccessful();
|
||||
|
||||
|
|
@ -76,24 +87,40 @@ test('cancels deletion when not confirmed', function () {
|
|||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('deletes user without associated data', function () {
|
||||
test('marks user as deleted without associated data', function () {
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 51, 24));
|
||||
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'test@example.com',
|
||||
'name' => 'Test User',
|
||||
]);
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('revokeSession');
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'test@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user 'Test User' (test@example.com) and all their data?", 'yes')
|
||||
->expectsOutput("User 'test@example.com' and all associated data have been deleted successfully.")
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Test User' (test@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->expectsOutput("User 'test@example.com' has been marked as deleted. Their data remains in the database.")
|
||||
->assertSuccessful();
|
||||
|
||||
$deletedUser = User::withTrashed()->find($user->id);
|
||||
|
||||
expect(User::query()->where('email', 'test@example.com')->exists())->toBeFalse();
|
||||
expect($deletedUser?->deleted_at)->not->toBeNull();
|
||||
expect($deletedUser?->email)->toBe('20260422105124_test@example.com');
|
||||
});
|
||||
|
||||
test('does not delete other users data', function () {
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 51, 24));
|
||||
|
||||
$userToDelete = User::factory()->onboarded()->create([
|
||||
'email' => 'delete@example.com',
|
||||
]);
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('revokeSession');
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
$otherUser = User::factory()->onboarded()->create([
|
||||
'email' => 'keep@example.com',
|
||||
]);
|
||||
|
|
@ -103,24 +130,59 @@ test('does not delete other users data', function () {
|
|||
Account::factory()->create(['user_id' => $otherUser->id]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'delete@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to delete user '{$userToDelete->name}' (delete@example.com) and all their data?", 'yes')
|
||||
->expectsConfirmation("Are you sure you want to mark user '{$userToDelete->name}' (delete@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify only the target user is deleted
|
||||
$deletedUser = User::withTrashed()->find($userToDelete->id);
|
||||
|
||||
expect(User::query()->where('email', 'delete@example.com')->exists())->toBeFalse();
|
||||
expect($deletedUser?->deleted_at)->not->toBeNull();
|
||||
expect($deletedUser?->email)->toBe('20260422105124_delete@example.com');
|
||||
expect(User::query()->where('email', 'keep@example.com')->exists())->toBeTrue();
|
||||
|
||||
// Verify other user's data is intact
|
||||
expect(Account::query()->where('user_id', $otherUser->id)->exists())->toBeTrue();
|
||||
});
|
||||
|
||||
test('prevents deletion of user with active subscription', function () {
|
||||
test('cancels active subscription before deleting user when confirmed', function () {
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 51, 24));
|
||||
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'subscribed@example.com',
|
||||
'name' => 'Subscribed User',
|
||||
]);
|
||||
|
||||
$subscription = Subscription::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'default',
|
||||
'stripe_id' => 'sub_test123',
|
||||
'stripe_status' => 'active',
|
||||
'stripe_price' => 'price_test123',
|
||||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('revokeSession');
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'subscribed@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Subscribed User' (subscribed@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->expectsConfirmation("User 'subscribed@example.com' has an active Stripe subscription. Cancel it before deleting the user?", 'yes')
|
||||
->expectsOutput("Cancelled active Stripe subscription for 'subscribed@example.com'.")
|
||||
->expectsOutput("User 'subscribed@example.com' has been marked as deleted. Their data remains in the database.")
|
||||
->assertSuccessful();
|
||||
|
||||
expect($subscription->fresh()->stripe_status)->toBe('canceled');
|
||||
expect($subscription->fresh()->ends_at)->not->toBeNull();
|
||||
expect(User::withTrashed()->find($user->id)?->deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cancels deletion when subscription cancellation is not confirmed', function () {
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'subscribed@example.com',
|
||||
'name' => 'Subscribed User',
|
||||
]);
|
||||
|
||||
// Create an active subscription
|
||||
Subscription::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'type' => 'default',
|
||||
|
|
@ -130,10 +192,68 @@ test('prevents deletion of user with active subscription', function () {
|
|||
'quantity' => 1,
|
||||
]);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'subscribed@example.com'])
|
||||
->expectsOutput('Cannot delete user with an active subscription. Please cancel the subscription first.')
|
||||
->assertFailed();
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('revokeSession');
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'subscribed@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Subscribed User' (subscribed@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->expectsConfirmation("User 'subscribed@example.com' has an active Stripe subscription. Cancel it before deleting the user?", 'no')
|
||||
->expectsOutput('Deletion cancelled.')
|
||||
->assertSuccessful();
|
||||
|
||||
// Verify user still exists
|
||||
expect(User::query()->where('email', 'subscribed@example.com')->exists())->toBeTrue();
|
||||
expect(Subscription::query()->where('user_id', $user->id)->first()?->stripe_status)->toBe('active');
|
||||
});
|
||||
|
||||
test('revokes enable banking connections before deleting user when confirmed', function () {
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 51, 24));
|
||||
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'banking@example.com',
|
||||
'name' => 'Banking User',
|
||||
]);
|
||||
$connection = BankingConnection::factory()->for($user)->create();
|
||||
$account = Account::factory()->for($user)->create([
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => 'ext-123',
|
||||
]);
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldReceive('revokeSession')->once()->with($connection->session_id);
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'banking@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Banking User' (banking@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->expectsConfirmation("User 'banking@example.com' has 1 Enable Banking connection(s). Revoke them and keep linked accounts as manual accounts?", 'yes')
|
||||
->expectsOutput("Revoked 1 Enable Banking connection(s) for 'banking@example.com'.")
|
||||
->expectsOutput("User 'banking@example.com' has been marked as deleted. Their data remains in the database.")
|
||||
->assertSuccessful();
|
||||
|
||||
expect($connection->fresh()->status)->toBe(BankingConnectionStatus::Revoked);
|
||||
expect($connection->fresh()->trashed())->toBeTrue();
|
||||
expect($account->fresh()->banking_connection_id)->toBeNull();
|
||||
expect($account->fresh()->external_account_id)->toBeNull();
|
||||
expect(User::withTrashed()->find($user->id)?->deleted_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('cancels deletion when enable banking revocation is not confirmed', function () {
|
||||
$user = User::factory()->onboarded()->create([
|
||||
'email' => 'banking@example.com',
|
||||
'name' => 'Banking User',
|
||||
]);
|
||||
BankingConnection::factory()->for($user)->create();
|
||||
|
||||
$mockProvider = Mockery::mock(BankingProviderInterface::class);
|
||||
$mockProvider->shouldNotReceive('revokeSession');
|
||||
app()->instance(BankingProviderInterface::class, $mockProvider);
|
||||
|
||||
$this->artisan('user:delete', ['email' => 'banking@example.com'])
|
||||
->expectsConfirmation("Are you sure you want to mark user 'Banking User' (banking@example.com) as deleted? Their data will be preserved.", 'yes')
|
||||
->expectsConfirmation("User 'banking@example.com' has 1 Enable Banking connection(s). Revoke them and keep linked accounts as manual accounts?", 'no')
|
||||
->expectsOutput('Deletion cancelled.')
|
||||
->assertSuccessful();
|
||||
|
||||
expect(User::query()->where('email', 'banking@example.com')->exists())->toBeTrue();
|
||||
expect(BankingConnection::query()->where('user_id', $user->id)->first()?->trashed())->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -36,3 +36,13 @@ test('welcome email is not sent if already received', function () {
|
|||
|
||||
Mail::assertNotQueued(WelcomeEmail::class);
|
||||
});
|
||||
|
||||
test('welcome email is not sent to deleted users', function () {
|
||||
$user = User::factory()->create();
|
||||
$user->delete();
|
||||
|
||||
SendWelcomeEmailJob::dispatchSync($user);
|
||||
|
||||
Mail::assertNotQueued(WelcomeEmail::class);
|
||||
expect(UserMailLog::query()->where('user_id', $user->id)->exists())->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,8 +86,12 @@ test('email verification status is unchanged when the email address is unchanged
|
|||
});
|
||||
|
||||
test('user can delete their account', function () {
|
||||
$this->travelTo(now()->setDate(2026, 4, 22)->setTime(10, 51, 24));
|
||||
|
||||
$user = User::factory()->create();
|
||||
|
||||
$originalEmail = $user->email;
|
||||
|
||||
$response = $this
|
||||
->actingAs($user)
|
||||
->delete(route('profile.destroy'), [
|
||||
|
|
@ -98,8 +102,12 @@ test('user can delete their account', function () {
|
|||
->assertSessionHasNoErrors()
|
||||
->assertRedirect(route('home'));
|
||||
|
||||
$deletedUser = User::withTrashed()->find($user->id);
|
||||
|
||||
$this->assertGuest();
|
||||
expect($user->fresh())->toBeNull();
|
||||
expect(User::query()->find($user->id))->toBeNull();
|
||||
expect($deletedUser?->deleted_at)->not->toBeNull();
|
||||
expect($deletedUser?->email)->toBe('20260422105124_'.$originalEmail);
|
||||
});
|
||||
|
||||
test('correct password must be provided to delete account', function () {
|
||||
|
|
|
|||
Loading…
Reference in New Issue