756 lines
29 KiB
PHP
756 lines
29 KiB
PHP
<?php
|
|
|
|
use App\Models\Account;
|
|
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;
|
|
use Illuminate\Support\Sleep;
|
|
|
|
afterEach(function () {
|
|
Carbon::setTestNow();
|
|
Sleep::fake(false);
|
|
});
|
|
|
|
function ecPrivateKeyForCoinbase(): string
|
|
{
|
|
$key = openssl_pkey_new([
|
|
'private_key_type' => OPENSSL_KEYTYPE_EC,
|
|
'curve_name' => 'prime256v1',
|
|
]);
|
|
|
|
openssl_pkey_export($key, $pem);
|
|
|
|
return $pem;
|
|
}
|
|
|
|
test('syncs coinbase balance with crypto and fiat wallets', function () {
|
|
$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([
|
|
'api.coinbase.com/api/v3/brokerage/accounts*' => 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' => '500.00', 'currency' => 'EUR'],
|
|
'hold' => ['value' => '0', 'currency' => 'EUR'],
|
|
'active' => true,
|
|
'type' => 'ACCOUNT_TYPE_FIAT',
|
|
],
|
|
],
|
|
'has_next' => false,
|
|
'cursor' => '',
|
|
'size' => 2,
|
|
]),
|
|
'api.coinbase.com/api/v3/brokerage/best_bid_ask*' => Http::response([
|
|
'pricebooks' => [
|
|
[
|
|
'product_id' => 'BTC-EUR',
|
|
'bids' => [['price' => '49900.00', 'size' => '1']],
|
|
'asks' => [['price' => '50100.00', 'size' => '1']],
|
|
],
|
|
],
|
|
]),
|
|
]);
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
$service = app(CoinbaseBalanceSyncService::class);
|
|
$service->sync($account, $client);
|
|
|
|
expect($account->balances()->count())->toBe(1);
|
|
|
|
// 1 BTC * 50000 (mid of 49900/50100) EUR + 500 EUR fiat = 50500 EUR → 5_050_000 cents
|
|
$balance = $account->balances()->first();
|
|
expect($balance->balance)->toBe(5_050_000);
|
|
expect($balance->balance_date->toDateString())->toBe(now()->toDateString());
|
|
});
|
|
|
|
test('prices an asset Coinbase only quotes in USD instead of dropping it from the balance', function () {
|
|
$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, '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')) {
|
|
// Coinbase has no SOL-EUR book, only SOL-USD.
|
|
if (! str_contains($url, 'SOL-USD')) {
|
|
return Http::response(['pricebooks' => []]);
|
|
}
|
|
|
|
return Http::response([
|
|
'pricebooks' => [
|
|
[
|
|
'product_id' => 'SOL-USD',
|
|
'bids' => [['price' => '100.00', 'size' => '1']],
|
|
'asks' => [['price' => '100.00', 'size' => '1']],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
return Http::response([], 404);
|
|
});
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
$service = app(CoinbaseBalanceSyncService::class);
|
|
$service->sync($account, $client);
|
|
|
|
// 2 SOL * 100 USD, at eur.usd = 2.0 → 100 EUR. Without the USD hop the
|
|
// fiat-only converter returns 0 for SOL and the holding vanishes.
|
|
expect($account->balances()->first()->balance)->toBe(10_000);
|
|
});
|
|
|
|
test('mixes fiat-quoted and USD-only assets in one balance', function () {
|
|
$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' => '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' => 2,
|
|
]);
|
|
}
|
|
|
|
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')) {
|
|
// BTC has a EUR book, SOL only a USD one, so the second pass must
|
|
// ask for SOL alone and leave the already-priced BTC untouched.
|
|
if (str_contains($url, 'SOL-USD')) {
|
|
return Http::response([
|
|
'pricebooks' => [
|
|
[
|
|
'product_id' => 'SOL-USD',
|
|
'bids' => [['price' => '100.00', 'size' => '1']],
|
|
'asks' => [['price' => '100.00', 'size' => '1']],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
return Http::response([
|
|
'pricebooks' => [
|
|
[
|
|
'product_id' => 'BTC-EUR',
|
|
'bids' => [['price' => '50000.00', 'size' => '1']],
|
|
'asks' => [['price' => '50000.00', 'size' => '1']],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
return Http::response([], 404);
|
|
});
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
$service = app(CoinbaseBalanceSyncService::class);
|
|
$service->sync($account, $client);
|
|
|
|
// 1 BTC * 50000 EUR + 2 SOL * (100 USD / 2.0) = 50100 EUR.
|
|
expect($account->balances()->first()->balance)->toBe(5_010_000);
|
|
|
|
// The USD pass asks only for what the EUR book missed. An expectation
|
|
// inside the fake would be swallowed by fetchBestBidAskPrices' catch.
|
|
Http::assertSent(fn (Request $request) => str_contains($request->url(), 'best_bid_ask')
|
|
&& str_contains($request->url(), 'SOL-USD')
|
|
&& ! str_contains($request->url(), 'BTC-USD'));
|
|
});
|
|
|
|
test('settles EURC at its euro peg instead of asking Coinbase to quote it', function () {
|
|
$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' => 'EURC',
|
|
'currency' => 'EURC',
|
|
'available_balance' => ['value' => '100.0', 'currency' => 'EURC'],
|
|
'hold' => ['value' => '0', 'currency' => 'EURC'],
|
|
'active' => true,
|
|
'type' => 'ACCOUNT_TYPE_CRYPTO',
|
|
],
|
|
],
|
|
'has_next' => false,
|
|
'cursor' => '',
|
|
'size' => 2,
|
|
]);
|
|
}
|
|
|
|
if (str_contains($url, '/api/v3/brokerage/best_bid_ask')) {
|
|
// Coinbase does not list EURC-EUR; asking for it 400s the batch.
|
|
if (str_contains($url, 'EURC')) {
|
|
return Http::response(['error' => 'invalid product_id provided: "EURC-EUR"'], 400);
|
|
}
|
|
|
|
return Http::response([
|
|
'pricebooks' => [
|
|
[
|
|
'product_id' => 'BTC-EUR',
|
|
'bids' => [['price' => '50000.00', 'size' => '1']],
|
|
'asks' => [['price' => '50000.00', 'size' => '1']],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
return Http::response([], 404);
|
|
});
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
app(CoinbaseBalanceSyncService::class)->sync($account, $client);
|
|
|
|
// 1 BTC * 50000 EUR + 100 EURC at par = 50100 EUR.
|
|
expect($account->balances()->first()->balance)->toBe(5_010_000);
|
|
|
|
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), 'EURC'));
|
|
});
|
|
|
|
test('prices the rest of the portfolio when Coinbase rejects one product id', function () {
|
|
$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' => 'NOPE',
|
|
'currency' => 'NOPE',
|
|
'available_balance' => ['value' => '5.0', 'currency' => 'NOPE'],
|
|
'hold' => ['value' => '0', 'currency' => 'NOPE'],
|
|
'active' => true,
|
|
'type' => 'ACCOUNT_TYPE_CRYPTO',
|
|
],
|
|
],
|
|
'has_next' => false,
|
|
'cursor' => '',
|
|
'size' => 2,
|
|
]);
|
|
}
|
|
|
|
if (str_contains($url, '/api/v3/brokerage/best_bid_ask')) {
|
|
// Anything asking for NOPE is refused, batch or not.
|
|
if (str_contains($url, 'NOPE')) {
|
|
return Http::response(['error' => 'invalid product_id provided: "NOPE-EUR"'], 400);
|
|
}
|
|
|
|
return Http::response([
|
|
'pricebooks' => [
|
|
[
|
|
'product_id' => 'BTC-EUR',
|
|
'bids' => [['price' => '50000.00', 'size' => '1']],
|
|
'asks' => [['price' => '50000.00', 'size' => '1']],
|
|
],
|
|
],
|
|
]);
|
|
}
|
|
|
|
return Http::response([], 404);
|
|
});
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
app(CoinbaseBalanceSyncService::class)->sync($account, $client);
|
|
|
|
// BTC survives the rejected batch: only the unquotable asset is lost.
|
|
expect($account->balances()->first()->balance)->toBe(5_000_000);
|
|
|
|
// The retry asked for BTC on its own rather than giving up on the batch.
|
|
Http::assertSent(fn (Request $request) => str_contains($request->url(), 'best_bid_ask')
|
|
&& str_contains($request->url(), 'BTC-EUR')
|
|
&& ! str_contains($request->url(), 'NOPE'));
|
|
});
|
|
|
|
test('gives up on a rate-limited price batch rather than retrying every asset', function () {
|
|
// CoinbaseClient backs off 10s/30s/60s on a 429; without this the test
|
|
// spends over three minutes sleeping for real.
|
|
Sleep::fake();
|
|
|
|
$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' => 'ETH',
|
|
'currency' => 'ETH',
|
|
'available_balance' => ['value' => '1.0', 'currency' => 'ETH'],
|
|
'hold' => ['value' => '0', 'currency' => 'ETH'],
|
|
'active' => true,
|
|
'type' => 'ACCOUNT_TYPE_CRYPTO',
|
|
],
|
|
],
|
|
'has_next' => false,
|
|
'cursor' => '',
|
|
'size' => 2,
|
|
]);
|
|
}
|
|
|
|
if (str_contains($url, '/api/v3/brokerage/best_bid_ask')) {
|
|
return Http::response(['error' => 'rate limit'], 429);
|
|
}
|
|
|
|
return Http::response([], 404);
|
|
});
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
app(CoinbaseBalanceSyncService::class)->sync($account, $client);
|
|
|
|
// A rate limit is not one bad product id, and CoinbaseClient already burns
|
|
// up to ~100s of backoff per call - fanning out would blow the job timeout.
|
|
Http::assertNotSent(fn (Request $request) => str_contains($request->url(), 'best_bid_ask')
|
|
&& str_contains($request->url(), 'BTC-EUR')
|
|
&& ! str_contains($request->url(), 'ETH-EUR'));
|
|
});
|
|
|
|
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([
|
|
'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' => 'USD',
|
|
]);
|
|
|
|
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);
|
|
|
|
$balance = $account->balances()->first();
|
|
expect($balance->balance)->toBe(100_000); // 1000 USD → 100000 cents
|
|
});
|
|
|
|
test('skips sync when external_account_id is missing', function () {
|
|
$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' => null,
|
|
'currency_code' => 'EUR',
|
|
]);
|
|
|
|
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
|
|
$service = app(CoinbaseBalanceSyncService::class);
|
|
$service->sync($account, $client);
|
|
|
|
expect($account->balances()->count())->toBe(0);
|
|
Http::assertNothingSent();
|
|
});
|