From fe76c2e43d2aa32210292456bc9d9c50355f3c2b Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?V=C3=ADctor=20Falc=C3=B3n?=
Date: Thu, 19 Feb 2026 09:26:31 +0100
Subject: [PATCH] feat: Add Bitpanda exchange integration (#132)
## Summary
- Add Bitpanda as a new exchange provider using a single API key
(`X-Api-Key` header) to sync crypto and fiat wallet balances
- Follow the same architecture as Binance: provider string on
`banking_connections`, dedicated API client, balance sync service,
controller, form request, job wiring, factory state, route, and frontend
dialog
- Convert crypto wallet balances to the user's target currency via
`CurrencyConversionService`; fiat wallets are added directly or
converted if in a different currency
- Bitpanda appears in all countries in the connect dialog (same as
Binance)
- 18 new tests covering controller validation, balance sync scenarios,
sync job delegation, expiry handling, and email suppression
---
.../OpenBanking/BitpandaController.php | 92 ++++
.../OpenBanking/ConnectBitpandaRequest.php | 25 +
app/Jobs/SyncBankingConnectionJob.php | 16 +
app/Models/BankingConnection.php | 5 +
.../Banking/BitpandaBalanceSyncService.php | 128 ++++++
app/Services/Banking/BitpandaClient.php | 148 ++++++
.../factories/BankingConnectionFactory.php | 15 +
.../open-banking/connect-account-dialog.tsx | 112 ++++-
routes/web.php | 2 +
.../OpenBanking/BitpandaBalanceSyncTest.php | 434 ++++++++++++++++++
.../OpenBanking/BitpandaControllerTest.php | 202 ++++++++
.../SyncBankingConnectionJobTest.php | 125 +++++
12 files changed, 1281 insertions(+), 23 deletions(-)
create mode 100644 app/Http/Controllers/OpenBanking/BitpandaController.php
create mode 100644 app/Http/Requests/OpenBanking/ConnectBitpandaRequest.php
create mode 100644 app/Services/Banking/BitpandaBalanceSyncService.php
create mode 100644 app/Services/Banking/BitpandaClient.php
create mode 100644 tests/Feature/OpenBanking/BitpandaBalanceSyncTest.php
create mode 100644 tests/Feature/OpenBanking/BitpandaControllerTest.php
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.',
+ )}