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:
parent
8df44c2ef4
commit
91929477ab
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue