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:
parent
202835f76e
commit
3f541ca4d6
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -13,7 +13,6 @@ interface AccountNameProps {
|
|||
export function AccountName({
|
||||
account,
|
||||
className = '',
|
||||
length = null,
|
||||
}: AccountNameProps) {
|
||||
}: Omit<AccountNameProps, 'length'>) {
|
||||
return <span className={className}>{account.name}</span>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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)',
|
||||
]);
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue