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
This commit is contained in:
Víctor Falcón 2026-02-19 09:26:31 +01:00 committed by GitHub
parent df9fc38562
commit fe76c2e43d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 1281 additions and 23 deletions

View File

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

View File

@ -0,0 +1,25 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
use Laravel\Pennant\Feature;
class ConnectBitpandaRequest 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'],
'country' => ['required', 'string', 'size:2'],
];
}
}

View File

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

View File

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

View File

@ -0,0 +1,128 @@
<?php
namespace App\Services\Banking;
use App\Models\Account;
use Illuminate\Support\Facades\Log;
class BitpandaBalanceSyncService
{
/**
* Sync the total portfolio value for a Bitpanda account.
* Uses Bitpanda's own ticker prices to match the values shown in the Bitpanda dashboard.
*/
public function sync(Account $account, BitpandaClient $client): void
{
if (! $account->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<string, array<string, string>> $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<string, array<string, string>> $ticker
*/
private function getTickerPrice(array $ticker, string $symbol, string $targetCurrency): ?float
{
$price = $ticker[$symbol][$targetCurrency] ?? null;
if ($price === null) {
return null;
}
return (float) $price;
}
}

View File

@ -0,0 +1,148 @@
<?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 BitpandaClient
{
private const BASE_URL = 'https://api.bitpanda.com/v1';
/** @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,
) {}
/**
* List all crypto wallets with balances.
*
* @return array{data: array<int, array{type: string, id: string, attributes: array{cryptocoin_id: string, cryptocoin_symbol: string, balance: string, is_default: bool, name: string, deleted: bool}}>}
*/
public function getCryptoWallets(): array
{
return $this->get('/wallets');
}
/**
* List all fiat wallets with balances.
*
* @return array{data: array<int, array{type: string, id: string, attributes: array{fiat_id: string, fiat_symbol: string, balance: string, name: string}}>}
*/
public function getFiatWallets(): array
{
return $this->get('/fiatwallets');
}
/**
* List all asset wallets (crypto + commodity) grouped by type.
*
* @return array<string, mixed>
*/
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<string, array<string, string>>
*/
public function getTickerPrices(): array
{
return $this->get('/ticker');
}
/**
* List trades with optional cursor-based pagination.
*
* @return array{data: array<int, array{type: string, id: string, attributes: 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<int, array{type: string, id: string, attributes: 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(),
]);
});
}
}

View File

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

View File

@ -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.',
)}
</DialogDescription>
</DialogHeader>
@ -361,17 +388,21 @@ export function ConnectAccountDialog({
{selectedBank.name}
</p>
<p className="text-sm text-muted-foreground">
{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.',
)}
</p>
</div>
</div>
@ -464,6 +495,40 @@ export function ConnectAccountDialog({
</div>
)}
{isBitpanda && (
<div className="space-y-2">
<Label htmlFor="bitpanda-api-key">
{__('API Key')}
</Label>
<Input
id="bitpanda-api-key"
type="password"
value={bitpandaApiKey}
onChange={(e) =>
setBitpandaApiKey(e.target.value)
}
className="mt-1"
placeholder={__(
'Paste your Bitpanda API Key',
)}
/>
<p className="text-xs text-muted-foreground">
{__(
'You can create API keys from your Bitpanda account under',
)}{' '}
<a
href="https://web.bitpanda.com/apikey"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Key Management')}
</a>
.
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
@ -477,7 +542,8 @@ export function ConnectAccountDialog({
disabled={
isSubmitting ||
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret))
(isBinance && (!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey)
}
>
{isSubmitting

View File

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

View File

@ -0,0 +1,434 @@
<?php
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\BitpandaBalanceSyncService;
use App\Services\Banking\BitpandaClient;
use Illuminate\Support\Facades\Http;
test('syncs bitpanda balance with crypto and 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([
'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);
});

View File

@ -0,0 +1,202 @@
<?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 bitpanda account with valid credentials', function () {
Queue::fake();
$user = User::factory()->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',
]);
});

View File

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