diff --git a/app/Console/Commands/SyncBankingConnections.php b/app/Console/Commands/SyncBankingConnections.php index 2f648a93..32804d64 100644 --- a/app/Console/Commands/SyncBankingConnections.php +++ b/app/Console/Commands/SyncBankingConnections.php @@ -43,15 +43,23 @@ class SyncBankingConnections extends Command $query = BankingConnection::query() ->whereHas('user') ->where(function ($query) { - $query->where('status', BankingConnectionStatus::Active) - ->orWhere(function ($query) { - $query->where('status', BankingConnectionStatus::Error) - ->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES); + $query->where(function ($query) { + $query->where(function ($query) { + $query->where('status', BankingConnectionStatus::Active) + ->orWhere(function ($query) { + $query->where('status', BankingConnectionStatus::Error) + ->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES); + }); + })->where(function ($query) { + $query->whereNull('valid_until') + ->orWhere('valid_until', '>', now()); }); - }) - ->where(function ($query) { - $query->whereNull('valid_until') - ->orWhere('valid_until', '>', now()); + })->orWhere(function ($query) { + $query->where('provider', 'enablebanking') + ->where('status', BankingConnectionStatus::Active) + ->whereNotNull('valid_until') + ->where('valid_until', '<=', now()); + }); }) ->where(function ($query) { $query->whereNull('rate_limited_until') diff --git a/app/Http/Controllers/OpenBanking/AuthorizationController.php b/app/Http/Controllers/OpenBanking/AuthorizationController.php index e428e678..27e3dbc4 100644 --- a/app/Http/Controllers/OpenBanking/AuthorizationController.php +++ b/app/Http/Controllers/OpenBanking/AuthorizationController.php @@ -69,12 +69,52 @@ class AuthorizationController extends Controller return $this->subscribeJsonResponse(); } + $result = $this->startReauthorization($connection, $provider); + + if (isset($result['error'])) { + return response()->json(['error' => $result['error']], 422); + } + + return response()->json([ + 'redirect_url' => $result['url'], + 'connection_id' => $connection->id, + ]); + } + + /** + * Start reconnect flow from email or UI links. + */ + public function reconnect(Request $request, BankingConnection $connection, BankingProviderInterface $provider): RedirectResponse + { + if ($connection->user_id !== auth()->id()) { + abort(403); + } + + if ($this->shouldBlockOpenBankingAccess($request->user())) { + return $this->subscribeRedirectResponse(); + } + + $result = $this->startReauthorization($connection, $provider); + + if (isset($result['error'])) { + return redirect()->route('settings.connections.index') + ->with('error', $result['error']); + } + + return redirect()->away($result['url']); + } + + /** + * @return array{url?: string, authorization_id?: string, error?: string} + */ + private function startReauthorization(BankingConnection $connection, BankingProviderInterface $provider): array + { if (! $connection->isEnableBanking()) { - return response()->json(['error' => 'Only EnableBanking connections can be re-authorized.'], 422); + return ['error' => 'Only EnableBanking connections can be re-authorized.']; } if ($connection->status !== BankingConnectionStatus::Error && ! $connection->isExpired()) { - return response()->json(['error' => 'Only connections with an error or expired status can be re-authorized.'], 422); + return ['error' => 'Only connections with an error or expired status can be re-authorized.']; } $redirectUrl = config('services.enablebanking.redirect_url'); @@ -91,10 +131,7 @@ class AuthorizationController extends Controller 'error_message' => null, ]); - return response()->json([ - 'redirect_url' => $result['url'], - 'connection_id' => $connection->id, - ]); + return $result; } /** diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 3bfb9ee8..fb08425d 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -3,6 +3,8 @@ namespace App\Http\Middleware; use App\Enums\AccountType; +use App\Enums\BankingConnectionStatus; +use App\Models\BankingConnection; use App\Services\CurrencyOptions; use Illuminate\Foundation\Inspiring; use Illuminate\Http\Request; @@ -92,6 +94,25 @@ class HandleInertiaRequests extends Middleware 'includeRealEstateInNetWorthChart' => $user?->setting->include_real_estate_in_net_worth_chart ?? true, 'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true', 'features' => $this->resolveFeatureFlags(), + 'expiredBankingConnections' => fn () => $user ? $user->bankingConnections() + ->where('provider', 'enablebanking') + ->where(function ($query) { + $query->where('status', BankingConnectionStatus::Expired) + ->orWhere(function ($query) { + $query->whereNotNull('valid_until') + ->where('valid_until', '<=', now()); + }); + }) + ->orderBy('valid_until') + ->limit(5) + ->get(['id', 'aspsp_name', 'provider', 'valid_until']) + ->map(fn (BankingConnection $connection): array => [ + 'id' => $connection->id, + 'aspsp_name' => $connection->aspsp_name, + 'provider' => $connection->provider, + 'valid_until' => $connection->valid_until?->toIso8601String(), + 'reconnect_url' => route('open-banking.reconnect', $connection), + ]) : [], 'accounts' => fn () => $user ? $user->accounts() ->with(['bank:id,name,logo', 'realEstateDetail:account_id,linked_loan_account_id']) ->orderBy('name') diff --git a/app/Jobs/SyncAllBankingConnectionsJob.php b/app/Jobs/SyncAllBankingConnectionsJob.php index 6ed4fc12..0f3de9af 100644 --- a/app/Jobs/SyncAllBankingConnectionsJob.php +++ b/app/Jobs/SyncAllBankingConnectionsJob.php @@ -23,15 +23,23 @@ class SyncAllBankingConnectionsJob implements ShouldQueue BankingConnection::query() ->whereHas('user') ->where(function ($query) { - $query->where('status', BankingConnectionStatus::Active) - ->orWhere(function ($query) { - $query->where('status', BankingConnectionStatus::Error) - ->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES); + $query->where(function ($query) { + $query->where(function ($query) { + $query->where('status', BankingConnectionStatus::Active) + ->orWhere(function ($query) { + $query->where('status', BankingConnectionStatus::Error) + ->where('consecutive_sync_failures', '<', SyncBankingConnectionJob::MAX_SCHEDULED_RETRIES); + }); + })->where(function ($query) { + $query->whereNull('valid_until') + ->orWhere('valid_until', '>', now()); }); - }) - ->where(function ($query) { - $query->whereNull('valid_until') - ->orWhere('valid_until', '>', now()); + })->orWhere(function ($query) { + $query->where('provider', 'enablebanking') + ->where('status', BankingConnectionStatus::Active) + ->whereNotNull('valid_until') + ->where('valid_until', '<=', now()); + }); }) ->where(function ($query) { $query->whereNull('rate_limited_until') diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index b60eefa2..a94b4673 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -6,6 +6,7 @@ use App\Enums\BankingConnectionStatus; use App\Enums\BankingSyncLogStatus; use App\Exceptions\Banking\TransientBankingProviderException; use App\Mail\BankingConnectionAuthFailedEmail; +use App\Mail\BankingConnectionExpiredEmail; use App\Models\BankingConnection; use App\Models\BankingSyncLog; use App\Services\Banking\BalanceSyncService; @@ -76,9 +77,18 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } if ($connection->isEnableBanking() && $connection->isExpired()) { + $shouldNotify = $connection->status !== BankingConnectionStatus::Expired; + $connection->update(['status' => BankingConnectionStatus::Expired]); Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]); + if ($shouldNotify && $connection->user->canReceiveEmails()) { + Mail::to($connection->user)->send(new BankingConnectionExpiredEmail( + $connection->user, + $connection, + )); + } + $this->logSyncAttempt($connection, BankingSyncLogStatus::Skipped, $startTime, metadata: ['reason' => 'expired']); return; diff --git a/app/Mail/BankingConnectionExpiredEmail.php b/app/Mail/BankingConnectionExpiredEmail.php new file mode 100644 index 00000000..c3584454 --- /dev/null +++ b/app/Mail/BankingConnectionExpiredEmail.php @@ -0,0 +1,70 @@ + + */ + public $backoff = [2, 5, 10, 30]; + + public function __construct( + public User $user, + public BankingConnection $bankingConnection, + ) { + $this->onQueue('emails'); + } + + public function envelope(): Envelope + { + return new Envelope( + subject: __('Action required: reconnect :provider', [ + 'provider' => $this->bankingConnection->aspsp_name, + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'mail.banking-connection-expired', + with: [ + 'userName' => $this->user->name, + 'providerName' => $this->bankingConnection->aspsp_name, + 'reconnectUrl' => route('open-banking.reconnect', $this->bankingConnection), + ], + ); + } + + /** + * 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 ddd75008..2055e679 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1047,6 +1047,7 @@ "Ready to take control of your finances?": "¿Listo para tomar el control de tus finanzas?", "Real Estate": "Bienes Raíces", "Reconnect": "Reconectar", + "Reconnect to resume automatic syncing.": "Reconecta para reanudar la sincronización automática.", "Recovery Codes": "Códigos de Recuperación", "Recovery codes": "Códigos de recuperación", "Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.": "Los códigos de recuperación te permiten recuperar acceso si pierdes tu dispositivo 2FA. Guárdalos en un administrador de contraseñas seguro.", @@ -1620,6 +1621,7 @@ "You can\\n request a copy of the personal data we hold\\n about you": "Puedes solicitar una copia de los datos personales que tenemos sobre ti", "You can\\n request deletion of your personal data\\n (right to be forgotten)": "Puedes solicitar la eliminación de tus datos personales (derecho al olvido)", "You get your personal referral link.": "Obtienes tu enlace personal de referidos.", + "You have :count expired bank connections.": "Tienes :count conexiones bancarias caducadas.", "You may cancel your subscription at any time. Upon cancellation, you will retain access until the end of your current billing period, after which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Al cancelar, mantendrás acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.", "You may cancel your subscription at any time.\\n Upon cancellation, you will retain access until\\n the end of your current billing period, after\\n which your subscription will not renew.": "Puedes cancelar tu suscripción en cualquier momento. Tras la cancelación, mantendrás el acceso hasta el final de tu período de facturación actual, después del cual tu suscripción no se renovará.", "You may not use the service to engage in any illegal activity, transmit malicious code, attempt to gain unauthorized access to our systems, or interfere with the proper functioning of the service.": "No puedes usar el servicio para participar en ninguna actividad ilegal, transmitir código malicioso, intentar obtener acceso no autorizado a nuestros sistemas o interferir con el funcionamiento adecuado del servicio.", @@ -1653,6 +1655,7 @@ "You\\n can request correction of inaccurate or\\n incomplete data": "Puedes solicitar la corrección de datos inexactos o incompletos", "You\\n can request restriction of processing in\\n certain circumstances": "Puedes solicitar la restricción del procesamiento en determinadas circunstancias", "Your :provider connection needs attention": "Tu conexión con :provider necesita atención", + "Your :provider connection has expired.": "Tu conexión con :provider ha caducado.", "Your Accounts": "Tus Cuentas", "Your Categories Include:": "Tus Categorías Incluyen:", "Your Data is Truly Private": "Tus Datos Son Verdaderamente Privados", diff --git a/resources/js/app.tsx b/resources/js/app.tsx index c0125d58..d879256c 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -11,9 +11,9 @@ import { OctagonXIcon, TriangleAlertIcon, } from 'lucide-react'; -import { StrictMode } from 'react'; +import { StrictMode, useEffect } from 'react'; import { createRoot } from 'react-dom/client'; -import { Toaster } from 'sonner'; +import { toast, Toaster } from 'sonner'; import { EncryptionKeyProvider } from './contexts/encryption-key-context'; import { PrivacyModeProvider } from './contexts/privacy-mode-context'; import { SyncProvider } from './contexts/sync-context'; @@ -26,8 +26,8 @@ import { isFacebookInAppBrowserJavaBridgeNoise, isPostMessageDataCloneNoise, } from './lib/sentry'; -import type { SharedData } from './types'; -import { setTranslations } from './utils/i18n'; +import type { ExpiredBankingConnectionNotification, SharedData } from './types'; +import { __, setTranslations } from './utils/i18n'; installChunkLoadRecovery(); @@ -60,6 +60,67 @@ initializeChartColorScheme(); const appName = import.meta.env.VITE_APP_NAME || 'Laravel'; let hasAttemptedTimezoneBackfill = false; +const notifiedExpiredConnectionIds = new Set(); + +function showExpiredConnectionsToast( + expiredConnections: ExpiredBankingConnectionNotification[] | undefined, +): void { + if (!expiredConnections || expiredConnections.length === 0) { + return; + } + + const newExpiredConnections = expiredConnections.filter( + (connection) => !notifiedExpiredConnectionIds.has(connection.id), + ); + + if (newExpiredConnections.length === 0) { + return; + } + + expiredConnections.forEach((connection) => { + notifiedExpiredConnectionIds.add(connection.id); + }); + + const firstConnection = expiredConnections[0]; + const count = expiredConnections.length; + + toast.error( + count === 1 + ? __('Your :provider connection has expired.', { + provider: firstConnection.aspsp_name, + }) + : __('You have :count expired bank connections.', { + count, + }), + { + description: __('Reconnect to resume automatic syncing.'), + duration: Infinity, + action: { + label: __('Reconnect'), + onClick: () => { + window.location.href = firstConnection.reconnect_url; + }, + }, + }, + ); +} + +function ExpiredConnectionsToast({ + initialExpiredConnections, +}: { + initialExpiredConnections: ExpiredBankingConnectionNotification[]; +}) { + useEffect(() => { + showExpiredConnectionsToast(initialExpiredConnections); + + return router.on('navigate', (event) => { + const pageProps = event.detail.page.props as unknown as SharedData; + showExpiredConnectionsToast(pageProps.expiredBankingConnections); + }); + }, [initialExpiredConnections]); + + return null; +} // Determine progress bar color based on current theme const getProgressBarColor = () => { @@ -87,6 +148,10 @@ createInertiaApp({ (initialPageProps?.hasEncryptedAccounts as boolean) ?? false; const hasEncryptedTransactions = (initialPageProps?.hasEncryptedTransactions as boolean) ?? false; + const initialExpiredConnections = + (initialPageProps?.expiredBankingConnections as + | ExpiredBankingConnectionNotification[] + | undefined) ?? []; const syncUserTimezone = async (pageProps?: Partial) => { const user = pageProps?.auth?.user ?? null; @@ -144,6 +209,11 @@ createInertiaApp({ initialUser={initialUser} > + +# {{ __('Reconnect your :provider account', ['provider' => $providerName]) }} + +{{ __('Hi :name,', ['name' => $userName]) }} + +{{ __('Your :provider connection has expired, so we cannot sync new transactions until you reconnect it.', ['provider' => $providerName]) }} + + +{{ __('Reconnect Account') }} + + +{{ __('If the button does not work, open your connection settings and reconnect :provider from there.', ['provider' => $providerName]) }} + +{{ __('Best,') }}
+{{ __('Álvaro & Víctor') }}
+{{ __('Founders of Whisper Money') }} + diff --git a/routes/web.php b/routes/web.php index 906bc3fc..27641c48 100644 --- a/routes/web.php +++ b/routes/web.php @@ -128,6 +128,7 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function Route::get('institutions', [InstitutionController::class, 'index'])->name('open-banking.institutions'); Route::post('authorize', [AuthorizationController::class, 'store'])->name('open-banking.authorize'); Route::post('connections/{connection}/reauthorize', [AuthorizationController::class, 'reauthorize'])->name('open-banking.reauthorize'); + Route::get('connections/{connection}/reconnect', [AuthorizationController::class, 'reconnect'])->name('open-banking.reconnect'); Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback'); Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts'); Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store'); diff --git a/tests/Feature/InertiaSharedDataTest.php b/tests/Feature/InertiaSharedDataTest.php index 466d1b4a..07c48b70 100644 --- a/tests/Feature/InertiaSharedDataTest.php +++ b/tests/Feature/InertiaSharedDataTest.php @@ -1,5 +1,7 @@ onboarded()->create(); + $expiredConnection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Santander', + 'status' => BankingConnectionStatus::Active, + 'valid_until' => now()->subDay(), + ]); + BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Fresh Bank', + 'status' => BankingConnectionStatus::Active, + 'valid_until' => now()->addDay(), + ]); + + $response = actingAs($user)->withoutVite()->get(route('dashboard')); + + $response->assertInertia(fn (Assert $page) => $page + ->has('expiredBankingConnections', 1) + ->where('expiredBankingConnections.0.id', $expiredConnection->id) + ->where('expiredBankingConnections.0.aspsp_name', 'Santander') + ->where('expiredBankingConnections.0.reconnect_url', route('open-banking.reconnect', $expiredConnection)) + ); +}); + test('shared currency options split profile and account currencies', function () { $response = $this->withoutVite()->get(route('home')); diff --git a/tests/Feature/OpenBanking/AuthorizationControllerTest.php b/tests/Feature/OpenBanking/AuthorizationControllerTest.php index 4cac3415..bac3dc6f 100644 --- a/tests/Feature/OpenBanking/AuthorizationControllerTest.php +++ b/tests/Feature/OpenBanking/AuthorizationControllerTest.php @@ -324,6 +324,34 @@ test('reauthorize starts new authorization for expired connections', function () expect($connection->authorization_id)->toBe('new-auth-id-789'); }); +test('reconnect link redirects expired connections to bank authorization', function () { + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->expired()->create([ + 'user_id' => $user->id, + 'aspsp_name' => 'Santander', + 'aspsp_country' => 'ES', + ]); + + $mockProvider = Mockery::mock(BankingProviderInterface::class); + $mockProvider->shouldReceive('startAuthorization') + ->with('Santander', 'ES', config('services.enablebanking.redirect_url')) + ->once() + ->andReturn([ + 'url' => 'https://bank.example.com/reauthorize', + 'authorization_id' => 'new-auth-id-987', + ]); + + $this->app->instance(BankingProviderInterface::class, $mockProvider); + + $response = $this->actingAs($user)->get(route('open-banking.reconnect', $connection)); + + $response->assertRedirect('https://bank.example.com/reauthorize'); + + $connection->refresh(); + expect($connection->status)->toBe(BankingConnectionStatus::Pending); + expect($connection->authorization_id)->toBe('new-auth-id-987'); +}); + test('free tier users cannot reauthorize after onboarding when subscriptions are enabled', function () { config(['subscriptions.enabled' => true]); diff --git a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php index cdc3967b..434b41b5 100644 --- a/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php +++ b/tests/Feature/OpenBanking/SyncRetryAndLoggingTest.php @@ -5,6 +5,7 @@ use App\Enums\BankingSyncLogStatus; use App\Exceptions\Banking\TransientBankingProviderException; use App\Jobs\SyncAllBankingConnectionsJob; use App\Jobs\SyncBankingConnectionJob; +use App\Mail\BankingConnectionExpiredEmail; use App\Models\Account; use App\Models\BankingConnection; use App\Models\BankingSyncLog; @@ -15,6 +16,7 @@ use GuzzleHttp\Psr7\Response; use Illuminate\Contracts\Queue\Job; use Illuminate\Http\Client\RequestException; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Queue; // --- Retry Behavior Tests --- @@ -359,7 +361,9 @@ test('failed sync creates a failed log entry', function () { expect($log->error_class)->toBe(RequestException::class); }); -test('skipped sync creates a skipped log entry for expired connection', function () { +test('skipped sync creates a skipped log entry and emails user for newly expired connection', function () { + Mail::fake(); + $user = User::factory()->onboarded()->create(); $connection = BankingConnection::factory()->create([ 'user_id' => $user->id, @@ -372,10 +376,17 @@ test('skipped sync creates a skipped log entry for expired connection', function $job = new SyncBankingConnectionJob($connection); $job->handle($transactionSync, $balanceSync); + $connection->refresh(); $log = BankingSyncLog::where('banking_connection_id', $connection->id)->first(); + expect($connection->status)->toBe(BankingConnectionStatus::Expired); expect($log)->not->toBeNull(); expect($log->status)->toBe(BankingSyncLogStatus::Skipped); expect($log->metadata)->toBe(['reason' => 'expired']); + + Mail::assertQueued(BankingConnectionExpiredEmail::class, function (BankingConnectionExpiredEmail $mail) use ($user, $connection) { + return $mail->user->is($user) + && $mail->bankingConnection->is($connection); + }); }); test('skipped sync creates a skipped log entry for non-syncable status', function () { @@ -559,6 +570,23 @@ test('scheduled sync excludes error connections with expired valid_until', funct Queue::assertNotPushed(SyncBankingConnectionJob::class); }); +test('scheduled sync includes active enablebanking connections whose valid_until expired', function () { + Queue::fake(SyncBankingConnectionJob::class); + + $user = User::factory()->onboarded()->create(); + $connection = BankingConnection::factory()->create([ + 'user_id' => $user->id, + 'status' => BankingConnectionStatus::Active, + 'valid_until' => now()->subDay(), + ]); + + $job = new SyncAllBankingConnectionsJob; + $job->handle(); + + Queue::assertPushed(SyncBankingConnectionJob::class, 1); + Queue::assertPushed(SyncBankingConnectionJob::class, fn ($job) => $job->bankingConnection->id === $connection->id); +}); + test('scheduled sync skips connections still within rate limit backoff window', function () { Queue::fake(SyncBankingConnectionJob::class); diff --git a/worktree.sh b/worktree.sh index 9a4755a6..19b135f6 100755 --- a/worktree.sh +++ b/worktree.sh @@ -3,6 +3,7 @@ ROOT_PATH=$1 cp "$ROOT_PATH/.env" .env +cp -r "$ROOT_PATH//storage/keys" ./storage/keys bun i composer install