feat(encryption): commands to warn and remove inactive encrypted-data accounts (#633)

## 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).
This commit is contained in:
Víctor Falcón 2026-07-03 17:57:04 +02:00 committed by GitHub
parent afa80b60fe
commit 477e4d50e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 332 additions and 0 deletions

View File

@ -0,0 +1,27 @@
<?php
namespace App\Console\Commands\Concerns;
use App\Models\User;
use Illuminate\Database\Eloquent\Builder;
trait FindsUsersWithLegacyEncryption
{
/**
* Users who still have at least one client-side encrypted transaction or account
* (a non-null `*_iv` column is the source of truth, not the stale `accounts.encrypted` flag).
*
* @return Builder<User>
*/
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');
});
});
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class DeleteEncryptedDataAccountsCommand extends Command
{
use FindsUsersWithLegacyEncryption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'encryption:delete-accounts
{--days=7 : Spare users active within the last N days}
{--dry-run : List the accounts that would be deleted without touching them}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Soft-delete and anonymize users who still have legacy encrypted data and did not sign in within the grace window';
/**
* Execute the console command.
*/
public function handle(): int
{
$days = (int) $this->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;
}
}

View File

@ -0,0 +1,93 @@
<?php
namespace App\Console\Commands;
use App\Console\Commands\Concerns\FindsUsersWithLegacyEncryption;
use App\Jobs\SendUpdateEmailJob;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Collection;
class NotifyEncryptedDataRemovalCommand extends Command
{
use FindsUsersWithLegacyEncryption;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'encryption:notify-removal
{--dry-run : List affected users without sending anything}
{--force : Skip the confirmation prompt}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Warn users with legacy encrypted data that their account will be deleted unless they sign in within 7 days';
private const VIEW = 'encrypted-data-removal';
private const IDENTIFIER = 'encrypted-data-removal';
private const SUBJECT = 'Action required: sign in to keep your Whisper Money account';
/**
* Execute the console command.
*/
public function handle(): int
{
$users = $this->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<int, User> $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());
}
}

View File

@ -0,0 +1,30 @@
<x-mail::message>
# 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**:
<x-mail::button :url="route('login')">
Sign in to Whisper Money
</x-mail::button>
## 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,<br>
The Whisper Money team
</x-mail::message>

View File

@ -0,0 +1,50 @@
<?php
use App\Models\Account;
use App\Models\Transaction;
use App\Models\User;
use function Pest\Laravel\artisan;
test('it soft-deletes users with encrypted data who did not sign in within the grace window', function () {
$inactive = User::factory()->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();
});

View File

@ -0,0 +1,47 @@
<?php
use App\Jobs\SendUpdateEmailJob;
use App\Models\Account;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Support\Facades\Queue;
use function Pest\Laravel\artisan;
beforeEach(fn () => 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);
});