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 && ( +
+
+ + + setApiKey(e.target.value) + } + className="mt-1" + placeholder={__( + 'Paste your Binance API Key', + )} + /> +
+
+ + + 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.', + )} +

+
+ + + {__( + 'Need help? Join our Discord', + )} + +
+
+
+
)} 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); +});