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(); +});