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:
parent
4917bd1c58
commit
f350fdde4f
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -42,6 +42,7 @@ export interface NavDivider {
|
|||
export interface Features {
|
||||
cashflow: boolean;
|
||||
calculateBalancesOnImport: boolean;
|
||||
interactiveBrokers: boolean;
|
||||
}
|
||||
|
||||
export interface ExpiredBankingConnectionNotification {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue