diff --git a/app/Features/InteractiveBrokers.php b/app/Features/InteractiveBrokers.php new file mode 100644 index 00000000..fe27d967 --- /dev/null +++ b/app/Features/InteractiveBrokers.php @@ -0,0 +1,23 @@ + ['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) { diff --git a/app/Http/Controllers/OpenBanking/InteractiveBrokersController.php b/app/Http/Controllers/OpenBanking/InteractiveBrokersController.php new file mode 100644 index 00000000..8473730c --- /dev/null +++ b/app/Http/Controllers/OpenBanking/InteractiveBrokersController.php @@ -0,0 +1,113 @@ +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, investedAmount: float|null}> $accounts + * @return array + */ + private function buildPendingAccounts(array $accounts): array + { + $pending = []; + + foreach ($accounts as $account) { + $pending[] = [ + 'uid' => $account['account_id'], + 'currency' => $account['currency'] !== '' ? $account['currency'] : 'USD', + 'name' => "Interactive Brokers ({$account['account_id']})", + ]; + } + + return $pending; + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index f7edc8fe..6750e4d0 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -5,6 +5,7 @@ namespace App\Http\Middleware; use App\Enums\BankingConnectionStatus; use App\Enums\BankingProvider; use App\Features\CalculateBalancesOnImport; +use App\Features\InteractiveBrokers; use App\Models\BankingConnection; use App\Services\CurrencyOptions; use Illuminate\Foundation\Inspiring; @@ -178,16 +179,19 @@ class HandleInertiaRequests extends Middleware return [ 'cashflow' => true, 'calculateBalancesOnImport' => false, + 'interactiveBrokers' => false, ]; } $features = Feature::for($user)->values([ CalculateBalancesOnImport::class, + InteractiveBrokers::class, ]); return [ 'cashflow' => true, 'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false, + 'interactiveBrokers' => $features[InteractiveBrokers::class] !== false, ]; } diff --git a/app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php b/app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php new file mode 100644 index 00000000..b471032e --- /dev/null +++ b/app/Http/Requests/OpenBanking/ConnectInteractiveBrokersRequest.php @@ -0,0 +1,24 @@ +> + */ + public function rules(): array + { + return [ + 'token' => ['required', 'string', 'min:10'], + 'query_id' => ['required', 'string', 'min:3'], + ]; + } +} diff --git a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php index 121a9e03..8e5627cf 100644 --- a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php +++ b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php @@ -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'], diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index cb8051da..d390cd8b 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -129,6 +129,11 @@ class BankingConnection extends Model return $this->provider === BankingProvider::EnableBanking; } + public function isInteractiveBrokers(): bool + { + return $this->provider === BankingProvider::InteractiveBrokers; + } + public function usesApiKey(): bool { return $this->provider->usesApiKey(); diff --git a/database/seeders/data/banks.json b/database/seeders/data/banks.json index 4c6753a8..cb4bb302 100644 --- a/database/seeders/data/banks.json +++ b/database/seeders/data/banks.json @@ -9451,6 +9451,10 @@ "name": "Indexa Capital", "logo": "/images/banks/logos/indexa-capital.jpg" }, + { + "name": "Interactive Brokers", + "logo": "/images/banks/logos/interactive-brokers.svg" + }, { "name": "Binance", "logo": "https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png" diff --git a/public/images/banks/logos/interactive-brokers.svg b/public/images/banks/logos/interactive-brokers.svg new file mode 100644 index 00000000..1509d521 --- /dev/null +++ b/public/images/banks/logos/interactive-brokers.svg @@ -0,0 +1,4 @@ + + + IBKR + diff --git a/resources/js/types/index.d.ts b/resources/js/types/index.d.ts index 525a0033..96ed5d43 100644 --- a/resources/js/types/index.d.ts +++ b/resources/js/types/index.d.ts @@ -42,6 +42,7 @@ export interface NavDivider { export interface Features { cashflow: boolean; calculateBalancesOnImport: boolean; + interactiveBrokers: boolean; } export interface ExpiredBankingConnectionNotification { diff --git a/routes/web.php b/routes/web.php index ff21adac..f5aeee12 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () { diff --git a/tests/Feature/OpenBanking/InteractiveBrokersControllerTest.php b/tests/Feature/OpenBanking/InteractiveBrokersControllerTest.php new file mode 100644 index 00000000..39d61233 --- /dev/null +++ b/tests/Feature/OpenBanking/InteractiveBrokersControllerTest.php @@ -0,0 +1,157 @@ +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 .= '' + .'' + .'' + .''; + } + + Http::fake([ + '*SendRequest*' => Http::response('Success999'), + '*GetStatement*' => Http::response(''.$statements.''), + ]); +} + +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('Fail1015Invalid token.'), + ]); + + $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(); +});