Cancel Enable Banking connections for free users (#289)

## Summary
- add month-end command to revoke old Enable Banking sessions for users
without a paid plan while keeping their linked accounts as manual
accounts
- extract banking disconnect flow into a reusable action so manual
disconnects and scheduled cleanup share same revoke and detach behavior
- add feature coverage for free-user cleanup, paid-user skips,
under-6-hour skips, non-Enable Banking skips, and revoke failure
fallback

## Testing
- vendor/bin/pint --dirty --format agent
- php artisan test --compact
tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php
tests/Feature/OpenBanking/ConnectionControllerTest.php
This commit is contained in:
Víctor Falcón 2026-04-15 15:23:03 +01:00 committed by GitHub
parent 319ca758e1
commit 2f583c0113
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 398 additions and 31 deletions

View File

@ -0,0 +1,44 @@
<?php
namespace App\Actions\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use Illuminate\Support\Facades\Log;
class DisconnectBankingConnection
{
public function __construct(private BankingProviderInterface $provider) {}
public function handle(BankingConnection $connection, bool $deleteAccounts = false): void
{
if ($connection->isEnableBanking() && $connection->session_id && $connection->isActive()) {
try {
$this->provider->revokeSession($connection->session_id);
} catch (\Throwable $e) {
Log::warning('Failed to revoke EnableBanking session', [
'connection_id' => $connection->id,
'session_id' => $connection->session_id,
'error' => $e->getMessage(),
]);
}
}
if ($deleteAccounts) {
$connection->accounts->each(function ($account): void {
$account->transactions()->delete();
$account->balances()->delete();
$account->delete();
});
} else {
$connection->accounts()->update([
'banking_connection_id' => null,
'external_account_id' => null,
]);
}
$connection->update(['status' => BankingConnectionStatus::Revoked]);
$connection->delete();
}
}

View File

@ -0,0 +1,70 @@
<?php
namespace App\Console\Commands;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus;
use App\Mail\EnableBankingConnectionsCancelledEmail;
use App\Models\BankingConnection;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class CancelFreeEnableBankingConnectionsCommand extends Command
{
protected $signature = 'banking:cancel-free-enablebanking';
protected $description = 'Close Enable Banking connections for free users at month end';
public function handle(DisconnectBankingConnection $disconnectBankingConnection): int
{
$cutoff = now()->subHours(6);
$connections = BankingConnection::query()
->with(['user', 'accounts'])
->where('provider', 'enablebanking')
->where('status', '!=', BankingConnectionStatus::Revoked)
->where('created_at', '<=', $cutoff)
->get();
$count = $connections->count();
if ($count === 0) {
$this->info('No eligible Enable Banking connections found for free users.');
return Command::SUCCESS;
}
$revoked = 0;
$skipped = 0;
$connections
->groupBy('user_id')
->each(function ($userConnections) use ($disconnectBankingConnection, &$revoked, &$skipped): void {
$user = $userConnections->first()?->user;
if (! $user) {
return;
}
if ($user->hasProPlan()) {
$skipped += $userConnections->count();
return;
}
foreach ($userConnections as $connection) {
$disconnectBankingConnection->handle($connection);
$revoked++;
}
Mail::to($user)->send(new EnableBankingConnectionsCancelledEmail(
$user,
$userConnections->count(),
));
});
$this->info("Revoked {$revoked} Enable Banking connection(s). Skipped paid users: {$skipped}.");
return Command::SUCCESS;
}
}

View File

@ -2,18 +2,20 @@
namespace App\Http\Controllers\OpenBanking;
use App\Contracts\BankingProviderInterface;
use App\Actions\OpenBanking\DisconnectBankingConnection;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\DestroyConnectionRequest;
use App\Http\Requests\OpenBanking\UpdateConnectionCredentialsRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\BinanceClient;
use App\Services\Banking\BitpandaClient;
use App\Services\Banking\IndexaCapitalClient;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Inertia\Inertia;
use Inertia\Response;
@ -27,8 +29,10 @@ class ConnectionController extends Controller
*/
public function index(): Response
{
$connections = auth()->user()
->bankingConnections()
/** @var User $user */
$user = Auth::user();
$connections = $user->bankingConnections()
->withCount('accounts')
->orderByDesc('created_at')
->get()
@ -46,7 +50,7 @@ class ConnectionController extends Controller
*/
public function sync(BankingConnection $connection): RedirectResponse
{
if ($connection->user_id !== auth()->id()) {
if ($connection->user_id !== Auth::id()) {
abort(403);
}
@ -127,34 +131,9 @@ class ConnectionController extends Controller
/**
* Revoke and delete a banking connection.
*/
public function destroy(DestroyConnectionRequest $request, BankingConnection $connection, BankingProviderInterface $provider): RedirectResponse
public function destroy(DestroyConnectionRequest $request, BankingConnection $connection, DisconnectBankingConnection $disconnectBankingConnection): RedirectResponse
{
if ($connection->isEnableBanking() && $connection->session_id && $connection->isActive()) {
try {
$provider->revokeSession($connection->session_id);
} catch (\Throwable $e) {
Log::warning('Failed to revoke EnableBanking session', [
'session_id' => $connection->session_id,
'error' => $e->getMessage(),
]);
}
}
if ($request->boolean('delete_accounts')) {
$connection->accounts->each(function ($account) {
$account->transactions()->delete();
$account->balances()->delete();
$account->delete();
});
} else {
$connection->accounts()->update([
'banking_connection_id' => null,
'external_account_id' => null,
]);
}
$connection->update(['status' => BankingConnectionStatus::Revoked]);
$connection->delete();
$disconnectBankingConnection->handle($connection, $request->boolean('delete_accounts'));
return redirect()->route('settings.connections.index')
->with('success', 'Banking connection disconnected.');

View File

@ -0,0 +1,63 @@
<?php
namespace App\Mail;
use App\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\Middleware\RateLimited;
use Illuminate\Queue\SerializesModels;
class EnableBankingConnectionsCancelledEmail extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/** @var int */
public $tries = 5;
/** @var array<int, int> */
public $backoff = [2, 5, 10, 30];
public function __construct(
public User $user,
public int $removedConnectionsCount,
) {
$this->onQueue('emails');
}
public function envelope(): Envelope
{
return new Envelope(
from: new Address(
config('mail.from.address', 'no-reply@whisper.money'),
config('mail.from.name', 'Whisper Money'),
),
subject: __('Your bank connections were disconnected'),
);
}
public function content(): Content
{
return new Content(
markdown: 'mail.enable-banking-connections-cancelled',
with: [
'userName' => $this->user->name,
'removedConnectionsCount' => $this->removedConnectionsCount,
],
);
}
/**
* Get the middleware the job should pass through.
*
* @return array<int, object>
*/
public function middleware(): array
{
return [(new RateLimited('emails'))->releaseAfter(1)];
}
}

View File

@ -589,6 +589,7 @@
"Get started at no cost. No bank connections included.": "Empieza sin coste. Sin conexiones bancarias incluidas.",
"Get started quickly with your existing financial data.": "Comienza r\u00e1pidamente con tus datos financieros existentes.",
"Github": "Github",
"We disconnected your bank connection to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.|We disconnected your :count bank connections to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.": "Desconectamos tu conexi\u00f3n bancaria para mantener tu cuenta con acceso gratuito. La sincronizaci\u00f3n bancaria autom\u00e1tica est\u00e1 ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.|Desconectamos tus :count conexiones bancarias para mantener tu cuenta con acceso gratuito. La sincronizaci\u00f3n bancaria autom\u00e1tica est\u00e1 ahora en pausa, pero todas tus cuentas, transacciones y saldos permanecen en Whisper Money.",
"Go to Dashboard": "Ir al Panel",
"Go to your account's transaction history": "Ve al historial de transacciones de tu cuenta",
"Go to your dashboard and click \"Import Transactions\". Select your CSV file and I'll map the columns automatically.": "Ve a tu panel y haz clic en \"Importar Transacciones\". Selecciona tu archivo CSV y mapear\u00e9 las columnas autom\u00e1ticamente.",
@ -617,6 +618,8 @@
"Hi! It's Victor and \u00c1lvaro, the founders of Whisper Money. We noticed you've completed your setup but haven't imported any transactions yet. Let us help you get started!": "\u00a1Hola! Somos V\u00edctor y \u00c1lvaro, los fundadores de Whisper Money. Notamos que completaste tu configuraci\u00f3n pero a\u00fan no has importado transacciones. \u00a1D\u00e9janos ayudarte a empezar!",
"Hi! It's Victor and \u00c1lvaro, the founders of Whisper Money. We see you've already started importing your transactions - that's awesome! You're well on your way to taking control of your finances while keeping your data private.": "\u00a1Hola! Somos V\u00edctor y \u00c1lvaro, los fundadores de Whisper Money. Vemos que ya has empezado a importar tus transacciones, \u00a1genial! Est\u00e1s en camino de tomar el control de tus finanzas mientras mantienes tus datos privados.",
"Hi! It's Victor here, the founder of Whisper Money. You've been using the app for a few days now, and I'd love to hear how it's working for you.": "\u00a1Hola! Soy V\u00edctor, el fundador de Whisper Money. Llevas unos d\u00edas usando la app y me encantar\u00eda saber c\u00f3mo te est\u00e1 funcionando.",
"You can continue using the app on the free plan, and you can reconnect your bank later if you upgrade again.": "Puedes seguir usando la app con el plan gratuito y volver a conectar tu banco m\u00e1s adelante si actualizas de nuevo.",
"Your bank connections were disconnected": "Tus conexiones bancarias fueron desconectadas",
"Hi! It's Victor, the founder of Whisper Money. I noticed you signed up but haven't completed your setup yet. I wanted to check in personally and see if something went wrong or if you need any help.": "\u00a1Hola! Soy V\u00edctor, el fundador de Whisper Money. Not\u00e9 que te registraste pero a\u00fan no has completado tu configuraci\u00f3n. Quer\u00eda contactarte personalmente para ver si algo sali\u00f3 mal o si necesitas ayuda.",
"Hi! It's Victor, the founder of Whisper Money. I noticed you've cancelled your subscription, and I wanted to reach out personally.": "\u00a1Hola! Soy V\u00edctor, el fundador de Whisper Money. Not\u00e9 que cancelaste tu suscripci\u00f3n y quer\u00eda contactarte personalmente.",
"Hi! It's Victor, the founder of Whisper Money. I noticed you've completed your setup but haven't imported any transactions yet. Let me help you get started!": "\u00a1Hola! Soy V\u00edctor, el fundador de Whisper Money. Not\u00e9 que completaste tu configuraci\u00f3n pero a\u00fan no has importado transacciones. \u00a1D\u00e9jame ayudarte a empezar!",

View File

@ -0,0 +1,17 @@
<x-mail::message>
# {{ __('Your bank connections were disconnected') }}
{{ __('Hi :name,', ['name' => $userName]) }}
{{ trans_choice('We disconnected your bank connection to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.|We disconnected your :count bank connections to keep your account on free access. Automatic bank sync is now paused, but all your accounts, transactions, and balances remain in Whisper Money.', $removedConnectionsCount, ['count' => $removedConnectionsCount]) }}
{{ __('You can continue using the app on the free plan, and you can reconnect your bank later if you upgrade again.') }}
<x-mail::button :url="route('dashboard')">
{{ __('Go to Dashboard') }}
</x-mail::button>
{{ __('Best,') }}<br>
{{ __('Álvaro & Víctor') }}<br>
{{ __('Founders of Whisper Money') }}
</x-mail::message>

View File

@ -7,6 +7,7 @@ Schedule::command('horizon:snapshot')->everyFiveMinutes();
Schedule::command('budgets:generate-periods')->daily();
Schedule::command('banking:sync')->everySixHours();
Schedule::command('banks:check-logos')->weekly();
Schedule::command('banking:cancel-free-enablebanking')->lastDayOfMonth('18:00');
Schedule::command('real-estate:apply-revaluation')->monthlyOn(1, '00:00');
Schedule::command('loans:generate-balances')->monthlyOn(1, '00:00');
Schedule::command('resend:sync-leads')->dailyAt('03:00');

View File

@ -0,0 +1,174 @@
<?php
use App\Contracts\BankingProviderInterface;
use App\Enums\BankingConnectionStatus;
use App\Mail\EnableBankingConnectionsCancelledEmail;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Mail;
use function Pest\Laravel\artisan;
test('revokes old enable banking connections for free users and keeps accounts manual', function () {
config(['subscriptions.enabled' => true]);
Mail::fake();
$user = User::factory()->create();
$connection = BankingConnection::factory()->for($user)->create([
'created_at' => now()->subHours(7),
]);
$account = Account::factory()->for($user)->create([
'banking_connection_id' => $connection->id,
'external_account_id' => 'ext-123',
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('revokeSession')->once()->with($connection->session_id);
app()->instance(BankingProviderInterface::class, $mockProvider);
artisan('banking:cancel-free-enablebanking')
->expectsOutputToContain('Revoked 1 Enable Banking connection(s). Skipped paid users: 0.')
->assertSuccessful();
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
expect($connection->trashed())->toBeTrue();
$account->refresh();
expect($account->banking_connection_id)->toBeNull();
expect($account->external_account_id)->toBeNull();
expect($account->trashed())->toBeFalse();
Mail::assertQueued(EnableBankingConnectionsCancelledEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && $mail->removedConnectionsCount === 1;
});
});
test('skips enable banking connections created less than six hours ago', function () {
config(['subscriptions.enabled' => true]);
Mail::fake();
$user = User::factory()->create();
$connection = BankingConnection::factory()->for($user)->create([
'created_at' => now()->subHours(5),
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldNotReceive('revokeSession');
app()->instance(BankingProviderInterface::class, $mockProvider);
artisan('banking:cancel-free-enablebanking')
->expectsOutputToContain('No eligible Enable Banking connections found for free users.')
->assertSuccessful();
expect($connection->fresh()->trashed())->toBeFalse();
expect($connection->fresh()->status)->not->toBe(BankingConnectionStatus::Revoked);
Mail::assertNothingOutgoing();
});
test('skips subscribed users', function () {
config(['subscriptions.enabled' => true]);
Mail::fake();
$user = User::factory()->create();
$user->subscriptions()->create([
'type' => 'default',
'stripe_id' => 'sub_test123',
'stripe_status' => 'active',
'stripe_price' => 'price_test123',
]);
$connection = BankingConnection::factory()->for($user)->create([
'created_at' => now()->subHours(7),
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldNotReceive('revokeSession');
app()->instance(BankingProviderInterface::class, $mockProvider);
artisan('banking:cancel-free-enablebanking')
->expectsOutputToContain('Revoked 0 Enable Banking connection(s). Skipped paid users: 1.')
->assertSuccessful();
expect($connection->fresh()->trashed())->toBeFalse();
Mail::assertNothingOutgoing();
});
test('skips non enable banking providers', function () {
config(['subscriptions.enabled' => true]);
Mail::fake();
$user = User::factory()->create();
$connection = BankingConnection::factory()->for($user)->indexaCapital()->create([
'created_at' => now()->subHours(7),
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldNotReceive('revokeSession');
app()->instance(BankingProviderInterface::class, $mockProvider);
artisan('banking:cancel-free-enablebanking')
->expectsOutputToContain('No eligible Enable Banking connections found for free users.')
->assertSuccessful();
expect($connection->fresh()->trashed())->toBeFalse();
Mail::assertNothingOutgoing();
});
test('continues disconnect when enable banking revoke fails', function () {
config(['subscriptions.enabled' => true]);
Mail::fake();
$user = User::factory()->create();
$connection = BankingConnection::factory()->for($user)->create([
'created_at' => now()->subHours(7),
]);
$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()->andThrow(new RuntimeException('API unavailable'));
app()->instance(BankingProviderInterface::class, $mockProvider);
artisan('banking:cancel-free-enablebanking')->assertSuccessful();
expect($connection->fresh()->trashed())->toBeTrue();
$account->refresh();
expect($account->banking_connection_id)->toBeNull();
expect($account->external_account_id)->toBeNull();
Mail::assertQueued(EnableBankingConnectionsCancelledEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && $mail->removedConnectionsCount === 1;
});
});
test('sends one email per user when multiple connections are removed', function () {
config(['subscriptions.enabled' => true]);
Mail::fake();
$user = User::factory()->create();
$firstConnection = BankingConnection::factory()->for($user)->create([
'created_at' => now()->subHours(7),
]);
$secondConnection = BankingConnection::factory()->for($user)->create([
'created_at' => now()->subHours(8),
]);
$mockProvider = Mockery::mock(BankingProviderInterface::class);
$mockProvider->shouldReceive('revokeSession')->once()->with($firstConnection->session_id);
$mockProvider->shouldReceive('revokeSession')->once()->with($secondConnection->session_id);
app()->instance(BankingProviderInterface::class, $mockProvider);
artisan('banking:cancel-free-enablebanking')
->expectsOutputToContain('Revoked 2 Enable Banking connection(s). Skipped paid users: 0.')
->assertSuccessful();
Mail::assertQueued(EnableBankingConnectionsCancelledEmail::class, 1);
Mail::assertQueued(EnableBankingConnectionsCancelledEmail::class, function ($mail) use ($user) {
return $mail->hasTo($user->email) && $mail->removedConnectionsCount === 2;
});
});

View File

@ -9,6 +9,7 @@ use App\Mail\Drip\OnboardingReminderEmail;
use App\Mail\Drip\PromoCodeEmail;
use App\Mail\Drip\SubscriptionCancelledEmail;
use App\Mail\Drip\WelcomeEmail;
use App\Mail\EnableBankingConnectionsCancelledEmail;
use App\Mail\UpdateEmail;
use App\Mail\WaitlistOvertaken;
use App\Mail\WaitlistReferralNotification;
@ -108,6 +109,7 @@ test('default sender is used for active non-drip mailables', function (string $m
UpdateEmail::class => new UpdateEmail($user, 'test-update'),
BankTransactionsSyncedEmail::class => new BankTransactionsSyncedEmail($user, 3, ['Test Bank' => 3]),
BankingConnectionAuthFailedEmail::class => new BankingConnectionAuthFailedEmail($user, BankingConnection::factory()->for($user)->create(['aspsp_name' => 'Test Bank'])),
EnableBankingConnectionsCancelledEmail::class => new EnableBankingConnectionsCancelledEmail($user, 2),
BrokenBankLogosReportEmail::class => new BrokenBankLogosReportEmail([['id' => 'bank-1', 'name' => 'Test Bank', 'previous_logo' => 'https://example.com/logo.png']]),
WaitlistWelcome::class => new WaitlistWelcome(UserLead::factory()->create()),
WaitlistReferralNotification::class => new WaitlistReferralNotification(UserLead::factory()->create()),
@ -124,6 +126,7 @@ test('default sender is used for active non-drip mailables', function (string $m
UpdateEmail::class,
BankTransactionsSyncedEmail::class,
BankingConnectionAuthFailedEmail::class,
EnableBankingConnectionsCancelledEmail::class,
BrokenBankLogosReportEmail::class,
WaitlistWelcome::class,
WaitlistReferralNotification::class,
@ -176,6 +179,7 @@ test('mail blade signatures use alvaro before victor', function () {
'mail/bank-transactions-synced.blade.php',
'mail/drip/feedback.blade.php',
'mail/banking-connection-auth-failed.blade.php',
'mail/enable-banking-connections-cancelled.blade.php',
];
foreach ($mailViews as $mailView) {
@ -186,3 +190,15 @@ test('mail blade signatures use alvaro before victor', function () {
->not->toContain("{{ __('Víctor & Álvaro') }}<br>");
}
});
test('enable banking cancellation email includes dashboard access messaging', function () {
$user = User::factory()->create(['name' => 'Test User']);
$mailable = new EnableBankingConnectionsCancelledEmail($user, 2);
$mailable->assertHasSubject('Your bank connections were disconnected');
$mailable->assertSeeInHtml('Go to Dashboard');
$mailable->assertSeeInHtml('free access');
$mailable->assertSeeInHtml('accounts, transactions, and balances remain in Whisper Money');
$mailable->assertSeeInHtml(route('dashboard'));
});