From ca084ce5d197b13d4060cc8a51fea3ff94bb3892 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Thu, 23 Jul 2026 10:26:11 +0200 Subject: [PATCH] feat(commands): delete transactions and balances of a user's non-connected accounts (#728) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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). --- .../DeleteManualAccountDataCommand.php | 69 ++++++++++++++ .../DeleteManualAccountDataCommandTest.php | 91 +++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 app/Console/Commands/DeleteManualAccountDataCommand.php create mode 100644 tests/Feature/Console/DeleteManualAccountDataCommandTest.php diff --git a/app/Console/Commands/DeleteManualAccountDataCommand.php b/app/Console/Commands/DeleteManualAccountDataCommand.php new file mode 100644 index 00000000..418c450c --- /dev/null +++ b/app/Console/Commands/DeleteManualAccountDataCommand.php @@ -0,0 +1,69 @@ +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; + } +} diff --git a/tests/Feature/Console/DeleteManualAccountDataCommandTest.php b/tests/Feature/Console/DeleteManualAccountDataCommandTest.php new file mode 100644 index 00000000..9de187f0 --- /dev/null +++ b/tests/Feature/Console/DeleteManualAccountDataCommandTest.php @@ -0,0 +1,91 @@ +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(); +});