feat: Add Binance integration (#131)

## 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
This commit is contained in:
Víctor Falcón 2026-02-18 15:23:46 +01:00 committed by GitHub
parent d7f0084338
commit df9fc38562
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1826 additions and 35 deletions

View File

@ -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;

View File

@ -0,0 +1,93 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Requests\OpenBanking\ConnectBinanceRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Services\Banking\BinanceClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
use Laravel\Pennant\Feature;
class BinanceController extends Controller
{
/**
* Validate Binance API credentials and create a connection.
*/
public function store(ConnectBinanceRequest $request): JsonResponse
{
$validated = $request->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,
]);
}
}

View File

@ -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.');

View File

@ -0,0 +1,26 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ConnectBinanceRequest extends FormRequest
{
public function authorize(): bool
{
return Feature::for($this->user())->active('open-banking');
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'api_key' => ['required', 'string', 'min:10'],
'api_secret' => ['required', 'string', 'min:10'],
'country' => ['required', 'string', 'size:2'],
];
}
}

View File

@ -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.');
}
}

View File

@ -0,0 +1,56 @@
<?php
namespace App\Jobs;
use App\Models\Account;
use App\Services\Banking\BinanceBalanceSyncService;
use App\Services\Banking\BinanceClient;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class SyncBinanceHistoricalBalancesJob implements ShouldBeUnique, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
/** @var array<int, int> */
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(),
]);
}
}

View File

@ -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';

View File

@ -0,0 +1,285 @@
<?php
namespace App\Services\Banking;
use App\Models\Account;
use App\Services\CurrencyConversionService;
use Carbon\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
class BinanceBalanceSyncService
{
/** @var array<string, string> 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<int, string> 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<int, 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<int, array{symbol: string, price: string}> $tickerPrices
* @return array<string, float>
*/
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<int, array{asset: string, free: string, locked: string}> $balances
* @param array<string, float> $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;
}
}

View File

@ -0,0 +1,107 @@
<?php
namespace App\Services\Banking;
use Exception;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class BinanceClient
{
private const BASE_URL = 'https://api.binance.com';
/** @var array<int, int> 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<int, array{asset: string, free: string, locked: string}>}
*/
public function getAccount(): array
{
return $this->signedRequest('/api/v3/account', ['omitZeroBalances' => 'true']);
}
/**
* Get all ticker prices for trading pairs.
*
* @return array<int, array{symbol: string, price: string}>
*/
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<int, array{type: string, updateTime: int, data: array{balances: array<int, array{asset: string, free: string, locked: string}>, 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,
);
}
}

View File

@ -0,0 +1,97 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use RuntimeException;
class CurrencyConversionService
{
private const PRIMARY_URL = 'https://cdn.jsdelivr.net/npm/@fawazahmed0/currency-api@';
private const FALLBACK_URL = 'https://currency-api.pages.dev/v1/';
/** @var array<string, array<string, float>> 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<string, float> 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<string, float>
*/
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);
}
}
}

View File

@ -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",

View File

@ -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) => [

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('banking_connections', function (Blueprint $table) {
$table->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');
});
}
};

View File

@ -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<string | null>(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.',
)}
</DialogDescription>
</DialogHeader>
@ -331,13 +361,17 @@ export function ConnectAccountDialog({
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{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.',
)}
</p>
</div>
</div>
@ -358,11 +392,74 @@ export function ConnectAccountDialog({
placeholder={__(
'Paste your Indexa Capital API token',
)}
className="my-2"
/>
<p className="text-xs text-muted-foreground">
{__(
'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',
)}{' '}
<a
href="https://indexacapital.com/es/u/user#settings-apps"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('Settings > Applications')}
</a>
.
</p>
</div>
)}
{isBinance && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="api-key">
{__('API Key')}
</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(e) =>
setApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Key',
)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="api-secret">
{__('API Secret')}
</Label>
<Input
id="api-secret"
type="password"
value={apiSecret}
onChange={(e) =>
setApiSecret(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Binance API Secret',
)}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Binance account under',
)}{' '}
<a
href="https://www.binance.com/es/my/settings/api-management"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Management')}
</a>
.
</p>
</div>
)}
@ -379,7 +476,8 @@ export function ConnectAccountDialog({
onClick={handleAuthorize}
disabled={
isSubmitting ||
(isIndexaCapital && !apiToken)
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret))
}
>
{isSubmitting

View File

@ -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')}
</DropdownMenuItem>
)}
{connection.status ===
'active' && (
{(connection.status ===
'active' ||
connection.status ===
'error') && (
<DropdownMenuItem
onClick={() =>
handleSync(
@ -169,7 +177,12 @@ export default function ConnectionsPage({ connections }: Props) {
}
>
<RefreshCw className="mr-2 h-4 w-4" />
{__('Sync Now')}
{connection.status ===
'error'
? __('Retry')
: __(
'Sync Now',
)}
</DropdownMenuItem>
)}
<DropdownMenuItem
@ -227,10 +240,45 @@ export default function ConnectionsPage({ connections }: Props) {
</span>
)}
</div>
{connection.error_message && (
<p className="mt-2 text-sm text-destructive">
{connection.error_message}
</p>
{connection.status === 'error' && (
<div className="mt-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3 dark:bg-destructive/10">
<div className="flex items-start gap-2">
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
<div className="space-y-2">
<p className="text-sm text-destructive">
{connection.error_message ??
__(
'An unexpected error occurred during sync.',
)}
</p>
<div className="flex flex-wrap items-center gap-3">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() =>
handleSync(
connection,
)
}
>
<RefreshCw className="mr-1.5 h-3 w-3" />
{__('Retry')}
</Button>
<a
href="https://discord.gg/2WZmDW9QZ8"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
{__(
'Need help? Join our Discord',
)}
</a>
</div>
</div>
</div>
</div>
)}
</CardContent>
</Card>

View File

@ -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 () {

View File

@ -0,0 +1,107 @@
<?php
use App\Services\CurrencyConversionService;
use Illuminate\Support\Facades\Http;
test('converts crypto to fiat using CDN rates', function () {
Http::fake([
'cdn.jsdelivr.net/*currencies/eur*' => 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);
});

View File

@ -0,0 +1,422 @@
<?php
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\BinanceBalanceSyncService;
use App\Services\Banking\BinanceClient;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Sleep;
beforeEach(function () {
Sleep::fake();
});
test('syncs binance balance using direct EUR pair', 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' => '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);
});

View File

@ -0,0 +1,175 @@
<?php
use App\Enums\AccountType;
use App\Enums\BankingConnectionStatus;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Laravel\Pennant\Feature;
test('users can connect a binance account with valid credentials', function () {
Queue::fake();
$user = User::factory()->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',
]);
});

View File

@ -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);
});

View File

@ -0,0 +1,102 @@
<?php
use App\Jobs\SyncBinanceHistoricalBalancesJob;
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\BinanceBalanceSyncService;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Sleep;
beforeEach(function () {
Sleep::fake();
});
test('job syncs historical balances via the service', 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,
'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);
});