feat(commands): delete transactions and balances of a user's non-connected accounts (#728)

## What

Adds an artisan command that deletes all transactions and balances of a
specific user's **non-connected** (manual) accounts, identified by
email.

```bash
php artisan user:delete-manual-account-data user@example.com
```

## Behavior

- Looks up the user by email (`withTrashed`, matching `user:delete`).
- Selects accounts with `banking_connection_id IS NULL` (manual
accounts).
- Shows a confirmation prompt with the transaction/balance counts and
the number of affected accounts.
- On confirm, deletes inside a `DB::transaction`:
- Transactions via `forceDelete()` (they use `SoftDeletes`, so this also
purges already soft-deleted rows).
- Balances via `delete()` (`AccountBalance` has no `SoftDeletes`, so
it's a hard delete).
- The accounts themselves are **not** deleted — only their transactions
and balances.

Related `label_transaction`, `budget_transactions` and
`category_corrections` rows are cleaned up by existing
`cascadeOnDelete()` foreign keys.

## Tests

Pest feature test covering: manual-only deletion (connected accounts
untouched), cross-user isolation, soft-deleted transactions counted and
purged, connected-only user reports zero, cancellation, and
user-not-found.

QA skipped per request (backend maintenance command, covered by feature
tests).
This commit is contained in:
Víctor Falcón 2026-07-23 10:26:11 +02:00 committed by GitHub
parent bd1ad633f8
commit ca084ce5d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 160 additions and 0 deletions

View File

@ -0,0 +1,69 @@
<?php
namespace App\Console\Commands;
use App\Models\AccountBalance;
use App\Models\Transaction;
use App\Models\User;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
class DeleteManualAccountDataCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'user:delete-manual-account-data {email : The email address of the user}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Delete all transactions and balances of a user\'s non-connected accounts';
/**
* Execute the console command.
*/
public function handle(): int
{
$email = $this->argument('email');
$user = User::withTrashed()->where('email', $email)->first();
if (! $user) {
$this->error("User with email '{$email}' not found.");
return self::FAILURE;
}
$accountIds = $user->accounts()->whereNull('banking_connection_id')->pluck('id');
if ($accountIds->isEmpty()) {
$this->info("User '{$email}' has no non-connected accounts.");
return self::SUCCESS;
}
$transactionCount = Transaction::withTrashed()->whereIn('account_id', $accountIds)->count();
$balanceCount = AccountBalance::query()->whereIn('account_id', $accountIds)->count();
if (! $this->confirm("Delete {$transactionCount} transaction(s) and {$balanceCount} balance(s) across {$accountIds->count()} non-connected account(s) of '{$user->email}'?")) {
$this->info('Deletion cancelled.');
return self::SUCCESS;
}
DB::transaction(function () use ($accountIds): void {
// forceDelete purges soft-deleted transactions too; balances have no SoftDeletes, so delete() is already a hard delete.
Transaction::query()->whereIn('account_id', $accountIds)->forceDelete();
AccountBalance::query()->whereIn('account_id', $accountIds)->delete();
});
$this->info("Deleted {$transactionCount} transaction(s) and {$balanceCount} balance(s) for '{$user->email}'.");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,91 @@
<?php
use App\Models\Account;
use App\Models\AccountBalance;
use App\Models\Transaction;
use App\Models\User;
test('deletes transactions and balances of non-connected accounts only', function () {
$user = User::factory()->onboarded()->create(['email' => 'test@example.com']);
$manual = Account::factory()->for($user)->create();
$connected = Account::factory()->connected()->for($user)->create();
Transaction::factory()->count(3)->create(['user_id' => $user->id, 'account_id' => $manual->id]);
AccountBalance::factory()->count(2)->create(['account_id' => $manual->id]);
Transaction::factory()->count(4)->create(['user_id' => $user->id, 'account_id' => $connected->id]);
AccountBalance::factory()->count(5)->create(['account_id' => $connected->id]);
$this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com'])
->expectsConfirmation("Delete 3 transaction(s) and 2 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'yes')
->assertSuccessful();
expect(Transaction::withTrashed()->where('account_id', $manual->id)->exists())->toBeFalse();
expect(AccountBalance::query()->where('account_id', $manual->id)->exists())->toBeFalse();
expect(Transaction::query()->where('account_id', $connected->id)->count())->toBe(4);
expect(AccountBalance::query()->where('account_id', $connected->id)->count())->toBe(5);
});
test('does not touch other users data', function () {
$user = User::factory()->onboarded()->create(['email' => 'test@example.com']);
$other = User::factory()->onboarded()->create(['email' => 'keep@example.com']);
$account = Account::factory()->for($user)->create();
Transaction::factory()->count(3)->create(['user_id' => $user->id, 'account_id' => $account->id]);
AccountBalance::factory()->count(2)->create(['account_id' => $account->id]);
$otherAccount = Account::factory()->for($other)->create();
Transaction::factory()->count(2)->create(['user_id' => $other->id, 'account_id' => $otherAccount->id]);
AccountBalance::factory()->count(2)->create(['account_id' => $otherAccount->id]);
$this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com'])
->expectsConfirmation("Delete 3 transaction(s) and 2 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'yes')
->assertSuccessful();
expect(Transaction::query()->where('account_id', $account->id)->count())->toBe(0);
expect(AccountBalance::query()->where('account_id', $account->id)->count())->toBe(0);
expect(Transaction::query()->where('account_id', $otherAccount->id)->count())->toBe(2);
expect(AccountBalance::query()->where('account_id', $otherAccount->id)->count())->toBe(2);
});
test('reports zero non-connected accounts when the user has only connected ones', function () {
$user = User::factory()->onboarded()->create(['email' => 'test@example.com']);
Account::factory()->connected()->for($user)->create();
$this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com'])
->expectsOutput("User 'test@example.com' has no non-connected accounts.")
->assertSuccessful();
});
test('cancels when not confirmed', function () {
$user = User::factory()->onboarded()->create(['email' => 'test@example.com']);
$manual = Account::factory()->for($user)->create();
Transaction::factory()->count(3)->create(['user_id' => $user->id, 'account_id' => $manual->id]);
$this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com'])
->expectsConfirmation("Delete 3 transaction(s) and 0 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'no')
->expectsOutput('Deletion cancelled.')
->assertSuccessful();
expect(Transaction::query()->where('account_id', $manual->id)->count())->toBe(3);
});
test('counts and purges soft-deleted transactions', function () {
$user = User::factory()->onboarded()->create(['email' => 'test@example.com']);
$manual = Account::factory()->for($user)->create();
Transaction::factory()->count(2)->create(['user_id' => $user->id, 'account_id' => $manual->id]);
Transaction::factory()->count(1)->create(['user_id' => $user->id, 'account_id' => $manual->id])->each->delete();
$this->artisan('user:delete-manual-account-data', ['email' => 'test@example.com'])
->expectsConfirmation("Delete 3 transaction(s) and 0 balance(s) across 1 non-connected account(s) of 'test@example.com'?", 'yes')
->assertSuccessful();
expect(Transaction::withTrashed()->where('account_id', $manual->id)->count())->toBe(0);
});
test('shows error when user not found', function () {
$this->artisan('user:delete-manual-account-data', ['email' => 'nobody@example.com'])
->expectsOutput("User with email 'nobody@example.com' not found.")
->assertFailed();
});