fix(open-banking): use net_amounts for Indexa Capital invested amount calculation (#156)

## Why

### Problem

The Indexa Capital dashboard shows +€11,339.01 profit for the Index
funds account, but our app shows +€9,680.43 — a €1,658.58 discrepancy.
The invested amount (and therefore the profit) is wrong.

### Root Cause

`calculateInvestedAmount()` used `instruments_cost + cash_amount` to
determine how much was invested. However, `instruments_cost` is the
**cost basis of currently held fund shares**, not the actual money
deposited. When Indexa rebalances portfolios, they sell shares
(realizing gains) and buy new ones at a higher cost basis. This inflates
`instruments_cost` over time without any new deposits, making the
"invested" figure too high and profit too low.

## What

### Changes

- Use `net_amounts` from the Indexa Capital API response instead of
`instruments_cost + cash_amount`
- `net_amounts` is an object keyed by date (`YYYYMMDD` format), where
each value is the cumulative net investment (inflows - outflows -
tax_outflows), matching Indexa Capital's own "investment" figure
- Keep `total_amount - return` as a fallback when `net_amounts` is
unavailable
- Return `null` when neither data source is available

### Concrete numbers (Index funds account, 2026-02-24)

| | Old (wrong) | New (correct) |
|---|---|---|
| Invested | €48,081.67 | €46,423.09 |
| Profit | €9,680.43 | €11,339.01 |

## Verification

### Tests

- Updated `stores invested_amount from net_amounts data` — verifies
invested_amount is sourced from `net_amounts`, not `instruments_cost +
cash_amount`
- Updated `stores null invested_amount when net_amounts is missing` —
verifies graceful null when API lacks `net_amounts`
- Updated `falls back to total_amount minus return when net_amounts is
missing` — verifies fallback path
- All 11 balance sync tests pass, all 23 SyncBankingConnectionJob tests
pass
This commit is contained in:
Víctor Falcón 2026-02-25 15:45:31 +01:00 committed by GitHub
parent e718f5df5c
commit ae2a8c0118
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 35 additions and 19 deletions

View File

@ -20,6 +20,7 @@ class IndexaCapitalBalanceSyncService
$performance = $client->getPerformance($account->external_account_id);
$portfolios = $performance['portfolios'] ?? [];
$netAmounts = $performance['net_amounts'] ?? [];
if (empty($portfolios)) {
Log::warning('No portfolio data from Indexa Capital', [
@ -55,7 +56,7 @@ class IndexaCapitalBalanceSyncService
}
$balanceCents = (int) round(floatval($value) * 100);
$investedAmountCents = $this->calculateInvestedAmount($entry);
$investedAmountCents = $this->calculateInvestedAmount($entry, $netAmounts);
$account->balances()->updateOrCreate(
['balance_date' => $date],
@ -76,20 +77,27 @@ class IndexaCapitalBalanceSyncService
}
/**
* Calculate invested amount from portfolio entry data.
* Calculate invested amount from the net_amounts data.
*
* Uses instruments_cost + cash_amount when available (cost basis approach).
* Falls back to total_amount - return if the return field is present.
* Uses net_amounts (cumulative net inflows keyed by YYYYMMDD) which represents
* the actual money invested (inflows - outflows - tax_outflows), matching
* what Indexa Capital shows as "investment" on their dashboard.
*
* Falls back to total_amount - return if net_amounts is unavailable.
*
* @param array<string, mixed> $entry
* @param array<string, float> $netAmounts
*/
private function calculateInvestedAmount(array $entry): ?int
private function calculateInvestedAmount(array $entry, array $netAmounts): ?int
{
$instrumentsCost = $entry['instruments_cost'] ?? null;
$cashAmount = $entry['cash_amount'] ?? null;
$date = $entry['date'] ?? null;
if ($instrumentsCost !== null && $cashAmount !== null) {
return (int) round((floatval($instrumentsCost) + floatval($cashAmount)) * 100);
if ($date !== null && ! empty($netAmounts)) {
$dateKey = str_replace('-', '', $date);
if (isset($netAmounts[$dateKey])) {
return (int) round(floatval($netAmounts[$dateKey]) * 100);
}
}
$totalAmount = $entry['total_amount'] ?? null;

View File

@ -164,7 +164,7 @@ test('handles empty portfolios array gracefully', function () {
expect($account->balances()->count())->toBe(0);
});
test('stores invested_amount from instruments_cost and cash_amount', function () {
test('stores invested_amount from net_amounts data', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
@ -175,24 +175,33 @@ test('stores invested_amount from instruments_cost and cash_amount', function ()
'external_account_id' => 'IC-001',
]);
$today = now()->toDateString();
$yesterday = now()->subDay()->toDateString();
$todayKey = str_replace('-', '', $today);
$yesterdayKey = str_replace('-', '', $yesterday);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
[
'date' => now()->toDateString(),
'date' => $today,
'total_amount' => 15000.00,
'instruments_cost' => 12000.00,
'instruments_amount' => 14700.00,
'cash_amount' => 300.00,
],
[
'date' => now()->subDay()->toDateString(),
'date' => $yesterday,
'total_amount' => 14500.00,
'instruments_cost' => 12000.00,
'instruments_amount' => 14200.00,
'cash_amount' => 300.00,
],
],
'net_amounts' => [
$todayKey => 11000.00,
$yesterdayKey => 10800.00,
],
]),
]);
@ -203,16 +212,15 @@ test('stores invested_amount from instruments_cost and cash_amount', function ()
expect($account->balances()->count())->toBe(2);
$latest = $account->balances()->orderBy('balance_date', 'desc')->first();
// invested_amount = instruments_cost + cash_amount = 12000 + 300 = 12300 → 1230000 cents
// invested_amount comes from net_amounts, not instruments_cost + cash_amount
expect($latest->balance)->toBe(1500000);
expect($latest->invested_amount)->toBe(1230000);
expect($latest->invested_amount)->toBe(1100000);
$previous = $account->balances()->orderBy('balance_date', 'asc')->first();
// invested_amount = 12000 + 300 = 12300 → 1230000 cents
expect($previous->invested_amount)->toBe(1230000);
expect($previous->invested_amount)->toBe(1080000);
});
test('stores null invested_amount when cost fields are missing', function () {
test('stores null invested_amount when net_amounts is missing', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
@ -242,7 +250,7 @@ test('stores null invested_amount when cost fields are missing', function () {
expect($balance->invested_amount)->toBeNull();
});
test('falls back to total_amount minus return when instruments_cost is missing', function () {
test('falls back to total_amount minus return when net_amounts is missing', function () {
$user = User::factory()->onboarded()->create();
$connection = BankingConnection::factory()->indexaCapital()->create([
'user_id' => $user->id,
@ -256,7 +264,7 @@ test('falls back to total_amount minus return when instruments_cost is missing',
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
// No instruments_cost/cash_amount, but has return field
// No net_amounts in response, but entry has return field
['date' => now()->toDateString(), 'total_amount' => 8000.00, 'return' => -500.00],
],
]),