feat(banking): enable Interactive Brokers for all users (#593)

## What

Removes the `InteractiveBrokers` Pennant feature flag. The integration
is now always available to every user.

## Changes

- Delete the `App\Features\InteractiveBrokers` flag class.
- Drop the per-user `abort_unless(...->active(...))` gate from
`InteractiveBrokersController`.
- Stop sharing the `interactiveBrokers` flag in Inertia props
(`HandleInertiaRequests`) and remove it from the `Features` type.
- Remove the now-unused `feature` gating mechanism from the
connect-provider registry and `useConnectFlow` — Interactive Brokers was
its only consumer.
- Update tests: drop the "blocked when flag off" case and all flag
activations; the connect dialog now asserts IB is always offered.

The Interactive Brokers integration itself (provider enum, client,
syncer) is untouched.

## Testing

- `php artisan test InteractiveBrokersControllerTest
InertiaSharedDataTest` — 17 passed
- `vitest connect-account-dialog` — 7 passed
- `bun run lint` / `bun run format` — clean
This commit is contained in:
Víctor Falcón 2026-06-26 11:03:21 +02:00 committed by GitHub
parent f72e2a64ca
commit 094ff4d5ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 3 additions and 82 deletions

View File

@ -1,23 +0,0 @@
<?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

@ -5,7 +5,6 @@ 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;
@ -17,7 +16,6 @@ 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
{
@ -32,8 +30,6 @@ class InteractiveBrokersController extends Controller
$validated = $request->validated();
$user = auth()->user();
abort_unless(Feature::for($user)->active(InteractiveBrokers::class), 403);
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}

View File

@ -6,7 +6,6 @@ use App\Enums\BankingConnectionStatus;
use App\Enums\BankingProvider;
use App\Features\AiConsentSettings;
use App\Features\CalculateBalancesOnImport;
use App\Features\InteractiveBrokers;
use App\Models\BankingConnection;
use App\Services\CurrencyOptions;
use Illuminate\Foundation\Inspiring;
@ -180,21 +179,18 @@ class HandleInertiaRequests extends Middleware
return [
'cashflow' => true,
'calculateBalancesOnImport' => false,
'interactiveBrokers' => false,
'aiConsentSettings' => false,
];
}
$features = Feature::for($user)->values([
CalculateBalancesOnImport::class,
InteractiveBrokers::class,
AiConsentSettings::class,
]);
return [
'cashflow' => true,
'calculateBalancesOnImport' => $features[CalculateBalancesOnImport::class] !== false,
'interactiveBrokers' => $features[InteractiveBrokers::class] !== false,
'aiConsentSettings' => $features[AiConsentSettings::class] !== false,
];
}

View File

@ -9,10 +9,8 @@ globalThis.ResizeObserver ??= class {
disconnect() {}
};
const mockFeatures = { interactiveBrokers: false };
vi.mock('@inertiajs/react', () => ({
usePage: () => ({ props: { features: mockFeatures } }),
usePage: () => ({ props: { features: {} } }),
}));
vi.mock('@/utils/i18n', () => ({
@ -116,11 +114,9 @@ async function reachBankStep(
describe('ConnectAccountDialog', () => {
beforeEach(() => {
vi.clearAllMocks();
mockFeatures.interactiveBrokers = false;
});
it('shows Interactive Brokers only when the feature flag is enabled', async () => {
mockFeatures.interactiveBrokers = true;
it('shows Interactive Brokers as a connectable provider', async () => {
await reachBankStep([]);
expect(
@ -128,14 +124,6 @@ describe('ConnectAccountDialog', () => {
).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 () => {
await reachBankStep([liveBbvaConnection()]);

View File

@ -9,13 +9,11 @@ import {
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 = [
@ -48,7 +46,6 @@ export type ConnectStep = 'country' | 'bank' | 'confirm';
* 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<
@ -144,7 +141,6 @@ export function useConnectFlow(connections: BankingConnection[]) {
const extraInstitutions = CONNECT_PROVIDERS.filter(
(p) =>
(!p.feature || features[p.feature]) &&
(!p.onlyCountry || p.onlyCountry === countryCode) &&
!hasLiveConnectionForProvider(
connections,
@ -175,7 +171,7 @@ export function useConnectFlow(connections: BankingConnection[]) {
setIsLoading(false);
}
},
[connections, features],
[connections],
);
const handleAuthorize = useCallback(async () => {

View File

@ -2,7 +2,6 @@ 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';
@ -41,8 +40,6 @@ export type ConnectProvider = {
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;
/** Confirm-step header copy (i18n key). */
headerDescription: string;
/** Confirm-card copy (i18n key). */
@ -215,7 +212,6 @@ export const CONNECT_PROVIDERS: ConnectProvider[] = [
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:

View File

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

View File

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

View File

@ -1,7 +1,6 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Features\InteractiveBrokers;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Models\BankingConnection;
@ -9,7 +8,6 @@ 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([
@ -41,23 +39,10 @@ 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());
@ -79,8 +64,6 @@ test('users can connect with valid flex credentials', function () {
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>'),
]);
@ -99,8 +82,6 @@ test('free tier users cannot connect after onboarding when subscriptions are ena
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);
@ -109,8 +90,6 @@ test('free tier users cannot connect after onboarding when subscriptions are ena
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']);
@ -121,7 +100,6 @@ test('auto-creates accounts during onboarding', function () {
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());
@ -161,8 +139,6 @@ test('reports a friendly message while the statement is still generating', funct
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>'),
@ -180,8 +156,6 @@ test('reports a friendly message while the statement is still generating', funct
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>'),
]);