From 2f583c01132c39c4a9dab5746fb7bacb4a2825b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 15 Apr 2026 15:23:03 +0100 Subject: [PATCH] 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 --- .../DisconnectBankingConnection.php | 44 +++++ ...celFreeEnableBankingConnectionsCommand.php | 70 +++++++ .../OpenBanking/ConnectionController.php | 41 +---- ...EnableBankingConnectionsCancelledEmail.php | 63 +++++++ lang/es.json | 3 + ...le-banking-connections-cancelled.blade.php | 17 ++ routes/console.php | 1 + ...reeEnableBankingConnectionsCommandTest.php | 174 ++++++++++++++++++ tests/Feature/MailSenderTest.php | 16 ++ 9 files changed, 398 insertions(+), 31 deletions(-) create mode 100644 app/Actions/OpenBanking/DisconnectBankingConnection.php create mode 100644 app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php create mode 100644 app/Mail/EnableBankingConnectionsCancelledEmail.php create mode 100644 resources/views/mail/enable-banking-connections-cancelled.blade.php create mode 100644 tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php diff --git a/app/Actions/OpenBanking/DisconnectBankingConnection.php b/app/Actions/OpenBanking/DisconnectBankingConnection.php new file mode 100644 index 00000000..bdaced65 --- /dev/null +++ b/app/Actions/OpenBanking/DisconnectBankingConnection.php @@ -0,0 +1,44 @@ +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(); + } +} diff --git a/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php b/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php new file mode 100644 index 00000000..ed4fb557 --- /dev/null +++ b/app/Console/Commands/CancelFreeEnableBankingConnectionsCommand.php @@ -0,0 +1,70 @@ +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; + } +} diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php index 683716e0..f526ce45 100644 --- a/app/Http/Controllers/OpenBanking/ConnectionController.php +++ b/app/Http/Controllers/OpenBanking/ConnectionController.php @@ -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.'); diff --git a/app/Mail/EnableBankingConnectionsCancelledEmail.php b/app/Mail/EnableBankingConnectionsCancelledEmail.php new file mode 100644 index 00000000..081efe99 --- /dev/null +++ b/app/Mail/EnableBankingConnectionsCancelledEmail.php @@ -0,0 +1,63 @@ + */ + 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 + */ + public function middleware(): array + { + return [(new RateLimited('emails'))->releaseAfter(1)]; + } +} diff --git a/lang/es.json b/lang/es.json index 22590b7c..7592b803 100644 --- a/lang/es.json +++ b/lang/es.json @@ -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!", diff --git a/resources/views/mail/enable-banking-connections-cancelled.blade.php b/resources/views/mail/enable-banking-connections-cancelled.blade.php new file mode 100644 index 00000000..e43ec8d0 --- /dev/null +++ b/resources/views/mail/enable-banking-connections-cancelled.blade.php @@ -0,0 +1,17 @@ + +# {{ __('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.') }} + + +{{ __('Go to Dashboard') }} + + +{{ __('Best,') }}
+{{ __('Álvaro & Víctor') }}
+{{ __('Founders of Whisper Money') }} +
diff --git a/routes/console.php b/routes/console.php index 4454d8c8..87614397 100644 --- a/routes/console.php +++ b/routes/console.php @@ -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'); diff --git a/tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php b/tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php new file mode 100644 index 00000000..5ac6392b --- /dev/null +++ b/tests/Feature/CancelFreeEnableBankingConnectionsCommandTest.php @@ -0,0 +1,174 @@ + 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; + }); +}); diff --git a/tests/Feature/MailSenderTest.php b/tests/Feature/MailSenderTest.php index afc92a74..c78351fb 100644 --- a/tests/Feature/MailSenderTest.php +++ b/tests/Feature/MailSenderTest.php @@ -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') }}
"); } }); + +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')); +});