feat(banking): add command to disconnect connections by id (#497)

## 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 <id1>,<id2>,<id3>
php artisan banking:disconnect <id1>,<id2> --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.
This commit is contained in:
Víctor Falcón 2026-06-06 11:16:01 +02:00 committed by GitHub
parent 8df44c2ef4
commit 91929477ab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 149 additions and 0 deletions

View File

@ -0,0 +1,68 @@
<?php
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
class DisconnectBankingConnectionsCommand extends Command
{
protected $signature = 'banking:disconnect
{ids : One or more banking connection IDs, comma-separated}
{--delete-accounts : Also delete linked accounts, transactions and balances}';
protected $description = 'Disconnect (soft-delete) banking connections and revoke their session on the provider';
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
{
$ids = collect(explode(',', (string) $this->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;
}
}

View File

@ -0,0 +1,81 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use function Pest\Laravel\artisan;
test('disconnects multiple connections by comma-separated ids and revokes sessions', function () {
$user = User::factory()->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();
});