whisper-money/tests/Feature/OpenBanking/IndexaCapitalBalanceSyncTes...

404 lines
15 KiB
PHP

<?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 = app(IndexaCapitalBalanceSyncService::class);
$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 = app(IndexaCapitalBalanceSyncService::class);
$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 = app(IndexaCapitalBalanceSyncService::class);
$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 = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client);
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([
'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 = app(IndexaCapitalBalanceSyncService::class);
$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 = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(0);
});
test('stores invested_amount from net_amounts 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',
'currency_code' => 'USD',
]);
$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' => $today,
'total_amount' => 15000.00,
'instruments_cost' => 12000.00,
'instruments_amount' => 14700.00,
'cash_amount' => 300.00,
],
[
'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,
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(2);
$latest = $account->balances()->orderBy('balance_date', 'desc')->first();
// invested_amount comes from net_amounts, not instruments_cost + cash_amount
expect($latest->balance)->toBe(1500000);
expect($latest->invested_amount)->toBe(1100000);
$previous = $account->balances()->orderBy('balance_date', 'asc')->first();
expect($previous->invested_amount)->toBe(1080000);
});
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,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'IC-001',
'currency_code' => 'USD',
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
['date' => now()->toDateString(), 'total_amount' => 15000.00],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(1);
$balance = $account->balances()->first();
expect($balance->balance)->toBe(1500000);
expect($balance->invested_amount)->toBeNull();
});
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,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'IC-001',
'currency_code' => 'USD',
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
// No net_amounts in response, but entry has return field
['date' => now()->toDateString(), 'total_amount' => 8000.00, 'return' => -500.00],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
expect($balance->balance)->toBe(800000);
// invested_amount = total_amount - return = 8000 - (-500) = 8500 → 850000 cents
expect($balance->invested_amount)->toBe(850000);
});
test('subsequent sync only processes entries since last balance 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',
]);
// Simulate existing balances from a previous full sync
$account->balances()->create([
'balance_date' => now()->subDays(5)->toDateString(),
'balance' => 1400000,
]);
$account->balances()->create([
'balance_date' => now()->subDays(4)->toDateString(),
'balance' => 1410000,
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
// Old entries that should be skipped
['date' => now()->subDays(10)->toDateString(), 'total_amount' => 13000.00],
['date' => now()->subDays(6)->toDateString(), 'total_amount' => 13900.00],
// Entry on the last balance date (should be processed — updated)
['date' => now()->subDays(4)->toDateString(), 'total_amount' => 14200.00],
// New entries
['date' => now()->subDays(3)->toDateString(), 'total_amount' => 14500.00],
['date' => now()->subDays(2)->toDateString(), 'total_amount' => 14800.00],
['date' => now()->toDateString(), 'total_amount' => 15000.00],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client, isFirstSync: false);
// 2 pre-existing + 3 new entries = 5 total (the one on the boundary date gets updated, not duplicated)
expect($account->balances()->count())->toBe(5);
// Verify old entry was NOT overwritten (the one from subDays(5) should still be there)
$oldBalance = $account->balances()->where('balance_date', now()->subDays(5)->toDateString())->first();
expect($oldBalance->balance)->toBe(1400000);
// Verify boundary entry was updated
$boundaryBalance = $account->balances()->where('balance_date', now()->subDays(4)->toDateString())->first();
expect($boundaryBalance->balance)->toBe(1420000);
// Verify new entry was created
$newBalance = $account->balances()->where('balance_date', now()->toDateString())->first();
expect($newBalance->balance)->toBe(1500000);
});
test('full sync processes all entries regardless of existing balances', 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',
]);
// Simulate existing balances from a previous sync
$account->balances()->create([
'balance_date' => now()->subDays(2)->toDateString(),
'balance' => 1400000,
]);
Http::fake([
'api.indexacapital.com/accounts/IC-001/performance' => Http::response([
'portfolios' => [
['date' => now()->subDays(5)->toDateString(), 'total_amount' => 13000.00],
['date' => now()->subDays(2)->toDateString(), 'total_amount' => 14000.00],
['date' => now()->toDateString(), 'total_amount' => 15000.00],
],
]),
]);
$client = new IndexaCapitalClient('test-token');
$service = app(IndexaCapitalBalanceSyncService::class);
$service->sync($account, $client, isFirstSync: true);
// All 3 entries processed (1 existing updated + 2 new)
expect($account->balances()->count())->toBe(3);
// The old entry at subDays(2) should be updated with new value
$updatedBalance = $account->balances()->where('balance_date', now()->subDays(2)->toDateString())->first();
expect($updatedBalance->balance)->toBe(1400000);
});