user(); if ($this->shouldBlockOpenBankingAccess($user)) { return $this->subscribeJsonResponse(); } $validated = $request->validated(); $redirectUrl = config('services.enablebanking.redirect_url'); $result = $provider->startAuthorization( $validated['aspsp_name'], $validated['country'], $redirectUrl, ); $connection = $user->bankingConnections()->create([ 'provider' => 'enablebanking', 'authorization_id' => $result['authorization_id'], 'aspsp_name' => $validated['aspsp_name'], 'aspsp_country' => $validated['country'], 'aspsp_logo' => $validated['logo'] ?? null, 'status' => BankingConnectionStatus::Pending, ]); return response()->json([ 'redirect_url' => $result['url'], 'connection_id' => $connection->id, ]); } /** * Re-authorize an existing EnableBanking connection whose session has been revoked. */ public function reauthorize(Request $request, BankingConnection $connection, BankingProviderInterface $provider): JsonResponse { if ($connection->user_id !== auth()->id()) { abort(403); } if ($this->shouldBlockOpenBankingAccess($request->user())) { 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 ['error' => 'Only EnableBanking connections can be re-authorized.']; } if ($connection->status !== BankingConnectionStatus::Error && ! $connection->isExpired()) { return ['error' => 'Only connections with an error or expired status can be re-authorized.']; } $redirectUrl = config('services.enablebanking.redirect_url'); $result = $provider->startAuthorization( $connection->aspsp_name, $connection->aspsp_country, $redirectUrl, ); $connection->update([ 'authorization_id' => $result['authorization_id'], 'status' => BankingConnectionStatus::Pending, 'error_message' => null, ]); return $result; } /** * Handle the callback from bank authorization. */ public function callback(Request $request, BankingProviderInterface $provider): RedirectResponse { $user = auth()->user(); $errorRedirectRoute = $user->isOnboarded() ? 'settings.connections.index' : 'onboarding'; $errorRedirectParams = $user->isOnboarded() ? [] : ['step' => 'create-account']; if ($request->has('error')) { $errorDescription = $request->query('error_description'); $errorMessage = is_string($errorDescription) && $errorDescription !== '' ? $errorDescription : 'Authorization was denied or cancelled.'; Log::warning('EnableBanking authorization error', [ 'error' => $request->query('error'), 'description' => $errorDescription, ]); $pendingConnection = $user->bankingConnections() ->where('status', BankingConnectionStatus::Pending) ->latest() ->first(); if ($pendingConnection) { if ($pendingConnection->accounts()->exists()) { $pendingConnection->update([ 'status' => BankingConnectionStatus::Error, 'error_message' => $errorMessage, ]); } else { $pendingConnection->delete(); } } return redirect()->route($errorRedirectRoute, $errorRedirectParams) ->with('error', $errorMessage); } $code = $request->query('code'); if (! $code) { return redirect()->route($errorRedirectRoute, $errorRedirectParams) ->with('error', 'No authorization code received.'); } try { $sessionData = $provider->createSession($code); } catch (\Throwable $e) { Log::error('EnableBanking session creation failed', ['error' => $e->getMessage()]); return redirect()->route($errorRedirectRoute, $errorRedirectParams) ->with('error', 'Failed to connect to your bank. Please try again.'); } $connection = $this->findPendingConnectionForSession($user, $sessionData); if (! $connection) { return redirect()->route($errorRedirectRoute, $errorRedirectParams) ->with('error', 'No pending connection found.'); } $isReconnect = $connection->accounts()->exists(); if ($isReconnect) { $connection->update([ 'session_id' => $sessionData['session_id'], 'status' => BankingConnectionStatus::Active, 'valid_until' => $sessionData['access']['valid_until'] ?? null, 'error_message' => null, ]); $this->refreshAccountIds($connection, $sessionData['accounts']); SyncBankingConnectionJob::dispatch($connection); return redirect()->route('settings.connections.index') ->with('success', __('Bank account reconnected successfully.')); } $connection->update([ 'session_id' => $sessionData['session_id'], 'status' => BankingConnectionStatus::AwaitingMapping, 'valid_until' => $sessionData['access']['valid_until'] ?? null, 'pending_accounts_data' => $sessionData['accounts'], ]); if (! $user->isOnboarded()) { $this->createAccountsFromPending($user, $connection); SyncBankingConnectionJob::dispatch($connection); return redirect()->route('onboarding', ['step' => 'create-account']) ->with('success', 'Bank account connected successfully.'); } return redirect()->route('open-banking.map-accounts', $connection); } /** * Find the pending connection that belongs to the callback session. * * Multiple reconnection flows may be pending at the same time. Never pick an * arbitrary latest connection, because that can attach one bank's session and * transactions to another bank's existing account. * * @param array{aspsp?: array{name?: string, country?: string}, accounts?: array>} $sessionData */ private function findPendingConnectionForSession(User $user, array $sessionData): ?BankingConnection { $pendingConnections = $user->bankingConnections() ->where('status', BankingConnectionStatus::Pending) ->get(); if ($pendingConnections->isEmpty()) { return null; } $aspspName = $sessionData['aspsp']['name'] ?? null; $aspspCountry = $sessionData['aspsp']['country'] ?? null; if (is_string($aspspName) && is_string($aspspCountry)) { $matchedByInstitution = $pendingConnections ->first(fn (BankingConnection $connection): bool => $connection->aspsp_name === $aspspName && $connection->aspsp_country === $aspspCountry); if ($matchedByInstitution) { return $matchedByInstitution; } } $ibans = collect($sessionData['accounts'] ?? []) ->map(fn (array $account): ?string => $account['account_id']['iban'] ?? null) ->filter() ->values(); if ($ibans->isNotEmpty()) { $matchedByIban = $pendingConnections ->first(fn (BankingConnection $connection): bool => $connection->accounts() ->whereIn('iban', $ibans) ->exists()); if ($matchedByIban) { return $matchedByIban; } } if ($pendingConnections->count() === 1) { return $pendingConnections->first(); } Log::warning('Unable to disambiguate pending EnableBanking callback', [ 'user_id' => $user->id, 'pending_connection_ids' => $pendingConnections->pluck('id')->all(), 'aspsp_name' => is_string($aspspName) ? $aspspName : null, 'aspsp_country' => is_string($aspspCountry) ? $aspspCountry : null, ]); return null; } /** * Refresh external_account_id and iban on existing accounts after a reconnect. * * Enable Banking issues new account UIDs with every new session, so the stored * external_account_id values become invalid as soon as the old session expires. * * Matching strategy (in priority order): * 1. Match by IBAN — reliable when the account was created after the iban column existed. * 2. Positional fallback — match by creation order for legacy accounts without a stored IBAN. * * @param array> $newAccounts */ private function refreshAccountIds(BankingConnection $connection, array $newAccounts): void { if (empty($newAccounts)) { return; } $existingAccounts = $connection->accounts()->orderBy('created_at')->get(); $unmatchedNew = collect($newAccounts); $unmatchedExisting = collect(); foreach ($existingAccounts as $account) { if ($account->iban) { $matched = $unmatchedNew->first(fn (array $data) => ($data['account_id']['iban'] ?? null) === $account->iban); if ($matched) { $account->update([ 'external_account_id' => $matched['uid'], 'iban' => $matched['account_id']['iban'] ?? $account->iban, ]); $unmatchedNew = $unmatchedNew->reject(fn (array $data) => ($data['uid'] ?? null) === $matched['uid'])->values(); continue; } } $unmatchedExisting->push($account); } foreach ($unmatchedExisting as $index => $account) { $newAccountData = $unmatchedNew->get($index); if (! $newAccountData) { continue; } $account->update([ 'external_account_id' => $newAccountData['uid'], 'iban' => $newAccountData['account_id']['iban'] ?? null, ]); } } }