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 && (
+
+
+ {__('API Key')}
+
+
+ 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')}
+
+ .
+
+
+ )}
+
{isSubmitting
diff --git a/routes/web.php b/routes/web.php
index c3859e14..da1dcecd 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -8,6 +8,7 @@ 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\BitpandaController;
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\RobotsController;
@@ -76,6 +77,7 @@ Route::middleware(['auth', 'verified', 'onboarded', 'subscribed', 'open-banking'
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::post('bitpanda/connect', [BitpandaController::class, 'store'])->name('open-banking.bitpanda.connect');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
diff --git a/tests/Feature/OpenBanking/BitpandaBalanceSyncTest.php b/tests/Feature/OpenBanking/BitpandaBalanceSyncTest.php
new file mode 100644
index 00000000..45f61ccc
--- /dev/null
+++ b/tests/Feature/OpenBanking/BitpandaBalanceSyncTest.php
@@ -0,0 +1,434 @@
+onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'BTC' => ['EUR' => '50000.00', 'USD' => '55000.00'],
+ ]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '1.00000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'fiat_wallet',
+ 'attributes' => [
+ 'fiat_id' => '1',
+ 'fiat_symbol' => 'EUR',
+ 'balance' => '500.00000000',
+ 'name' => 'EUR Wallet',
+ ],
+ 'id' => 'fiat-wallet-uuid-1',
+ ],
+ ],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+
+ // 1 BTC * 50000 EUR + 500 EUR fiat = 50500 EUR → 5050000 cents
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(5050000);
+ expect($balance->balance_date->toDateString())->toBe(now()->toDateString());
+});
+
+test('syncs bitpanda balance with crypto only', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'ETH' => ['EUR' => '2000.00', 'USD' => '2200.00'],
+ ]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '27',
+ 'cryptocoin_symbol' => 'ETH',
+ 'balance' => '5.00000000',
+ 'is_default' => true,
+ 'name' => 'ETH wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-2',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+
+ // 5 ETH * 2000 EUR = 10000 EUR → 1000000 cents
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(1000000);
+});
+
+test('syncs bitpanda balance including bitpanda-specific indices', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'BTC' => ['EUR' => '56911.68'],
+ 'BCI10' => ['EUR' => '10.40'],
+ 'BCI5' => ['EUR' => '16.00'],
+ ]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '0.01000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '100',
+ 'cryptocoin_symbol' => 'BCI10',
+ 'balance' => '0.20000000',
+ 'is_default' => true,
+ 'name' => 'BCI10 wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-2',
+ ],
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '101',
+ 'cryptocoin_symbol' => 'BCI5',
+ 'balance' => '0.02000000',
+ 'is_default' => true,
+ 'name' => 'BCI5 wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-3',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+
+ // 0.01 BTC * 56911.68 = 569.1168
+ // 0.20 BCI10 * 10.40 = 2.08
+ // 0.02 BCI5 * 16.00 = 0.32
+ // Total = 571.5168 EUR → 57152 cents
+ $balance = $account->balances()->first();
+ expect($balance->balance)->toBe(57152);
+});
+
+test('updates existing balance for same date', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ $account->balances()->create([
+ 'balance_date' => now()->toDateString(),
+ 'balance' => 100000,
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'BTC' => ['EUR' => '50000.00'],
+ ]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '1.00000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ // 1 BTC * 50000 EUR = 5000000 cents
+ expect($account->balances()->first()->balance)->toBe(5000000);
+});
+
+test('handles empty wallets gracefully', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ expect($account->balances()->first()->balance)->toBe(0);
+});
+
+test('skips deleted crypto wallets', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'BTC' => ['EUR' => '50000.00'],
+ ]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '1.00000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => true,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ expect($account->balances()->first()->balance)->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(BitpandaClient::class);
+ $client->shouldNotReceive('getTickerPrices');
+ $client->shouldNotReceive('getCryptoWallets');
+
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(0);
+});
+
+test('skips zero balance fiat wallets', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'fiat_wallet',
+ 'attributes' => [
+ 'fiat_id' => '1',
+ 'fiat_symbol' => 'EUR',
+ 'balance' => '0.00000000',
+ 'name' => 'EUR Wallet',
+ ],
+ 'id' => 'fiat-wallet-uuid-1',
+ ],
+ [
+ 'type' => 'fiat_wallet',
+ 'attributes' => [
+ 'fiat_id' => '1',
+ 'fiat_symbol' => 'EUR',
+ 'balance' => '250.00000000',
+ 'name' => 'EUR Wallet 2',
+ ],
+ 'id' => 'fiat-wallet-uuid-2',
+ ],
+ ],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ // Only the 250 EUR wallet counts (zero balance one is skipped)
+ expect($account->balances()->first()->balance)->toBe(25000);
+});
+
+test('uses correct currency from ticker when account is USD', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'USD',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'BTC' => ['EUR' => '50000.00', 'USD' => '55000.00'],
+ ]),
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '1.00000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [],
+ ]),
+ ]);
+
+ $client = new BitpandaClient('test-key');
+ $service = app(BitpandaBalanceSyncService::class);
+ $service->sync($account, $client);
+
+ expect($account->balances()->count())->toBe(1);
+ // Should use USD price: 1 BTC * 55000 USD = 5500000 cents
+ expect($account->balances()->first()->balance)->toBe(5500000);
+});
diff --git a/tests/Feature/OpenBanking/BitpandaControllerTest.php b/tests/Feature/OpenBanking/BitpandaControllerTest.php
new file mode 100644
index 00000000..ec1e8a41
--- /dev/null
+++ b/tests/Feature/OpenBanking/BitpandaControllerTest.php
@@ -0,0 +1,202 @@
+onboarded()->create(['currency_code' => 'EUR']);
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '0.50000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertOk();
+ $response->assertJsonStructure(['redirect_url', 'connection_id']);
+
+ $this->assertDatabaseHas('banking_connections', [
+ 'user_id' => $user->id,
+ 'provider' => 'bitpanda',
+ 'aspsp_name' => 'Bitpanda',
+ 'aspsp_country' => 'ES',
+ 'status' => BankingConnectionStatus::Active->value,
+ ]);
+
+ $this->assertDatabaseHas('accounts', [
+ 'user_id' => $user->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'type' => AccountType::Investment->value,
+ 'currency_code' => 'EUR',
+ 'name' => 'Crypto Portfolio',
+ ]);
+
+ Queue::assertPushed(SyncBankingConnectionJob::class);
+});
+
+test('invalid bitpanda credentials return 422', function () {
+ $user = User::factory()->onboarded()->create();
+ Feature::for($user)->activate('open-banking');
+
+ Http::fake([
+ 'api.bitpanda.com/v1/wallets' => Http::response(['error' => 'Unauthorized'], 401),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
+ 'api_key' => 'invalid-api-key-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertUnprocessable();
+ $response->assertJsonFragment(['message' => 'Invalid API key or failed to connect to Bitpanda.']);
+
+ $this->assertDatabaseMissing('banking_connections', [
+ 'user_id' => $user->id,
+ 'provider' => 'bitpanda',
+ ]);
+});
+
+test('bitpanda 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.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '1.00000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertOk();
+
+ $connection = BankingConnection::where('user_id', $user->id)->where('provider', 'bitpanda')->first();
+
+ expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
+ expect($connection->pending_accounts_data)->toHaveCount(1);
+ expect($connection->pending_accounts_data[0]['uid'])->toBe('bitpanda-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('bitpanda requires open-banking feature flag', function () {
+ $user = User::factory()->onboarded()->create();
+
+ $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
+ 'api_key' => 'valid-test-api-key-12345',
+ 'country' => 'ES',
+ ]);
+
+ $response->assertNotFound();
+});
+
+test('bitpanda api_key is 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/bitpanda/connect', [])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['api_key', 'country']);
+
+ $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
+ 'api_key' => 'short',
+ 'country' => 'ES',
+ ])
+ ->assertUnprocessable()
+ ->assertJsonValidationErrors(['api_key']);
+});
+
+test('bitpanda 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.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '0.50000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ ]);
+
+ $response = $this->actingAs($user)->postJson('/open-banking/bitpanda/connect', [
+ 'api_key' => 'valid-test-api-key-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' => 'bitpanda-portfolio',
+ ]);
+});
diff --git a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
index 4bf2398f..b6e2891c 100644
--- a/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
+++ b/tests/Feature/OpenBanking/SyncBankingConnectionJobTest.php
@@ -465,3 +465,128 @@ test('binance subsequent sync does not dispatch historical job', function () {
Queue::assertNotPushed(SyncBinanceHistoricalBalancesJob::class);
});
+
+test('bitpanda sync calls balance sync service and updates last_synced_at', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->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' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/wallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'wallet',
+ 'attributes' => [
+ 'cryptocoin_id' => '1',
+ 'cryptocoin_symbol' => 'BTC',
+ 'balance' => '0.50000000',
+ 'is_default' => true,
+ 'name' => 'BTC wallet',
+ 'deleted' => false,
+ ],
+ 'id' => 'wallet-uuid-1',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response([
+ 'data' => [
+ [
+ 'type' => 'fiat_wallet',
+ 'attributes' => [
+ 'fiat_id' => '1',
+ 'fiat_symbol' => 'EUR',
+ 'balance' => '200.00000000',
+ 'name' => 'EUR Wallet',
+ ],
+ 'id' => 'fiat-wallet-uuid-1',
+ ],
+ ],
+ ]),
+ 'api.bitpanda.com/v1/ticker' => Http::response([
+ 'BTC' => ['EUR' => '50000.00'],
+ ]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $transactionSync->shouldNotReceive('sync');
+
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+ $balanceSync->shouldNotReceive('sync');
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ $connection->refresh();
+ expect($connection->last_synced_at)->not->toBeNull();
+ expect($account->balances()->count())->toBe(1);
+});
+
+test('bitpanda connections do not expire', function () {
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->create([
+ 'user_id' => $user->id,
+ 'status' => BankingConnectionStatus::Active,
+ 'valid_until' => now()->subDay(),
+ 'last_synced_at' => now()->subDay(),
+ ]);
+ $account = Account::factory()->connected()->create([
+ 'user_id' => $user->id,
+ 'banking_connection_id' => $connection->id,
+ 'external_account_id' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
+ 'api.bitpanda.com/v1/ticker' => Http::response([]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ $connection->refresh();
+ expect($connection->status)->toBe(BankingConnectionStatus::Active);
+ expect($connection->last_synced_at)->not->toBeNull();
+});
+
+test('bitpanda sync does not send email', function () {
+ Mail::fake();
+
+ $user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
+ $connection = BankingConnection::factory()->bitpanda()->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' => 'bitpanda-portfolio',
+ 'currency_code' => 'EUR',
+ ]);
+
+ Http::fake([
+ 'api.bitpanda.com/v1/wallets' => Http::response(['data' => []]),
+ 'api.bitpanda.com/v1/fiatwallets' => Http::response(['data' => []]),
+ 'api.bitpanda.com/v1/ticker' => Http::response([]),
+ ]);
+
+ $transactionSync = Mockery::mock(TransactionSyncService::class);
+ $balanceSync = Mockery::mock(BalanceSyncService::class);
+
+ $job = new SyncBankingConnectionJob($connection);
+ $job->handle($transactionSync, $balanceSync);
+
+ Mail::assertNothingQueued();
+});