onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['EUR' => '50000.00', 'USD' => '55000.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '1.00000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-1', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [ [ 'type' => 'fiat_wallet', 'attributes' => [ 'fiat_id' => '1', 'fiat_symbol' => 'EUR', 'balance' => '500.00000000', 'name' => 'EUR Wallet', ], 'id' => 'fiat-wallet-uuid-1', ], ], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); // 1 BTC * 50000 EUR + 500 EUR fiat = 50500 EUR → 5050000 cents $balance = $account->balances()->first(); expect($balance->balance)->toBe(5050000); expect($balance->balance_date->toDateString())->toBe(now()->toDateString()); }); test('syncs bitpanda balance with crypto only', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'ETH' => ['EUR' => '2000.00', 'USD' => '2200.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '27', 'cryptocoin_symbol' => 'ETH', 'balance' => '5.00000000', 'is_default' => true, 'name' => 'ETH wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-2', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); // 5 ETH * 2000 EUR = 10000 EUR → 1000000 cents $balance = $account->balances()->first(); expect($balance->balance)->toBe(1000000); }); test('syncs bitpanda balance by converting fiat wallets to newly allowed primary currency', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'ARS']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'ARS', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['ARS' => '70000000.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '0.01000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-1', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [ [ 'type' => 'fiat_wallet', 'attributes' => [ 'fiat_id' => '1', 'fiat_symbol' => 'USD', 'balance' => '500.00000000', 'name' => 'USD Wallet', ], 'id' => 'fiat-wallet-uuid-1', ], ], ]), 'cdn.jsdelivr.net/*currencies/ars*' => Http::response([ 'ars' => [ 'usd' => 0.0007142857, ], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); $balance = $account->balances()->first(); expect($balance->balance)->toBe((int) round((700000 + (500 / 0.0007142857)) * 100)); }); test('syncs bitpanda balance including bitpanda-specific indices', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['EUR' => '56911.68'], 'BCI10' => ['EUR' => '10.40'], 'BCI5' => ['EUR' => '16.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '0.01000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-1', ], [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '100', 'cryptocoin_symbol' => 'BCI10', 'balance' => '0.20000000', 'is_default' => true, 'name' => 'BCI10 wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-2', ], [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '101', 'cryptocoin_symbol' => 'BCI5', 'balance' => '0.02000000', 'is_default' => true, 'name' => 'BCI5 wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-3', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); // 0.01 BTC * 56911.68 = 569.1168 // 0.20 BCI10 * 10.40 = 2.08 // 0.02 BCI5 * 16.00 = 0.32 // Total = 571.5168 EUR → 57152 cents $balance = $account->balances()->first(); expect($balance->balance)->toBe(57152); }); test('updates existing balance for same date', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); $account->balances()->create([ 'balance_date' => now()->toDateString(), 'balance' => 100000, ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['EUR' => '50000.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '1.00000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-1', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); // 1 BTC * 50000 EUR = 5000000 cents expect($account->balances()->first()->balance)->toBe(5000000); }); test('handles empty wallets gracefully', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); expect($account->balances()->first()->balance)->toBe(0); }); test('skips deleted crypto wallets', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['EUR' => '50000.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '1.00000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => true, ], 'id' => 'wallet-uuid-1', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); expect($account->balances()->first()->balance)->toBe(0); }); 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(BitpandaClient::class); $client->shouldNotReceive('getTickerPrices'); $client->shouldNotReceive('getCryptoWallets'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(0); }); test('skips zero balance fiat wallets', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [ [ 'type' => 'fiat_wallet', 'attributes' => [ 'fiat_id' => '1', 'fiat_symbol' => 'EUR', 'balance' => '0.00000000', 'name' => 'EUR Wallet', ], 'id' => 'fiat-wallet-uuid-1', ], [ 'type' => 'fiat_wallet', 'attributes' => [ 'fiat_id' => '1', 'fiat_symbol' => 'EUR', 'balance' => '250.00000000', 'name' => 'EUR Wallet 2', ], 'id' => 'fiat-wallet-uuid-2', ], ], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); // Only the 250 EUR wallet counts (zero balance one is skipped) expect($account->balances()->first()->balance)->toBe(25000); }); test('uses correct currency from ticker when account is USD', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'USD']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'USD', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['EUR' => '50000.00', 'USD' => '55000.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '1.00000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-1', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); expect($account->balances()->count())->toBe(1); // Should use USD price: 1 BTC * 55000 USD = 5500000 cents expect($account->balances()->first()->balance)->toBe(5500000); }); test('calculates invested_amount from fiat deposit and withdrawal history', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([ 'BTC' => ['EUR' => '50000.00'], ]), 'api.bitpanda.com/v1/wallets' => Http::response([ 'data' => [ [ 'type' => 'wallet', 'attributes' => [ 'cryptocoin_id' => '1', 'cryptocoin_symbol' => 'BTC', 'balance' => '0.10000000', 'is_default' => true, 'name' => 'BTC wallet', 'deleted' => false, ], 'id' => 'wallet-uuid-1', ], ], ]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence() ->push([ 'data' => [ [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-1', 'attributes' => [ 'amount' => '1000.00', 'status' => 'finished', 'fiat_id' => 'EUR', ], ], [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-2', 'attributes' => [ 'amount' => '500.00', 'status' => 'finished', 'fiat_id' => 'EUR', ], ], ], 'meta' => ['next_cursor' => null], 'links' => [], ]) ->push([ 'data' => [ [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-3', 'attributes' => [ 'amount' => '200.00', 'status' => 'finished', 'fiat_id' => 'EUR', ], ], ], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response([ 'data' => [], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); $balance = $account->balances()->first(); // Balance: 0.1 BTC * 50000 = 5000 EUR → 500000 cents expect($balance->balance)->toBe(500000); // Invested: deposits(1000 + 500) - withdrawals(200) = 1300 EUR → 130000 cents expect($balance->invested_amount)->toBe(130000); }); test('only counts finished fiat transactions for invested_amount', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([]), 'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence() ->push([ 'data' => [ [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-1', 'attributes' => [ 'amount' => '1000.00', 'status' => 'finished', 'fiat_id' => 'EUR', ], ], [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-2', 'attributes' => [ 'amount' => '500.00', 'status' => 'pending', 'fiat_id' => 'EUR', ], ], ], 'meta' => ['next_cursor' => null], 'links' => [], ]) ->push([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); $balance = $account->balances()->first(); // Only the finished deposit of 1000 should count, pending 500 is ignored expect($balance->invested_amount)->toBe(100000); // 1000 EUR → 100000 cents }); test('converts bitpanda fiat transactions that do not match the target currency for invested_amount', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([]), 'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence() ->push([ 'data' => [ [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-1', 'attributes' => [ 'amount' => '1000.00', 'status' => 'finished', 'fiat_id' => 'EUR', ], ], [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-2', 'attributes' => [ 'amount' => '500.00', 'status' => 'finished', 'fiat_id' => 'USD', ], ], [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-3', 'attributes' => [ 'amount' => '250.00', 'status' => 'finished', 'fiat_id' => 'EUR', ], ], ], 'meta' => ['next_cursor' => null], 'links' => [], ]) ->push([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]), 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([ 'eur' => [ 'usd' => 1.10, ], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); $balance = $account->balances()->first(); // 1000 EUR + 250 EUR + (500 USD / 1.10) = 1704.55 EUR → 170455 cents expect($balance->invested_amount)->toBe(170455); }); test('converts bitpanda fiat transactions to newly allowed primary currency for invested amount', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'ARS']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'ARS', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([]), 'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::sequence() ->push([ 'data' => [ [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-1', 'attributes' => [ 'amount' => '1000.00', 'status' => 'finished', 'fiat_id' => 'USD', ], ], [ 'type' => 'fiat_wallet_transaction', 'id' => 'tx-2', 'attributes' => [ 'amount' => '250.00', 'status' => 'finished', 'fiat_id' => 'ARS', ], ], ], 'meta' => ['next_cursor' => null], 'links' => [], ]) ->push([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]), 'cdn.jsdelivr.net/*currencies/ars*' => Http::response([ 'ars' => [ 'usd' => 0.0007142857, ], ]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); $balance = $account->balances()->first(); expect($balance->invested_amount)->toBe((int) round(((1000 / 0.0007142857) + 250) * 100)); }); test('returns null invested_amount when no fiat transactions exist', function () { $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']); $connection = BankingConnection::factory()->bitpanda()->create([ 'user_id' => $user->id, ]); $account = Account::factory()->connected()->create([ 'user_id' => $user->id, 'banking_connection_id' => $connection->id, 'external_account_id' => 'bitpanda-portfolio', 'currency_code' => 'EUR', ]); Http::fake([ 'api.bitpanda.com/v1/ticker' => Http::response([]), 'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]), 'api.bitpanda.com/v1/fiatwallets/transactions*' => Http::response([ 'data' => [], 'meta' => ['next_cursor' => null], 'links' => [], ]), 'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]), ]); $client = new BitpandaClient('test-key'); $service = app(BitpandaBalanceSyncService::class); $service->sync($account, $client); $balance = $account->balances()->first(); expect($balance->invested_amount)->toBeNull(); });