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 && (
+
+
+ {__('API Token')}
+
+
+ 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.',
+ )}
+
+
+ )}
+
{isSubmitting
? __('Connecting...')
diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx
index 86edb64d..528a4a2d 100644
--- a/resources/js/pages/settings/connections.tsx
+++ b/resources/js/pages/settings/connections.tsx
@@ -201,9 +201,14 @@ export default function ConnectionsPage({ connections }: Props) {
!connection.last_synced_at ? (
- {__(
- 'Syncing transactions and balances…',
- )}
+ {connection.provider ===
+ 'indexacapital'
+ ? __(
+ 'Syncing balances…',
+ )
+ : __(
+ 'Syncing transactions and balances…',
+ )}
) : (
diff --git a/resources/js/pages/transactions/index.tsx b/resources/js/pages/transactions/index.tsx
index d214819f..1dca1c92 100644
--- a/resources/js/pages/transactions/index.tsx
+++ b/resources/js/pages/transactions/index.tsx
@@ -506,8 +506,7 @@ export default function Transactions({
});
const json = await response.json();
- const next = json.props
- .transactions as CursorPaginatedResponse;
+ const next = json.props.transactions as CursorPaginatedResponse;
setAllTransactions((prev) => [
...prev,
diff --git a/routes/web.php b/routes/web.php
index 1c7a07fd..7e2def64 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -7,6 +7,7 @@ use App\Http\Controllers\DashboardController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\OpenBanking\AccountMappingController;
use App\Http\Controllers\OpenBanking\AuthorizationController;
+use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\RobotsController;
use App\Http\Controllers\SitemapController;
@@ -72,6 +73,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'open-banking'
Route::get('callback', [AuthorizationController::class, 'callback'])->name('open-banking.callback');
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts');
Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
+ Route::post('indexa-capital/connect', [IndexaCapitalController::class, 'store'])->name('open-banking.indexa-capital.connect');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
diff --git a/tests/Feature/OpenBanking/ConnectionControllerTest.php b/tests/Feature/OpenBanking/ConnectionControllerTest.php
index 22b5ac99..fb4bd8ea 100644
--- a/tests/Feature/OpenBanking/ConnectionControllerTest.php
+++ b/tests/Feature/OpenBanking/ConnectionControllerTest.php
@@ -175,6 +175,37 @@ test('users can trigger manual sync on active connection', function () {
$response->assertSessionHas('success');
});
+test('disconnecting indexa capital connection does not revoke session', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $connection = BankingConnection::factory()->indexaCapital()->create(['user_id' => $user->id]);
+ $account = Account::factory()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ $mockProvider = Mockery::mock(BankingProviderInterface::class);
+ $mockProvider->shouldNotReceive('revokeSession');
+ $this->app->instance(BankingProviderInterface::class, $mockProvider);
+
+ $response = $this->actingAs($user)->delete("/settings/connections/{$connection->id}", [
+ 'delete_accounts' => false,
+ 'confirmation' => null,
+ ]);
+
+ $response->assertRedirect(route('settings.connections.index'));
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Revoked);
+ expect($connection->trashed())->toBeTrue();
+
+ $account->refresh();
+ expect($account->banking_connection_id)->toBeNull();
+ expect($account->external_account_id)->toBeNull();
+});
+
test('users cannot sync expired connection', function () {
Queue::fake();
diff --git a/tests/Feature/OpenBanking/IndexaCapitalBalanceSyncTest.php b/tests/Feature/OpenBanking/IndexaCapitalBalanceSyncTest.php
new file mode 100644
index 00000000..d7763b81
--- /dev/null
+++ b/tests/Feature/OpenBanking/IndexaCapitalBalanceSyncTest.php
@@ -0,0 +1,165 @@
+onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [
+ ['date' => now()->toDateString(), 'total_amount' => 15234.56],
+ ['date' => now()->subDay()->toDateString(), 'total_amount' => 15200.00],
+ ['date' => now()->subDays(2)->toDateString(), 'total_amount' => 15100.00],
+ ],
+ ]),
+ ]);
+
+ $client = new IndexaCapitalClient('test-token');
+ $service = new IndexaCapitalBalanceSyncService;
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(3);
+
+ $latest = $account->balances()->orderBy('balance_date', 'desc')->first();
+ expect($latest->balance)->toBe(1523456);
+ expect($latest->balance_date->toDateString())->toBe(now()->toDateString());
+});
+
+test('syncs all available historical data', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [
+ ['date' => now()->toDateString(), 'total_amount' => 10000.00],
+ ['date' => now()->subMonths(12)->toDateString(), 'total_amount' => 9000.00],
+ ['date' => now()->subYears(3)->toDateString(), 'total_amount' => 8000.00],
+ ['date' => now()->subYears(5)->toDateString(), 'total_amount' => 7000.00],
+ ],
+ ]),
+ ]);
+
+ $client = new IndexaCapitalClient('test-token');
+ $service = new IndexaCapitalBalanceSyncService;
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(4);
+});
+
+test('updates existing balance for same date', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ $account->balances()->create([
+ 'balance_date' => now()->toDateString(),
+ 'balance' => 100000,
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [
+ ['date' => now()->toDateString(), 'total_amount' => 20000.00],
+ ],
+ ]),
+ ]);
+
+ $client = new IndexaCapitalClient('test-token');
+ $service = new IndexaCapitalBalanceSyncService;
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ expect($account->balances()->first()->balance)->toBe(2000000);
+});
+
+test('skips account without external_account_id', function () {
+ $user = User::factory()->onboarded()->create();
+ $account = Account::factory()->create([
+ 'user_id' => $user->id,
+ 'external_account_id' => null,
+ ]);
+
+ $client = Mockery::mock(IndexaCapitalClient::class);
+ $client->shouldNotReceive('getPerformance');
+
+ $service = new IndexaCapitalBalanceSyncService;
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(0);
+});
+
+test('handles missing portfolios gracefully', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'plan_expected_return' => 0.046,
+ ]),
+ ]);
+
+ $client = new IndexaCapitalClient('test-token');
+ $service = new IndexaCapitalBalanceSyncService;
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(0);
+});
+
+test('handles empty portfolios array gracefully', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [],
+ ]),
+ ]);
+
+ $client = new IndexaCapitalClient('test-token');
+ $service = new IndexaCapitalBalanceSyncService;
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(0);
+});
diff --git a/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php b/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php
new file mode 100644
index 00000000..1e826c1e
--- /dev/null
+++ b/tests/Feature/OpenBanking/IndexaCapitalControllerTest.php
@@ -0,0 +1,175 @@
+create([
+ 'name' => 'Indexa Capital',
+ 'user_id' => null,
+ 'logo' => '/images/banks/logos/indexa-capital.jpg',
+ ]);
+});
+
+test('users can connect an indexa capital account with valid token', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.indexacapital.com/users/me' => Http::response([
+ 'accounts' => [
+ ['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual'],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
+ 'api_token' => 'valid-test-token-12345',
+ ]);
+
+ $response->assertOk();
+ $response->assertJsonStructure(['redirect_url', 'connection_id']);
+
+ $this->assertDatabaseHas('banking_connections', [
+ 'user_id' => $user->id,
+ 'provider' => 'indexacapital',
+ 'aspsp_name' => 'Indexa Capital',
+ 'aspsp_country' => 'ES',
+ 'status' => BankingConnectionStatus::Active->value,
+ ]);
+
+ $this->assertDatabaseHas('accounts', [
+ 'user_id' => $user->id,
+ 'external_account_id' => 'IC-001',
+ 'type' => AccountType::Investment->value,
+ ]);
+
+ Queue::assertPushed(SyncBankingConnectionJob::class);
+});
+
+test('invalid token returns 422', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.indexacapital.com/users/me' => Http::response(['message' => 'Unauthorized'], 401),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
+ 'api_token' => 'invalid-token-12345',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonFragment(['message' => 'Invalid API token or failed to connect to Indexa Capital.']);
+
+ $this->assertDatabaseMissing('banking_connections', [
+ 'user_id' => $user->id,
+ 'provider' => 'indexacapital',
+ ]);
+});
+
+test('connection with account-mapping flag returns mapping redirect', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+ Feature::for($user)->activate('account-mapping');
+
+ Http::fake([
+ 'api.indexacapital.com/users/me' => Http::response([
+ 'accounts' => [
+ ['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual'],
+ ['account_number' => 'IC-002', 'status' => 'active', 'type' => 'pension'],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
+ 'api_token' => 'valid-test-token-12345',
+ ]);
+
+ $response->assertOk();
+
+ $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'indexacapital')->first();
+
+ expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
+ expect($connection->pending_accounts_data)->toHaveCount(2);
+ expect($connection->pending_accounts_data[0]['uid'])->toBe('IC-001');
+ expect($connection->pending_accounts_data[1]['uid'])->toBe('IC-002');
+
+ $this->assertDatabaseMissing('accounts', [
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ ]);
+
+ Queue::assertNothingPushed();
+});
+
+test('requires open-banking feature flag', function () {
+ $user = User::factory()->onboarded()->create();
+
+ $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
+ 'api_token' => 'valid-test-token-12345',
+ ]);
+
+ $response->assertNotFound();
+});
+
+test('api_token is required and must be at least 10 characters', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['api_token']);
+
+ $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
+ 'api_token' => 'short',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['api_token']);
+});
+
+test('creates multiple accounts for multiple indexa portfolios', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.indexacapital.com/users/me' => Http::response([
+ 'accounts' => [
+ ['account_number' => 'IC-001', 'status' => 'active', 'type' => 'mutual'],
+ ['account_number' => 'IC-002', 'status' => 'active', 'type' => 'pension'],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/indexa-capital/connect', [
+ 'api_token' => 'valid-test-token-12345',
+ ]);
+
+ $response->assertOk();
+
+ expect($user->accounts()->where('type', AccountType::Investment->value)->count())->toBe(2);
+
+ $this->assertDatabaseHas('accounts', [
+ 'user_id' => $user->id,
+ 'external_account_id' => 'IC-001',
+ 'name' => 'Investment Portfolio (IC-001)',
+ ]);
+ $this->assertDatabaseHas('accounts', [
+ 'user_id' => $user->id,
+ 'external_account_id' => 'IC-002',
+ 'name' => 'Pension Plan (IC-002)',
+ ]);
+});
diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
index 7c3cefd6..cf42f217 100644
--- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
+++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
@@ -1,5 +1,6 @@
transactionsPerBank === ['Bank A' => 4, 'Bank B' => 4];
});
});
+
+test('indexa capital sync only syncs balances, not transactions', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [
+ ['date' => '2026-02-18', 'total_amount' => 10000.00, 'cash_amount' => 0, 'instruments_amount' => 10000.00],
+ ],
+ ]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $transactionSync->shouldNotReceive('sync');
+
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+ $balanceSync->shouldNotReceive('sync');
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ $connection->refresh();
+ expect($connection->last_synced_at)->not->toBeNull();
+ expect($account->balances()->count())->toBe(1);
+});
+
+test('indexa capital connections do not expire', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ 'status' => BankingConnectionStatus::Active,
+ 'valid_until' => now()->subDay(),
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [
+ ['date' => '2026-02-18', 'total_amount' => 10000.00, 'cash_amount' => 0, 'instruments_amount' => 10000.00],
+ ],
+ ]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Active);
+ expect($connection->last_synced_at)->not->toBeNull();
+});
+
+test('indexa capital sync does not send email', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->indexaCapital()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'IC-001',
+ ]);
+
+ Http::fake([
+ 'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
+ 'portfolios' => [
+ ['date' => '2026-02-18', 'total_amount' => 10000.00, 'cash_amount' => 0, 'instruments_amount' => 10000.00],
+ ],
+ ]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ Mail::assertNothingQueued();
+});