fix(banking): treat Indexa Capital performance 404 as empty data (#386)

## Problem

Sentry:
[PHP-LARAVEL-1X](https://whisper-money.sentry.io/issues/PHP-LARAVEL-1X)
— 6 events, 2 users.

`IndexaCapitalClient::getPerformance()` throws `RequestException` when
Indexa Capital returns 404 (HTML page) for an account without
performance data — e.g. brand-new or inactive accounts. This aborts
`SyncBankingConnectionJob::syncIndexaCapital` and pages us via Sentry.

## Fix

Catch the 404 inside the client, log at info level, and return an empty
array. The downstream `IndexaCapitalBalanceSyncService` already
short-circuits when `portfolios` is empty, so the sync no-ops cleanly.
Other HTTP errors (auth, 5xx) keep throwing.

## Test

Added `returns empty performance when indexa capital responds 404` in
`IndexaCapitalBalanceSyncTest`.

```
php artisan test --filter=IndexaCapitalBalanceSyncTest
Tests:  12 passed
```

Fixes PHP-LARAVEL-1X
This commit is contained in:
Víctor Falcón 2026-05-13 09:34:28 +01:00 committed by GitHub
parent 31b9198775
commit 06e7eed4e2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 53 additions and 11 deletions

View File

@ -64,27 +64,46 @@ class IndexaCapitalClient
/**
* Get performance data for an account, including current portfolio value.
*
* Returns an empty array when Indexa Capital responds with 404 (no
* performance data available yet for the account, e.g. brand-new or
* inactive accounts) instead of throwing, so the sync can no-op cleanly.
*
* @return array{total_amount?: float, return?: float, return_percentage?: float, portfolios?: array<int, array{date?: string, total_amount?: float, return?: float}>, net_amounts?: array<string, float>}
*/
public function getPerformance(string $accountNumber): array
{
$response = $this->client()->get("/accounts/{$accountNumber}/performance");
$response = $this->client(throwOnError: false)->get("/accounts/{$accountNumber}/performance");
if ($response->status() === 404) {
Log::info('No Indexa Capital performance data available', [
'account_number' => $accountNumber,
]);
return [];
}
$response->throw();
return $response->json();
$json = $response->json();
return is_array($json) ? $json : [];
}
private function client(): PendingRequest
private function client(bool $throwOnError = true): PendingRequest
{
return Http::baseUrl(self::BASE_URL)
$client = 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(),
]);
});
->acceptJson();
if (! $throwOnError) {
return $client;
}
return $client->throw(function ($response, $exception) {
Log::error('Indexa Capital API error', [
'status' => $response->status(),
'body' => $response->json(),
]);
});
}
}

View File

@ -116,6 +116,29 @@ test('skips account without external_account_id', function () {
expect($account->balances()->count())->toBe(0);
});
test('returns empty performance when indexa capital responds 404', 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-404',
]);
Http::fake([
'api.indexacapital.com/accounts/IC-404/performance' => Http::response('<html>not found</html>', 404),
]);
$client = new IndexaCapitalClient('test-token');
$service = app(IndexaCapitalBalanceSyncService::class);
$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([