feat(banking): add Interactive Brokers connect flow behind a feature flag

Connect/update-credentials endpoints validate the Flex token + query ID by
pulling a statement, then build pending accounts from it. Gated by a Pennant
feature (InteractiveBrokers, off by default) so it can be enabled per beta
tester until validated against a live account.

Credentials reuse the encrypted api_token (Flex token) and api_secret (Flex
query ID) columns, so no migration is needed.
This commit is contained in:
Víctor Falcón 2026-06-22 14:18:48 +02:00
parent 4917bd1c58
commit f350fdde4f
12 changed files with 345 additions and 0 deletions

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

@ -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;
@ -99,6 +100,7 @@ class ConnectionController extends Controller
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']],
BankingProvider::InteractiveBrokers => ['api_token' => $validated['token'], 'api_secret' => $validated['query_id']],
default => [],
};
@ -125,6 +127,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

@ -0,0 +1,113 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
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\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' => 'Invalid Flex token or query ID, or failed to connect to Interactive Brokers.',
], 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.svg'],
);
$connection = $user->bankingConnections()->create([
'provider' => BankingProvider::InteractiveBrokers,
'api_token' => $validated['token'],
'api_secret' => $validated['query_id'],
'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,
]);
}
/**
* 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

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

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\OpenBanking;
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 [
'token' => ['required', 'string', 'min:10'],
'query_id' => ['required', 'string', 'min:3'],
];
}
}

View File

@ -38,6 +38,10 @@ class UpdateConnectionCredentialsRequest extends FormRequest
BankingProvider::Bitpanda => [
'api_key' => ['required', 'string', 'min:10'],
],
BankingProvider::InteractiveBrokers => [
'token' => ['required', 'string', 'min:10'],
'query_id' => ['required', 'string', 'min:3'],
],
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'],

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

@ -9451,6 +9451,10 @@
"name": "Indexa Capital",
"logo": "/images/banks/logos/indexa-capital.jpg"
},
{
"name": "Interactive Brokers",
"logo": "/images/banks/logos/interactive-brokers.svg"
},
{
"name": "Binance",
"logo": "https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png"

View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Interactive Brokers">
<rect width="64" height="64" rx="12" fill="#D81222"/>
<text x="32" y="40" font-family="Arial, Helvetica, sans-serif" font-size="22" font-weight="700" fill="#ffffff" text-anchor="middle">IBKR</text>
</svg>

After

Width:  |  Height:  |  Size: 314 B

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

@ -0,0 +1,157 @@
<?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 Laravel\Pennant\Feature;
beforeEach(function () {
Bank::factory()->create([
'name' => 'Interactive Brokers',
'user_id' => null,
'logo' => '/images/banks/logos/interactive-brokers.svg',
]);
});
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();
});