diff --git a/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php b/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php index ed4fb557..af238d5c 100644 --- a/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php +++ b/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php @@ -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}."); diff --git a/app/Console/Commands/DeleteUserCommand.php b/app/Console/Commands/DeleteUserCommand.php index ed1632b8..56a88c43 100644 --- a/app/Console/Commands/DeleteUserCommand.php +++ b/app/Console/Commands/DeleteUserCommand.php @@ -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(); + } } diff --git a/app/Console/Commands/SyncBankingConnections.php b/app/Console/Commands/SyncBankingConnections.php index 7568d767..119a179e 100644 --- a/app/Console/Commands/SyncBankingConnections.php +++ b/app/Console/Commands/SyncBankingConnections.php @@ -41,6 +41,7 @@ class SyncBankingConnections extends Command } $query = BankingConnection::query() + ->whereHas('user') ->where(function ($query) { $query->where('status', BankingConnectionStatus::Active) ->orWhere(function ($query) { diff --git a/app/Http/Controllers/Settings/ProfileController.php b/app/Http/Controllers/Settings/ProfileController.php index efb296ac..16e57857 100644 --- a/app/Http/Controllers/Settings/ProfileController.php +++ b/app/Http/Controllers/Settings/ProfileController.php @@ -71,7 +71,7 @@ class ProfileController extends Controller Auth::logout(); - $user->delete(); + $user->markAsDeleted(); $request->session()->invalidate(); $request->session()->regenerateToken(); diff --git a/app/Jobs/Drip/SendFeedbackEmailJob.php b/app/Jobs/Drip/SendFeedbackEmailJob.php index 7bf7f779..98baa741 100644 --- a/app/Jobs/Drip/SendFeedbackEmailJob.php +++ b/app/Jobs/Drip/SendFeedbackEmailJob.php @@ -24,6 +24,10 @@ class SendFeedbackEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->user->hasReceivedEmail(DripEmailType::Feedback)) { return; } diff --git a/app/Jobs/Drip/SendImportHelpEmailJob.php b/app/Jobs/Drip/SendImportHelpEmailJob.php index 6314028d..71708a2a 100644 --- a/app/Jobs/Drip/SendImportHelpEmailJob.php +++ b/app/Jobs/Drip/SendImportHelpEmailJob.php @@ -24,6 +24,10 @@ class SendImportHelpEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->user->hasReceivedEmail(DripEmailType::ImportHelp)) { return; } diff --git a/app/Jobs/Drip/SendOnboardingReminderEmailJob.php b/app/Jobs/Drip/SendOnboardingReminderEmailJob.php index 5d92235e..7b7c97d8 100644 --- a/app/Jobs/Drip/SendOnboardingReminderEmailJob.php +++ b/app/Jobs/Drip/SendOnboardingReminderEmailJob.php @@ -24,6 +24,10 @@ class SendOnboardingReminderEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->user->hasReceivedEmail(DripEmailType::OnboardingReminder)) { return; } diff --git a/app/Jobs/Drip/SendPromoCodeEmailJob.php b/app/Jobs/Drip/SendPromoCodeEmailJob.php index ef5c39c7..11d87dce 100644 --- a/app/Jobs/Drip/SendPromoCodeEmailJob.php +++ b/app/Jobs/Drip/SendPromoCodeEmailJob.php @@ -24,6 +24,10 @@ class SendPromoCodeEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->user->hasReceivedEmail(DripEmailType::PromoCode)) { return; } diff --git a/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php b/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php index 08eb1038..1f9283ed 100644 --- a/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php +++ b/app/Jobs/Drip/SendSubscriptionCancelledEmailJob.php @@ -24,6 +24,10 @@ class SendSubscriptionCancelledEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->user->hasReceivedEmail(DripEmailType::SubscriptionCancelled)) { return; } diff --git a/app/Jobs/Drip/SendWelcomeEmailJob.php b/app/Jobs/Drip/SendWelcomeEmailJob.php index 0d3739ab..6f2f0c9c 100644 --- a/app/Jobs/Drip/SendWelcomeEmailJob.php +++ b/app/Jobs/Drip/SendWelcomeEmailJob.php @@ -24,6 +24,10 @@ class SendWelcomeEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->user->hasReceivedEmail(DripEmailType::Welcome)) { return; } diff --git a/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php b/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php index a0c0fff3..6d783b2a 100644 --- a/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php +++ b/app/Jobs/SendDailyBankTransactionsSyncedEmailJob.php @@ -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) diff --git a/app/Jobs/SendUpdateEmailJob.php b/app/Jobs/SendUpdateEmailJob.php index e9f5e591..db9326e4 100644 --- a/app/Jobs/SendUpdateEmailJob.php +++ b/app/Jobs/SendUpdateEmailJob.php @@ -42,6 +42,10 @@ class SendUpdateEmailJob implements ShouldQueue public function handle(): void { + if (! $this->user->canReceiveEmails()) { + return; + } + if ($this->hasReceivedUpdate()) { return; } diff --git a/app/Jobs/SyncAllBankingConnectionsJob.php b/app/Jobs/SyncAllBankingConnectionsJob.php index 2ab2f3ca..e0b8409d 100644 --- a/app/Jobs/SyncAllBankingConnectionsJob.php +++ b/app/Jobs/SyncAllBankingConnectionsJob.php @@ -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) { diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 5732b154..6f18d2ba 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -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, diff --git a/app/Models/User.php b/app/Models/User.php index 7f9f3fe8..0f935505 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 */ - 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}"; + } } diff --git a/database/migrations/2026_04_22_084113_add_deleted_at_to_users_table.php b/database/migrations/2026_04_22_084113_add_deleted_at_to_users_table.php new file mode 100644 index 00000000..a7bda587 --- /dev/null +++ b/database/migrations/2026_04_22_084113_add_deleted_at_to_users_table.php @@ -0,0 +1,28 @@ +softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/lang/es.json b/lang/es.json index 7592b803..2d98f9ab 100644 --- a/lang/es.json +++ b/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." } diff --git a/resources/js/components/delete-user.tsx b/resources/js/components/delete-user.tsx index 69a808e7..b1fac13f 100644 --- a/resources/js/components/delete-user.tsx +++ b/resources/js/components/delete-user.tsx @@ -31,7 +31,7 @@ export default function DeleteUser() { @@ -49,7 +49,7 @@ export default function DeleteUser() {
@@ -79,7 +79,7 @@ export default function DeleteUser() { {__( - '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.', )} diff --git a/resources/js/pages/auth/login.tsx b/resources/js/pages/auth/login.tsx index a60a7145..4faf9316 100644 --- a/resources/js/pages/auth/login.tsx +++ b/resources/js/pages/auth/login.tsx @@ -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 && (
{__("Don't have an account?")}{' '} - + {__('Sign up')}
diff --git a/tests/Browser/AuthenticationTest.php b/tests/Browser/AuthenticationTest.php index 464db8a8..b32c3826 100644 --- a/tests/Browser/AuthenticationTest.php +++ b/tests/Browser/AuthenticationTest.php @@ -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') diff --git a/tests/Browser/OnboardingFlowTest.php b/tests/Browser/OnboardingFlowTest.php index 95e9d0c1..3b2a746f 100644 --- a/tests/Browser/OnboardingFlowTest.php +++ b/tests/Browser/OnboardingFlowTest.php @@ -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') diff --git a/tests/Feature/Auth/AuthenticationTest.php b/tests/Feature/Auth/AuthenticationTest.php index a75928c7..077259b3 100644 --- a/tests/Feature/Auth/AuthenticationTest.php +++ b/tests/Feature/Auth/AuthenticationTest.php @@ -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(); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index ab1411d9..8d57f7cf 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -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'); +}); diff --git a/tests/Feature/Console/SendUpdateEmailCommandTest.php b/tests/Feature/Console/SendUpdateEmailCommandTest.php index 95b86d69..cce1e13d 100644 --- a/tests/Feature/Console/SendUpdateEmailCommandTest.php +++ b/tests/Feature/Console/SendUpdateEmailCommandTest.php @@ -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')]); diff --git a/tests/Feature/DeleteUserCommandTest.php b/tests/Feature/DeleteUserCommandTest.php index 411b8fae..b4a38fda 100644 --- a/tests/Feature/DeleteUserCommandTest.php +++ b/tests/Feature/DeleteUserCommandTest.php @@ -1,9 +1,12 @@ 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(); }); diff --git a/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php b/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php index a9f83fd1..0fe7c45c 100644 --- a/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php +++ b/tests/Feature/Jobs/Drip/SendWelcomeEmailJobTest.php @@ -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(); +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index 9a57b1df..ed2a79ba 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -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 () {