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:
parent
d7f0084338
commit
df9fc38562
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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',
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue