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