From df9fc385623a1ace173d4fbbc6e9a79ed93dc5ed Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?=
Date: Wed, 18 Feb 2026 15:23:46 +0100
Subject: [PATCH] feat: Add Binance integration (#131)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
## Summary
- Adds Binance as a third banking provider alongside EnableBanking and
Indexa Capital
- Binance appears in the institution list for **all countries**, with
the full list sorted alphabetically
- Creates a single "Crypto Portfolio" investment account with total
portfolio value converted to the user's preferred currency
- Supports direct fiat pairs (e.g. BTCEUR), USD stablecoin 1:1 mapping,
and USDT fallback conversion
## Changes
- **Migration**: adds encrypted `api_secret` column to
`banking_connections`
- **BinanceClient**: HMAC-SHA256 authenticated API client for account
data and ticker prices
- **BinanceBalanceSyncService**: converts all non-zero balances to fiat
via direct pairs or USDT fallback
- **BinanceController + ConnectBinanceRequest**: validates credentials,
creates connection and single account
- **SyncBankingConnectionJob**: new `syncBinance()` branch
- **AccountMappingController**: Binance uses Investment account type
- **Frontend**: Binance institution for all countries, API Key + Secret
form fields, alphabetically sorted list
- **Factory**: `binance()` state on `BankingConnectionFactory`
## Test plan
- [x] `BinanceControllerTest` — 6 tests (valid connection, invalid
credentials, account-mapping flag, feature flag, validation, user
currency)
- [x] `BinanceBalanceSyncTest` — 7 tests (direct EUR pair, USDT
fallback, USD stablecoins, locked balances, same-date update, empty
balances, missing external ID)
- [x] Full test suite passes (545 tests)
- [x] Manual: open connection dialog → select any country → Binance
appears alphabetically → select Binance → API Key + Secret form →
connect
---
.../OpenBanking/AccountMappingController.php | 2 +-
.../OpenBanking/BinanceController.php | 93 ++++
.../OpenBanking/ConnectionController.php | 7 +-
.../OpenBanking/ConnectBinanceRequest.php | 26 ++
app/Jobs/SyncBankingConnectionJob.php | 41 +-
app/Jobs/SyncBinanceHistoricalBalancesJob.php | 56 +++
app/Models/BankingConnection.php | 7 +
.../Banking/BinanceBalanceSyncService.php | 285 ++++++++++++
app/Services/Banking/BinanceClient.php | 107 +++++
app/Services/CurrencyConversionService.php | 97 ++++
composer.json | 4 +-
.../factories/BankingConnectionFactory.php | 15 +
...pi_secret_to_banking_connections_table.php | 28 ++
.../open-banking/connect-account-dialog.tsx | 142 +++++-
resources/js/pages/settings/connections.tsx | 64 ++-
routes/web.php | 2 +
.../Feature/CurrencyConversionServiceTest.php | 107 +++++
.../OpenBanking/BinanceBalanceSyncTest.php | 422 ++++++++++++++++++
.../OpenBanking/BinanceControllerTest.php | 175 ++++++++
.../SyncBankingConnectionJobTest.php | 79 ++++
.../SyncBinanceHistoricalBalancesJobTest.php | 102 +++++
21 files changed, 1826 insertions(+), 35 deletions(-)
create mode 100644 app/Http/Controllers/OpenBanking/BinanceController.php
create mode 100644 app/Http/Requests/OpenBanking/ConnectBinanceRequest.php
create mode 100644 app/Jobs/SyncBinanceHistoricalBalancesJob.php
create mode 100644 app/Services/Banking/BinanceBalanceSyncService.php
create mode 100644 app/Services/Banking/BinanceClient.php
create mode 100644 app/Services/CurrencyConversionService.php
create mode 100644 database/migrations/2026_02_18_110556_add_api_secret_to_banking_connections_table.php
create mode 100644 tests/Feature/CurrencyConversionServiceTest.php
create mode 100644 tests/Feature/OpenBanking/BinanceBalanceSyncTest.php
create mode 100644 tests/Feature/OpenBanking/BinanceControllerTest.php
create mode 100644 tests/Feature/OpenBanking/SyncBinanceHistoricalBalancesJobTest.php
diff --git a/app/Http/Controllers/OpenBanking/AccountMappingController.php b/app/Http/Controllers/OpenBanking/AccountMappingController.php
index 6fda0a92..a45c3ea7 100644
--- a/app/Http/Controllers/OpenBanking/AccountMappingController.php
+++ b/app/Http/Controllers/OpenBanking/AccountMappingController.php
@@ -59,7 +59,7 @@ class AccountMappingController extends Controller
$pendingAccounts = collect($connection->pending_accounts_data)
->keyBy('uid');
- $accountType = $connection->isIndexaCapital()
+ $accountType = ($connection->isIndexaCapital() || $connection->isBinance())
? AccountType::Investment
: AccountType::Checking;
diff --git a/app/Http/Controllers/OpenBanking/BinanceController.php b/app/Http/Controllers/OpenBanking/BinanceController.php
new file mode 100644
index 00000000..edec476a
--- /dev/null
+++ b/app/Http/Controllers/OpenBanking/BinanceController.php
@@ -0,0 +1,93 @@
+validated();
+ $user = auth()->user();
+
+ $client = new BinanceClient($validated['api_key'], $validated['api_secret']);
+
+ try {
+ $client->getAccount();
+ } catch (\Throwable $e) {
+ Log::warning('Binance credential validation failed', ['error' => $e->getMessage()]);
+
+ return response()->json([
+ 'message' => 'Invalid API credentials or failed to connect to Binance.',
+ ], 422);
+ }
+
+ $bank = Bank::firstOrCreate(
+ ['name' => 'Binance', 'user_id' => null],
+ ['name' => 'Binance', 'logo' => 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png'],
+ );
+
+ $connection = $user->bankingConnections()->create([
+ 'provider' => 'binance',
+ 'api_token' => $validated['api_key'],
+ 'api_secret' => $validated['api_secret'],
+ 'aspsp_name' => 'Binance',
+ 'aspsp_country' => $validated['country'],
+ 'aspsp_logo' => $bank->logo,
+ 'status' => BankingConnectionStatus::Pending,
+ ]);
+
+ $pendingAccounts = [
+ [
+ 'uid' => 'binance-portfolio',
+ 'currency' => $user->currency_code,
+ 'name' => 'Crypto Portfolio',
+ ],
+ ];
+
+ if (Feature::for($user)->active('account-mapping')) {
+ $connection->update([
+ 'status' => BankingConnectionStatus::AwaitingMapping,
+ 'pending_accounts_data' => $pendingAccounts,
+ ]);
+
+ return response()->json([
+ 'redirect_url' => route('open-banking.map-accounts', $connection),
+ 'connection_id' => $connection->id,
+ ]);
+ }
+
+ $connection->update(['status' => BankingConnectionStatus::Active]);
+
+ $user->accounts()->create([
+ 'name' => 'Crypto Portfolio',
+ 'name_iv' => null,
+ 'encrypted' => false,
+ 'bank_id' => $bank->id,
+ 'currency_code' => $user->currency_code,
+ 'type' => AccountType::Investment->value,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ ]);
+
+ SyncBankingConnectionJob::dispatch($connection);
+
+ return response()->json([
+ 'redirect_url' => route('settings.connections.index'),
+ 'connection_id' => $connection->id,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php
index ac82a462..41be2bbb 100644
--- a/app/Http/Controllers/OpenBanking/ConnectionController.php
+++ b/app/Http/Controllers/OpenBanking/ConnectionController.php
@@ -46,10 +46,15 @@ class ConnectionController extends Controller
abort(403);
}
- if (! $connection->isActive()) {
+ if (! $connection->isActive() && $connection->status !== BankingConnectionStatus::Error) {
return back()->with('error', 'Connection is not active.');
}
+ $connection->update([
+ 'status' => BankingConnectionStatus::Active,
+ 'error_message' => null,
+ ]);
+
SyncBankingConnectionJob::dispatch($connection);
return back()->with('success', 'Sync started. Transactions will be updated shortly.');
diff --git a/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php b/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php
new file mode 100644
index 00000000..0f32ea83
--- /dev/null
+++ b/app/Http/Requests/OpenBanking/ConnectBinanceRequest.php
@@ -0,0 +1,26 @@
+user())->active('open-banking');
+ }
+
+ /**
+ * @return array>
+ */
+ public function rules(): array
+ {
+ return [
+ 'api_key' => ['required', 'string', 'min:10'],
+ 'api_secret' => ['required', 'string', 'min:10'],
+ 'country' => ['required', 'string', 'size:2'],
+ ];
+ }
+}
diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php
index 793c5f91..9756f734 100644
--- a/app/Jobs/SyncBankingConnectionJob.php
+++ b/app/Jobs/SyncBankingConnectionJob.php
@@ -6,6 +6,8 @@ use App\Enums\BankingConnectionStatus;
use App\Mail\BankTransactionsSyncedEmail;
use App\Models\BankingConnection;
use App\Services\Banking\BalanceSyncService;
+use App\Services\Banking\BinanceBalanceSyncService;
+use App\Services\Banking\BinanceClient;
use App\Services\Banking\IndexaCapitalBalanceSyncService;
use App\Services\Banking\IndexaCapitalClient;
use App\Services\Banking\TransactionSyncService;
@@ -13,6 +15,7 @@ use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
+use Illuminate\Http\Client\RequestException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
@@ -53,6 +56,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
try {
if ($connection->isIndexaCapital()) {
$this->syncIndexaCapital($connection);
+ } elseif ($connection->isBinance()) {
+ $this->syncBinance($connection);
} else {
$this->syncEnableBanking($connection, $transactionSync, $balanceSync);
}
@@ -69,7 +74,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
$connection->update([
'status' => BankingConnectionStatus::Error,
- 'error_message' => $e->getMessage(),
+ 'error_message' => $this->friendlyErrorMessage($e),
]);
throw $e;
@@ -88,6 +93,24 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
}
}
+ private function syncBinance(BankingConnection $connection): void
+ {
+ $isFirstSync = ! $connection->last_synced_at;
+ $client = new BinanceClient($connection->api_token, $connection->api_secret);
+ $syncService = app(BinanceBalanceSyncService::class);
+
+ $connection->load('accounts');
+
+ foreach ($connection->accounts as $account) {
+ if ($isFirstSync) {
+ $syncService->syncCurrentBalance($account, $client);
+ SyncBinanceHistoricalBalancesJob::dispatch($account)->delay(now()->addSeconds(30));
+ } else {
+ $syncService->sync($account, $client, isFirstSync: false);
+ }
+ }
+ }
+
private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void
{
$isFirstSync = ! $connection->last_synced_at;
@@ -138,4 +161,20 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
));
}
}
+
+ private function friendlyErrorMessage(\Throwable $e): string
+ {
+ if ($e instanceof RequestException) {
+ $status = $e->response->status();
+
+ return match (true) {
+ $status === 429 => __('Rate limit exceeded. Please wait a few minutes and try again.'),
+ $status === 401 || $status === 403 => __('Authentication failed. Your credentials may have expired or been revoked.'),
+ $status >= 500 => __('The provider is experiencing issues. Please try again later.'),
+ default => __('Failed to sync with the provider. Please try again later.'),
+ };
+ }
+
+ return __('An unexpected error occurred during sync. Please try again later.');
+ }
}
diff --git a/app/Jobs/SyncBinanceHistoricalBalancesJob.php b/app/Jobs/SyncBinanceHistoricalBalancesJob.php
new file mode 100644
index 00000000..eb9ac405
--- /dev/null
+++ b/app/Jobs/SyncBinanceHistoricalBalancesJob.php
@@ -0,0 +1,56 @@
+ */
+ public array $backoff = [30, 120, 300];
+
+ public int $timeout = 600;
+
+ public function __construct(
+ public Account $account,
+ ) {}
+
+ public function uniqueId(): string
+ {
+ return 'binance-historical-'.$this->account->id;
+ }
+
+ public function handle(BinanceBalanceSyncService $syncService): void
+ {
+ $connection = $this->account->bankingConnection;
+
+ if (! $connection || ! $connection->isBinance()) {
+ return;
+ }
+
+ $client = new BinanceClient($connection->api_token, $connection->api_secret);
+
+ $syncService->syncHistoricalBalances($this->account, $client, isFirstSync: true);
+ }
+
+ public function failed(?\Throwable $exception): void
+ {
+ Log::error('Binance historical balance sync failed', [
+ 'account_id' => $this->account->id,
+ 'error' => $exception?->getMessage(),
+ ]);
+ }
+}
diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php
index 0f6e6d35..89350220 100644
--- a/app/Models/BankingConnection.php
+++ b/app/Models/BankingConnection.php
@@ -29,6 +29,7 @@ class BankingConnection extends Model
'error_message',
'pending_accounts_data',
'api_token',
+ 'api_secret',
];
protected function casts(): array
@@ -39,6 +40,7 @@ class BankingConnection extends Model
'last_synced_at' => 'datetime',
'pending_accounts_data' => 'array',
'api_token' => 'encrypted',
+ 'api_secret' => 'encrypted',
];
}
@@ -62,6 +64,11 @@ class BankingConnection extends Model
return $this->provider === 'indexacapital';
}
+ public function isBinance(): bool
+ {
+ return $this->provider === 'binance';
+ }
+
public function isEnableBanking(): bool
{
return $this->provider === 'enablebanking';
diff --git a/app/Services/Banking/BinanceBalanceSyncService.php b/app/Services/Banking/BinanceBalanceSyncService.php
new file mode 100644
index 00000000..19dbc40a
--- /dev/null
+++ b/app/Services/Banking/BinanceBalanceSyncService.php
@@ -0,0 +1,285 @@
+ Maps fiat currency codes to Binance quote assets */
+ private const FIAT_QUOTE_MAP = [
+ 'USD' => 'USDT',
+ 'EUR' => 'EUR',
+ 'GBP' => 'GBP',
+ 'JPY' => 'JPY',
+ 'AUD' => 'AUD',
+ 'BRL' => 'BRL',
+ 'TRY' => 'TRY',
+ ];
+
+ /** @var array Stablecoins pegged 1:1 to USD */
+ private const USD_STABLECOINS = ['USDT', 'USDC', 'BUSD', 'FDUSD', 'TUSD'];
+
+ private const SNAPSHOT_MAX_DAYS = 180;
+
+ private const SNAPSHOT_WINDOW_DAYS = 30;
+
+ /** Seconds to wait between API calls to avoid hitting Binance rate limits */
+ private const THROTTLE_SECONDS = 1;
+
+ public function __construct(private CurrencyConversionService $currencyConverter) {}
+
+ /**
+ * Sync the total portfolio value for a Binance account.
+ * On first sync, fetches up to 180 days of historical snapshots.
+ * On subsequent syncs, fetches snapshots since the last recorded balance.
+ */
+ public function sync(Account $account, BinanceClient $client, bool $isFirstSync = false): void
+ {
+ if (! $account->external_account_id) {
+ return;
+ }
+
+ $hadHistoricalSync = $this->syncHistoricalBalances($account, $client, $isFirstSync);
+
+ if ($hadHistoricalSync) {
+ Sleep::for(self::THROTTLE_SECONDS)->seconds();
+ }
+
+ $this->syncCurrentBalance($account, $client);
+ }
+
+ /**
+ * Sync today's balance using live ticker prices.
+ */
+ public function syncCurrentBalance(Account $account, BinanceClient $client): void
+ {
+ $accountData = $client->getAccount();
+ $balances = $accountData['balances'] ?? [];
+
+ if (empty($balances)) {
+ return;
+ }
+
+ $tickerPrices = $client->getTickerPrices();
+ $priceMap = $this->buildPriceMap($tickerPrices);
+
+ $targetCurrency = strtoupper($account->currency_code);
+ $totalValueCents = $this->calculateTotalValue($balances, $priceMap, $targetCurrency);
+
+ $account->balances()->updateOrCreate(
+ ['balance_date' => now()->toDateString()],
+ ['balance' => $totalValueCents],
+ );
+ }
+
+ /**
+ * Fetch historical snapshots and convert each day's balances using the currency conversion API.
+ *
+ * @return bool Whether any API calls were made
+ */
+ public function syncHistoricalBalances(Account $account, BinanceClient $client, bool $isFirstSync): bool
+ {
+ $targetCurrency = strtoupper($account->currency_code);
+
+ $endDate = now()->subDay();
+ $startDate = $isFirstSync
+ ? now()->subDays(self::SNAPSHOT_MAX_DAYS)
+ : ($account->balances()->max('balance_date')
+ ? Carbon::parse($account->balances()->max('balance_date'))->addDay()
+ : now()->subDays(self::SNAPSHOT_MAX_DAYS));
+
+ if ($startDate->greaterThanOrEqualTo($endDate)) {
+ return false;
+ }
+
+ $snapshots = $this->fetchAllSnapshots($client, $startDate, $endDate);
+
+ if (empty($snapshots)) {
+ return true;
+ }
+
+ $count = 0;
+ $skippedAssets = [];
+
+ foreach ($snapshots as $snapshot) {
+ $updateTime = $snapshot['updateTime'] ?? null;
+ $balances = $snapshot['data']['balances'] ?? [];
+
+ if ($updateTime === null || empty($balances)) {
+ continue;
+ }
+
+ $date = Carbon::createFromTimestampMs($updateTime)->toDateString();
+ $totalValue = 0.0;
+
+ foreach ($balances as $balance) {
+ $asset = $balance['asset'];
+ $quantity = (float) ($balance['free'] ?? 0) + (float) ($balance['locked'] ?? 0);
+
+ if ($quantity <= 0 || isset($skippedAssets[$asset])) {
+ continue;
+ }
+
+ $converted = $this->currencyConverter->convert($asset, $targetCurrency, $quantity, $date);
+
+ if ($converted == 0.0) {
+ $skippedAssets[$asset] = true;
+
+ continue;
+ }
+
+ $totalValue += $converted;
+ }
+
+ $account->balances()->updateOrCreate(
+ ['balance_date' => $date],
+ ['balance' => (int) round($totalValue * 100)],
+ );
+
+ $count++;
+ }
+
+ Log::info('Synced Binance historical balances', [
+ 'account_id' => $account->id,
+ 'days_synced' => $count,
+ 'currency' => $targetCurrency,
+ ...($skippedAssets ? ['skipped_assets' => array_keys($skippedAssets)] : []),
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Fetch snapshots across multiple 30-day windows.
+ *
+ * @return array
+ */
+ private function fetchAllSnapshots(BinanceClient $client, Carbon $startDate, Carbon $endDate): array
+ {
+ $snapshots = [];
+ $windowStart = $startDate->copy();
+ $isFirst = true;
+
+ while ($windowStart->lessThan($endDate)) {
+ if (! $isFirst) {
+ Sleep::for(self::THROTTLE_SECONDS)->seconds();
+ }
+ $isFirst = false;
+
+ $windowEnd = $windowStart->copy()->addDays(self::SNAPSHOT_WINDOW_DAYS)->min($endDate);
+
+ $response = $client->getAccountSnapshots(
+ $windowStart->getTimestampMs(),
+ $windowEnd->endOfDay()->getTimestampMs(),
+ self::SNAPSHOT_WINDOW_DAYS,
+ );
+
+ foreach ($response['snapshotVos'] ?? [] as $snapshot) {
+ $snapshots[] = $snapshot;
+ }
+
+ $windowStart = $windowEnd->copy()->addDay()->startOfDay();
+ }
+
+ return $snapshots;
+ }
+
+ /**
+ * Build a lookup map of symbol => price from ticker data.
+ *
+ * @param array $tickerPrices
+ * @return array
+ */
+ private function buildPriceMap(array $tickerPrices): array
+ {
+ $map = [];
+ foreach ($tickerPrices as $ticker) {
+ $map[$ticker['symbol']] = (float) $ticker['price'];
+ }
+
+ return $map;
+ }
+
+ /**
+ * Calculate the total portfolio value in the target fiat currency (in cents).
+ *
+ * @param array $balances
+ * @param array $priceMap
+ */
+ private function calculateTotalValue(array $balances, array $priceMap, string $targetCurrency): int
+ {
+ $quoteAsset = self::FIAT_QUOTE_MAP[$targetCurrency] ?? 'USDT';
+ $totalValue = 0.0;
+
+ foreach ($balances as $balance) {
+ $asset = $balance['asset'];
+ $quantity = (float) $balance['free'] + (float) $balance['locked'];
+
+ if ($quantity <= 0) {
+ continue;
+ }
+
+ $value = $this->convertAssetToFiat($asset, $quantity, $priceMap, $targetCurrency, $quoteAsset);
+ $totalValue += $value;
+ }
+
+ return (int) round($totalValue * 100);
+ }
+
+ /**
+ * Convert a single asset's quantity to fiat value.
+ */
+ private function convertAssetToFiat(
+ string $asset,
+ float $quantity,
+ array $priceMap,
+ string $targetCurrency,
+ string $quoteAsset,
+ ): float {
+ // Asset IS the target currency (e.g., EUR balance when target is EUR)
+ if ($asset === $targetCurrency) {
+ return $quantity;
+ }
+
+ // USD stablecoins when target is USD → 1:1
+ if ($targetCurrency === 'USD' && in_array($asset, self::USD_STABLECOINS, true)) {
+ return $quantity;
+ }
+
+ // Direct pair exists (e.g., BTCEUR when target is EUR)
+ $directPair = $asset.$quoteAsset;
+ if (isset($priceMap[$directPair])) {
+ return $quantity * $priceMap[$directPair];
+ }
+
+ // Fallback: convert via USDT (e.g., BTCUSDT * quantity / EURUSDT)
+ $usdtPair = $asset.'USDT';
+ $fiatUsdtPair = $quoteAsset.'USDT';
+
+ if (isset($priceMap[$usdtPair])) {
+ $valueInUsdt = $quantity * $priceMap[$usdtPair];
+
+ // If target is already USD/USDT, no further conversion needed
+ if ($quoteAsset === 'USDT') {
+ return $valueInUsdt;
+ }
+
+ // Convert USDT to target fiat
+ if (isset($priceMap[$fiatUsdtPair]) && $priceMap[$fiatUsdtPair] > 0) {
+ return $valueInUsdt / $priceMap[$fiatUsdtPair];
+ }
+ }
+
+ Log::warning('Could not convert Binance asset to fiat', [
+ 'asset' => $asset,
+ 'target_currency' => $targetCurrency,
+ ]);
+
+ return 0.0;
+ }
+}
diff --git a/app/Services/Banking/BinanceClient.php b/app/Services/Banking/BinanceClient.php
new file mode 100644
index 00000000..189637f4
--- /dev/null
+++ b/app/Services/Banking/BinanceClient.php
@@ -0,0 +1,107 @@
+ Retry backoff: 10s, 30s, 60s */
+ private const RETRY_BACKOFF_MS = [10_000, 30_000, 60_000];
+
+ public function __construct(
+ private string $apiKey,
+ private string $apiSecret,
+ ) {}
+
+ /**
+ * Get account information including balances, omitting zero-balance assets.
+ *
+ * @return array{balances: array}
+ */
+ public function getAccount(): array
+ {
+ return $this->signedRequest('/api/v3/account', ['omitZeroBalances' => 'true']);
+ }
+
+ /**
+ * Get all ticker prices for trading pairs.
+ *
+ * @return array
+ */
+ public function getTickerPrices(): array
+ {
+ $response = $this->publicClient()->get('/api/v3/ticker/price');
+
+ $response->throw();
+
+ return $response->json();
+ }
+
+ /**
+ * Get daily account snapshots (up to 30 per request, max 180 days history).
+ *
+ * @return array{snapshotVos: array, totalAssetOfBtc: string}}>}
+ */
+ public function getAccountSnapshots(int $startTime, int $endTime, int $limit = 30): array
+ {
+ return $this->signedRequest('/sapi/v1/accountSnapshot', [
+ 'type' => 'SPOT',
+ 'startTime' => $startTime,
+ 'endTime' => $endTime,
+ 'limit' => $limit,
+ ]);
+ }
+
+ /**
+ * Execute a signed request with fresh timestamp on each retry attempt.
+ */
+ private function signedRequest(string $path, array $params = []): array
+ {
+ return retry(
+ self::RETRY_BACKOFF_MS,
+ function () use ($path, $params) {
+ $params['timestamp'] = (int) (microtime(true) * 1000);
+ $queryString = http_build_query($params);
+ $signature = hash_hmac('sha256', $queryString, $this->apiSecret);
+
+ $response = $this->authenticatedClient()
+ ->get("{$path}?{$queryString}&signature={$signature}");
+
+ $response->throw();
+
+ return $response->json();
+ },
+ when: fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429,
+ );
+ }
+
+ private function authenticatedClient(): PendingRequest
+ {
+ return Http::baseUrl(self::BASE_URL)
+ ->withHeaders(['X-MBX-APIKEY' => $this->apiKey])
+ ->acceptJson()
+ ->throw(function ($response, $exception) {
+ Log::error('Binance API error', [
+ 'status' => $response->status(),
+ 'body' => $response->json(),
+ ]);
+ });
+ }
+
+ private function publicClient(): PendingRequest
+ {
+ return Http::baseUrl(self::BASE_URL)
+ ->acceptJson()
+ ->retry(
+ self::RETRY_BACKOFF_MS,
+ fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429,
+ );
+ }
+}
diff --git a/app/Services/CurrencyConversionService.php b/app/Services/CurrencyConversionService.php
new file mode 100644
index 00000000..c50d7d50
--- /dev/null
+++ b/app/Services/CurrencyConversionService.php
@@ -0,0 +1,97 @@
+> Keyed by "{currency}:{date}" */
+ private array $rateCache = [];
+
+ /**
+ * Convert a quantity from one currency to another on a given date.
+ *
+ * @param string $source Source currency code (e.g., "btc", "eth", "usd")
+ * @param string $target Target currency code (e.g., "eur", "usd")
+ * @param float $quantity Amount to convert
+ * @param string $date Date string (YYYY-MM-DD) or "latest"
+ */
+ public function convert(string $source, string $target, float $quantity, string $date = 'latest'): float
+ {
+ $source = strtolower($source);
+ $target = strtolower($target);
+
+ if ($source === $target) {
+ return $quantity;
+ }
+
+ $rates = $this->getRatesForCurrency($target, $date);
+
+ if (! isset($rates[$source]) || $rates[$source] == 0) {
+ Log::debug('Currency rate not found', [
+ 'source' => $source,
+ 'target' => $target,
+ 'date' => $date,
+ ]);
+
+ return 0.0;
+ }
+
+ return $quantity / $rates[$source];
+ }
+
+ /**
+ * Fetch rates for a target currency, using in-memory cache.
+ *
+ * @return array Map of currency code => rate (e.g., "btc" => 0.000015)
+ */
+ private function getRatesForCurrency(string $currency, string $date): array
+ {
+ $cacheKey = "{$currency}:{$date}";
+
+ if (isset($this->rateCache[$cacheKey])) {
+ return $this->rateCache[$cacheKey];
+ }
+
+ $rates = $this->fetchRates($currency, $date);
+ $this->rateCache[$cacheKey] = $rates;
+
+ return $rates;
+ }
+
+ /**
+ * Fetch rates from CDN with fallback.
+ *
+ * @return array
+ */
+ private function fetchRates(string $currency, string $date): array
+ {
+ $primaryUrl = self::PRIMARY_URL."{$date}/v1/currencies/{$currency}.min.json";
+ $fallbackUrl = self::FALLBACK_URL."{$date}/currencies/{$currency}.min.json";
+
+ try {
+ $response = Http::timeout(10)->get($primaryUrl);
+ $response->throw();
+
+ return $response->json($currency) ?? [];
+ } catch (\Throwable) {
+ // Primary failed, try fallback
+ }
+
+ try {
+ $response = Http::timeout(10)->get($fallbackUrl);
+ $response->throw();
+
+ return $response->json($currency) ?? [];
+ } catch (\Throwable $e) {
+ throw new RuntimeException("Failed to fetch currency rates for {$currency} on {$date}: {$e->getMessage()}", 0, $e);
+ }
+ }
+}
diff --git a/composer.json b/composer.json
index 2f3ceefe..9a917f11 100644
--- a/composer.json
+++ b/composer.json
@@ -57,12 +57,12 @@
],
"dev": [
"Composer\\Config::disableProcessTimeout",
- "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,emails\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://whisper.money.local/stripe/webhook\" --names=server,queue,logs,vite --kill-others"
+ "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,emails\" \"php artisan pail --timeout=0\" \"bun run dev\" \"stripe listen --forward-to https://whisper.money.local/stripe/webhook\" --names=server,queue,logs,vite,stripe"
],
"dev:ssr": [
"bun run build:ssr",
"Composer\\Config::disableProcessTimeout",
- "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,emails\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr --runtime=bun\" --names=server,queue,logs,ssr --kill-others"
+ "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74,#2dd4bf\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --queue=default,emails\" \"php artisan pail --timeout=0\" \"php artisan inertia:start-ssr --runtime=bun\" --names=server,queue,logs,ssr,stripe --kill-others"
],
"test": [
"@php artisan config:clear --ansi",
diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php
index e33f4fea..ae3fc81c 100644
--- a/database/factories/BankingConnectionFactory.php
+++ b/database/factories/BankingConnectionFactory.php
@@ -86,6 +86,21 @@ class BankingConnectionFactory extends Factory
]);
}
+ public function binance(): static
+ {
+ return $this->state(fn (array $attributes) => [
+ 'provider' => 'binance',
+ 'authorization_id' => null,
+ 'session_id' => null,
+ 'api_token' => 'test-binance-api-key-'.fake()->uuid(),
+ 'api_secret' => 'test-binance-api-secret-'.fake()->uuid(),
+ 'aspsp_name' => 'Binance',
+ 'aspsp_country' => 'ES',
+ 'aspsp_logo' => 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png',
+ 'valid_until' => null,
+ ]);
+ }
+
public function error(): static
{
return $this->state(fn (array $attributes) => [
diff --git a/database/migrations/2026_02_18_110556_add_api_secret_to_banking_connections_table.php b/database/migrations/2026_02_18_110556_add_api_secret_to_banking_connections_table.php
new file mode 100644
index 00000000..62148eef
--- /dev/null
+++ b/database/migrations/2026_02_18_110556_add_api_secret_to_banking_connections_table.php
@@ -0,0 +1,28 @@
+text('api_secret')->nullable()->after('api_token');
+ });
+ }
+
+ /**
+ * Reverse the migrations.
+ */
+ public function down(): void
+ {
+ Schema::table('banking_connections', function (Blueprint $table) {
+ $table->dropColumn('api_secret');
+ });
+ }
+};
diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx
index d4307714..1b0fa1e8 100644
--- a/resources/js/components/open-banking/connect-account-dialog.tsx
+++ b/resources/js/components/open-banking/connect-account-dialog.tsx
@@ -48,6 +48,13 @@ const INDEXA_CAPITAL_INSTITUTION: EnableBankingInstitution = {
maximum_consent_validity: null,
};
+const BINANCE_INSTITUTION: EnableBankingInstitution = {
+ name: 'Binance',
+ country: 'ALL',
+ logo: 'https://whisper.money/storage/banks/logos/t1h5rqi19dJTPl6ZadziPjNwm0lrcdTFBRzB3iCy.png',
+ maximum_consent_validity: null,
+};
+
interface ConnectAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@@ -83,12 +90,19 @@ export function ConnectAccountDialog({
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState(null);
const [apiToken, setApiToken] = useState('');
+ const [apiKey, setApiKey] = useState('');
+ const [apiSecret, setApiSecret] = useState('');
const isIndexaCapital = useMemo(
() => selectedBank?.name === 'Indexa Capital',
[selectedBank],
);
+ const isBinance = useMemo(
+ () => selectedBank?.name === 'Binance',
+ [selectedBank],
+ );
+
const resetState = useCallback(() => {
setStep('country');
setCountry('');
@@ -100,6 +114,8 @@ export function ConnectAccountDialog({
setIsSubmitting(false);
setError(null);
setApiToken('');
+ setApiKey('');
+ setApiSecret('');
}, []);
useEffect(() => {
@@ -141,10 +157,14 @@ export function ConnectAccountDialog({
const data = await response.json();
- const allInstitutions =
- countryCode === 'ES'
- ? [INDEXA_CAPITAL_INSTITUTION, ...data]
- : data;
+ const extraInstitutions = [BINANCE_INSTITUTION];
+ if (countryCode === 'ES') {
+ extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION);
+ }
+
+ const allInstitutions = [...extraInstitutions, ...data].sort(
+ (a, b) => a.name.localeCompare(b.name),
+ );
setInstitutions(allInstitutions);
setFilteredInstitutions(allInstitutions);
@@ -163,17 +183,21 @@ export function ConnectAccountDialog({
setError(null);
try {
- const url = isIndexaCapital
- ? '/open-banking/indexa-capital/connect'
- : '/open-banking/authorize';
+ const url = isBinance
+ ? '/open-banking/binance/connect'
+ : isIndexaCapital
+ ? '/open-banking/indexa-capital/connect'
+ : '/open-banking/authorize';
- const body = isIndexaCapital
- ? { api_token: apiToken }
- : {
- aspsp_name: selectedBank.name,
- country: country,
- logo: selectedBank.logo,
- };
+ const body = isBinance
+ ? { api_key: apiKey, api_secret: apiSecret, country: country }
+ : isIndexaCapital
+ ? { api_token: apiToken }
+ : {
+ aspsp_name: selectedBank.name,
+ country: country,
+ logo: selectedBank.logo,
+ };
const response = await fetch(url, {
method: 'POST',
@@ -217,6 +241,7 @@ export function ConnectAccountDialog({
{step === 'bank' && __('Select your bank.')}
{step === 'confirm' &&
!isIndexaCapital &&
+ !isBinance &&
__(
'You will be redirected to your bank to authorize access.',
)}
@@ -225,6 +250,11 @@ export function ConnectAccountDialog({
__(
'Enter your API token to connect your Indexa Capital account.',
)}
+ {step === 'confirm' &&
+ isBinance &&
+ __(
+ 'Enter your API Key and Secret to connect your Binance account.',
+ )}
@@ -331,13 +361,17 @@ export function ConnectAccountDialog({
{selectedBank.name}
- {isIndexaCapital
+ {isBinance
? __(
- 'Connect your Indexa Capital account using your API token.',
+ 'Connect your Binance account using your API Key and Secret.',
)
- : __(
- 'You will be redirected to authorize access to your account data.',
- )}
+ : isIndexaCapital
+ ? __(
+ 'Connect your Indexa Capital account using your API token.',
+ )
+ : __(
+ 'You will be redirected to authorize access to your account data.',
+ )}
@@ -358,11 +392,74 @@ export function ConnectAccountDialog({
placeholder={__(
'Paste your Indexa Capital API token',
)}
+ className="my-2"
/>
{__(
- 'You can generate your API token from your Indexa Capital dashboard under Settings > Applications.',
- )}
+ 'You can generate your API token from your Indexa Capital dashboard under',
+ )}{' '}
+
+ {__('Settings > Applications')}
+
+ .
+
+
+ )}
+
+ {isBinance && (
+
+
+
+ {__('API Key')}
+
+
+ setApiKey(e.target.value)
+ }
+ className="mt-1"
+ placeholder={__(
+ 'Paste your Binance API Key',
+ )}
+ />
+
+
+
+ {__('API Secret')}
+
+
+ setApiSecret(e.target.value)
+ }
+ className="mt-1"
+ placeholder={__(
+ 'Paste your Binance API Secret',
+ )}
+ />
+
+
+ {__(
+ 'You can create API keys from your Binance account under',
+ )}{' '}
+
+ {__('API Management')}
+
+ .
)}
@@ -379,7 +476,8 @@ export function ConnectAccountDialog({
onClick={handleAuthorize}
disabled={
isSubmitting ||
- (isIndexaCapital && !apiToken)
+ (isIndexaCapital && !apiToken) ||
+ (isBinance && (!apiKey || !apiSecret))
}
>
{isSubmitting
diff --git a/resources/js/pages/settings/connections.tsx b/resources/js/pages/settings/connections.tsx
index 528a4a2d..33f7c563 100644
--- a/resources/js/pages/settings/connections.tsx
+++ b/resources/js/pages/settings/connections.tsx
@@ -23,7 +23,13 @@ import type { SharedData } from '@/types';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { Head, router, usePage, usePoll } from '@inertiajs/react';
-import { ArrowRight, MoreHorizontal, RefreshCw, Unplug } from 'lucide-react';
+import {
+ AlertCircle,
+ ArrowRight,
+ MoreHorizontal,
+ RefreshCw,
+ Unplug,
+} from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
@@ -159,8 +165,10 @@ export default function ConnectionsPage({ connections }: Props) {
{__('Map Accounts')}
)}
- {connection.status ===
- 'active' && (
+ {(connection.status ===
+ 'active' ||
+ connection.status ===
+ 'error') && (
handleSync(
@@ -169,7 +177,12 @@ export default function ConnectionsPage({ connections }: Props) {
}
>
- {__('Sync Now')}
+ {connection.status ===
+ 'error'
+ ? __('Retry')
+ : __(
+ 'Sync Now',
+ )}
)}
)}
- {connection.error_message && (
-
- {connection.error_message}
-
+ {connection.status === 'error' && (
+
+
+
+
+
+ {connection.error_message ??
+ __(
+ 'An unexpected error occurred during sync.',
+ )}
+
+
+
+
+
)}
diff --git a/routes/web.php b/routes/web.php
index 7e2def64..c3859e14 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -7,6 +7,7 @@ use App\Http\Controllers\DashboardController;
use App\Http\Controllers\OnboardingController;
use App\Http\Controllers\OpenBanking\AccountMappingController;
use App\Http\Controllers\OpenBanking\AuthorizationController;
+use App\Http\Controllers\OpenBanking\BinanceController;
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\RobotsController;
@@ -74,6 +75,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'open-banking'
Route::get('connections/{connection}/map-accounts', [AccountMappingController::class, 'show'])->name('open-banking.map-accounts');
Route::post('connections/{connection}/map-accounts', [AccountMappingController::class, 'store'])->name('open-banking.map-accounts.store');
Route::post('indexa-capital/connect', [IndexaCapitalController::class, 'store'])->name('open-banking.indexa-capital.connect');
+ Route::post('binance/connect', [BinanceController::class, 'store'])->name('open-banking.binance.connect');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
diff --git a/tests/Feature/CurrencyConversionServiceTest.php b/tests/Feature/CurrencyConversionServiceTest.php
new file mode 100644
index 00000000..8d8bf112
--- /dev/null
+++ b/tests/Feature/CurrencyConversionServiceTest.php
@@ -0,0 +1,107 @@
+ Http::response([
+ 'eur' => [
+ 'btc' => 0.000015,
+ 'eth' => 0.0004,
+ ],
+ ]),
+ ]);
+
+ $service = new CurrencyConversionService;
+
+ // 1 EUR = 0.000015 BTC → 1 BTC = 1/0.000015 = 66666.67 EUR
+ // 2 BTC = 133333.33 EUR
+ $result = $service->convert('BTC', 'EUR', 2.0, '2026-01-15');
+
+ expect($result)->toBe(2.0 / 0.000015);
+});
+
+test('returns quantity when source equals target', function () {
+ Http::fake();
+
+ $service = new CurrencyConversionService;
+ $result = $service->convert('EUR', 'EUR', 150.0, '2026-01-15');
+
+ expect($result)->toBe(150.0);
+ Http::assertNothingSent();
+});
+
+test('returns zero when source currency not found', function () {
+ Http::fake([
+ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'btc' => 0.000015,
+ ],
+ ]),
+ ]);
+
+ $service = new CurrencyConversionService;
+ $result = $service->convert('UNKNOWN', 'EUR', 10.0, '2026-01-15');
+
+ expect($result)->toBe(0.0);
+});
+
+test('uses fallback URL when primary fails', function () {
+ Http::fake([
+ 'cdn.jsdelivr.net/*' => Http::response('Server Error', 500),
+ 'currency-api.pages.dev/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'btc' => 0.00002,
+ ],
+ ]),
+ ]);
+
+ $service = new CurrencyConversionService;
+ $result = $service->convert('BTC', 'EUR', 1.0, '2026-01-15');
+
+ expect($result)->toBe(1.0 / 0.00002);
+});
+
+test('throws when both primary and fallback fail', function () {
+ Http::fake([
+ 'cdn.jsdelivr.net/*' => Http::response('Server Error', 500),
+ 'currency-api.pages.dev/*' => Http::response('Server Error', 500),
+ ]);
+
+ $service = new CurrencyConversionService;
+ $service->convert('BTC', 'EUR', 1.0, '2026-01-15');
+})->throws(RuntimeException::class);
+
+test('caches rates so same currency and date makes only one HTTP request', function () {
+ Http::fake([
+ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'btc' => 0.000015,
+ 'eth' => 0.0004,
+ ],
+ ]),
+ ]);
+
+ $service = new CurrencyConversionService;
+ $service->convert('BTC', 'EUR', 1.0, '2026-01-15');
+ $service->convert('ETH', 'EUR', 5.0, '2026-01-15');
+
+ Http::assertSentCount(1);
+});
+
+test('different dates make separate requests', function () {
+ Http::fake([
+ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'btc' => 0.000015,
+ ],
+ ]),
+ ]);
+
+ $service = new CurrencyConversionService;
+ $service->convert('BTC', 'EUR', 1.0, '2026-01-15');
+ $service->convert('BTC', 'EUR', 1.0, '2026-01-16');
+
+ Http::assertSentCount(2);
+});
diff --git a/tests/Feature/OpenBanking/BinanceBalanceSyncTest.php b/tests/Feature/OpenBanking/BinanceBalanceSyncTest.php
new file mode 100644
index 00000000..a0e1384d
--- /dev/null
+++ b/tests/Feature/OpenBanking/BinanceBalanceSyncTest.php
@@ -0,0 +1,422 @@
+onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCEUR', 'price' => '50000.00'],
+ ['symbol' => 'BTCUSDT', 'price' => '52000.00'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(5000000); // 50000.00 EUR * 100
+ expect($balance->balance_date->toDateString())->toBe(now()->toDateString());
+});
+
+test('syncs binance balance using USDT fallback conversion', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'SOL', 'free' => '10.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'SOLUSDT', 'price' => '100.00'],
+ ['symbol' => 'EURUSDT', 'price' => '1.10'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+
+ // 10 SOL * 100 USDT = 1000 USDT / 1.10 EUR/USDT = ~909.09 EUR
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(90909); // 909.09 EUR * 100
+});
+
+test('handles USD stablecoins as 1:1 when target is USD', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'USD',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'USDT', 'free' => '500.00', 'locked' => '0.0'],
+ ['asset' => 'USDC', 'free' => '300.00', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(80000); // (500 + 300) * 100
+});
+
+test('includes locked balances in total', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '0.5', 'locked' => '0.5'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCEUR', 'price' => '50000.00'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(5000000); // (0.5 + 0.5) * 50000 * 100
+});
+
+test('updates existing balance for same date', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ $account->balances()->create([
+ 'balance_date' => now()->toDateString(),
+ 'balance' => 100000,
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCEUR', 'price' => '60000.00'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ expect($account->balances()->first()->balance)->toBe(6000000);
+});
+
+test('handles empty balances gracefully', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->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(BinanceClient::class);
+ $client->shouldNotReceive('getAccount');
+
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(0);
+});
+
+test('first sync fetches historical snapshots and converts using currency API', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ $yesterday = now()->subDay();
+ $twoDaysAgo = now()->subDays(2);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response([
+ 'snapshotVos' => [
+ [
+ 'type' => 'spot',
+ 'updateTime' => $twoDaysAgo->getTimestampMs(),
+ 'data' => [
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '2.0', 'locked' => '0.0'],
+ ],
+ ],
+ ],
+ [
+ 'type' => 'spot',
+ 'updateTime' => $yesterday->getTimestampMs(),
+ 'data' => [
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '2.0', 'locked' => '0.0'],
+ ],
+ ],
+ ],
+ ],
+ ]),
+ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'btc' => 0.000019, // 1 EUR = 0.000019 BTC → 1 BTC = 52631.58 EUR
+ ],
+ ]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '2.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCUSDT', 'price' => '56100.00'],
+ ['symbol' => 'EURUSDT', 'price' => '1.10'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client, isFirstSync: true);
+
+ // 2 historical days + 1 current day = 3
+ expect($account->balances()->count())->toBe(3);
+
+ // Historical: 2 BTC / 0.000019 = 105263.16 EUR → 10526316 cents
+ $oldBalance = $account->balances()->where('balance_date', $twoDaysAgo->toDateString())->first();
+ expect($oldBalance->balance)->toBe(10526316);
+
+ $yesterdayBalance = $account->balances()->where('balance_date', $yesterday->toDateString())->first();
+ expect($yesterdayBalance->balance)->toBe(10526316);
+
+ // Current (ticker-based): 2 BTC * 56100 USDT / 1.10 = 102000 EUR
+ $todayBalance = $account->balances()->where('balance_date', now()->toDateString())->first();
+ expect($todayBalance->balance)->toBe(10200000);
+});
+
+test('subsequent sync only fetches snapshots since last balance date', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ // Pre-existing balance from 2 days ago — subsequent sync should start from yesterday
+ $account->balances()->create([
+ 'balance_date' => now()->subDays(2)->toDateString(),
+ 'balance' => 5000000,
+ ]);
+
+ $yesterday = now()->subDay();
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response([
+ 'snapshotVos' => [
+ [
+ 'type' => 'spot',
+ 'updateTime' => $yesterday->getTimestampMs(),
+ 'data' => [
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ],
+ ],
+ ],
+ ]),
+ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'btc' => 0.000018, // 1 BTC = 1/0.000018 = 55555.56 EUR
+ ],
+ ]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCUSDT', 'price' => '61600.00'],
+ ['symbol' => 'EURUSDT', 'price' => '1.10'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client, isFirstSync: false);
+
+ // 1 pre-existing + 1 historical (yesterday) + 1 current (today) = 3
+ expect($account->balances()->count())->toBe(3);
+
+ // Historical: 1 BTC / 0.000018 = 55555.56 EUR → 5555556 cents
+ $yesterdayBalance = $account->balances()->where('balance_date', $yesterday->toDateString())->first();
+ expect($yesterdayBalance->balance)->toBe(5555556);
+
+ // Current (ticker-based): 1 BTC * 61600 USDT / 1.10 = 56000 EUR
+ $todayBalance = $account->balances()->where('balance_date', now()->toDateString())->first();
+ expect($todayBalance->balance)->toBe(5600000);
+});
+
+test('historical sync converts assets using currency API', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ $yesterday = now()->subDay();
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response([
+ 'snapshotVos' => [
+ [
+ 'type' => 'spot',
+ 'updateTime' => $yesterday->getTimestampMs(),
+ 'data' => [
+ 'balances' => [
+ ['asset' => 'SOL', 'free' => '10.0', 'locked' => '0.0'],
+ ],
+ ],
+ ],
+ ],
+ ]),
+ 'cdn.jsdelivr.net/*currencies/eur*' => Http::response([
+ 'eur' => [
+ 'sol' => 0.01, // 1 EUR = 0.01 SOL → 1 SOL = 100 EUR
+ ],
+ ]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'SOL', 'free' => '10.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'SOLUSDT', 'price' => '105.00'],
+ ['symbol' => 'EURUSDT', 'price' => '1.10'],
+ ]),
+ ]);
+
+ $client = new BinanceClient('test-key', 'test-secret');
+ $service = app(BinanceBalanceSyncService::class);
+ $service->sync($account, $client, isFirstSync: true);
+
+ // Historical: 10 SOL / 0.01 = 1000 EUR → 100000 cents
+ $yesterdayBalance = $account->balances()->where('balance_date', $yesterday->toDateString())->first();
+ expect($yesterdayBalance->balance)->toBe(100000);
+});
diff --git a/tests/Feature/OpenBanking/BinanceControllerTest.php b/tests/Feature/OpenBanking/BinanceControllerTest.php
new file mode 100644
index 00000000..d5e75ac9
--- /dev/null
+++ b/tests/Feature/OpenBanking/BinanceControllerTest.php
@@ -0,0 +1,175 @@
+onboarded()->create(['currency_code' => 'EUR']);
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '0.5', 'locked' => '0.0'],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'api_secret' => 'valid-test-api-secret-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertOk();
+ $response->assertJsonStructure(['redirect_url', 'connection_id']);
+
+ $this->assertDatabaseHas('banking_connections', [
+ 'user_id' => $user->id,
+ 'provider' => 'binance',
+ 'aspsp_name' => 'Binance',
+ 'aspsp_country' => 'ES',
+ 'status' => BankingConnectionStatus::Active->value,
+ ]);
+
+ $this->assertDatabaseHas('accounts', [
+ 'user_id' => $user->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'type' => AccountType::Investment->value,
+ 'currency_code' => 'EUR',
+ 'name' => 'Crypto Portfolio',
+ ]);
+
+ Queue::assertPushed(SyncBankingConnectionJob::class);
+});
+
+test('invalid binance credentials return 422', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.binance.com/api/v3/account*' => Http::response(['msg' => 'Invalid API-key'], 401),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
+ 'api_key' => 'invalid-api-key-12345',
+ 'api_secret' => 'invalid-api-secret-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonFragment(['message' => 'Invalid API credentials or failed to connect to Binance.']);
+
+ $this->assertDatabaseMissing('banking_connections', [
+ 'user_id' => $user->id,
+ 'provider' => 'binance',
+ ]);
+});
+
+test('binance connection with account-mapping flag returns mapping redirect', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ Feature::for($user)->activate('open-banking');
+ Feature::for($user)->activate('account-mapping');
+
+ Http::fake([
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'api_secret' => 'valid-test-api-secret-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertOk();
+
+ $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'binance')->first();
+
+ expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
+ expect($connection->pending_accounts_data)->toHaveCount(1);
+ expect($connection->pending_accounts_data[0]['uid'])->toBe('binance-portfolio');
+ expect($connection->pending_accounts_data[0]['name'])->toBe('Crypto Portfolio');
+
+ $this->assertDatabaseMissing('accounts', [
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ ]);
+
+ Queue::assertNothingPushed();
+});
+
+test('binance requires open-banking feature flag', function () {
+ $user = User::factory()->onboarded()->create();
+
+ $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'api_secret' => 'valid-test-api-secret-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertNotFound();
+});
+
+test('binance api_key and api_secret are required and must be at least 10 characters', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ $this->actingAs($user)->postJson('/open-banking/binance/connect', [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['api_key', 'api_secret', 'country']);
+
+ $this->actingAs($user)->postJson('/open-banking/binance/connect', [
+ 'api_key' => 'short',
+ 'api_secret' => 'short',
+ 'country' => 'ES',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['api_key', 'api_secret']);
+});
+
+test('binance creates single crypto portfolio account with user currency', function () {
+ Queue::fake();
+
+ $user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '0.5', 'locked' => '0.0'],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/binance/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'api_secret' => 'valid-test-api-secret-12345',
+ 'country' => 'DE',
+ ]);
+
+ $response->assertOk();
+
+ expect($user->accounts()->count())->toBe(1);
+
+ $this->assertDatabaseHas('accounts', [
+ 'user_id' => $user->id,
+ 'name' => 'Crypto Portfolio',
+ 'currency_code' => 'USD',
+ 'type' => AccountType::Investment->value,
+ 'external_account_id' => 'binance-portfolio',
+ ]);
+});
diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
index cf42f217..4bf2398f 100644
--- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
+++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
@@ -2,6 +2,7 @@
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
+use App\Jobs\SyncBinanceHistoricalBalancesJob;
use App\Mail\BankTransactionsSyncedEmail;
use App\Models\Account;
use App\Models\Bank;
@@ -12,6 +13,7 @@ use App\Services\Banking\BalanceSyncService;
use App\Services\Banking\TransactionSyncService;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
+use Illuminate\Support\Facades\Queue;
test('first sync calculates historical balances', function () {
$user = User::factory()->onboarded()->create();
@@ -386,3 +388,80 @@ test('indexa capital sync does not send email', function () {
Mail::assertNothingQueued();
});
+
+test('binance first sync gets current balance immediately and dispatches historical job', function () {
+ Queue::fake(SyncBinanceHistoricalBalancesJob::class);
+
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => null,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCEUR', 'price' => '50000.00'],
+ ]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ expect($account->balances()->count())->toBe(1);
+ expect($account->balances()->first()->balance)->toBe(5000000);
+
+ Queue::assertPushed(SyncBinanceHistoricalBalancesJob::class, function ($job) use ($account) {
+ return $job->account->id === $account->id
+ && $job->delay !== null;
+ });
+});
+
+test('binance subsequent sync does not dispatch historical job', function () {
+ Queue::fake(SyncBinanceHistoricalBalancesJob::class);
+
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.binance.com/sapi/v1/accountSnapshot*' => Http::response(['snapshotVos' => []]),
+ 'api.binance.com/api/v3/account*' => Http::response([
+ 'balances' => [
+ ['asset' => 'BTC', 'free' => '1.0', 'locked' => '0.0'],
+ ],
+ ]),
+ 'api.binance.com/api/v3/ticker/price' => Http::response([
+ ['symbol' => 'BTCEUR', 'price' => '50000.00'],
+ ]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ Queue::assertNotPushed(SyncBinanceHistoricalBalancesJob::class);
+});
diff --git a/tests/Feature/OpenBanking/SyncBinanceHistoricalBalancesJobTest.php b/tests/Feature/OpenBanking/SyncBinanceHistoricalBalancesJobTest.php
new file mode 100644
index 00000000..c8ddb325
--- /dev/null
+++ b/tests/Feature/OpenBanking/SyncBinanceHistoricalBalancesJobTest.php
@@ -0,0 +1,102 @@
+onboarded()->create();
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'binance-portfolio',
+ ]);
+
+ $syncService = Mockery::mock(BinanceBalanceSyncService::class);
+ $syncService->shouldReceive('syncHistoricalBalances')
+ ->once()
+ ->withArgs(function ($acct, $client, $isFirstSync) use ($account) {
+ return $acct->id === $account->id && $isFirstSync === true;
+ })
+ ->andReturn(true);
+
+ $job = new SyncBinanceHistoricalBalancesJob($account);
+ $job->handle($syncService);
+});
+
+test('job does nothing if account has no banking connection', function () {
+ $user = User::factory()->onboarded()->create();
+ $account = Account::factory()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => null,
+ ]);
+
+ $syncService = Mockery::mock(BinanceBalanceSyncService::class);
+ $syncService->shouldNotReceive('syncHistoricalBalances');
+
+ $job = new SyncBinanceHistoricalBalancesJob($account);
+ $job->handle($syncService);
+});
+
+test('job does nothing if connection is not binance', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ ]);
+
+ $syncService = Mockery::mock(BinanceBalanceSyncService::class);
+ $syncService->shouldNotReceive('syncHistoricalBalances');
+
+ $job = new SyncBinanceHistoricalBalancesJob($account);
+ $job->handle($syncService);
+});
+
+test('failed method logs error but does not update connection status', function () {
+ $user = User::factory()->onboarded()->create();
+ $connection = BankingConnection::factory()->binance()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ ]);
+
+ Log::shouldReceive('error')
+ ->once()
+ ->withArgs(function ($message, $context) use ($account) {
+ return $message === 'Binance historical balance sync failed'
+ && $context['account_id'] === $account->id;
+ });
+
+ $job = new SyncBinanceHistoricalBalancesJob($account);
+ $job->failed(new RuntimeException('API rate limit'));
+
+ $connection->refresh();
+ expect($connection->status)->toBe(\App\Enums\BankingConnectionStatus::Active);
+});
+
+test('uniqueId returns account-based identifier', function () {
+ $user = User::factory()->onboarded()->create();
+ $account = Account::factory()->create([
+ 'user_id' => $user->id,
+ ]);
+
+ $job = new SyncBinanceHistoricalBalancesJob($account);
+
+ expect($job->uniqueId())->toBe('binance-historical-'.$account->id);
+});