From f60e6d70355d77b05ff4cad690cea65c1eb5792d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Tue, 23 Jun 2026 11:39:24 +0200 Subject: [PATCH] feat(banking): add Interactive Brokers sync via Flex Web Service (#581) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## What Adds **Interactive Brokers** as a banking sync provider (investment account, balances only), mirroring the Indexa Capital integration. It uses the **Flex Web Service** rather than IBKR's Web API: the Web API requires registering as an IBKR third party (business entity, Compliance approval, RSA-signed OAuth, ~3-5 weeks), which is overkill for read-only balance sync. Flex is a read-only token + Query ID model that fits our existing API-key provider shape. ### How the sync works - The user creates an Activity Flex Query (NAV + Open Positions) and a Flex Web Service token in their IBKR Client Portal, then pastes both. - Client flow: `SendRequest` → reference code → poll `GetStatement` → parse the XML statement. - Mapping: `EquitySummaryByReportDateInBase@total` → `balance` (daily rows give historical backfill on first sync); `Σ(OpenPosition costBasisMoney × fxRateToBase) + cash` → `invested_amount`, so **profit derives as `balance − invested_amount`** (unrealized P&L), like Indexa Capital. Everything is already in base currency, so no FX conversion is needed. - One statement covers every account, so the syncer fetches once per connection to respect IB's per-query rate limit. - IB returns HTTP 200 with an error XML, so the client translates Flex error codes into the exceptions the sync job already understands: `RequestException(401)` for token problems, `RequestException(429)` for throttling, `TransientBankingProviderException` otherwise. ### Connect flow - New connect/update-credentials endpoints validate the credentials by pulling a statement, then build pending accounts from it. - Credentials reuse the encrypted `api_token` (Flex token) and `api_secret` (Flex Query ID) columns — **no migration**. - The IB option (two fields) is added to the connect dialog, inline connect flow, and update-credentials dialog, with Spanish translations. ## Feature flag (why this is a draft) Gated behind a Pennant feature `App\Features\InteractiveBrokers` (off by default). It was built against documented/open-source Flex XML fixtures, **not a live IBKR account** (we don't have one). Before enabling, validate against a real account (beta tester or a free IBKR account): ``` php artisan feature:enable InteractiveBrokers user@example.com ``` If the parser needs tweaks against real XML, they should be minor (field-name level). ## Tests - Client + balance sync: NAV → balance, invested/profit, daily backfill, since-date incremental, multi-account, GetStatement polling, token-401 / rate-limit-429 mapping. - Controller: feature-flag gate (403), valid/invalid credentials, subscription gate, onboarding auto-create, validation. - Factory wiring, enum cases, job-level sync, feature-flag visibility (vitest). - Spanish translations added (enforced by `LocalizationTest`). All green: `pint --test`, `phpstan`, OpenBanking + localization suite, vitest. --- app/Enums/BankingProvider.php | 75 ++- app/Features/InteractiveBrokers.php | 23 + .../OpenBanking/BinanceController.php | 3 +- .../OpenBanking/BitpandaController.php | 2 +- .../OpenBanking/CoinbaseController.php | 3 +- .../OpenBanking/ConnectionController.php | 12 +- .../OpenBanking/IndexaCapitalController.php | 2 +- .../InteractiveBrokersController.php | 135 ++++ .../OpenBanking/WiseController.php | 3 +- app/Http/Middleware/HandleInertiaRequests.php | 4 + .../OpenBanking/ConnectBinanceRequest.php | 4 +- .../OpenBanking/ConnectBitpandaRequest.php | 3 +- .../OpenBanking/ConnectCoinbaseRequest.php | 4 +- .../ConnectIndexaCapitalRequest.php | 5 +- .../ConnectInteractiveBrokersRequest.php | 22 + .../OpenBanking/ConnectWiseRequest.php | 7 +- .../UpdateConnectionCredentialsRequest.php | 19 +- app/Models/BankingConnection.php | 5 + app/Services/Banking/CredentialField.php | 24 + .../InteractiveBrokersBalanceSyncService.php | 74 +++ .../Banking/InteractiveBrokersClient.php | 274 ++++++++ .../Sync/BankingConnectionSyncerFactory.php | 1 + .../Banking/Sync/InteractiveBrokersSyncer.php | 28 + .../factories/BankingConnectionFactory.php | 15 + database/seeders/data/banks.json | 4 + docs/adding-a-banking-provider.md | 28 +- lang/es.json | 9 +- .../banks/logos/interactive-brokers.png | Bin 0 -> 10778 bytes .../connect-account-dialog.test.tsx | 44 ++ .../open-banking/connect-account-dialog.tsx | 619 ++---------------- .../open-banking/connect-account-inline.tsx | 489 ++------------ .../update-credentials-dialog.tsx | 263 ++------ resources/js/hooks/use-connect-flow.ts | 255 ++++++++ resources/js/lib/connect-providers.tsx | 358 ++++++++++ resources/js/pages/settings/connections.tsx | 19 +- resources/js/types/index.d.ts | 1 + routes/web.php | 3 + tests/Feature/InertiaSharedDataTest.php | 1 + .../BankingConnectionSyncerFactoryTest.php | 4 + .../InteractiveBrokersBalanceSyncTest.php | 305 +++++++++ .../InteractiveBrokersControllerTest.php | 192 ++++++ .../SyncBankingConnectionJobTest.php | 64 ++ tests/Unit/Enums/BankingProviderTest.php | 23 + 43 files changed, 2137 insertions(+), 1291 deletions(-) create mode 100644 app/Features/InteractiveBrokers.php create mode 100644 app/Http/Controllers/OpenBanking/InteractiveBrokersController.php create mode 100644 app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php create mode 100644 app/Services/Banking/CredentialField.php create mode 100644 app/Services/Banking/InteractiveBrokersBalanceSyncService.php create mode 100644 app/Services/Banking/InteractiveBrokersClient.php create mode 100644 app/Services/Banking/Sync/InteractiveBrokersSyncer.php create mode 100644 public/images/banks/logos/interactive-brokers.png create mode 100644 resources/js/hooks/use-connect-flow.ts create mode 100644 resources/js/lib/connect-providers.tsx create mode 100644 tests/Feature/OpenBanking/InteractiveBrokersBalanceSyncTest.php create mode 100644 tests/Feature/OpenBanking/InteractiveBrokersControllerTest.php diff --git a/app/Enums/BankingProvider.php b/app/Enums/BankingProvider.php index 179181d6..28faca15 100644 --- a/app/Enums/BankingProvider.php +++ b/app/Enums/BankingProvider.php @@ -2,12 +2,15 @@ namespace App\Enums; +use App\Services\Banking\CredentialField; + enum BankingProvider: string { case IndexaCapital = 'indexacapital'; case Binance = 'binance'; case Bitpanda = 'bitpanda'; case Coinbase = 'coinbase'; + case InteractiveBrokers = 'interactivebrokers'; case Wise = 'wise'; case EnableBanking = 'enablebanking'; @@ -26,8 +29,78 @@ enum BankingProvider: string public function defaultAccountType(): AccountType { return match ($this) { - self::IndexaCapital, self::Binance, self::Bitpanda, self::Coinbase => AccountType::Investment, + self::IndexaCapital, self::Binance, self::Bitpanda, self::Coinbase, self::InteractiveBrokers => AccountType::Investment, self::Wise, self::EnableBanking => AccountType::Checking, }; } + + /** + * The credential inputs this provider collects, each mapped to the + * encrypted connection column it is stored in, with its validation rules. + * + * Single source of truth for credential shape: the connect controllers, + * the update request and the update controller all derive from this so a + * new provider is described in exactly one place. Empty for consent-based + * providers that authenticate without user-supplied credentials. + * + * @return array + */ + public function credentialFields(): array + { + return match ($this) { + self::IndexaCapital, self::Wise => [ + new CredentialField('api_token', 'api_token', ['required', 'string', 'min:10']), + ], + self::Binance => [ + new CredentialField('api_key', 'api_token', ['required', 'string', 'min:10']), + new CredentialField('api_secret', 'api_secret', ['required', 'string', 'min:10']), + ], + self::Bitpanda => [ + new CredentialField('api_key', 'api_token', ['required', 'string', 'min:10']), + ], + self::Coinbase => [ + new CredentialField('api_key_name', 'api_token', ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i']), + new CredentialField('private_key', 'api_secret', ['required', 'string', 'min:40']), + ], + self::InteractiveBrokers => [ + new CredentialField('token', 'api_token', ['required', 'string', 'min:10']), + new CredentialField('query_id', 'api_secret', ['required', 'string', 'min:3']), + ], + self::EnableBanking => [], + }; + } + + /** + * Validation rules for this provider's credential inputs, keyed by input + * name. Shared by the connect Form Requests and the update request. + * + * @return array> + */ + public function credentialRules(): array + { + $rules = []; + + foreach ($this->credentialFields() as $field) { + $rules[$field->input] = $field->rules; + } + + return $rules; + } + + /** + * Map validated request input to the encrypted connection columns. + * + * @param array $input + * @return array + */ + public function credentialColumns(array $input): array + { + $columns = []; + + foreach ($this->credentialFields() as $field) { + $columns[$field->column] = $input[$field->input]; + } + + return $columns; + } } diff --git a/app/Features/InteractiveBrokers.php b/app/Features/InteractiveBrokers.php new file mode 100644 index 00000000..fe27d967 --- /dev/null +++ b/app/Features/InteractiveBrokers.php @@ -0,0 +1,23 @@ +bankingConnections()->create([ 'provider' => BankingProvider::Binance, - 'api_token' => $validated['api_key'], - 'api_secret' => $validated['api_secret'], + ...BankingProvider::Binance->credentialColumns($validated), 'aspsp_name' => 'Binance', 'aspsp_country' => $validated['country'], 'aspsp_logo' => $bank->logo, diff --git a/app/Http/Controllers/OpenBanking/BitpandaController.php b/app/Http/Controllers/OpenBanking/BitpandaController.php index c9a5947b..c0365550 100644 --- a/app/Http/Controllers/OpenBanking/BitpandaController.php +++ b/app/Http/Controllers/OpenBanking/BitpandaController.php @@ -51,7 +51,7 @@ class BitpandaController extends Controller $connection = $user->bankingConnections()->create([ 'provider' => BankingProvider::Bitpanda, - 'api_token' => $validated['api_key'], + ...BankingProvider::Bitpanda->credentialColumns($validated), 'aspsp_name' => 'Bitpanda', 'aspsp_country' => $validated['country'], 'aspsp_logo' => $bank->logo, diff --git a/app/Http/Controllers/OpenBanking/CoinbaseController.php b/app/Http/Controllers/OpenBanking/CoinbaseController.php index b4284a63..312728e5 100644 --- a/app/Http/Controllers/OpenBanking/CoinbaseController.php +++ b/app/Http/Controllers/OpenBanking/CoinbaseController.php @@ -51,8 +51,7 @@ class CoinbaseController extends Controller $connection = $user->bankingConnections()->create([ 'provider' => BankingProvider::Coinbase, - 'api_token' => $validated['api_key_name'], - 'api_secret' => $validated['private_key'], + ...BankingProvider::Coinbase->credentialColumns($validated), 'aspsp_name' => 'Coinbase', 'aspsp_country' => $validated['country'], 'aspsp_logo' => $bank->logo, diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php index 02d1410d..2c83d0be 100644 --- a/app/Http/Controllers/OpenBanking/ConnectionController.php +++ b/app/Http/Controllers/OpenBanking/ConnectionController.php @@ -16,6 +16,7 @@ use App\Services\Banking\BinanceClient; use App\Services\Banking\BitpandaClient; use App\Services\Banking\CoinbaseClient; use App\Services\Banking\IndexaCapitalClient; +use App\Services\Banking\InteractiveBrokersClient; use Illuminate\Foundation\Auth\Access\AuthorizesRequests; use Illuminate\Http\RedirectResponse; use Illuminate\Support\Facades\Auth; @@ -94,16 +95,8 @@ class ConnectionController extends Controller return back()->withErrors(['credentials' => $validationError]); } - $updateData = match ($connection->provider) { - BankingProvider::IndexaCapital => ['api_token' => $validated['api_token']], - BankingProvider::Binance => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']], - BankingProvider::Bitpanda => ['api_token' => $validated['api_key']], - BankingProvider::Coinbase => ['api_token' => $validated['api_key_name'], 'api_secret' => $validated['private_key']], - default => [], - }; - $connection->update([ - ...$updateData, + ...$connection->provider->credentialColumns($validated), 'status' => BankingConnectionStatus::Active, 'error_message' => null, 'consecutive_sync_failures' => 0, @@ -125,6 +118,7 @@ class ConnectionController extends Controller BankingProvider::Binance => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(), BankingProvider::Bitpanda => (new BitpandaClient($validated['api_key']))->getCryptoWallets(), BankingProvider::Coinbase => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1), + BankingProvider::InteractiveBrokers => (new InteractiveBrokersClient($validated['token'], $validated['query_id']))->fetchStatement(), default => throw new \InvalidArgumentException('Unsupported provider for credential update.'), }; } catch (\InvalidArgumentException $e) { diff --git a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php index 76643d23..7dfd0c28 100644 --- a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php +++ b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php @@ -51,7 +51,7 @@ class IndexaCapitalController extends Controller $connection = $user->bankingConnections()->create([ 'provider' => BankingProvider::IndexaCapital, - 'api_token' => $validated['api_token'], + ...BankingProvider::IndexaCapital->credentialColumns($validated), 'aspsp_name' => 'Indexa Capital', 'aspsp_country' => 'ES', 'aspsp_logo' => $bank->logo, diff --git a/app/Http/Controllers/OpenBanking/InteractiveBrokersController.php b/app/Http/Controllers/OpenBanking/InteractiveBrokersController.php new file mode 100644 index 00000000..32cf3264 --- /dev/null +++ b/app/Http/Controllers/OpenBanking/InteractiveBrokersController.php @@ -0,0 +1,135 @@ +validated(); + $user = auth()->user(); + + abort_unless(Feature::for($user)->active(InteractiveBrokers::class), 403); + + if ($this->shouldBlockOpenBankingAccess($user)) { + return $this->subscribeJsonResponse(); + } + + $client = new InteractiveBrokersClient($validated['token'], $validated['query_id']); + + try { + $accounts = $client->fetchStatement(); + } catch (\Throwable $e) { + Log::warning('Interactive Brokers connection validation failed', ['error' => $e->getMessage()]); + + return response()->json([ + 'message' => $this->connectErrorMessage($e), + ], 422); + } + + if (empty($accounts)) { + return response()->json([ + 'message' => 'No accounts found in the Flex statement. Check that your Flex Query includes the NAV section.', + ], 422); + } + + $bank = Bank::firstOrCreate( + ['name' => 'Interactive Brokers', 'user_id' => null], + ['name' => 'Interactive Brokers', 'logo' => '/images/banks/logos/interactive-brokers.png'], + ); + + $connection = $user->bankingConnections()->create([ + 'provider' => BankingProvider::InteractiveBrokers, + ...BankingProvider::InteractiveBrokers->credentialColumns($validated), + 'aspsp_name' => 'Interactive Brokers', + 'aspsp_country' => 'US', + 'aspsp_logo' => $bank->logo, + 'status' => BankingConnectionStatus::Pending, + ]); + + $connection->update([ + 'status' => BankingConnectionStatus::AwaitingMapping, + 'pending_accounts_data' => $this->buildPendingAccounts($accounts), + ]); + + if (! $user->isOnboarded()) { + $this->createAccountsFromPending($user, $connection, $accountUserCurrencyService); + SyncBankingConnectionJob::dispatch($connection); + + return response()->json([ + 'redirect_url' => route('onboarding', ['step' => 'create-account']), + 'connection_id' => $connection->id, + ]); + } + + return response()->json([ + 'redirect_url' => route('open-banking.map-accounts', $connection), + 'connection_id' => $connection->id, + ]); + } + + /** + * Turn a Flex failure into a message the user can act on: bad credentials, + * a busy/rate-limited service, or a statement that is still generating. + */ + private function connectErrorMessage(\Throwable $e): string + { + if ($e instanceof RequestException && in_array($e->response->status(), [401, 403], true)) { + return 'Invalid Flex token or query ID, or failed to connect to Interactive Brokers.'; + } + + if ($e instanceof RequestException && $e->response->status() === 429) { + return 'Interactive Brokers is rate limiting requests. Please wait a few minutes and try again.'; + } + + if ($e instanceof TransientBankingProviderException) { + return 'Interactive Brokers is still preparing your statement. Please try again in a moment.'; + } + + return 'Invalid Flex token or query ID, or failed to connect to Interactive Brokers.'; + } + + /** + * Build pending accounts from the parsed Flex statement. + * + * @param array, investedAmount: float|null}> $accounts + * @return array + */ + private function buildPendingAccounts(array $accounts): array + { + $pending = []; + + foreach ($accounts as $account) { + $pending[] = [ + 'uid' => $account['account_id'], + 'currency' => $account['currency'] !== '' ? $account['currency'] : 'USD', + 'name' => "Interactive Brokers ({$account['account_id']})", + ]; + } + + return $pending; + } +} diff --git a/app/Http/Controllers/OpenBanking/WiseController.php b/app/Http/Controllers/OpenBanking/WiseController.php index c10cda33..8bd0cd9e 100644 --- a/app/Http/Controllers/OpenBanking/WiseController.php +++ b/app/Http/Controllers/OpenBanking/WiseController.php @@ -60,8 +60,7 @@ class WiseController extends Controller $connection = $user->bankingConnections()->create([ 'provider' => BankingProvider::Wise, - 'api_token' => $request->api_token, - 'api_secret' => null, + ...BankingProvider::Wise->credentialColumns($request->validated()), 'aspsp_name' => 'Wise', 'aspsp_country' => 'GB', 'aspsp_logo' => $bank->logo, diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f7edc8fe..6750e4d0 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; use App\Features\CalculateBalancesOnImport; +use App\Features\InteractiveBrokers; use App\Models\BankingConnection; use App\Services\CurrencyOptions; use Illuminate\Foundation\Inspiring; @@ -178,16 +179,19 @@ class HandleInertiaRequests extends Middleware return [ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'interactiveBrokers' => false, ]; } $features = Feature::for($user)->values([ CalculateBalancesOnImport::class, + InteractiveBrokers::class, ]); return [ 'cashflow' => true, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, + 'interactiveBrokers' => $features[InteractiveBrokers::class] !== false, ]; } diff --git a/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php b/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php index 02597348..78661859 100644 --- a/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php +++ b/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\OpenBanking; +use App\Enums\BankingProvider; use Illuminate\Foundation\Http\FormRequest; class ConnectBinanceRequest extends FormRequest @@ -17,8 +18,7 @@ class ConnectBinanceRequest extends FormRequest public function rules(): array { return [ - 'api_key' => ['required', 'string', 'min:10'], - 'api_secret' => ['required', 'string', 'min:10'], + ...BankingProvider::Binance->credentialRules(), 'country' => ['required', 'string', 'size:2'], ]; } diff --git a/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php b/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php index 500152f5..93c8b27b 100644 --- a/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php +++ b/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\OpenBanking; +use App\Enums\BankingProvider; use Illuminate\Foundation\Http\FormRequest; class ConnectBitpandaRequest extends FormRequest @@ -17,7 +18,7 @@ class ConnectBitpandaRequest extends FormRequest public function rules(): array { return [ - 'api_key' => ['required', 'string', 'min:10'], + ...BankingProvider::Bitpanda->credentialRules(), 'country' => ['required', 'string', 'size:2'], ]; } diff --git a/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php b/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php index 05433830..f74e9129 100644 --- a/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php +++ b/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\OpenBanking; +use App\Enums\BankingProvider; use Illuminate\Foundation\Http\FormRequest; class ConnectCoinbaseRequest extends FormRequest @@ -17,8 +18,7 @@ class ConnectCoinbaseRequest extends FormRequest public function rules(): array { return [ - 'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'], - 'private_key' => ['required', 'string', 'min:40'], + ...BankingProvider::Coinbase->credentialRules(), 'country' => ['required', 'string', 'size:2'], ]; } diff --git a/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php b/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php index 27b0d3c1..894c35ca 100644 --- a/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php +++ b/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\OpenBanking; +use App\Enums\BankingProvider; use Illuminate\Foundation\Http\FormRequest; class ConnectIndexaCapitalRequest extends FormRequest @@ -16,8 +17,6 @@ class ConnectIndexaCapitalRequest extends FormRequest */ public function rules(): array { - return [ - 'api_token' => ['required', 'string', 'min:10'], - ]; + return BankingProvider::IndexaCapital->credentialRules(); } } diff --git a/app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php b/app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php new file mode 100644 index 00000000..c1ce9e84 --- /dev/null +++ b/app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php @@ -0,0 +1,22 @@ +> + */ + public function rules(): array + { + return BankingProvider::InteractiveBrokers->credentialRules(); + } +} diff --git a/app/Http/Requests/OpenBanking/ConnectWiseRequest.php b/app/Http/Requests/OpenBanking/ConnectWiseRequest.php index 02262b5a..21e1d402 100644 --- a/app/Http/Requests/OpenBanking/ConnectWiseRequest.php +++ b/app/Http/Requests/OpenBanking/ConnectWiseRequest.php @@ -2,6 +2,7 @@ namespace App\Http\Requests\OpenBanking; +use App\Enums\BankingProvider; use Illuminate\Foundation\Http\FormRequest; class ConnectWiseRequest extends FormRequest @@ -12,13 +13,11 @@ class ConnectWiseRequest extends FormRequest } /** - * @return array> + * @return array> */ public function rules(): array { - return [ - 'api_token' => ['required', 'string', 'min:10'], - ]; + return BankingProvider::Wise->credentialRules(); } /** diff --git a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php index 121a9e03..ec0f400f 100644 --- a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php +++ b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php @@ -2,7 +2,6 @@ namespace App\Http\Requests\OpenBanking; -use App\Enums\BankingProvider; use App\Models\BankingConnection; use Illuminate\Foundation\Http\FormRequest; @@ -27,22 +26,6 @@ class UpdateConnectionCredentialsRequest extends FormRequest return []; } - return match ($connection->provider) { - BankingProvider::IndexaCapital => [ - 'api_token' => ['required', 'string', 'min:10'], - ], - BankingProvider::Binance => [ - 'api_key' => ['required', 'string', 'min:10'], - 'api_secret' => ['required', 'string', 'min:10'], - ], - BankingProvider::Bitpanda => [ - 'api_key' => ['required', 'string', 'min:10'], - ], - BankingProvider::Coinbase => [ - 'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'], - 'private_key' => ['required', 'string', 'min:40'], - ], - default => [], - }; + return $connection->provider->credentialRules(); } } diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index cb8051da..d390cd8b 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -129,6 +129,11 @@ class BankingConnection extends Model return $this->provider === BankingProvider::EnableBanking; } + public function isInteractiveBrokers(): bool + { + return $this->provider === BankingProvider::InteractiveBrokers; + } + public function usesApiKey(): bool { return $this->provider->usesApiKey(); diff --git a/app/Services/Banking/CredentialField.php b/app/Services/Banking/CredentialField.php new file mode 100644 index 00000000..2598de75 --- /dev/null +++ b/app/Services/Banking/CredentialField.php @@ -0,0 +1,24 @@ + $rules + */ + public function __construct( + public string $input, + public string $column, + public array $rules, + ) {} +} diff --git a/app/Services/Banking/InteractiveBrokersBalanceSyncService.php b/app/Services/Banking/InteractiveBrokersBalanceSyncService.php new file mode 100644 index 00000000..1ae1f9cb --- /dev/null +++ b/app/Services/Banking/InteractiveBrokersBalanceSyncService.php @@ -0,0 +1,74 @@ +, investedAmount: float|null}> $accounts + */ + public function sync(Account $account, array $accounts, bool $isFirstSync = true): void + { + if (! $account->external_account_id) { + return; + } + + $data = $accounts[$account->external_account_id] ?? null; + + if ($data === null || empty($data['navByDate'])) { + Log::warning('No Interactive Brokers data for account', [ + 'account_id' => $account->id, + 'external_account_id' => $account->external_account_id, + ]); + + return; + } + + $navByDate = $data['navByDate']; + ksort($navByDate); + $latestDate = array_key_last($navByDate); + + $sinceDate = null; + + if (! $isFirstSync) { + $lastBalanceDate = $account->balances()->max('balance_date'); + + if ($lastBalanceDate) { + $sinceDate = $lastBalanceDate; + } + } + + $count = 0; + + foreach ($navByDate as $date => $nav) { + if ($sinceDate !== null && $date < $sinceDate) { + continue; + } + + $attributes = ['balance' => (int) round($nav * 100)]; + + if ($date === $latestDate && $data['investedAmount'] !== null) { + $attributes['invested_amount'] = (int) round($data['investedAmount'] * 100); + } + + $account->balances()->updateOrCreate(['balance_date' => $date], $attributes); + + $count++; + } + + Log::info('Synced Interactive Brokers balances', [ + 'account_id' => $account->id, + 'days_synced' => $count, + ...($sinceDate ? ['since_date' => $sinceDate] : []), + ]); + } +} diff --git a/app/Services/Banking/InteractiveBrokersClient.php b/app/Services/Banking/InteractiveBrokersClient.php new file mode 100644 index 00000000..0a26da48 --- /dev/null +++ b/app/Services/Banking/InteractiveBrokersClient.php @@ -0,0 +1,274 @@ +, investedAmount: float|null}> + */ + public function fetchStatement(): array + { + $referenceCode = $this->sendRequest(); + $xml = $this->getStatement($referenceCode); + + return $this->parseStatement($xml); + } + + private function sendRequest(): string + { + $response = $this->http()->get(self::BASE_URL.'/SendRequest', [ + 't' => $this->token, + 'q' => $this->queryId, + 'v' => self::VERSION, + ]); + + $response->throw(); + + $xml = $this->loadXml($response->body()); + + if ((string) $xml->Status !== 'Success') { + $this->throwForError((string) $xml->ErrorCode, (string) $xml->ErrorMessage); + } + + $referenceCode = (string) $xml->ReferenceCode; + + if ($referenceCode === '') { + throw new TransientBankingProviderException( + 'Interactive Brokers did not return a reference code', + provider: 'interactivebrokers', + ); + } + + return $referenceCode; + } + + private function getStatement(string $referenceCode): SimpleXMLElement + { + for ($attempt = 1; $attempt <= self::MAX_STATEMENT_ATTEMPTS; $attempt++) { + $response = $this->http()->get(self::BASE_URL.'/GetStatement', [ + 't' => $this->token, + 'q' => $referenceCode, + 'v' => self::VERSION, + ]); + + $response->throw(); + + $xml = $this->loadXml($response->body()); + + if ($xml->getName() === 'FlexQueryResponse') { + return $xml; + } + + $code = (string) $xml->ErrorCode; + $message = (string) $xml->ErrorMessage; + + if (! $this->isStillGenerating($code, $message)) { + $this->throwForError($code, $message); + } + + if ($attempt < self::MAX_STATEMENT_ATTEMPTS) { + Sleep::for(self::STATEMENT_RETRY_SECONDS)->seconds(); + } + } + + throw new TransientBankingProviderException( + 'Interactive Brokers statement was not ready in time', + provider: 'interactivebrokers', + providerCode: '1019', + ); + } + + /** + * @return array, investedAmount: float|null}> + */ + private function parseStatement(SimpleXMLElement $xml): array + { + $accounts = []; + + if (! isset($xml->FlexStatements->FlexStatement)) { + return $accounts; + } + + foreach ($xml->FlexStatements->FlexStatement as $statement) { + $accountId = (string) $statement['accountId']; + + if ($accountId === '') { + continue; + } + + $navByDate = []; + $cashByDate = []; + + if (isset($statement->EquitySummaryInBase->EquitySummaryByReportDateInBase)) { + foreach ($statement->EquitySummaryInBase->EquitySummaryByReportDateInBase as $row) { + $date = $this->normalizeDate((string) $row['reportDate']); + $total = (string) $row['total']; + + if ($date === null || $total === '') { + continue; + } + + $navByDate[$date] = (float) $total; + $cashByDate[$date] = (float) $row['cash']; + } + } + + ksort($navByDate); + + // Cost basis only exists on the current snapshot (OpenPositions), so + // invested_amount — and therefore profit — is only known for the + // latest date; historical NAV rows store balance alone. + $costBasisBase = 0.0; + $hasPositions = isset($statement->OpenPositions->OpenPosition); + + if ($hasPositions) { + foreach ($statement->OpenPositions->OpenPosition as $position) { + $fxRate = (float) ($position['fxRateToBase'] ?: 1); + $costBasisBase += (float) $position['costBasisMoney'] * $fxRate; + } + } + + $investedAmount = null; + + if (! empty($navByDate) && $hasPositions) { + $latestDate = array_key_last($navByDate); + $investedAmount = $costBasisBase + ($cashByDate[$latestDate] ?? 0.0); + } + + $accounts[$accountId] = [ + 'account_id' => $accountId, + 'currency' => (string) ($statement->AccountInformation['currency'] ?? ''), + 'navByDate' => $navByDate, + 'investedAmount' => $investedAmount, + ]; + } + + return $accounts; + } + + private function http(): PendingRequest + { + return Http::connectTimeout(self::HTTP_CONNECT_TIMEOUT_SECONDS) + ->timeout(self::HTTP_TIMEOUT_SECONDS); + } + + private function loadXml(string $body): SimpleXMLElement + { + $previous = libxml_use_internal_errors(true); + $xml = simplexml_load_string($body); + libxml_use_internal_errors($previous); + + if ($xml === false) { + throw new TransientBankingProviderException( + 'Interactive Brokers returned an unreadable response', + provider: 'interactivebrokers', + ); + } + + return $xml; + } + + private function isStillGenerating(string $code, string $message): bool + { + $text = strtolower($message); + + if (str_contains($text, 'too many')) { + return false; + } + + return in_array($code, ['1005', '1006', '1019'], true) + || str_contains($text, 'in progress') + || str_contains($text, 'try again shortly'); + } + + /** + * Map an IB Flex failure onto the right exception. IB's numeric codes are + * inconsistent across endpoints, so we classify on the message text too. + * + * ponytail: text-match because IB returns 200 + XML; tighten to exact codes + * only if a real account shows the message wording drifting. + */ + private function throwForError(string $code, string $message): never + { + Log::warning('Interactive Brokers Flex error', ['code' => $code, 'message' => $message]); + + $text = strtolower($code.' '.$message); + + // Rate-limit first: IB's throttle message itself says "from this token", + // which would otherwise trip the token (auth) branch below. + if (str_contains($text, 'too many') || $code === '1018') { + throw new RequestException( + new Response(new PsrResponse(429, [], $message ?: 'Too many Flex requests')), + ); + } + + // Bad token or bad/deleted query ID: surface as an auth failure so the + // user is prompted to fix the credentials instead of retrying forever. + if (str_contains($text, 'token') || str_contains($text, 'invalid') || $code === '1020') { + throw new RequestException( + new Response(new PsrResponse(401, [], $message ?: 'Invalid Flex token or query ID')), + ); + } + + throw new TransientBankingProviderException( + $message !== '' ? $message : "Interactive Brokers error {$code}", + provider: 'interactivebrokers', + providerCode: $code !== '' ? $code : null, + ); + } + + private function normalizeDate(string $date): ?string + { + $date = trim($date); + + if (preg_match('/^\d{8}$/', $date) === 1) { + return substr($date, 0, 4).'-'.substr($date, 4, 2).'-'.substr($date, 6, 2); + } + + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $date) === 1) { + return $date; + } + + return null; + } +} diff --git a/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php b/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php index bf995a2d..3a9970a8 100644 --- a/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php +++ b/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php @@ -16,6 +16,7 @@ class BankingConnectionSyncerFactory BankingProvider::Wise => WiseSyncer::class, BankingProvider::Bitpanda => BitpandaSyncer::class, BankingProvider::Coinbase => CoinbaseSyncer::class, + BankingProvider::InteractiveBrokers => InteractiveBrokersSyncer::class, BankingProvider::EnableBanking => EnableBankingSyncer::class, }; diff --git a/app/Services/Banking/Sync/InteractiveBrokersSyncer.php b/app/Services/Banking/Sync/InteractiveBrokersSyncer.php new file mode 100644 index 00000000..c813bb1e --- /dev/null +++ b/app/Services/Banking/Sync/InteractiveBrokersSyncer.php @@ -0,0 +1,28 @@ +api_token, $connection->api_secret); + $accounts = $client->fetchStatement(); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $this->balanceSync->sync($account, $accounts, $isFirstSync); + } + + return []; + } +} diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php index 5d87bc88..71edb947 100644 --- a/database/factories/BankingConnectionFactory.php +++ b/database/factories/BankingConnectionFactory.php @@ -133,6 +133,21 @@ class BankingConnectionFactory extends Factory ]); } + public function interactiveBrokers(): static + { + return $this->state(fn (array $attributes) => [ + 'provider' => BankingProvider::InteractiveBrokers, + 'authorization_id' => null, + 'session_id' => null, + 'api_token' => 'test-ib-token-'.fake()->uuid(), + 'api_secret' => (string) fake()->numberBetween(100000, 999999), + 'aspsp_name' => 'Interactive Brokers', + 'aspsp_country' => 'US', + 'aspsp_logo' => '/images/banks/logos/interactive-brokers.png', + 'valid_until' => null, + ]); + } + public function wise(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/seeders/data/banks.json b/database/seeders/data/banks.json index 4c6753a8..de048853 100644 --- a/database/seeders/data/banks.json +++ b/database/seeders/data/banks.json @@ -9451,6 +9451,10 @@ "name": "Indexa Capital", "logo": "/images/banks/logos/indexa-capital.jpg" }, + { + "name": "Interactive Brokers", + "logo": "/images/banks/logos/interactive-brokers.png" + }, { "name": "Binance", "logo": "https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png" diff --git a/docs/adding-a-banking-provider.md b/docs/adding-a-banking-provider.md index dcb40cf1..fd16c1bd 100644 --- a/docs/adding-a-banking-provider.md +++ b/docs/adding-a-banking-provider.md @@ -61,7 +61,7 @@ provider differs: | Provider kind | `expires()` | `notifiesOnAuthFailure()` | Example | | --- | --- | --- | --- | -| API-key (user supplies a key/token) | `false` (default) | `true` (default) | Binance, Coinbase, Bitpanda, Indexa Capital, Wise | +| API-key (user supplies a key/token) | `false` (default) | `true` (default) | Binance, Coinbase, Bitpanda, Indexa Capital, Wise, Interactive Brokers | | Consent-based (OAuth, expires) | **`true`** | **`false`** | EnableBanking | This matches `BankingProvider::usesApiKey()` (everything except EnableBanking). @@ -289,12 +289,28 @@ account, you also need the pieces below. Use an existing API-key provider `Concerns/CreatesAccountsFromPending`). You already set this in Step 4 when you added the enum case, so there is nothing extra to wire here. -4. **Frontend** — the connect flow lives under `resources/js/pages/` / - `resources/js/components/`. Mirror an existing provider's form and provider - list entry. +4. **Frontend — one registry entry.** The connect dialog, the inline connect + flow, and the update-credentials dialog are all driven by a single registry: + `resources/js/lib/connect-providers.tsx`. Add **one** `CONNECT_PROVIDERS` + entry describing the provider — `institution` (name/country/logo), the + connect `endpoint`, the credential `fields`, and the on-screen copy. There is + **no per-component wiring**: the fields render the form *and* define the POST + body (each field `key` is the body key), so you don't touch the dialogs. -> Search the codebase for an existing provider's identifier -> (e.g. `grep -rn "'binance'" app resources`) to find every touchpoint to mirror. + Useful per-entry flags: + + - `onlyCountry` — offer only when connecting from that country (e.g. Indexa: `'ES'`). + - `sendsCountry` — also POST the selected `country` (banks/crypto that need it). + - `feature` — hide behind a Pennant flag (a `keyof Features`) until it's ready. + - `updatable: false` — the provider has no credential-update path on the backend. + +> Copy in the registry is plain English wrapped with `__()` at render (like +> `COUNTRIES`), so it is **not** auto-detected by `LocalizationTest` — add the +> Spanish strings to `lang/es.json` yourself or the UI renders them untranslated. + +> The backend touchpoints (controller, Form Request, route, optional model +> helper) are still per-provider. `grep -rn "'binance'" app routes` finds them +> to mirror; the frontend is just the registry entry above. --- diff --git a/lang/es.json b/lang/es.json index 282406d1..70129d58 100644 --- a/lang/es.json +++ b/lang/es.json @@ -2145,5 +2145,12 @@ "or": "o", "Create rule": "Crear regla", "Ignore rule": "Ignorar regla", - ":count matches": ":count coincidencias" + ":count matches": ":count coincidencias", + "Flex Web Service Token": "Token del Flex Web Service", + "Flex Query ID": "Query ID de Flex", + "Paste your Flex Web Service token": "Pega tu token del Flex Web Service", + "Connect your Interactive Brokers account using a Flex Web Service token and Query ID.": "Conecta tu cuenta de Interactive Brokers usando un token del Flex Web Service y un Query ID.", + "Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.": "Introduce tu token del Flex Web Service y el Query ID para conectar tu cuenta de Interactive Brokers.", + "In Client Portal, create an Activity Flex Query including the \"Net Asset Value (NAV)\" and \"Open Positions\" sections, then generate a Flex Web Service token under": "En el Client Portal, crea una Activity Flex Query que incluya las secciones \"Net Asset Value (NAV)\" y \"Open Positions\", y luego genera un token del Flex Web Service en", + "Performance & Reports → Flex Queries": "Rendimiento e Informes → Flex Queries" } diff --git a/public/images/banks/logos/interactive-brokers.png b/public/images/banks/logos/interactive-brokers.png new file mode 100644 index 0000000000000000000000000000000000000000..870c8467a1906994fcc3f0834e16f0e294547892 GIT binary patch literal 10778 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 literal 0 HcmV?d00001 diff --git a/resources/js/components/open-banking/connect-account-dialog.test.tsx b/resources/js/components/open-banking/connect-account-dialog.test.tsx index fc25fcaa..b9436e8c 100644 --- a/resources/js/components/open-banking/connect-account-dialog.test.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.test.tsx @@ -9,6 +9,12 @@ globalThis.ResizeObserver ??= class { disconnect() {} }; +const mockFeatures = { interactiveBrokers: false }; + +vi.mock('@inertiajs/react', () => ({ + usePage: () => ({ props: { features: mockFeatures } }), +})); + vi.mock('@/utils/i18n', () => ({ __: (key: string) => key, })); @@ -106,6 +112,24 @@ async function reachBankStep(connections: BankingConnection[]) { describe('ConnectAccountDialog', () => { beforeEach(() => { vi.clearAllMocks(); + mockFeatures.interactiveBrokers = false; + }); + + it('shows Interactive Brokers only when the feature flag is enabled', async () => { + mockFeatures.interactiveBrokers = true; + await reachBankStep([]); + + expect( + screen.getByRole('button', { name: /Interactive Brokers/ }), + ).toBeInTheDocument(); + }); + + it('hides Interactive Brokers when the feature flag is disabled', async () => { + await reachBankStep([]); + + expect( + screen.queryByRole('button', { name: /Interactive Brokers/ }), + ).not.toBeInTheDocument(); }); it('keeps an already-connected bank selectable and badges it', async () => { @@ -137,6 +161,26 @@ describe('ConnectAccountDialog', () => { expect(connect).toBeEnabled(); }); + it('requires every provider credential before connecting', async () => { + await reachBankStep([]); + + fireEvent.click(screen.getByRole('button', { name: /Binance/ })); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + const connect = screen.getByRole('button', { name: 'Connect' }); + expect(connect).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('API Key'), { + target: { value: 'key' }, + }); + expect(connect).toBeDisabled(); + + fireEvent.change(screen.getByLabelText('API Secret'), { + target: { value: 'secret' }, + }); + expect(connect).toBeEnabled(); + }); + it('does not warn when connecting a fresh bank', async () => { await reachBankStep([liveBbvaConnection()]); diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index c44aaf4f..c7d52ecc 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -19,74 +19,11 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { - alreadyConnectedBankNames, - hasLiveConnectionForProvider, -} from '@/lib/banking-connections'; -import { getCsrfToken } from '@/lib/csrf'; -import type { - BankingConnection, - EnableBankingInstitution, -} from '@/types/banking'; +import { CONNECT_COUNTRIES, useConnectFlow } from '@/hooks/use-connect-flow'; +import { ProviderCredentialFields } from '@/lib/connect-providers'; +import type { BankingConnection } from '@/types/banking'; import { __ } from '@/utils/i18n'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -const COUNTRIES = [ - { code: 'ES', name: 'Spain' }, - { code: 'DE', name: 'Germany' }, - { code: 'FR', name: 'France' }, - { code: 'IT', name: 'Italy' }, - { code: 'NL', name: 'Netherlands' }, - { code: 'PT', name: 'Portugal' }, - { code: 'BE', name: 'Belgium' }, - { code: 'AT', name: 'Austria' }, - { code: 'FI', name: 'Finland' }, - { code: 'IE', name: 'Ireland' }, - { code: 'LT', name: 'Lithuania' }, - { code: 'LV', name: 'Latvia' }, - { code: 'EE', name: 'Estonia' }, - { code: 'SE', name: 'Sweden' }, - { code: 'NO', name: 'Norway' }, - { code: 'DK', name: 'Denmark' }, - { code: 'PL', name: 'Poland' }, - { code: 'GB', name: 'United Kingdom' }, -] as const; - -const INDEXA_CAPITAL_INSTITUTION: EnableBankingInstitution = { - name: 'Indexa Capital', - country: 'ES', - logo: '/images/banks/logos/indexa-capital.jpg', - maximum_consent_validity: null, -}; - -const BINANCE_INSTITUTION: EnableBankingInstitution = { - name: 'Binance', - country: 'ALL', - logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png', - maximum_consent_validity: null, -}; - -const BITPANDA_INSTITUTION: EnableBankingInstitution = { - name: 'Bitpanda', - country: 'ALL', - logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png', - maximum_consent_validity: null, -}; - -const COINBASE_INSTITUTION: EnableBankingInstitution = { - name: 'Coinbase', - country: 'ALL', - logo: 'https://whisper.money/storage/banks/logos/coinbase.png', - maximum_consent_validity: null, -}; - -const WISE_INSTITUTION: EnableBankingInstitution = { - name: 'Wise', - country: 'ALL', - logo: '/images/banks/logos/wise.png', - maximum_consent_validity: null, -}; +import { useEffect, useState } from 'react'; interface ConnectAccountDialogProps { open: boolean; @@ -94,240 +31,44 @@ interface ConnectAccountDialogProps { connections?: BankingConnection[]; } -type Step = 'country' | 'bank' | 'confirm'; - export function ConnectAccountDialog({ open, onOpenChange, connections = [], }: ConnectAccountDialogProps) { - const [step, setStep] = useState('country'); + const { + step, + setStep, + country, + setCountry, + filteredInstitutions, + searchQuery, + setSearchQuery, + selectedBank, + setSelectedBank, + isLoading, + isSubmitting, + error, + credentials, + setCredential, + provider, + connectedBankNames, + isAlreadyConnected, + acknowledgedReplace, + setAcknowledgedReplace, + canSubmit, + fetchInstitutions, + handleAuthorize, + reset, + } = useConnectFlow(connections); + const [integrationDrawerOpen, setIntegrationDrawerOpen] = useState(false); - const [country, setCountry] = useState(''); - const [institutions, setInstitutions] = useState< - EnableBankingInstitution[] - >([]); - const [filteredInstitutions, setFilteredInstitutions] = useState< - EnableBankingInstitution[] - >([]); - const [searchQuery, setSearchQuery] = useState(''); - const [selectedBank, setSelectedBank] = - useState(null); - const [isLoading, setIsLoading] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - const [apiToken, setApiToken] = useState(''); - const [apiKey, setApiKey] = useState(''); - const [apiSecret, setApiSecret] = useState(''); - const [bitpandaApiKey, setBitpandaApiKey] = useState(''); - const [coinbaseKeyName, setCoinbaseKeyName] = useState(''); - const [coinbasePrivateKey, setCoinbasePrivateKey] = useState(''); - const [wiseApiToken, setWiseApiToken] = useState(''); - const [acknowledgedReplace, setAcknowledgedReplace] = useState(false); - - const isIndexaCapital = useMemo( - () => selectedBank?.name === 'Indexa Capital', - [selectedBank], - ); - - const isBinance = useMemo( - () => selectedBank?.name === 'Binance', - [selectedBank], - ); - - const isBitpanda = useMemo( - () => selectedBank?.name === 'Bitpanda', - [selectedBank], - ); - - const isCoinbase = useMemo( - () => selectedBank?.name === 'Coinbase', - [selectedBank], - ); - - const isWise = useMemo(() => selectedBank?.name === 'Wise', [selectedBank]); - - const connectedBankNames = useMemo( - () => alreadyConnectedBankNames(connections), - [connections], - ); - - const isAlreadyConnected = useMemo( - () => !!selectedBank && connectedBankNames.has(selectedBank.name), - [selectedBank, connectedBankNames], - ); - - useEffect(() => { - setAcknowledgedReplace(false); - }, [selectedBank]); - - const resetState = useCallback(() => { - setStep('country'); - setCountry(''); - setInstitutions([]); - setFilteredInstitutions([]); - setSearchQuery(''); - setSelectedBank(null); - setIsLoading(false); - setIsSubmitting(false); - setError(null); - setApiToken(''); - setApiKey(''); - setApiSecret(''); - setBitpandaApiKey(''); - setCoinbaseKeyName(''); - setCoinbasePrivateKey(''); - setWiseApiToken(''); - setAcknowledgedReplace(false); - }, []); useEffect(() => { if (!open) { - resetState(); + reset(); } - }, [open, resetState]); - - useEffect(() => { - if (searchQuery) { - setFilteredInstitutions( - institutions.filter((i) => - i.name.toLowerCase().includes(searchQuery.toLowerCase()), - ), - ); - } else { - setFilteredInstitutions(institutions); - } - }, [searchQuery, institutions]); - - async function fetchInstitutions(countryCode: string) { - setIsLoading(true); - setError(null); - - try { - const response = await fetch( - `/open-banking/institutions?country=${countryCode}`, - { - headers: { - Accept: 'application/json', - 'X-XSRF-TOKEN': getCsrfToken(), - }, - }, - ); - - if (!response.ok) { - throw new Error('Failed to fetch banks'); - } - - const data = await response.json(); - - const hasProvider = (provider: string) => - hasLiveConnectionForProvider(connections, provider); - - const extraInstitutions = [ - BINANCE_INSTITUTION, - BITPANDA_INSTITUTION, - COINBASE_INSTITUTION, - WISE_INSTITUTION, - ]; - if (countryCode === 'ES') { - extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION); - } - - const allInstitutions = [...extraInstitutions, ...data] - .filter((institution) => { - if (institution.name === 'Binance') { - return !hasProvider('binance'); - } - if (institution.name === 'Bitpanda') { - return !hasProvider('bitpanda'); - } - if (institution.name === 'Coinbase') { - return !hasProvider('coinbase'); - } - if (institution.name === 'Indexa Capital') { - return !hasProvider('indexacapital'); - } - return true; - }) - .sort((a, b) => a.name.localeCompare(b.name)); - - setInstitutions(allInstitutions); - setFilteredInstitutions(allInstitutions); - setStep('bank'); - } catch { - setError(__('Failed to load banks. Please try again.')); - } finally { - setIsLoading(false); - } - } - - async function handleAuthorize() { - if (!selectedBank) return; - - setIsSubmitting(true); - setError(null); - - try { - const url = isBitpanda - ? '/open-banking/bitpanda/connect' - : isBinance - ? '/open-banking/binance/connect' - : isIndexaCapital - ? '/open-banking/indexa-capital/connect' - : isCoinbase - ? '/open-banking/coinbase/connect' - : isWise - ? '/open-banking/wise/connect' - : '/open-banking/authorize'; - - const body = isBitpanda - ? { api_key: bitpandaApiKey, country: country } - : isBinance - ? { api_key: apiKey, api_secret: apiSecret, country: country } - : isIndexaCapital - ? { api_token: apiToken } - : isCoinbase - ? { - api_key_name: coinbaseKeyName, - private_key: coinbasePrivateKey, - country: country, - } - : isWise - ? { api_token: wiseApiToken } - : { - aspsp_name: selectedBank.name, - country: country, - logo: selectedBank.logo, - }; - - const response = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - Accept: 'application/json', - 'X-XSRF-TOKEN': getCsrfToken(), - }, - body: JSON.stringify(body), - }); - - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error( - data.message || 'Failed to start authorization', - ); - } - - const data = await response.json(); - window.location.href = data.redirect_url; - } catch (e) { - setError( - e instanceof Error - ? e.message - : __('Failed to connect. Please try again.'), - ); - setIsSubmitting(false); - } - } + }, [open, reset]); return ( <> @@ -342,39 +83,11 @@ export function ConnectAccountDialog({ )} {step === 'bank' && __('Select your bank.')} {step === 'confirm' && - isWise && - __( - 'Enter your Wise Personal API token to connect your account.', - )} - {step === 'confirm' && - !isIndexaCapital && - !isBinance && - !isBitpanda && - !isCoinbase && - !isWise && - __( - 'You will be redirected to your bank to authorize access.', - )} - {step === 'confirm' && - isIndexaCapital && - __( - 'Enter your API token to connect your Indexa Capital account.', - )} - {step === 'confirm' && - isBinance && - __( - 'Enter your API Key and Secret to connect your Binance account.', - )} - {step === 'confirm' && - isBitpanda && - __( - 'Enter your API Key to connect your Bitpanda account.', - )} - {step === 'confirm' && - isCoinbase && - __( - 'Enter your CDP App Key ID and Secret to connect your Coinbase account.', - )} + (provider + ? __(provider.headerDescription) + : __( + 'You will be redirected to your bank to authorize access.', + ))} @@ -396,7 +109,7 @@ export function ConnectAccountDialog({ /> - {COUNTRIES.map((c) => ( + {CONNECT_COUNTRIES.map((c) => (

- {isBitpanda - ? __( - 'Connect your Bitpanda account using your API Key.', - ) - : isBinance - ? __( - 'Connect your Binance account using your API Key and Secret.', - ) - : isIndexaCapital - ? __( - 'Connect your Indexa Capital account using your API token.', - ) - : isCoinbase - ? __( - 'Connect your Coinbase account using a CDP API key.', - ) - : isWise - ? __( - 'Connect your Wise account using a Personal API token.', - ) - : __( - 'You will be redirected to authorize access to your account data.', - )} + {provider + ? __(provider.cardDescription) + : __( + 'You will be redirected to authorize access to your account data.', + )}

@@ -555,214 +250,12 @@ export function ConnectAccountDialog({ /> )} - {isIndexaCapital && ( -
- - - setApiToken(e.target.value) - } - placeholder={__( - 'Paste your Indexa Capital API token', - )} - className="my-2" - /> -

- {__( - 'You can generate your API token from your Indexa Capital dashboard under', - )}{' '} - - {__('Settings > Applications')} - - . -

-
- )} - - {isBinance && ( -
-
- - - setApiKey(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Binance API Key', - )} - /> -
-
- - - setApiSecret(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Binance API Secret', - )} - /> -
-

- {__( - 'You can create API keys from your Binance account under', - )}{' '} - - {__('API Management')} - - . -

-
- )} - - {isBitpanda && ( -
- - - setBitpandaApiKey(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Bitpanda API Key', - )} - /> -

- {__( - 'You can create API keys from your Bitpanda account under', - )}{' '} - - {__('API Key Management')} - - . -

-
- )} - - {isWise && ( -
- - - setWiseApiToken(e.target.value) - } - className="mt-1" - placeholder={__( - 'Paste your Wise API token', - )} - /> -

- {__('Generate a token in Wise under')}{' '} - - {__( - 'Settings → Developer Tools → API tokens', - )} - - . -

-
- )} - - {isCoinbase && ( -
-
- - - setCoinbaseKeyName( - e.target.value, - ) - } - className="mt-1 font-mono text-xs" - placeholder="00000000-0000-0000-0000-000000000000" - /> -
-
- -