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 00000000..870c8467 Binary files /dev/null and b/public/images/banks/logos/interactive-brokers.png differ 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" - /> -
-
- -