feat(banking): add Interactive Brokers sync via Flex Web Service (#581)

## 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.
This commit is contained in:
Víctor Falcón 2026-06-23 11:39:24 +02:00 committed by GitHub
parent 8e3871370a
commit f60e6d7035
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 2137 additions and 1291 deletions

View File

@ -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<int, CredentialField>
*/
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<string, array<int, mixed>>
*/
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<string, mixed> $input
* @return array<string, mixed>
*/
public function credentialColumns(array $input): array
{
$columns = [];
foreach ($this->credentialFields() as $field) {
$columns[$field->column] = $input[$field->input];
}
return $columns;
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* @api
*/
class InteractiveBrokers
{
/**
* Resolve the feature's initial value.
*
* Off by default; enable per beta tester with
* `php artisan feature:enable InteractiveBrokers user@example.com`
* until the Flex integration is validated against a live account.
*/
public function resolve(?User $user): bool
{
return false;
}
}

View File

@ -51,8 +51,7 @@ class BinanceController extends Controller
$connection = $user->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,

View File

@ -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,

View File

@ -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,

View File

@ -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) {

View File

@ -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,

View File

@ -0,0 +1,135 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Exceptions\Banking\TransientBankingProviderException;
use App\Features\InteractiveBrokers;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectInteractiveBrokersRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Services\AccountUserCurrencyService;
use App\Services\Banking\InteractiveBrokersClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Feature;
class InteractiveBrokersController extends Controller
{
use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/**
* Validate the Flex credentials and create a connection.
*/
public function store(ConnectInteractiveBrokersRequest $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
{
$validated = $request->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<string, array{account_id: string, currency: string, navByDate: array<string, float>, investedAmount: float|null}> $accounts
* @return array<int, array{uid: string, currency: string, name: string}>
*/
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;
}
}

View File

@ -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,

View File

@ -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,
];
}

View File

@ -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'],
];
}

View File

@ -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'],
];
}

View File

@ -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'],
];
}

View File

@ -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();
}
}

View File

@ -0,0 +1,22 @@
<?php
namespace App\Http\Requests\OpenBanking;
use App\Enums\BankingProvider;
use Illuminate\Foundation\Http\FormRequest;
class ConnectInteractiveBrokersRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return BankingProvider::InteractiveBrokers->credentialRules();
}
}

View File

@ -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<string, array<int, string>>
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'api_token' => ['required', 'string', 'min:10'],
];
return BankingProvider::Wise->credentialRules();
}
/**

View File

@ -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();
}
}

View File

@ -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();

View File

@ -0,0 +1,24 @@
<?php
namespace App\Services\Banking;
/**
* Describes one credential input for an API-key banking provider: the request
* field name, the encrypted connection column it is stored in, and its
* validation rules.
*
* Centralizes the credential shape so it lives in one place (the
* BankingProvider enum) instead of being duplicated across each connect
* controller, the update request and the update controller.
*/
final class CredentialField
{
/**
* @param array<int, mixed> $rules
*/
public function __construct(
public string $input,
public string $column,
public array $rules,
) {}
}

View File

@ -0,0 +1,74 @@
<?php
namespace App\Services\Banking;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class InteractiveBrokersBalanceSyncService
{
/**
* Sync NAV balances for one IB account from an already-fetched statement.
*
* On first sync every daily NAV row is stored (backfill); afterwards only
* rows newer than the last recorded balance. invested_amount (and therefore
* profit) is only set on the latest date, where cost basis is available.
*
* @param array<string, array{account_id: string, currency: string, navByDate: array<string, float>, 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] : []),
]);
}
}

View File

@ -0,0 +1,274 @@
<?php
namespace App\Services\Banking;
use App\Exceptions\Banking\TransientBankingProviderException;
use GuzzleHttp\Psr7\Response as PsrResponse;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
use SimpleXMLElement;
/**
* Interactive Brokers Flex Web Service client.
*
* Flex is a two-step pull: SendRequest returns a reference code, GetStatement
* returns the XML once IB finishes generating it. IB answers with HTTP 200 even
* for failures (the error lives in the XML), so we translate its status/error
* codes into the exceptions SyncBankingConnectionJob already understands:
* RequestException(401) for token problems, RequestException(429) for throttling,
* TransientBankingProviderException for anything retryable.
*/
class InteractiveBrokersClient
{
private const BASE_URL = 'https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService';
private const VERSION = '3';
private const MAX_STATEMENT_ATTEMPTS = 5;
private const STATEMENT_RETRY_SECONDS = 3;
private const HTTP_CONNECT_TIMEOUT_SECONDS = 5;
private const HTTP_TIMEOUT_SECONDS = 15;
public function __construct(
private string $token,
private string $queryId,
) {}
/**
* Fetch the Flex statement and return one entry per IB account.
*
* @return array<string, array{account_id: string, currency: string, navByDate: array<string, float>, 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<string, array{account_id: string, currency: string, navByDate: array<string, float>, 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;
}
}

View File

@ -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,
};

View File

@ -0,0 +1,28 @@
<?php
namespace App\Services\Banking\Sync;
use App\Models\BankingConnection;
use App\Services\Banking\InteractiveBrokersBalanceSyncService;
use App\Services\Banking\InteractiveBrokersClient;
class InteractiveBrokersSyncer extends AbstractBankingConnectionSyncer
{
public function __construct(private InteractiveBrokersBalanceSyncService $balanceSync) {}
public function sync(BankingConnection $connection, bool $isFirstSync): array
{
// One Flex statement covers every account; fetch once to stay within
// IB's per-query rate limit, then distribute to each account.
$client = new InteractiveBrokersClient($connection->api_token, $connection->api_secret);
$accounts = $client->fetchStatement();
$connection->load('accounts');
foreach ($connection->accounts as $account) {
$this->balanceSync->sync($account, $accounts, $isFirstSync);
}
return [];
}
}

View File

@ -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) => [

View File

@ -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"

View File

@ -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.
---

View File

@ -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"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -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()]);

View File

@ -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<Step>('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<string>('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [filteredInstitutions, setFilteredInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedBank, setSelectedBank] =
useState<EnableBankingInstitution | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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.',
))}
</DialogDescription>
</DialogHeader>
@ -396,7 +109,7 @@ export function ConnectAccountDialog({
/>
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
{CONNECT_COUNTRIES.map((c) => (
<SelectItem
key={c.code}
value={c.code}
@ -518,29 +231,11 @@ export function ConnectAccountDialog({
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{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.',
)}
</p>
</div>
</div>
@ -555,214 +250,12 @@ export function ConnectAccountDialog({
/>
)}
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="api-token">
{__('API Token')}
</Label>
<Input
id="api-token"
type="password"
value={apiToken}
onChange={(e) =>
setApiToken(e.target.value)
}
placeholder={__(
'Paste your Indexa Capital API token',
)}
className="my-2"
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">
{__('API Key')}
</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) =>
setApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-secret">
{__('API Secret')}
</Label>
<Input
id="api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</div>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="bitpanda-api-key"
type="password"
value={bitpandaApiKey}
onChange={(e) =>
setBitpandaApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Bitpanda API Key',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
{isWise && (
<div className="space-y-2">
<Label htmlFor="wise-api-token">
{__('Personal API Token')}
</Label>
<Input
id="wise-api-token"
type="password"
value={wiseApiToken}
onChange={(e) =>
setWiseApiToken(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Wise API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__('Generate a token in Wise under')}{' '}
<a
href="https://wise.com/user/account#/developer"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__(
'Settings → Developer Tools → API tokens',
)}
</a>
.
</p>
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(
e.target.value,
)
}
className="mt-1 font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(
e.target.value,
)
}
rows={6}
className="mt-1 font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
. {__('Use a view-only key.')}
</p>
</div>
{provider && (
<ProviderCredentialFields
provider={provider}
values={credentials}
onChange={setCredential}
/>
)}
<div className="flex justify-end gap-2">
@ -775,19 +268,7 @@ export function ConnectAccountDialog({
</Button>
<Button
onClick={handleAuthorize}
disabled={
isSubmitting ||
(isAlreadyConnected &&
!acknowledgedReplace) ||
(isIndexaCapital && !apiToken) ||
(isBinance &&
(!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName ||
!coinbasePrivateKey)) ||
(isWise && !wiseApiToken)
}
disabled={!canSubmit}
>
{isSubmitting
? __('Connecting...')

View File

@ -11,71 +11,13 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { CONNECT_COUNTRIES, useConnectFlow } from '@/hooks/use-connect-flow';
import { useWebHaptics } from '@/hooks/use-web-haptics';
import {
alreadyConnectedBankNames,
hasLiveConnectionForProvider,
} from '@/lib/banking-connections';
import { getCsrfToken } from '@/lib/csrf';
import type {
BankingConnection,
EnableBankingInstitution,
} from '@/types/banking';
import { ProviderCredentialFields } from '@/lib/connect-providers';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { ArrowLeft } from 'lucide-react';
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,
};
type Step = 'country' | 'bank' | 'confirm';
import { useCallback } from 'react';
interface ConnectAccountInlineProps {
onBack: () => void;
@ -86,212 +28,44 @@ export function ConnectAccountInline({
onBack,
connections = [],
}: ConnectAccountInlineProps) {
const [step, setStep] = useState<Step>('country');
const {
step,
setStep,
country,
setCountry,
filteredInstitutions,
searchQuery,
setSearchQuery,
selectedBank,
setSelectedBank,
isLoading,
isSubmitting,
error,
credentials,
setCredential,
provider,
connectedBankNames,
isAlreadyConnected,
acknowledgedReplace,
setAcknowledgedReplace,
canSubmit,
fetchInstitutions,
handleAuthorize,
clearBankSelection,
} = useConnectFlow(connections);
const { trigger } = useWebHaptics();
const [country, setCountry] = useState<string>('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [filteredInstitutions, setFilteredInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedBank, setSelectedBank] =
useState<EnableBankingInstitution | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(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 [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 connectedBankNames = useMemo(
() => alreadyConnectedBankNames(connections),
[connections],
);
const isAlreadyConnected = useMemo(
() => !!selectedBank && connectedBankNames.has(selectedBank.name),
[selectedBank, connectedBankNames],
);
useEffect(() => {
setAcknowledgedReplace(false);
}, [selectedBank]);
useEffect(() => {
if (searchQuery) {
setFilteredInstitutions(
institutions.filter((i) =>
i.name.toLowerCase().includes(searchQuery.toLowerCase()),
),
);
} else {
setFilteredInstitutions(institutions);
}
}, [searchQuery, institutions]);
const handleBack = useCallback(() => {
if (step === 'country') {
onBack();
} else if (step === 'bank') {
setStep('country');
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
clearBankSelection();
} else if (step === 'confirm') {
setStep('bank');
}
}, [step, onBack]);
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,
];
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'
: '/open-banking/authorize';
const body = isBitpanda
? { api_key: bitpandaApiKey, country }
: isBinance
? { api_key: apiKey, api_secret: apiSecret, country }
: isIndexaCapital
? { api_token: apiToken }
: isCoinbase
? {
api_key_name: coinbaseKeyName,
private_key: coinbasePrivateKey,
country,
}
: {
aspsp_name: selectedBank.name,
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);
}
}
}, [step, onBack, setStep, clearBankSelection]);
return (
<div className="w-full max-w-md space-y-4">
@ -312,7 +86,7 @@ export function ConnectAccountInline({
/>
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((c) => (
{CONNECT_COUNTRIES.map((c) => (
<SelectItem key={c.code} value={c.code}>
{__(c.name)}
</SelectItem>
@ -428,25 +202,11 @@ export function ConnectAccountInline({
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{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.',
)
: __(
'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.',
)}
</p>
</div>
</div>
@ -459,177 +219,20 @@ export function ConnectAccountInline({
/>
)}
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="api-token">{__('API Token')}</Label>
<Input
id="api-token"
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder={__(
'Paste your Indexa Capital API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">{__('API Key')}</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-secret">
{__('API Secret')}
</Label>
<Input
id="api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</div>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="bitpanda-api-key"
type="password"
value={bitpandaApiKey}
onChange={(e) =>
setBitpandaApiKey(e.target.value)
}
placeholder={__('Paste your Bitpanda API Key')}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(e.target.value)
}
className="font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(e.target.value)
}
rows={6}
className="font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
. {__('Use a view-only key.')}
</p>
</div>
{provider && (
<ProviderCredentialFields
provider={provider}
values={credentials}
onChange={setCredential}
idPrefix="inline"
/>
)}
<Button
className="w-full"
size="lg"
onClick={handleAuthorize}
disabled={
isSubmitting ||
(isAlreadyConnected && !acknowledgedReplace) ||
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName || !coinbasePrivateKey))
}
disabled={!canSubmit}
>
{isSubmitting ? __('Connecting...') : __('Connect')}
</Button>

View File

@ -7,13 +7,16 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
connectProviderByKey,
credentialPayload,
isProviderComplete,
ProviderCredentialFields,
} from '@/lib/connect-providers';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
import { useState } from 'react';
import { useCallback, useState } from 'react';
interface UpdateCredentialsDialogProps {
connection: BankingConnection;
@ -27,59 +30,54 @@ export function UpdateCredentialsDialog({
onOpenChange,
}: UpdateCredentialsDialogProps) {
const [isSubmitting, setIsSubmitting] = useState(false);
const [apiToken, setApiToken] = useState('');
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [coinbaseKeyName, setCoinbaseKeyName] = useState('');
const [coinbasePrivateKey, setCoinbasePrivateKey] = useState('');
const [credentials, setCredentials] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const isIndexaCapital = connection.provider === 'indexacapital';
const isBinance = connection.provider === 'binance';
const isBitpanda = connection.provider === 'bitpanda';
const isCoinbase = connection.provider === 'coinbase';
const provider = connectProviderByKey(connection.provider);
// Only providers the backend exposes a credential-update path for (Wise has none).
const updatableProvider =
provider && provider.updatable !== false ? provider : undefined;
const isValid = isIndexaCapital
? apiToken.length > 0
: isBinance
? apiKey.length > 0 && apiSecret.length > 0
: isBitpanda
? apiKey.length > 0
: isCoinbase
? coinbaseKeyName.length > 0 && coinbasePrivateKey.length > 0
: false;
const setCredential = useCallback((key: string, value: string) => {
setCredentials((current) => ({ ...current, [key]: value }));
}, []);
function resetState() {
setCredentials({});
setError(null);
}
function handleOpenChange(value: boolean) {
if (!value) {
resetState();
}
onOpenChange(value);
}
function handleSubmit() {
if (!updatableProvider) {
return;
}
setIsSubmitting(true);
setError(null);
const data = isIndexaCapital
? { api_token: apiToken }
: isBinance
? { api_key: apiKey, api_secret: apiSecret }
: isCoinbase
? {
api_key_name: coinbaseKeyName,
private_key: coinbasePrivateKey,
}
: { api_key: apiKey };
router.patch(
`/settings/connections/${connection.id}/credentials`,
data,
credentialPayload(updatableProvider, credentials),
{
onSuccess: () => {
onOpenChange(false);
resetState();
},
onError: (errors) => {
const fieldError = updatableProvider.fields
.map((f) => errors[f.key])
.find(Boolean);
setError(
errors.credentials ??
errors.api_token ??
errors.api_key ??
errors.api_secret ??
errors.api_key_name ??
errors.private_key ??
fieldError ??
__(
'Failed to update credentials. Please try again.',
),
@ -92,21 +90,9 @@ export function UpdateCredentialsDialog({
);
}
function resetState() {
setApiToken('');
setApiKey('');
setApiSecret('');
setCoinbaseKeyName('');
setCoinbasePrivateKey('');
setError(null);
}
function handleOpenChange(value: boolean) {
if (!value) {
resetState();
}
onOpenChange(value);
}
const isValid = updatableProvider
? isProviderComplete(updatableProvider, credentials)
: false;
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
@ -122,167 +108,14 @@ export function UpdateCredentialsDialog({
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="space-y-4">
{isIndexaCapital && (
<div className="space-y-2">
<Label htmlFor="update-api-token">
{__('API Token')}
</Label>
<Input
id="update-api-token"
type="password"
value={apiToken}
onChange={(e) => setApiToken(e.target.value)}
placeholder={__(
'Paste your Indexa Capital API token',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can generate your API token from your Indexa Capital dashboard under',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<>
<div className="space-y-2">
<Label htmlFor="update-api-key">
{__('API Key')}
</Label>
<Input
id="update-api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="update-api-secret">
{__('API Secret')}
</Label>
<Input
id="update-api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="update-bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="update-bitpanda-api-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={__('Paste your Bitpanda API Key')}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="update-coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="update-coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(e.target.value)
}
className="font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="update-coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="update-coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(e.target.value)
}
rows={6}
className="font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
.
</p>
</div>
)}
</div>
{updatableProvider && (
<ProviderCredentialFields
provider={updatableProvider}
values={credentials}
onChange={setCredential}
idPrefix="update"
/>
)}
<DialogFooter>
<Button

View File

@ -0,0 +1,255 @@
import {
alreadyConnectedBankNames,
hasLiveConnectionForProvider,
} from '@/lib/banking-connections';
import {
CONNECT_PROVIDERS,
connectProviderForBank,
credentialPayload,
isProviderComplete,
} from '@/lib/connect-providers';
import { getCsrfToken } from '@/lib/csrf';
import type { SharedData } from '@/types';
import type {
BankingConnection,
EnableBankingInstitution,
} from '@/types/banking';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import { useCallback, useEffect, useMemo, useState } from 'react';
export const CONNECT_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;
export type ConnectStep = 'country' | 'bank' | 'confirm';
/**
* Shared state and behavior for the bank-connect flow: country bank list
* confirm/credentials POST. Both the dialog and the inline flow consume this;
* they only differ in chrome (layout, haptics, back navigation), which stays in
* the components.
*/
export function useConnectFlow(connections: BankingConnection[]) {
const { features } = usePage<SharedData>().props;
const [step, setStep] = useState<ConnectStep>('country');
const [country, setCountry] = useState('');
const [institutions, setInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [filteredInstitutions, setFilteredInstitutions] = useState<
EnableBankingInstitution[]
>([]);
const [searchQuery, setSearchQuery] = useState('');
const [selectedBank, setSelectedBank] =
useState<EnableBankingInstitution | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [credentials, setCredentials] = useState<Record<string, string>>({});
const [acknowledgedReplace, setAcknowledgedReplace] = useState(false);
const provider = useMemo(
() => connectProviderForBank(selectedBank?.name),
[selectedBank],
);
const connectedBankNames = useMemo(
() => alreadyConnectedBankNames(connections),
[connections],
);
const isAlreadyConnected = useMemo(
() => !!selectedBank && connectedBankNames.has(selectedBank.name),
[selectedBank, connectedBankNames],
);
const setCredential = useCallback((key: string, value: string) => {
setCredentials((current) => ({ ...current, [key]: value }));
}, []);
useEffect(() => {
setAcknowledgedReplace(false);
}, [selectedBank]);
useEffect(() => {
setFilteredInstitutions(
searchQuery
? institutions.filter((i) =>
i.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: institutions,
);
}, [searchQuery, institutions]);
const reset = useCallback(() => {
setStep('country');
setCountry('');
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
setIsLoading(false);
setIsSubmitting(false);
setError(null);
setCredentials({});
setAcknowledgedReplace(false);
}, []);
const clearBankSelection = useCallback(() => {
setInstitutions([]);
setFilteredInstitutions([]);
setSearchQuery('');
setSelectedBank(null);
}, []);
const fetchInstitutions = useCallback(
async (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 extraInstitutions = CONNECT_PROVIDERS.filter(
(p) =>
(!p.feature || features[p.feature]) &&
(!p.onlyCountry || p.onlyCountry === countryCode) &&
!hasLiveConnectionForProvider(
connections,
p.providerKey,
),
).map((p) => p.institution);
const allInstitutions = [...extraInstitutions, ...data].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);
}
},
[connections, features],
);
const handleAuthorize = useCallback(async () => {
if (!selectedBank) {
return;
}
setIsSubmitting(true);
setError(null);
try {
const url = provider
? provider.endpoint
: '/open-banking/authorize';
const body = provider
? {
...credentialPayload(provider, credentials),
...(provider.sendsCountry ? { country } : {}),
}
: {
aspsp_name: selectedBank.name,
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);
}
}, [selectedBank, provider, credentials, country]);
const canSubmit =
!isSubmitting &&
!(isAlreadyConnected && !acknowledgedReplace) &&
(!provider || isProviderComplete(provider, credentials));
return {
step,
setStep,
country,
setCountry,
filteredInstitutions,
searchQuery,
setSearchQuery,
selectedBank,
setSelectedBank,
isLoading,
isSubmitting,
error,
credentials,
setCredential,
provider,
connectedBankNames,
isAlreadyConnected,
acknowledgedReplace,
setAcknowledgedReplace,
canSubmit,
fetchInstitutions,
handleAuthorize,
reset,
clearBankSelection,
};
}

View File

@ -0,0 +1,358 @@
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import type { Features } from '@/types';
import type { EnableBankingInstitution } from '@/types/banking';
import { __ } from '@/utils/i18n';
/**
* Single source of truth for the API-key banking providers.
*
* Every provider used to be branched on in ~9 places across the connect
* dialog, the inline connect flow and the update-credentials dialog. They are
* now described once here: the registry drives the institution list, the
* request endpoint/body, the on-screen copy and the credential form. Adding a
* provider is a single entry; consent-based EnableBanking has no entry and is
* handled as the default redirect flow by the consumers.
*/
type CredentialField = {
/** Doubles as the POST body key and the form-state key. */
key: string;
/** i18n key for the field label. */
label: string;
type: 'password' | 'text' | 'textarea';
/** i18n key for a translated placeholder. */
placeholder?: string;
/** Literal, non-translatable placeholder (e.g. an example value). */
placeholderExample?: string;
mono?: boolean;
small?: boolean;
};
export type ConnectProvider = {
/** `banking_connections.provider` value. */
providerKey: string;
institution: EnableBankingInstitution;
/** Connect endpoint (the update flow always PATCHes the connection). */
endpoint: string;
/** Whether the connect request also sends the selected `country`. */
sendsCountry?: boolean;
/** Only offered when connecting from this country (e.g. Indexa: ES). */
onlyCountry?: string;
/** Hidden unless this Pennant feature is active. */
feature?: keyof Features;
/** Backend exposes a credential-update path for this provider. */
updatable?: boolean;
/** Confirm-step header copy (i18n key). */
headerDescription: string;
/** Confirm-card copy (i18n key). */
cardDescription: string;
fields: CredentialField[];
help: { before: string; href: string; link: string; after?: string };
};
export const CONNECT_PROVIDERS: ConnectProvider[] = [
{
providerKey: 'indexacapital',
institution: {
name: 'Indexa Capital',
country: 'ES',
logo: '/images/banks/logos/indexa-capital.jpg',
maximum_consent_validity: null,
},
endpoint: '/open-banking/indexa-capital/connect',
onlyCountry: 'ES',
headerDescription:
'Enter your API token to connect your Indexa Capital account.',
cardDescription:
'Connect your Indexa Capital account using your API token.',
fields: [
{
key: 'api_token',
label: 'API Token',
type: 'password',
placeholder: 'Paste your Indexa Capital API token',
},
],
help: {
before: 'You can generate your API token from your Indexa Capital dashboard under',
href: 'https://indexacapital.com/es/u/user#settings-apps',
link: 'Settings > Applications',
},
},
{
providerKey: 'binance',
institution: {
name: 'Binance',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png',
maximum_consent_validity: null,
},
endpoint: '/open-banking/binance/connect',
sendsCountry: true,
headerDescription:
'Enter your API Key and Secret to connect your Binance account.',
cardDescription:
'Connect your Binance account using your API Key and Secret.',
fields: [
{
key: 'api_key',
label: 'API Key',
type: 'password',
placeholder: 'Paste your Binance API Key',
},
{
key: 'api_secret',
label: 'API Secret',
type: 'password',
placeholder: 'Paste your Binance API Secret',
},
],
help: {
before: 'You can create API keys from your Binance account under',
href: 'https://www.binance.com/es/my/settings/api-management',
link: 'API Management',
},
},
{
providerKey: 'bitpanda',
institution: {
name: 'Bitpanda',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png',
maximum_consent_validity: null,
},
endpoint: '/open-banking/bitpanda/connect',
sendsCountry: true,
headerDescription:
'Enter your API Key to connect your Bitpanda account.',
cardDescription: 'Connect your Bitpanda account using your API Key.',
fields: [
{
key: 'api_key',
label: 'API Key',
type: 'password',
placeholder: 'Paste your Bitpanda API Key',
},
],
help: {
before: 'You can create API keys from your Bitpanda account under',
href: 'https://web.bitpanda.com/apikey',
link: 'API Key Management',
},
},
{
providerKey: 'coinbase',
institution: {
name: 'Coinbase',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/coinbase.png',
maximum_consent_validity: null,
},
endpoint: '/open-banking/coinbase/connect',
sendsCountry: true,
headerDescription:
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
cardDescription: 'Connect your Coinbase account using a CDP API key.',
fields: [
{
key: 'api_key_name',
label: 'App Key ID',
type: 'text',
placeholderExample: '00000000-0000-0000-0000-000000000000',
mono: true,
small: true,
},
{
key: 'private_key',
label: 'Secret',
type: 'textarea',
placeholderExample: 'Paste your CDP API key secret',
mono: true,
small: true,
},
],
help: {
before: 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
href: 'https://portal.cdp.coinbase.com/access/api',
link: 'API Keys',
after: 'Use a view-only key.',
},
},
{
providerKey: 'wise',
institution: {
name: 'Wise',
country: 'ALL',
logo: '/images/banks/logos/wise.png',
maximum_consent_validity: null,
},
endpoint: '/open-banking/wise/connect',
updatable: false,
headerDescription:
'Enter your Wise Personal API token to connect your account.',
cardDescription:
'Connect your Wise account using a Personal API token.',
fields: [
{
key: 'api_token',
label: 'Personal API Token',
type: 'password',
placeholder: 'Paste your Wise API token',
},
],
help: {
before: 'Generate a token in Wise under',
href: 'https://wise.com/user/account#/developer',
link: 'Settings → Developer Tools → API tokens',
},
},
{
providerKey: 'interactivebrokers',
institution: {
name: 'Interactive Brokers',
country: 'ALL',
logo: '/images/banks/logos/interactive-brokers.png',
maximum_consent_validity: null,
},
endpoint: '/open-banking/interactive-brokers/connect',
feature: 'interactiveBrokers',
headerDescription:
'Enter your Flex Web Service token and Query ID to connect your Interactive Brokers account.',
cardDescription:
'Connect your Interactive Brokers account using a Flex Web Service token and Query ID.',
fields: [
{
key: 'token',
label: 'Flex Web Service Token',
type: 'password',
placeholder: 'Paste your Flex Web Service token',
},
{
key: 'query_id',
label: 'Flex Query ID',
type: 'text',
placeholderExample: '123456',
mono: true,
},
],
help: {
before: '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',
href: 'https://www.ibkrguides.com/clientportal/performanceandstatements/flex3.htm',
link: 'Performance & Reports → Flex Queries',
},
},
];
/** Find a provider by the selected institution name. */
export function connectProviderForBank(
name: string | undefined,
): ConnectProvider | undefined {
return CONNECT_PROVIDERS.find((p) => p.institution.name === name);
}
/** Find a provider by its `banking_connections.provider` value. */
export function connectProviderByKey(
providerKey: string,
): ConnectProvider | undefined {
return CONNECT_PROVIDERS.find((p) => p.providerKey === providerKey);
}
/** The credential payload (field values), shared by connect and update. */
export function credentialPayload(
provider: ConnectProvider,
values: Record<string, string>,
): Record<string, string> {
return Object.fromEntries(
provider.fields.map((f) => [f.key, values[f.key] ?? '']),
);
}
/** Whether every credential field has been filled in. */
export function isProviderComplete(
provider: ConnectProvider,
values: Record<string, string>,
): boolean {
return provider.fields.every((f) => (values[f.key] ?? '').length > 0);
}
export function ProviderHelp({ help }: { help: ConnectProvider['help'] }) {
return (
<p className="text-xs text-muted-foreground">
{__(help.before)}{' '}
<a
href={help.href}
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__(help.link)}
</a>
{help.after ? <>. {__(help.after)}</> : '.'}
</p>
);
}
/**
* Renders a provider's credential inputs, bound to a flat values record.
* `idPrefix` keeps element ids unique between the connect and update dialogs.
*/
export function ProviderCredentialFields({
provider,
values,
onChange,
idPrefix = 'connect',
}: {
provider: ConnectProvider;
values: Record<string, string>;
onChange: (key: string, value: string) => void;
idPrefix?: string;
}) {
return (
<div className="space-y-4">
{provider.fields.map((field) => {
const id = `${idPrefix}-${provider.providerKey}-${field.key}`;
const placeholder =
field.placeholderExample ??
(field.placeholder ? __(field.placeholder) : undefined);
const className = cn(
'mt-1',
field.mono && 'font-mono',
field.small && 'text-xs',
);
return (
<div key={field.key} className="space-y-2">
<Label htmlFor={id}>{__(field.label)}</Label>
{field.type === 'textarea' ? (
<Textarea
id={id}
value={values[field.key] ?? ''}
onChange={(e) =>
onChange(field.key, e.target.value)
}
rows={6}
className={className}
placeholder={placeholder}
/>
) : (
<Input
id={id}
type={field.type}
value={values[field.key] ?? ''}
onChange={(e) =>
onChange(field.key, e.target.value)
}
className={className}
placeholder={placeholder}
/>
)}
</div>
);
})}
<ProviderHelp help={provider.help} />
</div>
);
}

View File

@ -21,6 +21,7 @@ import {
import { Spinner } from '@/components/ui/spinner';
import AppLayout from '@/layouts/app-layout';
import SettingsLayout from '@/layouts/settings/layout';
import { CONNECT_PROVIDERS } from '@/lib/connect-providers';
import { getCsrfToken } from '@/lib/csrf';
import type { SharedData } from '@/types';
import type { BankingConnection } from '@/types/banking';
@ -123,13 +124,9 @@ export default function ConnectionsPage({ connections }: Props) {
}
function isApiKeyProvider(connection: BankingConnection): boolean {
return [
'indexacapital',
'binance',
'bitpanda',
'coinbase',
'wise',
].includes(connection.provider);
return CONNECT_PROVIDERS.some(
(provider) => provider.providerKey === connection.provider,
);
}
function hasAuthError(connection: BankingConnection): boolean {
@ -388,8 +385,12 @@ export default function ConnectionsPage({ connections }: Props) {
!connection.last_synced_at ? (
<span className="flex items-center gap-1.5">
<Spinner className="size-3" />
{connection.provider ===
'indexacapital'
{[
'indexacapital',
'interactivebrokers',
].includes(
connection.provider,
)
? __(
'Syncing balances…',
)

View File

@ -42,6 +42,7 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
calculateBalancesOnImport: boolean;
interactiveBrokers: boolean;
}
export interface ExpiredBankingConnectionNotification {

View File

@ -18,6 +18,7 @@ use App\Http\Controllers\OpenBanking\CoinbaseController;
use App\Http\Controllers\OpenBanking\ConnectionAccountController;
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\OpenBanking\InteractiveBrokersController;
use App\Http\Controllers\OpenBanking\WiseController;
use App\Http\Controllers\RealEstateDetailController;
use App\Http\Controllers\ReEvaluateTransactionRulesController;
@ -175,6 +176,8 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function
->name('open-banking.coinbase.connect');
Route::post('wise/connect', [WiseController::class, 'store'])
->name('open-banking.wise.connect');
Route::post('interactive-brokers/connect', [InteractiveBrokersController::class, 'store'])
->name('open-banking.interactive-brokers.connect');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {

View File

@ -43,6 +43,7 @@ test('shared feature flags do not include coinbase flag', function () {
expect($props['features'])->toBe([
'cashflow' => true,
'calculateBalancesOnImport' => false,
'interactiveBrokers' => false,
]);
});

View File

@ -9,6 +9,7 @@ use App\Services\Banking\Sync\BitpandaSyncer;
use App\Services\Banking\Sync\CoinbaseSyncer;
use App\Services\Banking\Sync\EnableBankingSyncer;
use App\Services\Banking\Sync\IndexaCapitalSyncer;
use App\Services\Banking\Sync\InteractiveBrokersSyncer;
use App\Services\Banking\Sync\WiseSyncer;
dataset('providers', [
@ -17,6 +18,7 @@ dataset('providers', [
'wise' => [BankingProvider::Wise, WiseSyncer::class],
'bitpanda' => [BankingProvider::Bitpanda, BitpandaSyncer::class],
'coinbase' => [BankingProvider::Coinbase, CoinbaseSyncer::class],
'interactivebrokers' => [BankingProvider::InteractiveBrokers, InteractiveBrokersSyncer::class],
'enablebanking' => [BankingProvider::EnableBanking, EnableBankingSyncer::class],
]);
@ -46,6 +48,8 @@ it('notifies on auth failure for every API-key provider but not EnableBanking',
->and(app(BinanceSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
->and(app(BitpandaSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
->and(app(CoinbaseSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
->and(app(InteractiveBrokersSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
->and(app(InteractiveBrokersSyncer::class)->expires())->toBeFalse()
->and(app(WiseSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
->and(app(EnableBankingSyncer::class)->notifiesOnAuthFailure())->toBeFalse();
});

View File

@ -0,0 +1,305 @@
<?php
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\InteractiveBrokersBalanceSyncService;
use App\Services\Banking\InteractiveBrokersClient;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
/**
* Build a Flex GetStatement (FlexQueryResponse) body. Each statement maps an
* accountId to its daily NAV rows and (optional) open positions.
*
* @param array<int, array{accountId: string, currency?: string, nav: array<string, array{total: float, cash: float}>, positions?: array<int, array{costBasisMoney: float, fxRateToBase: float}>}> $statements
*/
function ibStatementXml(array $statements): string
{
$statementsXml = '';
foreach ($statements as $statement) {
$currency = $statement['currency'] ?? 'USD';
$navXml = '';
foreach ($statement['nav'] as $date => $values) {
$navXml .= sprintf(
'<EquitySummaryByReportDateInBase reportDate="%s" cash="%s" total="%s" />',
$date,
$values['cash'],
$values['total'],
);
}
$positionsXml = '';
foreach ($statement['positions'] ?? [] as $position) {
$positionsXml .= sprintf(
'<OpenPosition currency="%s" fxRateToBase="%s" costBasisMoney="%s" />',
$currency,
$position['fxRateToBase'],
$position['costBasisMoney'],
);
}
$positionsBlock = $positionsXml !== '' ? "<OpenPositions>{$positionsXml}</OpenPositions>" : '';
$statementsXml .= <<<XML
<FlexStatement accountId="{$statement['accountId']}" fromDate="20250101" toDate="20250115">
<AccountInformation accountId="{$statement['accountId']}" currency="{$currency}" />
<EquitySummaryInBase>{$navXml}</EquitySummaryInBase>
{$positionsBlock}
</FlexStatement>
XML;
}
return <<<XML
<FlexQueryResponse queryName="Whisper" type="AF">
<FlexStatements count="1">{$statementsXml}</FlexStatements>
</FlexQueryResponse>
XML;
}
function ibSendRequestXml(string $referenceCode = '1234567890'): string
{
return <<<XML
<FlexStatementResponse timestamp="20250115;093000">
<Status>Success</Status>
<ReferenceCode>{$referenceCode}</ReferenceCode>
<Url>https://ndcdyn.interactivebrokers.com/AccountManagement/FlexWebService/GetStatement</Url>
</FlexStatementResponse>
XML;
}
function ibFlexError(string $code, string $message): string
{
return <<<XML
<FlexStatementResponse timestamp="20250115;093000">
<Status>Fail</Status>
<ErrorCode>{$code}</ErrorCode>
<ErrorMessage>{$message}</ErrorMessage>
</FlexStatementResponse>
XML;
}
function ibAccount(User $user, BankingConnection $connection, string $externalId = 'U1234567'): Account
{
return Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => $externalId,
'currency_code' => 'USD',
]);
}
function ibSetup(): array
{
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->interactiveBrokers()->create(['user_id' => $user->id]);
return [$user, $connection];
}
test('syncs daily NAV balances and stores invested amount on the latest date', function () {
[$user, $connection] = ibSetup();
$account = ibAccount($user, $connection);
Http::fake([
'*SendRequest*' => Http::response(ibSendRequestXml()),
'*GetStatement*' => Http::response(ibStatementXml([[
'accountId' => 'U1234567',
'nav' => [
'2025-01-14' => ['total' => 9500.00, 'cash' => 500.00],
'2025-01-15' => ['total' => 10000.00, 'cash' => 500.00],
],
'positions' => [
['costBasisMoney' => 8000.00, 'fxRateToBase' => 1],
['costBasisMoney' => 1000.00, 'fxRateToBase' => 0.72],
],
]])),
]);
$accounts = (new InteractiveBrokersClient('token', '123456'))->fetchStatement();
app(InteractiveBrokersBalanceSyncService::class)->sync($account, $accounts);
expect($account->balances()->count())->toBe(2);
$latest = $account->balances()->orderBy('balance_date', 'desc')->first();
expect($latest->balance)->toBe(1000000);
// invested = costBasis (8000 + 1000*0.72 = 8720) + cash (500) = 9220
expect($latest->invested_amount)->toBe(922000);
// profit derives from balance - invested = 1000000 - 922000 = 78000
expect($latest->balance - $latest->invested_amount)->toBe(78000);
// Historical row carries balance only (no cost basis available for past days).
$previous = $account->balances()->orderBy('balance_date', 'asc')->first();
expect($previous->balance)->toBe(950000);
expect($previous->invested_amount)->toBeNull();
});
test('parses the compact YYYYMMDD date format', function () {
[$user, $connection] = ibSetup();
$account = ibAccount($user, $connection);
Http::fake([
'*SendRequest*' => Http::response(ibSendRequestXml()),
'*GetStatement*' => Http::response(ibStatementXml([[
'accountId' => 'U1234567',
'nav' => ['20250115' => ['total' => 10000.00, 'cash' => 0.00]],
]])),
]);
$accounts = (new InteractiveBrokersClient('token', '123456'))->fetchStatement();
app(InteractiveBrokersBalanceSyncService::class)->sync($account, $accounts);
expect($account->balances()->first()->balance_date->toDateString())->toBe('2025-01-15');
});
test('distributes a multi-account statement to each account', function () {
[$user, $connection] = ibSetup();
$first = ibAccount($user, $connection, 'U1111111');
$second = ibAccount($user, $connection, 'U2222222');
Http::fake([
'*SendRequest*' => Http::response(ibSendRequestXml()),
'*GetStatement*' => Http::response(ibStatementXml([
['accountId' => 'U1111111', 'nav' => ['2025-01-15' => ['total' => 10000.00, 'cash' => 0.00]]],
['accountId' => 'U2222222', 'nav' => ['2025-01-15' => ['total' => 25000.00, 'cash' => 0.00]]],
])),
]);
$accounts = (new InteractiveBrokersClient('token', '123456'))->fetchStatement();
$service = app(InteractiveBrokersBalanceSyncService::class);
$service->sync($first, $accounts);
$service->sync($second, $accounts);
expect($first->balances()->first()->balance)->toBe(1000000);
expect($second->balances()->first()->balance)->toBe(2500000);
});
test('subsequent sync only processes entries since the last balance date', function () {
[$user, $connection] = ibSetup();
$account = ibAccount($user, $connection);
$account->balances()->create(['balance_date' => '2025-01-14', 'balance' => 940000]);
Http::fake([
'*SendRequest*' => Http::response(ibSendRequestXml()),
'*GetStatement*' => Http::response(ibStatementXml([[
'accountId' => 'U1234567',
'nav' => [
'2025-01-10' => ['total' => 9000.00, 'cash' => 0.00],
'2025-01-14' => ['total' => 9500.00, 'cash' => 0.00],
'2025-01-15' => ['total' => 10000.00, 'cash' => 0.00],
],
]])),
]);
$accounts = (new InteractiveBrokersClient('token', '123456'))->fetchStatement();
app(InteractiveBrokersBalanceSyncService::class)->sync($account, $accounts, isFirstSync: false);
// The old 2025-01-10 row is skipped; 2025-01-14 is updated and 2025-01-15 added.
expect($account->balances()->count())->toBe(2);
expect($account->balances()->where('balance_date', '2025-01-14')->first()->balance)->toBe(950000);
});
test('polls GetStatement while the statement is still generating', function () {
Sleep::fake();
[$user, $connection] = ibSetup();
$account = ibAccount($user, $connection);
Http::fake([
'*SendRequest*' => Http::response(ibSendRequestXml()),
'*GetStatement*' => Http::sequence()
->push(ibFlexError('1019', 'Statement generation in progress. Please try again shortly.'))
->push(ibStatementXml([[
'accountId' => 'U1234567',
'nav' => ['2025-01-15' => ['total' => 10000.00, 'cash' => 0.00]],
]])),
]);
$accounts = (new InteractiveBrokersClient('token', '123456'))->fetchStatement();
app(InteractiveBrokersBalanceSyncService::class)->sync($account, $accounts);
expect($account->balances()->first()->balance)->toBe(1000000);
});
test('throws an auth error for an invalid token', function () {
Http::fake([
'*SendRequest*' => Http::response(ibFlexError('1015', 'Invalid token has been provided.')),
]);
$client = new InteractiveBrokersClient('bad-token', '123456');
try {
$client->fetchStatement();
$this->fail('Expected a RequestException');
} catch (RequestException $e) {
expect($e->response->status())->toBe(401);
}
});
test('throws a rate-limit error when IB throttles the query', function () {
Http::fake([
'*SendRequest*' => Http::response(ibFlexError('1018', 'Too many requests have been made from this token.')),
]);
$client = new InteractiveBrokersClient('token', '123456');
try {
$client->fetchStatement();
$this->fail('Expected a RequestException');
} catch (RequestException $e) {
expect($e->response->status())->toBe(429);
}
});
test('skips an account without an external_account_id', function () {
[$user] = ibSetup();
$account = Account::factory()->create(['user_id' => $user->id, 'external_account_id' => null]);
app(InteractiveBrokersBalanceSyncService::class)->sync($account, []);
expect($account->balances()->count())->toBe(0);
});
test('classifies an invalid or deleted query id as an auth error', function () {
Http::fake([
'*SendRequest*' => Http::response(
ibFlexError('1020', 'Invalid request or unable to validate request.'),
),
]);
$client = new InteractiveBrokersClient('token', 'deleted-query');
try {
$client->fetchStatement();
$this->fail('Expected a RequestException');
} catch (RequestException $e) {
expect($e->response->status())->toBe(401);
}
});
test('skips NAV rows that have no total', function () {
[$user, $connection] = ibSetup();
$account = ibAccount($user, $connection);
$statement = '<FlexQueryResponse queryName="Whisper" type="AF"><FlexStatements count="1">'
.'<FlexStatement accountId="U1234567"><AccountInformation accountId="U1234567" currency="USD" />'
.'<EquitySummaryInBase>'
.'<EquitySummaryByReportDateInBase reportDate="20250114" cash="0" />'
.'<EquitySummaryByReportDateInBase reportDate="20250115" cash="0" total="10000.00" />'
.'</EquitySummaryInBase></FlexStatement></FlexStatements></FlexQueryResponse>';
Http::fake([
'*SendRequest*' => Http::response(ibSendRequestXml()),
'*GetStatement*' => Http::response($statement),
]);
$accounts = (new InteractiveBrokersClient('token', '123456'))->fetchStatement();
app(InteractiveBrokersBalanceSyncService::class)->sync($account, $accounts);
expect($account->balances()->count())->toBe(1);
expect($account->balances()->first()->balance_date->toDateString())->toBe('2025-01-15');
});

View File

@ -0,0 +1,192 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Features\InteractiveBrokers;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Sleep;
use Laravel\Pennant\Feature;
beforeEach(function () {
Bank::factory()->create([
'name' => 'Interactive Brokers',
'user_id' => null,
'logo' => '/images/banks/logos/interactive-brokers.png',
]);
});
function ibFakeFlex(array $accountIds = ['U1234567']): void
{
$statements = '';
foreach ($accountIds as $accountId) {
$statements .= '<FlexStatement accountId="'.$accountId.'">'
.'<AccountInformation accountId="'.$accountId.'" currency="USD" />'
.'<EquitySummaryInBase><EquitySummaryByReportDateInBase reportDate="20250115" cash="0" total="10000.00" /></EquitySummaryInBase>'
.'</FlexStatement>';
}
Http::fake([
'*SendRequest*' => Http::response('<FlexStatementResponse><Status>Success</Status><ReferenceCode>999</ReferenceCode></FlexStatementResponse>'),
'*GetStatement*' => Http::response('<FlexQueryResponse queryName="Whisper" type="AF"><FlexStatements count="1">'.$statements.'</FlexStatements></FlexQueryResponse>'),
]);
}
function ibConnect(): array
{
return ['token' => 'flex-token-1234567890', 'query_id' => '123456'];
}
test('is blocked when the feature flag is off', function () {
$user = User::factory()->onboarded()->create();
$this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect())
->assertForbidden();
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'interactivebrokers',
]);
});
test('users can connect with valid flex credentials', function () {
Queue::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
ibFakeFlex();
$response = $this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect());
$response->assertOk();
$response->assertJsonStructure(['redirect_url', 'connection_id']);
$connection = BankingConnection::where('user_id', $user->id)->where('provider', 'interactivebrokers')->first();
expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
expect($connection->api_secret)->toBe('123456');
expect($connection->pending_accounts_data)->toHaveCount(1);
expect($connection->pending_accounts_data[0]['uid'])->toBe('U1234567');
expect($connection->pending_accounts_data[0]['name'])->toBe('Interactive Brokers (U1234567)');
expect($connection->pending_accounts_data[0]['currency'])->toBe('USD');
Queue::assertNothingPushed();
});
test('invalid flex credentials return 422', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
Http::fake([
'*SendRequest*' => Http::response('<FlexStatementResponse><Status>Fail</Status><ErrorCode>1015</ErrorCode><ErrorMessage>Invalid token.</ErrorMessage></FlexStatementResponse>'),
]);
$response = $this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect());
$response->assertUnprocessable();
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'interactivebrokers',
]);
});
test('free tier users cannot connect after onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
$response = $this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect());
$response->assertStatus(402);
$response->assertJson(['redirect' => route('subscribe')]);
});
test('token and query_id are required', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
$this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', [])
->assertUnprocessable()
->assertJsonValidationErrors(['token', 'query_id']);
});
test('auto-creates accounts during onboarding', function () {
config(['subscriptions.enabled' => true]);
Queue::fake();
$user = User::factory()->notOnboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
ibFakeFlex(['U1111111', 'U2222222']);
$response = $this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect());
$response->assertOk();
$response->assertJsonPath('redirect_url', route('onboarding', ['step' => 'create-account']));
$connection = BankingConnection::where('user_id', $user->id)->where('provider', 'interactivebrokers')->first();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($connection->pending_accounts_data)->toBeNull();
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'U1111111',
'type' => 'investment',
]);
$this->assertDatabaseHas('accounts', [
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'U2222222',
'type' => 'investment',
]);
Queue::assertPushed(SyncBankingConnectionJob::class);
});
test('requires authentication', function () {
$this->postJson('/open-banking/interactive-brokers/connect', [
'token' => 'flex-token-1234567890',
'query_id' => '123456',
])->assertUnauthorized();
});
test('reports a friendly message while the statement is still generating', function () {
Sleep::fake();
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
Http::fake([
'*SendRequest*' => Http::response('<FlexStatementResponse><Status>Success</Status><ReferenceCode>999</ReferenceCode></FlexStatementResponse>'),
'*GetStatement*' => Http::response('<FlexStatementResponse><Status>Warn</Status><ErrorCode>1019</ErrorCode><ErrorMessage>Statement generation in progress. Please try again shortly.</ErrorMessage></FlexStatementResponse>'),
]);
$this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect())
->assertUnprocessable()
->assertJsonFragment(['message' => 'Interactive Brokers is still preparing your statement. Please try again in a moment.']);
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'interactivebrokers',
]);
});
test('reports a rate-limit message when IB throttles the request', function () {
$user = User::factory()->onboarded()->create();
Feature::for($user)->activate(InteractiveBrokers::class);
Http::fake([
'*SendRequest*' => Http::response('<FlexStatementResponse><Status>Fail</Status><ErrorCode>1018</ErrorCode><ErrorMessage>Too many requests have been made from this token.</ErrorMessage></FlexStatementResponse>'),
]);
$this->actingAs($user)->postJson('/open-banking/interactive-brokers/connect', ibConnect())
->assertUnprocessable()
->assertJsonFragment(['message' => 'Interactive Brokers is rate limiting requests. Please wait a few minutes and try again.']);
});

View File

@ -889,6 +889,70 @@ test('indexa capital sync does not send email', function () {
Mail::assertNothingQueued();
});
test('interactive brokers sync stores NAV balances through the real factory', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->interactiveBrokers()->create([
'user_id' => $user->id,
'last_synced_at' => null,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'U1234567',
'currency_code' => 'USD',
]);
$sendXml = '<FlexStatementResponse><Status>Success</Status><ReferenceCode>999</ReferenceCode></FlexStatementResponse>';
$statementXml = '<FlexQueryResponse queryName="Whisper" type="AF"><FlexStatements count="1">'
.'<FlexStatement accountId="U1234567"><AccountInformation accountId="U1234567" currency="USD" />'
.'<EquitySummaryInBase><EquitySummaryByReportDateInBase reportDate="20250115" cash="0" total="12345.67" /></EquitySummaryInBase>'
.'</FlexStatement></FlexStatements></FlexQueryResponse>';
Http::fake([
'*SendRequest*' => Http::response($sendXml),
'*GetStatement*' => Http::response($statementXml),
]);
$job = new SyncBankingConnectionJob($connection);
runSync($job);
$connection->refresh();
expect($connection->last_synced_at)->not->toBeNull();
expect($connection->status)->toBe(BankingConnectionStatus::Active);
expect($account->balances()->first()->balance)->toBe(1234567);
});
test('interactive brokers invalid token sends auth failed email', function () {
Mail::fake();
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->interactiveBrokers()->create([
'user_id' => $user->id,
'last_synced_at' => now()->subDay(),
]);
Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'U1234567',
]);
Http::fake([
'*SendRequest*' => Http::response('<FlexStatementResponse><Status>Fail</Status><ErrorCode>1015</ErrorCode><ErrorMessage>Invalid token.</ErrorMessage></FlexStatementResponse>'),
]);
$job = new SyncBankingConnectionJob($connection);
try {
runSync($job);
} catch (RequestException) {
// The job rethrows after flagging the permanent auth failure.
}
$connection->refresh();
expect($connection->status)->toBe(BankingConnectionStatus::Error);
Mail::assertQueued(BankingConnectionAuthFailedEmail::class);
});
test('binance first sync gets current balance immediately and dispatches historical job', function () {
Queue::fake(SyncBinanceHistoricalBalancesJob::class);

View File

@ -10,6 +10,7 @@ it('uses an API key for non-EnableBanking providers', function (BankingProvider
'binance' => BankingProvider::Binance,
'bitpanda' => BankingProvider::Bitpanda,
'coinbase' => BankingProvider::Coinbase,
'interactive brokers' => BankingProvider::InteractiveBrokers,
'wise' => BankingProvider::Wise,
]);
@ -24,6 +25,7 @@ it('defaults investment providers to an investment account', function (BankingPr
'binance' => BankingProvider::Binance,
'bitpanda' => BankingProvider::Bitpanda,
'coinbase' => BankingProvider::Coinbase,
'interactive brokers' => BankingProvider::InteractiveBrokers,
]);
it('defaults cash providers to a checking account', function (BankingProvider $provider) {
@ -32,3 +34,24 @@ it('defaults cash providers to a checking account', function (BankingProvider $p
'wise' => BankingProvider::Wise,
'enable banking' => BankingProvider::EnableBanking,
]);
it('maps credential inputs onto the encrypted connection columns', function () {
expect(BankingProvider::Binance->credentialColumns([
'api_key' => 'key',
'api_secret' => 'secret',
'country' => 'ES',
]))->toBe([
'api_token' => 'key',
'api_secret' => 'secret',
]);
expect(BankingProvider::InteractiveBrokers->credentialColumns([
'token' => 'flex-token',
'query_id' => '123456',
]))->toBe([
'api_token' => 'flex-token',
'api_secret' => '123456',
]);
expect(BankingProvider::EnableBanking->credentialColumns([]))->toBe([]);
});