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:
parent
8e3871370a
commit
f60e6d7035
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
) {}
|
||||
}
|
||||
|
|
@ -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] : []),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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()]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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...')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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…',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ test('shared feature flags do not include coinbase flag', function () {
|
|||
expect($props['features'])->toBe([
|
||||
'cashflow' => true,
|
||||
'calculateBalancesOnImport' => false,
|
||||
'interactiveBrokers' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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.']);
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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([]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue