feat: Add Indexa Capital integration (#130)

## 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
This commit is contained in:
Víctor Falcón 2026-02-18 10:42:13 +01:00 committed by GitHub
parent 202835f76e
commit 3f541ca4d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 1002 additions and 84 deletions

View File

@ -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,
]);

View File

@ -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) {

View File

@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\ConnectIndexaCapitalRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Services\Banking\IndexaCapitalClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Feature;
class IndexaCapitalController extends Controller
{
/**
* Validate the Indexa Capital API token and create a connection.
*/
public function store(ConnectIndexaCapitalRequest $request): JsonResponse
{
$validated = $request->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<int, array{uid: string, currency: string, name: string}>
*/
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;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ConnectIndexaCapitalRequest extends FormRequest
{
public function authorize(): bool
{
return Feature::for($this->user())->active('open-banking');
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'api_token' => ['required', 'string', 'min:10'],
];
}
}

View File

@ -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,
));
}
}
}

View File

@ -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);

View File

@ -0,0 +1,55 @@
<?php
namespace App\Services\Banking;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class IndexaCapitalBalanceSyncService
{
/**
* Sync portfolio balances for an Indexa Capital account.
* Stores up to one year of daily historical balances from the portfolios data.
*/
public function sync(Account $account, IndexaCapitalClient $client): void
{
if (! $account->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,
]);
}
}

View File

@ -0,0 +1,94 @@
<?php
namespace App\Services\Banking;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class IndexaCapitalClient
{
private const BASE_URL = 'https://api.indexacapital.com';
public function __construct(
private string $apiToken,
) {}
/**
* Get the authenticated user's info, including accounts list.
*
* @return array{accounts: array<int, array{account_number: string, status: string, type: string}>, 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<int, array{account_number: string, status: string, type: string, currency: string}>
*/
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(),
]);
});
}
}

View File

@ -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) => [

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -13,7 +13,6 @@ interface AccountNameProps {
export function AccountName({
account,
className = '',
length = null,
}: AccountNameProps) {
}: Omit<AccountNameProps, 'length'>) {
return <span className={className}>{account.name}</span>;
}

View File

@ -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<string | null>(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.',
)}
</DialogDescription>
</DialogHeader>
@ -291,14 +331,42 @@ export function ConnectAccountDialog({
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{__(
'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.',
)}
</p>
</div>
</div>
</div>
{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 Settings > Applications.',
)}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
@ -309,7 +377,10 @@ export function ConnectAccountDialog({
</Button>
<Button
onClick={handleAuthorize}
disabled={isSubmitting}
disabled={
isSubmitting ||
(isIndexaCapital && !apiToken)
}
>
{isSubmitting
? __('Connecting...')

View File

@ -201,9 +201,14 @@ export default function ConnectionsPage({ connections }: Props) {
!connection.last_synced_at ? (
<span className="flex items-center gap-1.5">
<Spinner className="size-3" />
{__(
'Syncing transactions and balances…',
)}
{connection.provider ===
'indexacapital'
? __(
'Syncing balances…',
)
: __(
'Syncing transactions and balances…',
)}
</span>
) : (
<span>

View File

@ -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,

View File

@ -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 () {

View File

@ -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();

View File

@ -0,0 +1,165 @@
<?php
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\IndexaCapitalBalanceSyncService;
use App\Services\Banking\IndexaCapitalClient;
use Illuminate\Support\Facades\Http;
test('syncs historical balances from indexa capital portfolios', 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' => 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);
});

View File

@ -0,0 +1,175 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
beforeEach(function () {
Bank::factory()->create([
'name' => '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)',
]);
});

View File

@ -1,5 +1,6 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Mail\BankTransactionsSyncedEmail;
use App\Models\Account;
@ -9,6 +10,7 @@ use App\Models\Transaction;
use App\Models\User;
use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
test('first sync calculates historical balances', function () {
@ -286,3 +288,101 @@ test('lists different banks separately in email', function () {
&& $mail->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();
});