From 91929477ab94cd721bc8b6c3a7c9a28dcdfb31cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Sat, 6 Jun 2026 11:16:01 +0200 Subject: [PATCH] feat(banking): add command to disconnect connections by id (#497) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds a `banking:disconnect` artisan command to disconnect (soft-delete) one or more banking connections by ID, for ops use on prod. ```bash php artisan banking:disconnect ,, php artisan banking:disconnect , --delete-accounts ``` ## How - Parses comma-separated IDs (trims spaces, dedups). - Reuses the existing `DisconnectBankingConnection` action, which: - Revokes the session on Enable Banking's side (`DELETE /sessions/{id}`) when the connection is an active Enable Banking one. - Sets status to `Revoked` and soft-deletes the connection. - Default behavior unlinks linked accounts (kept as manual accounts). `--delete-accounts` hard-deletes accounts, transactions and balances. - Warns on unknown IDs, reports per-connection results, and exits with failure if any ID was missing or any disconnect threw. - A provider-side revoke failure does not block the soft-delete (action catches + logs a warning), matching the existing `banking:cancel-free-enablebanking` behavior. ## Tests `tests/Feature/DisconnectBankingConnectionsCommandTest.php` — covers multi-ID disconnect + session revoke, `--delete-accounts`, missing IDs, and no-match. All pass. --- .../DisconnectBankingConnectionsCommand.php | 68 ++++++++++++++++ ...isconnectBankingConnectionsCommandTest.php | 81 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 app/Console/Commands/DisconnectBankingConnectionsCommand.php create mode 100644 tests/Feature/DisconnectBankingConnectionsCommandTest.php diff --git a/app/Console/Commands/DisconnectBankingConnectionsCommand.php b/app/Console/Commands/DisconnectBankingConnectionsCommand.php new file mode 100644 index 00000000..fba318dc --- /dev/null +++ b/app/Console/Commands/DisconnectBankingConnectionsCommand.php @@ -0,0 +1,68 @@ +argument('ids'))) + ->map(fn (string $id): string => trim($id)) + ->filter() + ->unique() + ->values(); + + if ($ids->isEmpty()) { + $this->error('No connection IDs provided.'); + + return Command::FAILURE; + } + + $deleteAccounts = $this->option('delete-accounts'); + + $connections = BankingConnection::query() + ->with('accounts') + ->whereIn('id', $ids) + ->get(); + + $missing = $ids->diff($connections->pluck('id')); + + foreach ($missing as $id) { + $this->warn("Connection not found: {$id}"); + } + + if ($connections->isEmpty()) { + $this->error('No matching banking connections found.'); + + return Command::FAILURE; + } + + $disconnected = 0; + + foreach ($connections as $connection) { + try { + $disconnectBankingConnection->handle($connection, $deleteAccounts); + $this->info("Disconnected connection {$connection->id} ({$connection->aspsp_name})."); + $disconnected++; + } catch (\Throwable $e) { + $this->error("Failed to disconnect {$connection->id}: {$e->getMessage()}"); + } + } + + $this->info("Disconnected {$disconnected} of {$connections->count()} connection(s)."); + + return $missing->isEmpty() && $disconnected === $connections->count() + ? Command::SUCCESS + : Command::FAILURE; + } +} diff --git a/tests/Feature/DisconnectBankingConnectionsCommandTest.php b/tests/Feature/DisconnectBankingConnectionsCommandTest.php new file mode 100644 index 00000000..da132371 --- /dev/null +++ b/tests/Feature/DisconnectBankingConnectionsCommandTest.php @@ -0,0 +1,81 @@ +create(); + $first = BankingConnection::factory()->for($user)->create(); + $second = BankingConnection::factory()->for($user)->create(); + $account = Account::factory()->for($user)->create([ + 'banking_connection_id' => $first->id, + 'external_account_id' => 'ext-123', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('revokeSession')->once()->with($first->session_id); + $mockProvider->shouldReceive('revokeSession')->once()->with($second->session_id); + app()->instance(BankingProviderInterface::class, $mockProvider); + + artisan('banking:disconnect', ['ids' => "{$first->id}, {$second->id}"]) + ->expectsOutputToContain('Disconnected 2 of 2 connection(s).') + ->assertSuccessful(); + + expect($first->fresh()->trashed())->toBeTrue(); + expect($first->fresh()->status)->toBe(BankingConnectionStatus::Revoked); + expect($second->fresh()->trashed())->toBeTrue(); + + $account->refresh(); + expect($account->banking_connection_id)->toBeNull(); + expect($account->external_account_id)->toBeNull(); + expect($account->trashed())->toBeFalse(); +}); + +test('hard deletes linked accounts with delete-accounts flag', function () { + $user = User::factory()->create(); + $connection = BankingConnection::factory()->for($user)->create(); + $account = Account::factory()->for($user)->create([ + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'ext-456', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('revokeSession')->once()->with($connection->session_id); + app()->instance(BankingProviderInterface::class, $mockProvider); + + artisan('banking:disconnect', ['ids' => $connection->id, '--delete-accounts' => true]) + ->assertSuccessful(); + + expect($connection->fresh()->trashed())->toBeTrue(); + expect($account->fresh()->trashed())->toBeTrue(); +}); + +test('warns about missing ids and fails', function () { + $user = User::factory()->create(); + $connection = BankingConnection::factory()->for($user)->create(); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('revokeSession')->once()->with($connection->session_id); + app()->instance(BankingProviderInterface::class, $mockProvider); + + artisan('banking:disconnect', ['ids' => "{$connection->id},missing-id"]) + ->expectsOutputToContain('Connection not found: missing-id') + ->assertFailed(); + + expect($connection->fresh()->trashed())->toBeTrue(); +}); + +test('fails when no matching connections found', function () { + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldNotReceive('revokeSession'); + app()->instance(BankingProviderInterface::class, $mockProvider); + + artisan('banking:disconnect', ['ids' => 'nope-1,nope-2']) + ->expectsOutputToContain('No matching banking connections found.') + ->assertFailed(); +});