From 477e4d50e26760d387dee1784db7093c05ce593e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Fri, 3 Jul 2026 17:57:04 +0200 Subject: [PATCH] feat(encryption): commands to warn and remove inactive encrypted-data accounts (#633) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Two artisan commands to retire the remaining legacy client-side encrypted data in production (transactions with `description_iv`/`notes_iv`, accounts with `name_iv`). - **`encryption:notify-removal`** — queues a re-engagement/deletion warning to every user who still has an encrypted transaction or account. Reuses `SendUpdateEmailJob` (queue `emails`, dedup via `UserMailLog`, 50/day pacing). The email is framed as an **inactivity** cleanup (no mention of encryption) and pitches recent additions (AI insights, bank integrations, etc.). - **`encryption:delete-accounts`** — soft-deletes and anonymizes (`User::markAsDeleted()`) those users **only** if they have not signed in within the grace window (`--days`, default 7, via `last_active_at`). Anyone who came back is spared, and the demo account is always excluded. Both support `--dry-run` and `--force`, and share the target query via the `FindsUsersWithLegacyEncryption` concern so the warning and the deletion always target the same set. ## Intended manual run `notify-removal --dry-run` → run for real → wait 7 days → `delete-accounts --dry-run` → run for real. ## Notes / scope - Deletion is a **soft-delete + email anonymization** (accounts become unusable); it does not hard-purge rows, and does not cancel Stripe subscriptions or revoke bank connections (unlike `user:delete`). Legacy inactive accounts are unlikely to have either; handle separately if needed. - Email copy is plain English (matches the existing `mail/updates/*` convention), no new `__()` keys. ## Tests `tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php` and `DeleteEncryptedDataAccountsCommandTest.php` — 6 passing (targeting, queue, dry-run, grace window, demo exclusion). --- .../FindsUsersWithLegacyEncryption.php | 27 ++++++ .../DeleteEncryptedDataAccountsCommand.php | 85 +++++++++++++++++ .../NotifyEncryptedDataRemovalCommand.php | 93 +++++++++++++++++++ .../updates/encrypted-data-removal.blade.php | 30 ++++++ ...DeleteEncryptedDataAccountsCommandTest.php | 50 ++++++++++ .../NotifyEncryptedDataRemovalCommandTest.php | 47 ++++++++++ 6 files changed, 332 insertions(+) create mode 100644 app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php create mode 100644 app/Console/Commands/DeleteEncryptedDataAccountsCommand.php create mode 100644 app/Console/Commands/NotifyEncryptedDataRemovalCommand.php create mode 100644 resources/views/mail/updates/encrypted-data-removal.blade.php create mode 100644 tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php create mode 100644 tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php diff --git a/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php b/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php new file mode 100644 index 00000000..fce90ce2 --- /dev/null +++ b/app/Console/Commands/Concerns/FindsUsersWithLegacyEncryption.php @@ -0,0 +1,27 @@ + + */ + protected function usersWithLegacyEncryption(): Builder + { + return User::query()->where(function (Builder $query): void { + $query->whereHas('transactions', function (Builder $transactions): void { + $transactions->whereNotNull('description_iv') + ->orWhereNotNull('notes_iv'); + })->orWhereHas('accounts', function (Builder $accounts): void { + $accounts->whereNotNull('name_iv'); + }); + }); + } +} diff --git a/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php b/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php new file mode 100644 index 00000000..1b45a774 --- /dev/null +++ b/app/Console/Commands/DeleteEncryptedDataAccountsCommand.php @@ -0,0 +1,85 @@ +option('days'); + $cutoff = now()->subDays($days); + + $users = $this->usersWithLegacyEncryption() + ->where('email', '!=', config('app.demo.email')) + ->where(function (Builder $query) use ($cutoff): void { + $query->whereNull('last_active_at') + ->orWhere('last_active_at', '<', $cutoff); + }) + ->get(); + + if ($users->isEmpty()) { + $this->info('No accounts to delete.'); + + return self::SUCCESS; + } + + $this->info("Found {$users->count()} account(s) with encrypted data and no activity in the last {$days} day(s)."); + + if ($this->option('dry-run')) { + $this->table(['Email', 'Last active'], $users->map(fn (User $user) => [ + $user->email, + $user->last_active_at?->toDateTimeString() ?? 'never', + ])->all()); + $this->info('[dry-run] No accounts deleted.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm("Soft-delete and anonymize {$users->count()} account(s)?", false)) { + $this->info('Cancelled.'); + + return self::SUCCESS; + } + + $progressBar = $this->output->createProgressBar($users->count()); + $progressBar->start(); + + foreach ($users as $user) { + $user->markAsDeleted(); + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(); + $this->info("Deleted {$users->count()} account(s)."); + + return self::SUCCESS; + } +} diff --git a/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php b/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php new file mode 100644 index 00000000..fe14f651 --- /dev/null +++ b/app/Console/Commands/NotifyEncryptedDataRemovalCommand.php @@ -0,0 +1,93 @@ +usersWithLegacyEncryption()->get(); + + if ($users->isEmpty()) { + $this->info('No users with encrypted data found.'); + + return self::SUCCESS; + } + + $this->info("Found {$users->count()} user(s) with encrypted data."); + + if ($this->option('dry-run')) { + $this->renderTable($users); + $this->info('[dry-run] No emails sent.'); + + return self::SUCCESS; + } + + if (! $this->option('force') && ! $this->confirm("Send the deletion warning to {$users->count()} user(s)?", true)) { + $this->info('Cancelled.'); + + return self::SUCCESS; + } + + $progressBar = $this->output->createProgressBar($users->count()); + $progressBar->start(); + + foreach ($users->values() as $index => $user) { + // Spread over the 'emails' queue at 50/day to match the existing bulk-email convention. + SendUpdateEmailJob::dispatch($user, self::VIEW, self::IDENTIFIER, self::SUBJECT) + ->delay(now()->addDays((int) floor($index / 50))); + + $progressBar->advance(); + } + + $progressBar->finish(); + $this->newLine(); + $this->info("Queued {$users->count()} warning email(s) to the 'emails' queue (50/day)."); + + return self::SUCCESS; + } + + /** + * @param Collection $users + */ + private function renderTable($users): void + { + $this->table(['Email', 'Last active'], $users->map(fn (User $user) => [ + $user->email, + $user->last_active_at?->toDateTimeString() ?? 'never', + ])->all()); + } +} diff --git a/resources/views/mail/updates/encrypted-data-removal.blade.php b/resources/views/mail/updates/encrypted-data-removal.blade.php new file mode 100644 index 00000000..59b80e64 --- /dev/null +++ b/resources/views/mail/updates/encrypted-data-removal.blade.php @@ -0,0 +1,30 @@ + +# We've missed you at Whisper Money + +Hi {{ $user->name }}, + +We noticed you haven't signed in to Whisper Money for a while. To keep things tidy, **accounts that stay inactive will be removed** — and yours is on that list. + +If you'd like to keep your account and your data, just **sign in within the next 7 days**: + + +Sign in to Whisper Money + + +## A lot has changed since you were last here + +We've been busy adding features to make managing your money effortless: + +- **AI-powered insights** that categorise your transactions automatically. +- **Bank integrations** that import your transactions for you — no more manual entry. +- And plenty of smaller improvements across the app. + +It's a great time to come back and take a look. + +If you don't sign in before then, your account and all of its data will be permanently deleted. + +If you have any questions, **just reply to this email** — we read every message personally. + +Thanks,
+The Whisper Money team +
diff --git a/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php b/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php new file mode 100644 index 00000000..8489411f --- /dev/null +++ b/tests/Feature/Console/DeleteEncryptedDataAccountsCommandTest.php @@ -0,0 +1,50 @@ +create(['last_active_at' => now()->subDays(30)]); + Transaction::factory()->for($inactive)->create(); + + $neverActive = User::factory()->create(['last_active_at' => null]); + Account::factory()->for($neverActive)->create(['name_iv' => 'abcdef0123456789']); + + $recentlyActive = User::factory()->create(['last_active_at' => now()->subDay()]); + Transaction::factory()->for($recentlyActive)->create(); + + $clean = User::factory()->create(['last_active_at' => now()->subDays(30)]); + Transaction::factory()->for($clean)->plaintext()->create(); + + artisan('encryption:delete-accounts', ['--force' => true])->assertSuccessful(); + + // The default soft-delete scope hides trashed users, so a missing row means it was deleted. + expect(User::whereKey($inactive->id)->exists())->toBeFalse(); + expect(User::whereKey($neverActive->id)->exists())->toBeFalse(); + expect(User::whereKey($recentlyActive->id)->exists())->toBeTrue(); + expect(User::whereKey($clean->id)->exists())->toBeTrue(); +}); + +test('it never deletes the demo account', function () { + $demo = User::factory()->create([ + 'email' => config('app.demo.email'), + 'last_active_at' => now()->subDays(30), + ]); + Transaction::factory()->for($demo)->create(); + + artisan('encryption:delete-accounts', ['--force' => true])->assertSuccessful(); + + expect(User::whereKey($demo->id)->exists())->toBeTrue(); +}); + +test('dry run deletes nothing', function () { + $user = User::factory()->create(['last_active_at' => null]); + Transaction::factory()->for($user)->create(); + + artisan('encryption:delete-accounts', ['--dry-run' => true])->assertSuccessful(); + + expect(User::whereKey($user->id)->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php b/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php new file mode 100644 index 00000000..5077fac7 --- /dev/null +++ b/tests/Feature/Console/NotifyEncryptedDataRemovalCommandTest.php @@ -0,0 +1,47 @@ + Queue::fake()); + +test('it queues a warning email for users with encrypted transactions or accounts', function () { + $encryptedTransaction = User::factory()->create(); + Transaction::factory()->for($encryptedTransaction)->create(); // description_iv set by default + + $encryptedAccount = User::factory()->create(); + Account::factory()->for($encryptedAccount)->create(['name_iv' => 'abcdef0123456789']); + + $clean = User::factory()->create(); + Transaction::factory()->for($clean)->plaintext()->create(); + + artisan('encryption:notify-removal', ['--force' => true])->assertSuccessful(); + + Queue::assertPushed(SendUpdateEmailJob::class, 2); + Queue::assertPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($encryptedTransaction)); + Queue::assertPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($encryptedAccount)); + Queue::assertNotPushed(SendUpdateEmailJob::class, fn (SendUpdateEmailJob $job) => $job->user->is($clean)); +}); + +test('it queues on the emails queue', function () { + $user = User::factory()->create(); + Transaction::factory()->for($user)->create(); + + artisan('encryption:notify-removal', ['--force' => true])->assertSuccessful(); + + Queue::assertPushedOn('emails', SendUpdateEmailJob::class); +}); + +test('dry run sends nothing', function () { + $user = User::factory()->create(); + Transaction::factory()->for($user)->create(); + + artisan('encryption:notify-removal', ['--dry-run' => true])->assertSuccessful(); + + Queue::assertNotPushed(SendUpdateEmailJob::class); +});