From 3f541ca4d6376bc8a04d54851eccc7265060fe78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?= Date: Wed, 18 Feb 2026 10:42:13 +0100 Subject: [PATCH] feat: Add Indexa Capital integration (#130) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Allow users to connect their Indexa Capital (Spanish robo-advisor) account via API token - Adds "Indexa Capital" option when selecting Spain in the bank connection dialog - Token-based auth flow: user enters API token (instead of OAuth redirect), validated against Indexa API, stored encrypted - Syncs portfolio balance only (no transactions) from the `/accounts/{id}/performance` endpoint - Accounts created as `Investment` type - Reuses existing account mapping flow when the `account-mapping` feature flag is active - Disconnect flow skips session revocation for Indexa (no OAuth session to revoke) ## New files - `IndexaCapitalClient` — HTTP client for Indexa Capital API (`X-AUTH-TOKEN`) - `IndexaCapitalBalanceSyncService` — Syncs portfolio value into `account_balances` - `IndexaCapitalController` — Token validation + connection creation - `ConnectIndexaCapitalRequest` — Form request validation - Migration adding encrypted `api_token` column to `banking_connections` ## Test plan - [x] 6 controller tests (connect, invalid token, mapping, feature flag, validation, multi-account) - [x] 4 balance sync tests (sync, update, skip, missing field) - [x] 3 sync job tests (balance-only, no expire, no email for Indexa) - [x] 1 disconnect test (no revokeSession for Indexa) - [x] All 530 existing tests still pass --- .../OpenBanking/AccountMappingController.php | 6 +- .../OpenBanking/ConnectionController.php | 2 +- .../OpenBanking/IndexaCapitalController.php | 117 ++++++++++++ .../ConnectIndexaCapitalRequest.php | 24 +++ app/Jobs/SyncBankingConnectionJob.php | 117 +++++++----- app/Models/BankingConnection.php | 12 ++ .../IndexaCapitalBalanceSyncService.php | 55 ++++++ app/Services/Banking/IndexaCapitalClient.php | 94 ++++++++++ .../factories/BankingConnectionFactory.php | 14 ++ ...api_token_to_banking_connections_table.php | 28 +++ .../js/components/accounts/account-name.tsx | 3 +- .../open-banking/connect-account-dialog.tsx | 127 ++++++++++--- resources/js/pages/settings/connections.tsx | 11 +- resources/js/pages/transactions/index.tsx | 3 +- routes/web.php | 2 + .../OpenBanking/ConnectionControllerTest.php | 31 ++++ .../IndexaCapitalBalanceSyncTest.php | 165 +++++++++++++++++ .../IndexaCapitalControllerTest.php | 175 ++++++++++++++++++ .../SyncBankingConnectionJobTest.php | 100 ++++++++++ 19 files changed, 1002 insertions(+), 84 deletions(-) create mode 100644 app/Http/Controllers/OpenBanking/IndexaCapitalController.php create mode 100644 app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php create mode 100644 app/Services/Banking/IndexaCapitalBalanceSyncService.php create mode 100644 app/Services/Banking/IndexaCapitalClient.php create mode 100644 database/migrations/2026_02_18_084241_add_api_token_to_banking_connections_table.php create mode 100644 tests/Feature/OpenBanking/IndexaCapitalBalanceSyncTest.php create mode 100644 tests/Feature/OpenBanking/IndexaCapitalControllerTest.php diff --git a/app/Http/Controllers/OpenBanking/AccountMappingController.php b/app/Http/Controllers/OpenBanking/AccountMappingController.php index e3495229..6fda0a92 100644 --- a/app/Http/Controllers/OpenBanking/AccountMappingController.php +++ b/app/Http/Controllers/OpenBanking/AccountMappingController.php @@ -59,6 +59,10 @@ class AccountMappingController extends Controller $pendingAccounts = collect($connection->pending_accounts_data) ->keyBy('uid'); + $accountType = $connection->isIndexaCapital() + ? AccountType::Investment + : AccountType::Checking; + foreach ($mappings as $mapping) { $uid = $mapping['bank_account_uid']; $action = $mapping['action']; @@ -80,7 +84,7 @@ class AccountMappingController extends Controller 'encrypted' => false, 'bank_id' => $bank->id, 'currency_code' => $currency, - 'type' => AccountType::Checking->value, + 'type' => $accountType->value, 'banking_connection_id' => $connection->id, 'external_account_id' => $uid, ]); diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php index 01a17ca6..ac82a462 100644 --- a/app/Http/Controllers/OpenBanking/ConnectionController.php +++ b/app/Http/Controllers/OpenBanking/ConnectionController.php @@ -60,7 +60,7 @@ class ConnectionController extends Controller */ public function destroy(DestroyConnectionRequest $request, BankingConnection $connection, BankingProviderInterface $provider): RedirectResponse { - if ($connection->session_id && $connection->isActive()) { + if ($connection->isEnableBanking() && $connection->session_id && $connection->isActive()) { try { $provider->revokeSession($connection->session_id); } catch (\Throwable $e) { diff --git a/app/Http/Controllers/OpenBanking/IndexaCapitalController.php b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php new file mode 100644 index 00000000..dbead00f --- /dev/null +++ b/app/Http/Controllers/OpenBanking/IndexaCapitalController.php @@ -0,0 +1,117 @@ +validated(); + $user = auth()->user(); + + $client = new IndexaCapitalClient($validated['api_token']); + + try { + $userData = $client->getUser(); + } catch (\Throwable $e) { + Log::warning('Indexa Capital token validation failed', ['error' => $e->getMessage()]); + + return response()->json([ + 'message' => 'Invalid API token or failed to connect to Indexa Capital.', + ], 422); + } + + $bank = Bank::firstOrCreate( + ['name' => 'Indexa Capital', 'user_id' => null], + ['name' => 'Indexa Capital', 'logo' => '/images/banks/logos/indexa-capital.jpg'], + ); + + $connection = $user->bankingConnections()->create([ + 'provider' => 'indexacapital', + 'api_token' => $validated['api_token'], + 'aspsp_name' => 'Indexa Capital', + 'aspsp_country' => 'ES', + 'aspsp_logo' => $bank->logo, + 'status' => BankingConnectionStatus::Pending, + ]); + + $pendingAccounts = $this->buildPendingAccounts($userData); + + if (Feature::for($user)->active('account-mapping')) { + $connection->update([ + 'status' => BankingConnectionStatus::AwaitingMapping, + 'pending_accounts_data' => $pendingAccounts, + ]); + + return response()->json([ + 'redirect_url' => route('open-banking.map-accounts', $connection), + 'connection_id' => $connection->id, + ]); + } + + $connection->update(['status' => BankingConnectionStatus::Active]); + + foreach ($pendingAccounts as $accountData) { + $user->accounts()->create([ + 'name' => $accountData['name'], + 'name_iv' => null, + 'encrypted' => false, + 'bank_id' => $bank->id, + 'currency_code' => $accountData['currency'], + 'type' => AccountType::Investment->value, + 'banking_connection_id' => $connection->id, + 'external_account_id' => $accountData['uid'], + ]); + } + + SyncBankingConnectionJob::dispatch($connection); + + return response()->json([ + 'redirect_url' => route('settings.connections.index'), + 'connection_id' => $connection->id, + ]); + } + + /** + * Build the pending accounts data in the same format as EnableBanking. + * + * @return array + */ + private function buildPendingAccounts(array $userData): array + { + $accounts = []; + + foreach ($userData['accounts'] ?? [] as $account) { + $accountNumber = $account['account_number'] ?? null; + + if (! $accountNumber) { + continue; + } + + $type = $account['type'] ?? 'mutual'; + $typeName = $type === 'pension' ? 'Pension Plan' : 'Investment Portfolio'; + + $accounts[] = [ + 'uid' => $accountNumber, + 'currency' => 'EUR', + 'name' => "{$typeName} ({$accountNumber})", + ]; + } + + return $accounts; + } +} diff --git a/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php b/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php new file mode 100644 index 00000000..cd5cb060 --- /dev/null +++ b/app/Http/Requests/OpenBanking/ConnectIndexaCapitalRequest.php @@ -0,0 +1,24 @@ +user())->active('open-banking'); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'api_token' => ['required', 'string', 'min:10'], + ]; + } +} diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index bf850473..793c5f91 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -6,6 +6,8 @@ use App\Enums\BankingConnectionStatus; use App\Mail\BankTransactionsSyncedEmail; use App\Models\BankingConnection; use App\Services\Banking\BalanceSyncService; +use App\Services\Banking\IndexaCapitalBalanceSyncService; +use App\Services\Banking\IndexaCapitalClient; use App\Services\Banking\TransactionSyncService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeUnique; @@ -37,7 +39,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue { $connection = $this->bankingConnection; - if ($connection->isExpired()) { + if ($connection->isEnableBanking() && $connection->isExpired()) { $connection->update(['status' => BankingConnectionStatus::Expired]); Log::info('Banking connection expired, skipping sync', ['connection_id' => $connection->id]); @@ -48,59 +50,17 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue return; } - $isFirstSync = ! $connection->last_synced_at; - $dateFrom = $isFirstSync - ? now()->subYear()->toDateString() - : $connection->last_synced_at->toDateString(); - $dateTo = now()->toDateString(); - $strategy = $isFirstSync ? 'longest' : null; - try { - $transactionsPerBank = []; - - $connection->load('accounts.bank'); - - foreach ($connection->accounts as $account) { - if ($account->isLinked()) { - $lastTransaction = $account->transactions() - ->latest('transaction_date') - ->first(); - - $linkedDateFrom = $lastTransaction - ? $lastTransaction->transaction_date->toDateString() - : $dateFrom; - - $created = $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); - $balanceSync->sync($account); - } else { - $created = $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); - $balanceSync->sync($account); - - if ($isFirstSync) { - $balanceSync->calculateHistoricalBalances($account); - } - } - - if ($created > 0) { - $bankName = $account->bank?->name ?? __('Unknown Bank'); - $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; - } + if ($connection->isIndexaCapital()) { + $this->syncIndexaCapital($connection); + } else { + $this->syncEnableBanking($connection, $transactionSync, $balanceSync); } $connection->update([ 'last_synced_at' => now(), 'error_message' => null, ]); - - $totalTransactions = array_sum($transactionsPerBank); - - if (! $isFirstSync && $totalTransactions > 0) { - Mail::to($connection->user)->send(new BankTransactionsSyncedEmail( - $connection->user, - $totalTransactions, - $transactionsPerBank, - )); - } } catch (\Throwable $e) { Log::error('Banking sync failed', [ 'connection_id' => $connection->id, @@ -115,4 +75,67 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue throw $e; } } + + private function syncIndexaCapital(BankingConnection $connection): void + { + $client = new IndexaCapitalClient($connection->api_token); + $syncService = new IndexaCapitalBalanceSyncService; + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $syncService->sync($account, $client); + } + } + + private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void + { + $isFirstSync = ! $connection->last_synced_at; + $dateFrom = $isFirstSync + ? now()->subYear()->toDateString() + : $connection->last_synced_at->toDateString(); + $dateTo = now()->toDateString(); + $strategy = $isFirstSync ? 'longest' : null; + + $transactionsPerBank = []; + + $connection->load('accounts.bank'); + + foreach ($connection->accounts as $account) { + if ($account->isLinked()) { + $lastTransaction = $account->transactions() + ->latest('transaction_date') + ->first(); + + $linkedDateFrom = $lastTransaction + ? $lastTransaction->transaction_date->toDateString() + : $dateFrom; + + $created = $transactionSync->sync($account, $linkedDateFrom, $dateTo, $strategy, saveDailyBalances: false); + $balanceSync->sync($account); + } else { + $created = $transactionSync->sync($account, $dateFrom, $dateTo, $strategy); + $balanceSync->sync($account); + + if ($isFirstSync) { + $balanceSync->calculateHistoricalBalances($account); + } + } + + if ($created > 0) { + $bankName = $account->bank?->name ?? __('Unknown Bank'); + $transactionsPerBank[$bankName] = ($transactionsPerBank[$bankName] ?? 0) + $created; + } + } + + $totalTransactions = array_sum($transactionsPerBank); + + if (! $isFirstSync && $totalTransactions > 0) { + Mail::to($connection->user)->send(new BankTransactionsSyncedEmail( + $connection->user, + $totalTransactions, + $transactionsPerBank, + )); + } + } } diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index 570976dc..0f6e6d35 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -28,6 +28,7 @@ class BankingConnection extends Model 'last_synced_at', 'error_message', 'pending_accounts_data', + 'api_token', ]; protected function casts(): array @@ -37,6 +38,7 @@ class BankingConnection extends Model 'valid_until' => 'datetime', 'last_synced_at' => 'datetime', 'pending_accounts_data' => 'array', + 'api_token' => 'encrypted', ]; } @@ -55,6 +57,16 @@ class BankingConnection extends Model return $this->status === BankingConnectionStatus::Active; } + public function isIndexaCapital(): bool + { + return $this->provider === 'indexacapital'; + } + + public function isEnableBanking(): bool + { + return $this->provider === 'enablebanking'; + } + public function hasPendingAccounts(): bool { return ! empty($this->pending_accounts_data); diff --git a/app/Services/Banking/IndexaCapitalBalanceSyncService.php b/app/Services/Banking/IndexaCapitalBalanceSyncService.php new file mode 100644 index 00000000..5f580eed --- /dev/null +++ b/app/Services/Banking/IndexaCapitalBalanceSyncService.php @@ -0,0 +1,55 @@ +external_account_id) { + return; + } + + $performance = $client->getPerformance($account->external_account_id); + $portfolios = $performance['portfolios'] ?? []; + + if (empty($portfolios)) { + Log::warning('No portfolio data from Indexa Capital', [ + 'account_id' => $account->id, + 'external_account_id' => $account->external_account_id, + ]); + + return; + } + + $count = 0; + + foreach ($portfolios as $entry) { + $date = $entry['date'] ?? null; + $value = $entry['total_amount'] ?? null; + + if ($date === null || $value === null) { + continue; + } + + $account->balances()->updateOrCreate( + ['balance_date' => $date], + ['balance' => (int) round(floatval($value) * 100)], + ); + + $count++; + } + + Log::info('Synced Indexa Capital balances', [ + 'account_id' => $account->id, + 'days_synced' => $count, + ]); + } +} diff --git a/app/Services/Banking/IndexaCapitalClient.php b/app/Services/Banking/IndexaCapitalClient.php new file mode 100644 index 00000000..732dca34 --- /dev/null +++ b/app/Services/Banking/IndexaCapitalClient.php @@ -0,0 +1,94 @@ +, accounts_relations: array} + */ + public function getUser(): array + { + $response = $this->client()->get('/users/me'); + + $response->throw(); + + return $response->json(); + } + + /** + * Get all accounts for the authenticated user. + * + * @return array + */ + public function getAccounts(): array + { + $userData = $this->getUser(); + + $accounts = []; + + foreach ($userData['accounts'] ?? [] as $account) { + if (! isset($account['account_number'])) { + continue; + } + + $accountDetails = $this->getAccount($account['account_number']); + $accounts[] = $accountDetails; + } + + return $accounts; + } + + /** + * Get detailed account information. + * + * @return array{account_number: string, currency: string, status: string, type: string, profile: array} + */ + public function getAccount(string $accountNumber): array + { + $response = $this->client()->get("/accounts/{$accountNumber}"); + + $response->throw(); + + return $response->json(); + } + + /** + * Get performance data for an account, including current portfolio value. + * + * @return array{total_amount: float, return: float, return_percentage: float} + */ + public function getPerformance(string $accountNumber): array + { + $response = $this->client()->get("/accounts/{$accountNumber}/performance"); + + $response->throw(); + + return $response->json(); + } + + private function client(): PendingRequest + { + return Http::baseUrl(self::BASE_URL) + ->withHeaders(['X-AUTH-TOKEN' => $this->apiToken]) + ->acceptJson() + ->throw(function ($response, $exception) { + Log::error('Indexa Capital API error', [ + 'status' => $response->status(), + 'body' => $response->json(), + ]); + }); + } +} diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php index 1acfa510..e33f4fea 100644 --- a/database/factories/BankingConnectionFactory.php +++ b/database/factories/BankingConnectionFactory.php @@ -72,6 +72,20 @@ class BankingConnectionFactory extends Factory ]); } + public function indexaCapital(): static + { + return $this->state(fn (array $attributes) => [ + 'provider' => 'indexacapital', + 'authorization_id' => null, + 'session_id' => null, + 'api_token' => 'test-indexa-token-'.fake()->uuid(), + 'aspsp_name' => 'Indexa Capital', + 'aspsp_country' => 'ES', + 'aspsp_logo' => '/images/banks/logos/indexa-capital.jpg', + 'valid_until' => null, + ]); + } + public function error(): static { return $this->state(fn (array $attributes) => [ diff --git a/database/migrations/2026_02_18_084241_add_api_token_to_banking_connections_table.php b/database/migrations/2026_02_18_084241_add_api_token_to_banking_connections_table.php new file mode 100644 index 00000000..aab51e92 --- /dev/null +++ b/database/migrations/2026_02_18_084241_add_api_token_to_banking_connections_table.php @@ -0,0 +1,28 @@ +text('api_token')->nullable()->after('session_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('banking_connections', function (Blueprint $table) { + $table->dropColumn('api_token'); + }); + } +}; diff --git a/resources/js/components/accounts/account-name.tsx b/resources/js/components/accounts/account-name.tsx index d2704027..3c4cba57 100644 --- a/resources/js/components/accounts/account-name.tsx +++ b/resources/js/components/accounts/account-name.tsx @@ -13,7 +13,6 @@ interface AccountNameProps { export function AccountName({ account, className = '', - length = null, -}: AccountNameProps) { +}: Omit) { return {account.name}; } diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index 1885b3c7..d4307714 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -18,7 +18,7 @@ import { } from '@/components/ui/select'; import type { EnableBankingInstitution } from '@/types/banking'; import { __ } from '@/utils/i18n'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; const COUNTRIES = [ { code: 'ES', name: 'Spain' }, @@ -41,6 +41,13 @@ const COUNTRIES = [ { 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, +}; + interface ConnectAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -48,6 +55,15 @@ interface ConnectAccountDialogProps { type Step = 'country' | 'bank' | 'confirm'; +function getCsrfToken(): string { + return decodeURIComponent( + document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + ?.split('=')[1] || '', + ); +} + export function ConnectAccountDialog({ open, onOpenChange, @@ -66,6 +82,12 @@ export function ConnectAccountDialog({ const [isLoading, setIsLoading] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [apiToken, setApiToken] = useState(''); + + const isIndexaCapital = useMemo( + () => selectedBank?.name === 'Indexa Capital', + [selectedBank], + ); const resetState = useCallback(() => { setStep('country'); @@ -77,6 +99,7 @@ export function ConnectAccountDialog({ setIsLoading(false); setIsSubmitting(false); setError(null); + setApiToken(''); }, []); useEffect(() => { @@ -107,12 +130,7 @@ export function ConnectAccountDialog({ { headers: { Accept: 'application/json', - 'X-XSRF-TOKEN': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), }, }, ); @@ -122,8 +140,14 @@ export function ConnectAccountDialog({ } const data = await response.json(); - setInstitutions(data); - setFilteredInstitutions(data); + + const allInstitutions = + countryCode === 'ES' + ? [INDEXA_CAPITAL_INSTITUTION, ...data] + : data; + + setInstitutions(allInstitutions); + setFilteredInstitutions(allInstitutions); setStep('bank'); } catch { setError(__('Failed to load banks. Please try again.')); @@ -139,33 +163,43 @@ export function ConnectAccountDialog({ setError(null); try { - const response = await fetch('/open-banking/authorize', { + const url = isIndexaCapital + ? '/open-banking/indexa-capital/connect' + : '/open-banking/authorize'; + + const body = isIndexaCapital + ? { api_token: apiToken } + : { + 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': decodeURIComponent( - document.cookie - .split('; ') - .find((row) => row.startsWith('XSRF-TOKEN=')) - ?.split('=')[1] || '', - ), + 'X-XSRF-TOKEN': getCsrfToken(), }, - body: JSON.stringify({ - aspsp_name: selectedBank.name, - country: country, - logo: selectedBank.logo, - }), + body: JSON.stringify(body), }); if (!response.ok) { - throw new Error('Failed to start authorization'); + 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 { - setError(__('Failed to connect to your bank. Please try again.')); + } catch (e) { + setError( + e instanceof Error + ? e.message + : __('Failed to connect. Please try again.'), + ); setIsSubmitting(false); } } @@ -182,9 +216,15 @@ export function ConnectAccountDialog({ )} {step === 'bank' && __('Select your bank.')} {step === 'confirm' && + !isIndexaCapital && __( 'You will be redirected to your bank to authorize access.', )} + {step === 'confirm' && + isIndexaCapital && + __( + 'Enter your API token to connect your Indexa Capital account.', + )} @@ -291,14 +331,42 @@ export function ConnectAccountDialog({ {selectedBank.name}

- {__( - 'You will be redirected to authorize access to your account data.', - )} + {isIndexaCapital + ? __( + 'Connect your Indexa Capital account using your API token.', + ) + : __( + 'You will be redirected to authorize access to your account data.', + )}

+ {isIndexaCapital && ( +
+ + + setApiToken(e.target.value) + } + placeholder={__( + 'Paste your Indexa Capital API token', + )} + /> +

+ {__( + 'You can generate your API token from your Indexa Capital dashboard under Settings > Applications.', + )} +

+
+ )} +