diff --git a/app/Http/Controllers/OpenBanking/AccountMappingController.php b/app/Http/Controllers/OpenBanking/AccountMappingController.php index 312e8cb3..d7aff07b 100644 --- a/app/Http/Controllers/OpenBanking/AccountMappingController.php +++ b/app/Http/Controllers/OpenBanking/AccountMappingController.php @@ -76,7 +76,7 @@ class AccountMappingController extends Controller $pendingAccounts = collect($connection->pending_accounts_data) ->keyBy('uid'); - $accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda()) + $accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda() || $connection->isCoinbase()) ? AccountType::Investment : AccountType::Checking; diff --git a/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php b/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php index 3d4d340b..03778233 100644 --- a/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php +++ b/app/Http/Controllers/OpenBanking/Concerns/CreatesAccountsFromPending.php @@ -14,7 +14,7 @@ trait CreatesAccountsFromPending * Auto-create all pending accounts from a banking connection without user interaction. * * This resolves the correct account type based on the provider (Investment for - * Indexa Capital, Binance, and Bitpanda; Checking for everything else) and clears the + * Indexa Capital, Binance, Bitpanda, and Coinbase; Checking for everything else) and clears the * pending data once accounts have been created. */ private function createAccountsFromPending(User $user, BankingConnection $connection): void @@ -28,7 +28,7 @@ trait CreatesAccountsFromPending $bank->update(['logo' => $connection->aspsp_logo]); } - $accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda()) + $accountType = ($connection->isIndexaCapital() || $connection->isBinance() || $connection->isBitpanda() || $connection->isCoinbase()) ? AccountType::Investment : AccountType::Checking; diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 93c0e7e4..b60eefa2 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -115,7 +115,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } elseif ($connection->isBitpanda()) { $this->syncBitpanda($connection); } elseif ($connection->isCoinbase()) { - $this->syncCoinbase($connection); + $this->syncCoinbase($connection, $isFirstSync); } else { $metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync); @@ -331,7 +331,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } } - private function syncCoinbase(BankingConnection $connection): void + private function syncCoinbase(BankingConnection $connection, bool $isFirstSync): void { $client = new CoinbaseClient($connection->api_token, $connection->api_secret); $syncService = app(CoinbaseBalanceSyncService::class); @@ -339,7 +339,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue $connection->load('accounts'); foreach ($connection->accounts as $account) { - $syncService->sync($account, $client); + $syncService->sync($account, $client, $isFirstSync, backfillMissingHistory: true); } } diff --git a/app/Services/Banking/CoinbaseBalanceSyncService.php b/app/Services/Banking/CoinbaseBalanceSyncService.php index f2020605..eb96535b 100644 --- a/app/Services/Banking/CoinbaseBalanceSyncService.php +++ b/app/Services/Banking/CoinbaseBalanceSyncService.php @@ -4,6 +4,7 @@ namespace App\Services\Banking; use App\Models\Account; use App\Services\CurrencyConversionService; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; class CoinbaseBalanceSyncService @@ -13,6 +14,10 @@ class CoinbaseBalanceSyncService private const USD_CURRENCY = 'USD'; + private const HISTORICAL_MONTHS = 12; + + private const CANDLE_WINDOW_DAYS = 300; + public function __construct(private CurrencyConversionService $currencyConverter) {} /** @@ -21,22 +26,34 @@ class CoinbaseBalanceSyncService * * @api */ - public function sync(Account $account, CoinbaseClient $client): void + public function sync(Account $account, CoinbaseClient $client, bool $isFirstSync = false, bool $backfillMissingHistory = false): void { if (! $account->external_account_id) { return; } - $this->syncCurrentBalance($account, $client); + $coinbaseAccounts = $client->getAllAccounts(); + + if (empty($coinbaseAccounts)) { + return; + } + + if ($isFirstSync || ($backfillMissingHistory && $this->needsHistoricalBackfill($account))) { + $this->syncHistoricalBalances($account, $client, $coinbaseAccounts); + } + + $this->syncCurrentBalance($account, $client, $coinbaseAccounts); } /** * Sync today's balance by listing every Coinbase account and converting to target currency. + * + * @param array>|null $coinbaseAccounts */ - public function syncCurrentBalance(Account $account, CoinbaseClient $client): void + public function syncCurrentBalance(Account $account, CoinbaseClient $client, ?array $coinbaseAccounts = null): void { $targetCurrency = strtoupper($account->currency_code); - $coinbaseAccounts = $client->getAllAccounts(); + $coinbaseAccounts ??= $client->getAllAccounts(); if (empty($coinbaseAccounts)) { return; @@ -56,6 +73,73 @@ class CoinbaseBalanceSyncService ); } + /** + * Backfill one year of monthly balances using current holdings valued at each month's matching day close. + * + * @param array> $coinbaseAccounts + */ + public function syncHistoricalBalances(Account $account, CoinbaseClient $client, array $coinbaseAccounts): void + { + $targetCurrency = strtoupper($account->currency_code); + $historicalDates = $this->historicalDates(); + + if ($historicalDates === []) { + return; + } + + $startDate = $historicalDates[0]; + $endDate = $historicalDates[array_key_last($historicalDates)]; + + [$fiatBalances, $cryptoAssets] = $this->partitionBalancesByCurrency($coinbaseAccounts); + $priceHistory = $this->fetchHistoricalPriceMaps($client, array_keys($cryptoAssets), $targetCurrency, $startDate, $endDate); + $count = 0; + + foreach ($historicalDates as $date) { + $dateString = $date->toDateString(); + $totalValue = $this->convertHistoricalFiatBalances($fiatBalances, $targetCurrency, $dateString); + $totalValue += $this->convertHistoricalCryptoAssets($cryptoAssets, $priceHistory, $targetCurrency, $dateString); + + if ($totalValue <= 0) { + continue; + } + + $account->balances()->updateOrCreate( + ['balance_date' => $dateString], + ['balance' => (int) round($totalValue * 100)], + ); + + $count++; + } + + Log::info('Synced Coinbase historical balances', [ + 'account_id' => $account->id, + 'days_synced' => $count, + 'currency' => $targetCurrency, + ]); + } + + private function needsHistoricalBackfill(Account $account): bool + { + return ! $account->balances() + ->where('balance_date', '<=', $this->historicalStartDate()->toDateString()) + ->exists(); + } + + private function historicalStartDate(): Carbon + { + return now()->subMonthsNoOverflow(self::HISTORICAL_MONTHS)->startOfDay(); + } + + /** + * @return array + */ + private function historicalDates(): array + { + return collect(range(self::HISTORICAL_MONTHS, 1)) + ->map(fn (int $monthsAgo): Carbon => now()->subMonthsNoOverflow($monthsAgo)->startOfDay()) + ->all(); + } + /** * Split Coinbase accounts into fiat (converted directly) and crypto holdings. * @@ -64,7 +148,24 @@ class CoinbaseBalanceSyncService */ private function partitionBalances(array $coinbaseAccounts, string $targetCurrency): array { + [$fiatBalances, $cryptoAssets] = $this->partitionBalancesByCurrency($coinbaseAccounts); + $fiatTotal = 0.0; + + foreach ($fiatBalances as $currency => $balance) { + $fiatTotal += $this->convertFiat($currency, $balance, $targetCurrency); + } + + return [$fiatTotal, $cryptoAssets]; + } + + /** + * @param array> $coinbaseAccounts + * @return array{0: array, 1: array} + */ + private function partitionBalancesByCurrency(array $coinbaseAccounts): array + { + $fiatBalances = []; $cryptoAssets = []; foreach ($coinbaseAccounts as $coinbaseAccount) { @@ -78,7 +179,7 @@ class CoinbaseBalanceSyncService } if ($this->isFiatCurrency($currency)) { - $fiatTotal += $this->convertFiat($currency, $balance, $targetCurrency); + $fiatBalances[$currency] = ($fiatBalances[$currency] ?? 0.0) + $balance; continue; } @@ -86,7 +187,7 @@ class CoinbaseBalanceSyncService $cryptoAssets[$currency] = ($cryptoAssets[$currency] ?? 0.0) + $balance; } - return [$fiatTotal, $cryptoAssets]; + return [$fiatBalances, $cryptoAssets]; } private function convertFiat(string $currency, float $amount, string $targetCurrency): float @@ -152,6 +253,163 @@ class CoinbaseBalanceSyncService return $map; } + /** + * Fetch daily close prices keyed by asset and date. + * + * @param array $assets + * @return array> + */ + private function fetchHistoricalPriceMaps(CoinbaseClient $client, array $assets, string $targetCurrency, Carbon $startDate, Carbon $endDate): array + { + $priceHistory = []; + + foreach ($assets as $asset) { + if (in_array($asset, self::USD_STABLECOINS, true)) { + continue; + } + + $priceHistory[$asset] = $this->fetchHistoricalPricesForAsset($client, $asset, $targetCurrency, $startDate, $endDate); + } + + return $priceHistory; + } + + /** + * @return array + */ + private function fetchHistoricalPricesForAsset(CoinbaseClient $client, string $asset, string $targetCurrency, Carbon $startDate, Carbon $endDate): array + { + $directPrices = $this->fetchHistoricalProductPrices($client, "{$asset}-{$targetCurrency}", $startDate, $endDate); + + if ($directPrices !== [] || $targetCurrency === self::USD_CURRENCY) { + return $directPrices; + } + + $usdPrices = $this->fetchHistoricalProductPrices($client, "{$asset}-".self::USD_CURRENCY, $startDate, $endDate); + $targetPrices = []; + + foreach ($usdPrices as $date => $usdPrice) { + $targetPrice = $this->currencyConverter->convert(self::USD_CURRENCY, $targetCurrency, $usdPrice, $date); + + if ($targetPrice > 0) { + $targetPrices[$date] = $targetPrice; + } + } + + return $targetPrices; + } + + /** + * @return array + */ + private function fetchHistoricalProductPrices(CoinbaseClient $client, string $productId, Carbon $startDate, Carbon $endDate): array + { + $prices = []; + + foreach ($this->fetchProductCandles($client, $productId, $startDate, $endDate) as $candle) { + $timestamp = $candle['start'] ?? null; + $close = (float) ($candle['close'] ?? 0); + + if ($timestamp === null || $close <= 0) { + continue; + } + + $prices[Carbon::createFromTimestamp((int) $timestamp)->toDateString()] = $close; + } + + return $prices; + } + + /** + * @return array> + */ + private function fetchProductCandles(CoinbaseClient $client, string $productId, Carbon $startDate, Carbon $endDate): array + { + $candles = []; + $windowStart = $startDate->copy(); + + while ($windowStart->lessThanOrEqualTo($endDate)) { + $windowEnd = $windowStart->copy()->addDays(self::CANDLE_WINDOW_DAYS)->min($endDate); + + try { + $response = $client->getProductCandles( + $productId, + $windowStart->getTimestamp(), + $windowEnd->copy()->endOfDay()->getTimestamp(), + ); + } catch (\Throwable $e) { + Log::warning('Coinbase historical candles failed', [ + 'product_id' => $productId, + 'start' => $windowStart->toDateString(), + 'end' => $windowEnd->toDateString(), + 'error' => $e->getMessage(), + ]); + + break; + } + + foreach ($response['candles'] ?? [] as $candle) { + $candles[] = $candle; + } + + $windowStart = $windowEnd->copy()->addDay()->startOfDay(); + } + + return $candles; + } + + /** + * @param array $fiatBalances + */ + private function convertHistoricalFiatBalances(array $fiatBalances, string $targetCurrency, string $date): float + { + $total = 0.0; + + foreach ($fiatBalances as $currency => $amount) { + $total += $this->convertFiatOnDate($currency, $amount, $targetCurrency, $date); + } + + return $total; + } + + private function convertFiatOnDate(string $currency, float $amount, string $targetCurrency, string $date): float + { + if ($currency === $targetCurrency) { + return $amount; + } + + return $this->currencyConverter->convert($currency, $targetCurrency, $amount, $date); + } + + /** + * @param array $cryptoAssets + * @param array> $priceHistory + */ + private function convertHistoricalCryptoAssets(array $cryptoAssets, array $priceHistory, string $targetCurrency, string $date): float + { + $total = 0.0; + + foreach ($cryptoAssets as $asset => $quantity) { + if (in_array($asset, self::USD_STABLECOINS, true)) { + $total += $this->convertFiatOnDate(self::USD_CURRENCY, $quantity, $targetCurrency, $date); + + continue; + } + + $price = $priceHistory[$asset][$date] ?? null; + + if ($price !== null) { + $total += $quantity * $price; + + continue; + } + + $total += $this->currencyConverter->convert($asset, $targetCurrency, $quantity, $date); + } + + return $total; + } + /** * Convert each crypto holding to target fiat. Falls back via USD pair + currency converter. * diff --git a/app/Services/Banking/CoinbaseClient.php b/app/Services/Banking/CoinbaseClient.php index 9867d49b..51f35d8a 100644 --- a/app/Services/Banking/CoinbaseClient.php +++ b/app/Services/Banking/CoinbaseClient.php @@ -106,6 +106,20 @@ class CoinbaseClient return $this->signedRequest('GET', '/api/v3/brokerage/best_bid_ask', $params); } + /** + * Get historical candles for a product. + * + * @return array + */ + public function getProductCandles(string $productId, int $start, int $end, string $granularity = 'ONE_DAY'): array + { + return $this->signedRequest('GET', "/api/v3/brokerage/products/{$productId}/candles", [ + 'start' => $start, + 'end' => $end, + 'granularity' => $granularity, + ]); + } + /** * Execute a signed JWT request with retry on rate limiting. * diff --git a/tests/Feature/OpenBanking/AccountMappingTest.php b/tests/Feature/OpenBanking/AccountMappingTest.php index 368a0b8a..3edfbced 100644 --- a/tests/Feature/OpenBanking/AccountMappingTest.php +++ b/tests/Feature/OpenBanking/AccountMappingTest.php @@ -106,17 +106,17 @@ test('store with action create creates new accounts', function () { Queue::assertPushed(SyncBankingConnectionJob::class); }); -test('store creates investment accounts for bitpanda connections', function () { +test('store creates investment accounts for crypto provider connections', function (string $provider, string $providerName, string $uid) { Queue::fake(); $user = User::factory()->onboarded()->create(); $connection = BankingConnection::factory()->awaitingMapping()->create([ 'user_id' => $user->id, - 'provider' => 'bitpanda', - 'aspsp_name' => 'Bitpanda', + 'provider' => $provider, + 'aspsp_name' => $providerName, 'pending_accounts_data' => [ [ - 'uid' => 'bitpanda-portfolio', + 'uid' => $uid, 'currency' => 'EUR', 'name' => 'Crypto Portfolio', 'account_id' => [], @@ -128,7 +128,7 @@ test('store creates investment accounts for bitpanda connections', function () { ->post(route('open-banking.map-accounts.store', $connection), [ 'mappings' => [ [ - 'bank_account_uid' => 'bitpanda-portfolio', + 'bank_account_uid' => $uid, 'action' => 'create', 'existing_account_id' => null, ], @@ -140,12 +140,15 @@ test('store creates investment accounts for bitpanda connections', function () { $this->assertDatabaseHas('accounts', [ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, - 'external_account_id' => 'bitpanda-portfolio', + 'external_account_id' => $uid, 'type' => 'investment', ]); Queue::assertPushed(SyncBankingConnectionJob::class); -}); +})->with([ + 'bitpanda' => ['bitpanda', 'Bitpanda', 'bitpanda-portfolio'], + 'coinbase' => ['coinbase', 'Coinbase', 'coinbase-portfolio'], +]); test('store with action link links existing account', function () { Queue::fake(); diff --git a/tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php b/tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php index ec0257ec..3c447203 100644 --- a/tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php +++ b/tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php @@ -5,8 +5,12 @@ use App\Models\BankingConnection; use App\Models\User; use App\Services\Banking\CoinbaseBalanceSyncService; use App\Services\Banking\CoinbaseClient; +use Illuminate\Http\Client\Request; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Http; +afterEach(fn () => Carbon::setTestNow()); + function ecPrivateKeyForCoinbase(): string { $key = openssl_pkey_new([ @@ -80,6 +84,248 @@ test('syncs coinbase balance with crypto and fiat wallets', function () { expect($balance->balance_date->toDateString())->toBe(now()->toDateString()); }); +test('first sync creates twelve monthly coinbase historical balances', function () { + Carbon::setTestNow('2026-05-14 12:00:00'); + + $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); + $connection = BankingConnection::factory()->coinbase()->create([ + 'user_id' => $user->id, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'coinbase-portfolio', + 'currency_code' => 'EUR', + ]); + + Http::fake(function (Request $request) { + $url = $request->url(); + + if (str_contains($url, '/api/v3/brokerage/accounts')) { + return Http::response([ + 'accounts' => [ + [ + 'uuid' => 'cb-1', + 'name' => 'BTC', + 'currency' => 'BTC', + 'available_balance' => ['value' => '1.0', 'currency' => 'BTC'], + 'hold' => ['value' => '0', 'currency' => 'BTC'], + 'active' => true, + 'type' => 'ACCOUNT_TYPE_CRYPTO', + ], + [ + 'uuid' => 'cb-2', + 'name' => 'EUR', + 'currency' => 'EUR', + 'available_balance' => ['value' => '50.00', 'currency' => 'EUR'], + 'hold' => ['value' => '0', 'currency' => 'EUR'], + 'active' => true, + 'type' => 'ACCOUNT_TYPE_FIAT', + ], + ], + 'has_next' => false, + 'cursor' => '', + 'size' => 2, + ]); + } + + if (str_contains($url, '/api/v3/brokerage/products/BTC-EUR/candles')) { + parse_str(parse_url($url, PHP_URL_QUERY) ?: '', $query); + + $start = Carbon::createFromTimestamp((int) $query['start'])->startOfDay(); + $end = Carbon::createFromTimestamp((int) $query['end'])->startOfDay(); + $candles = []; + + for ($date = $start->copy(); $date->lessThanOrEqualTo($end); $date->addDay()) { + $candles[] = [ + 'start' => (string) $date->getTimestamp(), + 'low' => '100.00', + 'high' => '100.00', + 'open' => '100.00', + 'close' => '100.00', + 'volume' => '1.00', + ]; + } + + return Http::response(['candles' => $candles]); + } + + if (str_contains($url, '/api/v3/brokerage/best_bid_ask')) { + return Http::response([ + 'pricebooks' => [ + [ + 'product_id' => 'BTC-EUR', + 'bids' => [['price' => '200.00', 'size' => '1']], + 'asks' => [['price' => '200.00', 'size' => '1']], + ], + ], + ]); + } + + return Http::response([], 404); + }); + + $client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase()); + $service = app(CoinbaseBalanceSyncService::class); + $service->sync($account, $client, isFirstSync: true); + + $balances = $account->balances()->orderBy('balance_date')->get(); + + expect($balances)->toHaveCount(13); + expect($balances->first()->balance_date->toDateString())->toBe('2025-05-14'); + expect($balances->first()->balance)->toBe(15_000); + expect($balances->get(11)->balance_date->toDateString())->toBe('2026-04-14'); + expect($balances->get(11)->balance)->toBe(15_000); + expect($balances->last()->balance_date->toDateString())->toBe('2026-05-14'); + expect($balances->last()->balance)->toBe(25_000); +}); + +test('historical sync falls back to usd coinbase candles when target pair is unavailable', function () { + Carbon::setTestNow('2026-05-14 12:00:00'); + + $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); + $connection = BankingConnection::factory()->coinbase()->create([ + 'user_id' => $user->id, + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'coinbase-portfolio', + 'currency_code' => 'EUR', + ]); + + Http::fake(function (Request $request) { + $url = $request->url(); + + if (str_contains($url, '/api/v3/brokerage/accounts')) { + return Http::response([ + 'accounts' => [ + [ + 'uuid' => 'cb-1', + 'name' => 'SOL', + 'currency' => 'SOL', + 'available_balance' => ['value' => '2.0', 'currency' => 'SOL'], + 'hold' => ['value' => '0', 'currency' => 'SOL'], + 'active' => true, + 'type' => 'ACCOUNT_TYPE_CRYPTO', + ], + ], + 'has_next' => false, + 'cursor' => '', + 'size' => 1, + ]); + } + + if (str_contains($url, '/api/v3/brokerage/products/SOL-EUR/candles')) { + return Http::response(['error' => 'not found'], 404); + } + + if (str_contains($url, '/api/v3/brokerage/products/SOL-USD/candles')) { + parse_str(parse_url($url, PHP_URL_QUERY) ?: '', $query); + + $start = Carbon::createFromTimestamp((int) $query['start'])->startOfDay(); + $end = Carbon::createFromTimestamp((int) $query['end'])->startOfDay(); + $candles = []; + + for ($date = $start->copy(); $date->lessThanOrEqualTo($end); $date->addDay()) { + $candles[] = [ + 'start' => (string) $date->getTimestamp(), + 'low' => '100.00', + 'high' => '100.00', + 'open' => '100.00', + 'close' => '100.00', + 'volume' => '1.00', + ]; + } + + return Http::response(['candles' => $candles]); + } + + if (str_contains($url, 'cdn.jsdelivr.net') || str_contains($url, 'currency-api.pages.dev')) { + return Http::response(['eur' => ['usd' => 2.0]]); + } + + if (str_contains($url, '/api/v3/brokerage/best_bid_ask')) { + return Http::response([ + 'pricebooks' => [ + [ + 'product_id' => 'SOL-EUR', + 'bids' => [['price' => '50.00', 'size' => '1']], + 'asks' => [['price' => '50.00', 'size' => '1']], + ], + ], + ]); + } + + return Http::response([], 404); + }); + + $client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase()); + $service = app(CoinbaseBalanceSyncService::class); + $service->sync($account, $client, isFirstSync: true); + + $balances = $account->balances()->orderBy('balance_date')->get(); + + expect($balances)->toHaveCount(13); + expect($balances->first()->balance)->toBe(10_000); + expect($balances->last()->balance)->toBe(10_000); +}); + +test('subsequent sync backfills coinbase monthly history when missing', function () { + Carbon::setTestNow('2026-05-14 12:00:00'); + + $user = User::factory()->onboarded()->create(['currency_code' => 'USD']); + $connection = BankingConnection::factory()->coinbase()->create([ + 'user_id' => $user->id, + 'last_synced_at' => now()->subHour(), + ]); + $account = Account::factory()->connected()->create([ + 'user_id' => $user->id, + 'banking_connection_id' => $connection->id, + 'external_account_id' => 'coinbase-portfolio', + 'currency_code' => 'USD', + ]); + + $account->balances()->create([ + 'balance_date' => now()->toDateString(), + 'balance' => 100_000, + ]); + + Http::fake([ + 'api.coinbase.com/api/v3/brokerage/accounts*' => Http::response([ + 'accounts' => [ + [ + 'uuid' => 'cb-1', + 'name' => 'USDC', + 'currency' => 'USDC', + 'available_balance' => ['value' => '1000.00', 'currency' => 'USDC'], + 'hold' => ['value' => '0', 'currency' => 'USDC'], + 'active' => true, + 'type' => 'ACCOUNT_TYPE_CRYPTO', + ], + ], + 'has_next' => false, + 'cursor' => '', + 'size' => 1, + ]), + 'api.coinbase.com/api/v3/brokerage/best_bid_ask*' => Http::response([ + 'pricebooks' => [], + ]), + ]); + + $client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase()); + $service = app(CoinbaseBalanceSyncService::class); + $service->sync($account, $client, isFirstSync: false, backfillMissingHistory: true); + + $balances = $account->balances()->orderBy('balance_date')->get(); + + expect($balances)->toHaveCount(13); + expect($balances->first()->balance_date->toDateString())->toBe('2025-05-14'); + expect($balances->first()->balance)->toBe(100_000); + expect($balances->get(11)->balance_date->toDateString())->toBe('2026-04-14'); + expect($balances->last()->balance_date->toDateString())->toBe('2026-05-14'); +}); + test('treats usd stablecoins as usd when valuing', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'USD']); $connection = BankingConnection::factory()->coinbase()->create([