diff --git a/app/Http/Controllers/OpenBanking/BitpandaController.php b/app/Http/Controllers/OpenBanking/BitpandaController.php new file mode 100644 index 00000000..51ad640d --- /dev/null +++ b/app/Http/Controllers/OpenBanking/BitpandaController.php @@ -0,0 +1,92 @@ +validated(); + $user = auth()->user(); + + $client = new BitpandaClient($validated['api_key']); + + try { + $client->getCryptoWallets(); + } catch (\Throwable $e) { + Log::warning('Bitpanda credential validation failed', ['error' => $e->getMessage()]); + + return response()->json([ + 'message' => 'Invalid API key or failed to connect to Bitpanda.', + ], 422); + } + + $bank = Bank::firstOrCreate( + ['name' => 'Bitpanda', 'user_id' => null], + ['name' => 'Bitpanda', 'logo' => 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png'], + ); + + $connection = $user->bankingConnections()->create([ + 'provider' => 'bitpanda', + 'api_token' => $validated['api_key'], + 'aspsp_name' => 'Bitpanda', + 'aspsp_country' => $validated['country'], + 'aspsp_logo' => $bank->logo, + 'status' => BankingConnectionStatus::Pending, + ]); + + $pendingAccounts = [ + [ + 'uid' => 'bitpanda-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' => 'bitpanda-portfolio', + ]); + + SyncBankingConnectionJob::dispatch($connection); + + return response()->json([ + 'redirect_url' => route('settings.connections.index'), + 'connection_id' => $connection->id, + ]); + } +} diff --git a/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php b/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php new file mode 100644 index 00000000..9ada9afe --- /dev/null +++ b/app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php @@ -0,0 +1,25 @@ +user())->active('open-banking'); + } + + /** + * @return array> + */ + public function rules(): array + { + return [ + 'api_key' => ['required', 'string', 'min:10'], + 'country' => ['required', 'string', 'size:2'], + ]; + } +} diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php index 9756f734..8beb9bc9 100644 --- a/app/Jobs/SyncBankingConnectionJob.php +++ b/app/Jobs/SyncBankingConnectionJob.php @@ -8,6 +8,8 @@ use App\Models\BankingConnection; use App\Services\Banking\BalanceSyncService; use App\Services\Banking\BinanceBalanceSyncService; use App\Services\Banking\BinanceClient; +use App\Services\Banking\BitpandaBalanceSyncService; +use App\Services\Banking\BitpandaClient; use App\Services\Banking\IndexaCapitalBalanceSyncService; use App\Services\Banking\IndexaCapitalClient; use App\Services\Banking\TransactionSyncService; @@ -58,6 +60,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue $this->syncIndexaCapital($connection); } elseif ($connection->isBinance()) { $this->syncBinance($connection); + } elseif ($connection->isBitpanda()) { + $this->syncBitpanda($connection); } else { $this->syncEnableBanking($connection, $transactionSync, $balanceSync); } @@ -111,6 +115,18 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue } } + private function syncBitpanda(BankingConnection $connection): void + { + $client = new BitpandaClient($connection->api_token); + $syncService = app(BitpandaBalanceSyncService::class); + + $connection->load('accounts'); + + foreach ($connection->accounts as $account) { + $syncService->sync($account, $client); + } + } + private function syncEnableBanking(BankingConnection $connection, TransactionSyncService $transactionSync, BalanceSyncService $balanceSync): void { $isFirstSync = ! $connection->last_synced_at; diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php index 89350220..abd79166 100644 --- a/app/Models/BankingConnection.php +++ b/app/Models/BankingConnection.php @@ -69,6 +69,11 @@ class BankingConnection extends Model return $this->provider === 'binance'; } + public function isBitpanda(): bool + { + return $this->provider === 'bitpanda'; + } + public function isEnableBanking(): bool { return $this->provider === 'enablebanking'; diff --git a/app/Services/Banking/BitpandaBalanceSyncService.php b/app/Services/Banking/BitpandaBalanceSyncService.php new file mode 100644 index 00000000..2eb9f7b0 --- /dev/null +++ b/app/Services/Banking/BitpandaBalanceSyncService.php @@ -0,0 +1,128 @@ +external_account_id) { + return; + } + + $this->syncCurrentBalance($account, $client); + } + + /** + * Sync today's balance by fetching all wallets and converting to target currency + * using Bitpanda's own ticker prices. + */ + public function syncCurrentBalance(Account $account, BitpandaClient $client): void + { + $targetCurrency = strtoupper($account->currency_code); + $ticker = $client->getTickerPrices(); + $totalValue = 0.0; + + $totalValue += $this->sumCryptoWallets($client, $ticker, $targetCurrency); + $totalValue += $this->sumFiatWallets($client, $targetCurrency); + + $totalValueCents = (int) round($totalValue * 100); + + $account->balances()->updateOrCreate( + ['balance_date' => now()->toDateString()], + ['balance' => $totalValueCents], + ); + } + + /** + * Sum all crypto wallet balances using Bitpanda's ticker prices. + * + * @param array> $ticker + */ + private function sumCryptoWallets(BitpandaClient $client, array $ticker, string $targetCurrency): float + { + $wallets = $client->getCryptoWallets(); + $total = 0.0; + + foreach ($wallets['data'] ?? [] as $wallet) { + $attributes = $wallet['attributes'] ?? []; + $balance = (float) ($attributes['balance'] ?? 0); + $symbol = $attributes['cryptocoin_symbol'] ?? null; + $deleted = $attributes['deleted'] ?? false; + + if ($balance <= 0 || ! $symbol || $deleted) { + continue; + } + + $price = $this->getTickerPrice($ticker, $symbol, $targetCurrency); + + if ($price === null) { + Log::warning('Bitpanda ticker price not found for asset', [ + 'asset' => $symbol, + 'target_currency' => $targetCurrency, + ]); + + continue; + } + + $total += $balance * $price; + } + + return $total; + } + + /** + * Sum all fiat wallet balances, using ticker to convert if needed. + */ + private function sumFiatWallets(BitpandaClient $client, string $targetCurrency): float + { + $wallets = $client->getFiatWallets(); + $total = 0.0; + + foreach ($wallets['data'] ?? [] as $wallet) { + $attributes = $wallet['attributes'] ?? []; + $balance = (float) ($attributes['balance'] ?? 0); + $symbol = strtoupper($attributes['fiat_symbol'] ?? ''); + + if ($balance <= 0 || ! $symbol) { + continue; + } + + if ($symbol === $targetCurrency) { + $total += $balance; + } else { + // Fiat-to-fiat conversion is rare on Bitpanda; use simple rate if available + Log::warning('Bitpanda fiat wallet in different currency than target', [ + 'fiat_symbol' => $symbol, + 'target_currency' => $targetCurrency, + 'balance' => $balance, + ]); + } + } + + return $total; + } + + /** + * Get the ticker price for an asset in the target currency. + * + * @param array> $ticker + */ + private function getTickerPrice(array $ticker, string $symbol, string $targetCurrency): ?float + { + $price = $ticker[$symbol][$targetCurrency] ?? null; + + if ($price === null) { + return null; + } + + return (float) $price; + } +} diff --git a/app/Services/Banking/BitpandaClient.php b/app/Services/Banking/BitpandaClient.php new file mode 100644 index 00000000..5da7dd67 --- /dev/null +++ b/app/Services/Banking/BitpandaClient.php @@ -0,0 +1,148 @@ + Retry backoff: 10s, 30s, 60s */ + private const RETRY_BACKOFF_MS = [10_000, 30_000, 60_000]; + + public function __construct( + private string $apiKey, + ) {} + + /** + * List all crypto wallets with balances. + * + * @return array{data: array} + */ + public function getCryptoWallets(): array + { + return $this->get('/wallets'); + } + + /** + * List all fiat wallets with balances. + * + * @return array{data: array} + */ + public function getFiatWallets(): array + { + return $this->get('/fiatwallets'); + } + + /** + * List all asset wallets (crypto + commodity) grouped by type. + * + * @return array + */ + public function getAssetWallets(): array + { + return $this->get('/asset-wallets'); + } + + /** + * Get Bitpanda's own ticker prices for all assets. + * Returns a map of asset symbol to fiat prices (e.g., BTC => {EUR => "56911.68", USD => "67150.45"}). + * This is a public endpoint — no authentication required. + * + * @return array> + */ + public function getTickerPrices(): array + { + return $this->get('/ticker'); + } + + /** + * List trades with optional cursor-based pagination. + * + * @return array{data: array, meta: array, links: array} + */ + public function getTrades(?string $cursor = null, int $pageSize = 25): array + { + $params = ['page_size' => $pageSize]; + + if ($cursor) { + $params['cursor'] = $cursor; + } + + return $this->get('/trades', $params); + } + + /** + * Fetch all trades by paginating through the cursor-based API. + * Trades are returned newest-first from the API but this method + * reverses them to chronological order (oldest first). + * + * @param string|null $sinceDate Optional ISO date string (YYYY-MM-DD) to stop fetching older trades + * @return array + */ + public function getAllTrades(?string $sinceDate = null): array + { + $allTrades = []; + $cursor = null; + $sinceTimestamp = $sinceDate ? strtotime($sinceDate) : null; + + do { + $response = $this->getTrades($cursor, 100); + $trades = $response['data'] ?? []; + + if (empty($trades)) { + break; + } + + foreach ($trades as $trade) { + $tradeTimestamp = (int) ($trade['attributes']['time']['unix'] ?? 0); + + if ($sinceTimestamp && $tradeTimestamp < $sinceTimestamp) { + return array_reverse($allTrades); + } + + $allTrades[] = $trade; + } + + $cursor = $response['meta']['next_cursor'] ?? null; + } while ($cursor); + + return array_reverse($allTrades); + } + + /** + * Execute an authenticated GET request with retry on rate limiting. + */ + private function get(string $path, array $params = []): array + { + return retry( + self::RETRY_BACKOFF_MS, + function () use ($path, $params) { + $response = $this->client()->get($path, $params); + + $response->throw(); + + return $response->json(); + }, + when: fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429, + ); + } + + private function client(): PendingRequest + { + return Http::baseUrl(self::BASE_URL) + ->withHeaders(['X-Api-Key' => $this->apiKey]) + ->acceptJson() + ->throw(function ($response, $exception) { + Log::error('Bitpanda API error', [ + 'status' => $response->status(), + 'body' => $response->json(), + ]); + }); + } +} diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php index ae3fc81c..07cada1e 100644 --- a/database/factories/BankingConnectionFactory.php +++ b/database/factories/BankingConnectionFactory.php @@ -101,6 +101,21 @@ class BankingConnectionFactory extends Factory ]); } + public function bitpanda(): static + { + return $this->state(fn (array $attributes) => [ + 'provider' => 'bitpanda', + 'authorization_id' => null, + 'session_id' => null, + 'api_token' => 'test-bitpanda-api-key-'.fake()->uuid(), + 'api_secret' => null, + 'aspsp_name' => 'Bitpanda', + 'aspsp_country' => 'ES', + 'aspsp_logo' => 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png', + 'valid_until' => null, + ]); + } + public function error(): static { return $this->state(fn (array $attributes) => [ diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx index 1b0fa1e8..d1734a3a 100644 --- a/resources/js/components/open-banking/connect-account-dialog.tsx +++ b/resources/js/components/open-banking/connect-account-dialog.tsx @@ -55,6 +55,13 @@ const BINANCE_INSTITUTION: EnableBankingInstitution = { maximum_consent_validity: null, }; +const BITPANDA_INSTITUTION: EnableBankingInstitution = { + name: 'Bitpanda', + country: 'ALL', + logo: 'https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png', + maximum_consent_validity: null, +}; + interface ConnectAccountDialogProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -92,6 +99,7 @@ export function ConnectAccountDialog({ const [apiToken, setApiToken] = useState(''); const [apiKey, setApiKey] = useState(''); const [apiSecret, setApiSecret] = useState(''); + const [bitpandaApiKey, setBitpandaApiKey] = useState(''); const isIndexaCapital = useMemo( () => selectedBank?.name === 'Indexa Capital', @@ -103,6 +111,11 @@ export function ConnectAccountDialog({ [selectedBank], ); + const isBitpanda = useMemo( + () => selectedBank?.name === 'Bitpanda', + [selectedBank], + ); + const resetState = useCallback(() => { setStep('country'); setCountry(''); @@ -116,6 +129,7 @@ export function ConnectAccountDialog({ setApiToken(''); setApiKey(''); setApiSecret(''); + setBitpandaApiKey(''); }, []); useEffect(() => { @@ -157,7 +171,10 @@ export function ConnectAccountDialog({ const data = await response.json(); - const extraInstitutions = [BINANCE_INSTITUTION]; + const extraInstitutions = [ + BINANCE_INSTITUTION, + BITPANDA_INSTITUTION, + ]; if (countryCode === 'ES') { extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION); } @@ -183,21 +200,25 @@ export function ConnectAccountDialog({ setError(null); try { - const url = isBinance - ? '/open-banking/binance/connect' - : isIndexaCapital - ? '/open-banking/indexa-capital/connect' - : '/open-banking/authorize'; + const url = isBitpanda + ? '/open-banking/bitpanda/connect' + : isBinance + ? '/open-banking/binance/connect' + : isIndexaCapital + ? '/open-banking/indexa-capital/connect' + : '/open-banking/authorize'; - 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 body = isBitpanda + ? { api_key: bitpandaApiKey, country: country } + : 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', @@ -242,6 +263,7 @@ export function ConnectAccountDialog({ {step === 'confirm' && !isIndexaCapital && !isBinance && + !isBitpanda && __( 'You will be redirected to your bank to authorize access.', )} @@ -255,6 +277,11 @@ export function ConnectAccountDialog({ __( 'Enter your API Key and Secret to connect your Binance account.', )} + {step === 'confirm' && + isBitpanda && + __( + 'Enter your API Key to connect your Bitpanda account.', + )} @@ -361,17 +388,21 @@ export function ConnectAccountDialog({ {selectedBank.name}

- {isBinance + {isBitpanda ? __( - 'Connect your Binance account using your API Key and Secret.', + 'Connect your Bitpanda account using your API Key.', ) - : 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.', + )}

@@ -464,6 +495,40 @@ export function ConnectAccountDialog({ )} + {isBitpanda && ( +
+ + + setBitpandaApiKey(e.target.value) + } + className="mt-1" + placeholder={__( + 'Paste your Bitpanda API Key', + )} + /> +

+ {__( + 'You can create API keys from your Bitpanda account under', + )}{' '} + + {__('API Key Management')} + + . +

+
+ )} +