feat: Coinbase banking integration (#388)

## Summary

Adds **Coinbase** as a banking/investment provider, alongside existing
Binance, Bitpanda, and Indexa Capital integrations. Connection auth uses
Coinbase Developer Platform (CDP) JWT (ES256) API keys.

Sync model mirrors Bitpanda: no historical balance reconstruction
(Coinbase API has no daily snapshot endpoint). Balance tracking starts
from connection date.

## Account model

One single **Crypto Portfolio** Whisper Account in the user's fiat
currency, aggregating all Coinbase wallets (crypto + fiat).

## Backend

- `app/Services/Banking/CoinbaseClient.php` — JWT-signed Coinbase
Advanced Trade client (`firebase/php-jwt` ES256). Per-request JWT, 120s
TTL, `uri` claim `"METHOD host/path"`. Methods:
`getAccounts`/`getAllAccounts` (cursor pagination), `getProduct`,
`getBestBidAsk` (batched), with 429 retry/backoff.
- `app/Services/Banking/CoinbaseBalanceSyncService.php` — Partitions
wallets into fiat vs crypto. Batched `best_bid_ask` for prices, USD
stablecoin shortcut (USDT/USDC/DAI/PYUSD/GUSD),
`CurrencyConversionService` fallback. Aggregates everything into user
fiat.
- `app/Http/Controllers/OpenBanking/CoinbaseController.php` +
`ConnectCoinbaseRequest.php` — mirror Bitpanda flow, validates
`api_key_name` (`organizations/{org}/apiKeys/{id}`) + PEM `private_key`.
Stores key_name in `api_token`, PEM in `api_secret` (already encrypted
TEXT).
- `BankingConnection::isCoinbase()`,
`SyncBankingConnectionJob::syncCoinbase()`, credential-update flow.
- `routes/web.php`: `POST /open-banking/coinbase/connect`.
- Bank seeder entry + factory `coinbase()` state.

## Frontend

- `connect-account-dialog.tsx` / `connect-account-inline.tsx`: Coinbase
appears in the bank picker. Confirm step shows `<Input>` for API key
name and `<Textarea>` (multi-line) for the PEM private key, with link to
CDP portal.
- `update-credentials-dialog.tsx`: Coinbase credentials editable.
- `settings/connections.tsx`: coinbase included in `isApiKeyProvider`.
- Wayfinder auto-generated `CoinbaseController.ts` +
`routes/open-banking/coinbase`.

## Tests

- `tests/Feature/OpenBanking/CoinbaseControllerTest.php` — happy path,
invalid creds (401 → 422), validation errors, subscription gate.
- `tests/Feature/OpenBanking/CoinbaseBalanceSyncTest.php` — mixed
crypto+fiat aggregation, USD stablecoin valuation, skip when
external_account_id missing.

All **217 OpenBanking tests pass**. Pint + ESLint clean.

## Follow-ups (not in this PR)

- Upload `storage/banks/logos/coinbase.png` to production storage (URL
referenced in seeder + frontend).
- Invested-amount calc from transaction history (deferred).
- Historical balance reconstruction (deferred — Coinbase has no daily
snapshot endpoint).
This commit is contained in:
Víctor Falcón 2026-05-13 18:53:30 +01:00 committed by GitHub
parent a09339d3c0
commit e71a743a0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1155 additions and 32 deletions

View File

@ -0,0 +1,19 @@
<?php
namespace App\Features;
use App\Models\User;
/**
* @api
*/
class CoinbaseIntegration
{
/**
* Off by default. Enable per-user with `php artisan feature:enable CoinbaseIntegration user@example.com`.
*/
public function resolve(User $user): bool
{
return false;
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Http\Controllers\OpenBanking;
use App\Enums\BankingConnectionStatus;
use App\Http\Controllers\Controller;
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
use App\Http\Requests\OpenBanking\ConnectCoinbaseRequest;
use App\Jobs\SyncBankingConnectionJob;
use App\Models\Bank;
use App\Services\Banking\CoinbaseClient;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Log;
class CoinbaseController extends Controller
{
use CreatesAccountsFromPending;
use HandlesSubscriptionGate;
/**
* Validate Coinbase CDP API credentials and create a connection.
*/
public function store(ConnectCoinbaseRequest $request): JsonResponse
{
$validated = $request->validated();
$user = auth()->user();
if ($this->shouldBlockOpenBankingAccess($user)) {
return $this->subscribeJsonResponse();
}
$client = new CoinbaseClient($validated['api_key_name'], $validated['private_key']);
try {
$client->getAccounts(limit: 1);
} catch (\Throwable $e) {
Log::warning('Coinbase credential validation failed', ['error' => $e->getMessage()]);
return response()->json([
'message' => 'Invalid API credentials or failed to connect to Coinbase.',
], 422);
}
$bank = Bank::firstOrCreate(
['name' => 'Coinbase', 'user_id' => null],
['name' => 'Coinbase', 'logo' => 'https://whisper.money/storage/banks/logos/coinbase.png'],
);
$connection = $user->bankingConnections()->create([
'provider' => 'coinbase',
'api_token' => $validated['api_key_name'],
'api_secret' => $validated['private_key'],
'aspsp_name' => 'Coinbase',
'aspsp_country' => $validated['country'],
'aspsp_logo' => $bank->logo,
'status' => BankingConnectionStatus::Pending,
]);
$pendingAccounts = [
[
'uid' => 'coinbase-portfolio',
'currency' => $user->currency_code,
'name' => 'Crypto Portfolio',
],
];
$connection->update([
'status' => BankingConnectionStatus::AwaitingMapping,
'pending_accounts_data' => $pendingAccounts,
]);
if (! $user->isOnboarded()) {
$this->createAccountsFromPending($user, $connection);
SyncBankingConnectionJob::dispatch($connection);
return response()->json([
'redirect_url' => route('onboarding', ['step' => 'create-account']),
'connection_id' => $connection->id,
]);
}
return response()->json([
'redirect_url' => route('open-banking.map-accounts', $connection),
'connection_id' => $connection->id,
]);
}
}

View File

@ -13,6 +13,7 @@ use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\BinanceClient;
use App\Services\Banking\BitpandaClient;
use App\Services\Banking\CoinbaseClient;
use App\Services\Banking\IndexaCapitalClient;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\RedirectResponse;
@ -96,6 +97,7 @@ class ConnectionController extends Controller
'indexacapital' => ['api_token' => $validated['api_token']],
'binance' => ['api_token' => $validated['api_key'], 'api_secret' => $validated['api_secret']],
'bitpanda' => ['api_token' => $validated['api_key']],
'coinbase' => ['api_token' => $validated['api_key_name'], 'api_secret' => $validated['private_key']],
default => [],
};
@ -121,6 +123,7 @@ class ConnectionController extends Controller
'indexacapital' => (new IndexaCapitalClient($validated['api_token']))->getUser(),
'binance' => (new BinanceClient($validated['api_key'], $validated['api_secret']))->getAccount(),
'bitpanda' => (new BitpandaClient($validated['api_key']))->getCryptoWallets(),
'coinbase' => (new CoinbaseClient($validated['api_key_name'], $validated['private_key']))->getAccounts(limit: 1),
default => throw new \InvalidArgumentException('Unsupported provider for credential update.'),
};
} catch (\InvalidArgumentException $e) {

View File

@ -3,10 +3,12 @@
namespace App\Http\Middleware;
use App\Enums\AccountType;
use App\Features\CoinbaseIntegration;
use App\Services\CurrencyOptions;
use Illuminate\Foundation\Inspiring;
use Illuminate\Http\Request;
use Inertia\Middleware;
use Laravel\Pennant\Feature;
class HandleInertiaRequests extends Middleware
{
@ -150,8 +152,11 @@ class HandleInertiaRequests extends Middleware
*/
protected function resolveFeatureFlags(): array
{
$user = request()->user();
return [
'cashflow' => true,
'coinbase' => $user ? Feature::for($user)->active(CoinbaseIntegration::class) : false,
];
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\OpenBanking;
use Illuminate\Foundation\Http\FormRequest;
class ConnectCoinbaseRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, array<mixed>>
*/
public function rules(): array
{
return [
'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'],
'private_key' => ['required', 'string', 'min:40'],
'country' => ['required', 'string', 'size:2'],
];
}
/**
* @return array<string, string>
*/
public function messages(): array
{
return [
'api_key_name.regex' => 'The App Key ID must be a valid UUID (Ed25519) or organizations/{org_id}/apiKeys/{key_id} (ECDSA).',
'private_key.min' => 'The Secret looks too short. Paste the full secret from Coinbase.',
];
}
}

View File

@ -37,6 +37,10 @@ class UpdateConnectionCredentialsRequest extends FormRequest
'bitpanda' => [
'api_key' => ['required', 'string', 'min:10'],
],
'coinbase' => [
'api_key_name' => ['required', 'string', 'regex:/^(organizations\/[a-z0-9-]+\/apiKeys\/[a-z0-9-]+|[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/i'],
'private_key' => ['required', 'string', 'min:40'],
],
default => [],
};
}

View File

@ -13,6 +13,8 @@ use App\Services\Banking\BinanceBalanceSyncService;
use App\Services\Banking\BinanceClient;
use App\Services\Banking\BitpandaBalanceSyncService;
use App\Services\Banking\BitpandaClient;
use App\Services\Banking\CoinbaseBalanceSyncService;
use App\Services\Banking\CoinbaseClient;
use App\Services\Banking\IndexaCapitalBalanceSyncService;
use App\Services\Banking\IndexaCapitalClient;
use App\Services\Banking\TransactionSyncService;
@ -112,6 +114,8 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
$this->syncBinance($connection, $isFirstSync);
} elseif ($connection->isBitpanda()) {
$this->syncBitpanda($connection);
} elseif ($connection->isCoinbase()) {
$this->syncCoinbase($connection);
} else {
$metadata = $this->syncEnableBanking($connection, $transactionSync, $balanceSync, $isFirstSync);
@ -327,6 +331,18 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
}
}
private function syncCoinbase(BankingConnection $connection): void
{
$client = new CoinbaseClient($connection->api_token, $connection->api_secret);
$syncService = app(CoinbaseBalanceSyncService::class);
$connection->load('accounts');
foreach ($connection->accounts as $account) {
$syncService->sync($account, $client);
}
}
/**
* @return array<string, mixed>
*/
@ -454,6 +470,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
{
return $connection->isIndexaCapital()
|| $connection->isBinance()
|| $connection->isBitpanda();
|| $connection->isBitpanda()
|| $connection->isCoinbase();
}
}

View File

@ -114,6 +114,11 @@ class BankingConnection extends Model
return $this->provider === 'bitpanda';
}
public function isCoinbase(): bool
{
return $this->provider === 'coinbase';
}
public function isEnableBanking(): bool
{
return $this->provider === 'enablebanking';

View File

@ -0,0 +1,211 @@
<?php
namespace App\Services\Banking;
use App\Models\Account;
use App\Services\CurrencyConversionService;
use Illuminate\Support\Facades\Log;
class CoinbaseBalanceSyncService
{
/** @var array<int, string> Stablecoins pegged 1:1 to USD */
private const USD_STABLECOINS = ['USDT', 'USDC', 'DAI', 'PYUSD', 'GUSD'];
private const USD_CURRENCY = 'USD';
public function __construct(private CurrencyConversionService $currencyConverter) {}
/**
* Sync the total portfolio value for a Coinbase account.
* Aggregates every wallet balance (crypto + fiat) into the user's fiat currency.
*
* @api
*/
public function sync(Account $account, CoinbaseClient $client): void
{
if (! $account->external_account_id) {
return;
}
$this->syncCurrentBalance($account, $client);
}
/**
* Sync today's balance by listing every Coinbase account and converting to target currency.
*/
public function syncCurrentBalance(Account $account, CoinbaseClient $client): void
{
$targetCurrency = strtoupper($account->currency_code);
$coinbaseAccounts = $client->getAllAccounts();
if (empty($coinbaseAccounts)) {
return;
}
[$fiatTotal, $cryptoAssets] = $this->partitionBalances($coinbaseAccounts, $targetCurrency);
$priceMap = $this->fetchPriceMap($client, array_keys($cryptoAssets), $targetCurrency);
$cryptoTotal = $this->convertCryptoAssets($cryptoAssets, $priceMap, $targetCurrency);
$totalValueCents = (int) round(($fiatTotal + $cryptoTotal) * 100);
$account->balances()->updateOrCreate(
['balance_date' => now()->toDateString()],
['balance' => $totalValueCents],
);
}
/**
* Split Coinbase accounts into fiat (converted directly) and crypto holdings.
*
* @param array<int, array<string, mixed>> $coinbaseAccounts
* @return array{0: float, 1: array<string, float>}
*/
private function partitionBalances(array $coinbaseAccounts, string $targetCurrency): array
{
$fiatTotal = 0.0;
$cryptoAssets = [];
foreach ($coinbaseAccounts as $coinbaseAccount) {
$currency = strtoupper($coinbaseAccount['currency'] ?? '');
$available = (float) ($coinbaseAccount['available_balance']['value'] ?? 0);
$hold = (float) ($coinbaseAccount['hold']['value'] ?? 0);
$balance = $available + $hold;
if ($currency === '' || $balance <= 0) {
continue;
}
if ($this->isFiatCurrency($currency)) {
$fiatTotal += $this->convertFiat($currency, $balance, $targetCurrency);
continue;
}
$cryptoAssets[$currency] = ($cryptoAssets[$currency] ?? 0.0) + $balance;
}
return [$fiatTotal, $cryptoAssets];
}
private function convertFiat(string $currency, float $amount, string $targetCurrency): float
{
if ($currency === $targetCurrency) {
return $amount;
}
return $this->currencyConverter->convert(
$currency,
$targetCurrency,
$amount,
now()->toDateString(),
);
}
/**
* Build a price map (asset => price in target currency) using batched best_bid_ask.
*
* @param array<int, string> $assets
* @return array<string, float>
*/
private function fetchPriceMap(CoinbaseClient $client, array $assets, string $targetCurrency): array
{
$productIds = array_map(fn (string $asset) => "{$asset}-{$targetCurrency}", $assets);
if (empty($productIds)) {
return [];
}
try {
$response = $client->getBestBidAsk($productIds);
} catch (\Throwable $e) {
Log::warning('Coinbase best_bid_ask failed, falling back to per-asset USD conversion', [
'error' => $e->getMessage(),
]);
return [];
}
$map = [];
foreach ($response['pricebooks'] ?? [] as $pricebook) {
$productId = $pricebook['product_id'] ?? '';
$bid = (float) ($pricebook['bids'][0]['price'] ?? 0);
$ask = (float) ($pricebook['asks'][0]['price'] ?? 0);
if ($productId === '') {
continue;
}
$asset = explode('-', $productId)[0];
if ($bid > 0 && $ask > 0) {
$map[$asset] = ($bid + $ask) / 2;
} elseif ($bid > 0) {
$map[$asset] = $bid;
} elseif ($ask > 0) {
$map[$asset] = $ask;
}
}
return $map;
}
/**
* Convert each crypto holding to target fiat. Falls back via USD pair + currency converter.
*
* @param array<string, float> $cryptoAssets
* @param array<string, float> $priceMap
*/
private function convertCryptoAssets(array $cryptoAssets, array $priceMap, string $targetCurrency): float
{
$total = 0.0;
foreach ($cryptoAssets as $asset => $quantity) {
if (in_array($asset, self::USD_STABLECOINS, true)) {
$total += $this->convertFiat(self::USD_CURRENCY, $quantity, $targetCurrency);
continue;
}
if (isset($priceMap[$asset])) {
$total += $quantity * $priceMap[$asset];
continue;
}
$converted = $this->currencyConverter->convert(
$asset,
$targetCurrency,
$quantity,
now()->toDateString(),
);
if ($converted > 0) {
$total += $converted;
continue;
}
Log::warning('Could not price Coinbase asset', [
'asset' => $asset,
'target_currency' => $targetCurrency,
'quantity' => $quantity,
]);
}
return $total;
}
/**
* Heuristic: ISO 4217 fiat codes are 3 letters; Coinbase exposes them like USD/EUR/GBP.
* Stablecoins are not fiat (priced via crypto pairs).
*/
private function isFiatCurrency(string $currency): bool
{
static $fiats = ['USD', 'EUR', 'GBP', 'JPY', 'AUD', 'CAD', 'CHF', 'CNY', 'NZD', 'SEK', 'NOK', 'DKK', 'BRL', 'TRY', 'MXN', 'ZAR', 'SGD', 'HKD', 'PLN'];
return in_array($currency, $fiats, true);
}
}

View File

@ -0,0 +1,181 @@
<?php
namespace App\Services\Banking;
use Exception;
use Firebase\JWT\JWT;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class CoinbaseClient
{
private const BASE_URL = 'https://api.coinbase.com';
private const HOST = 'api.coinbase.com';
/** @var array<int, int> Retry backoff: 10s, 30s, 60s */
private const RETRY_BACKOFF_MS = [10_000, 30_000, 60_000];
private const JWT_TTL_SECONDS = 120;
/**
* @param string $keyName CDP API key name (organizations/{org}/apiKeys/{id}) or Ed25519 key ID (UUID).
* @param string $privateKey PEM EC private key (ES256) or base64 Ed25519 secret.
*/
public function __construct(
private string $keyName,
private string $privateKey,
) {}
/**
* ES256 (ECDSA PEM) when keyName is the org/apiKeys path; EdDSA (Ed25519) when keyName is a UUID.
*/
private function algorithm(): string
{
return str_starts_with($this->keyName, 'organizations/') ? 'ES256' : 'EdDSA';
}
/**
* List all brokerage accounts (one per currency) with paginated cursor.
*
* @return array<string, mixed>
*/
public function getAccounts(?string $cursor = null, int $limit = 250): array
{
$params = ['limit' => $limit];
if ($cursor !== null && $cursor !== '') {
$params['cursor'] = $cursor;
}
return $this->signedRequest('GET', '/api/v3/brokerage/accounts', $params);
}
/**
* Fetch every account by paginating through the cursor.
*
* @return array<int, array<string, mixed>>
*/
public function getAllAccounts(): array
{
$all = [];
$cursor = null;
do {
$response = $this->getAccounts($cursor);
$batch = $response['accounts'] ?? [];
foreach ($batch as $account) {
$all[] = $account;
}
$cursor = $response['has_next'] ?? false ? ($response['cursor'] ?? null) : null;
} while ($cursor);
return $all;
}
/**
* Get the latest spot price for a single product (e.g. BTC-EUR).
*
* @return array<string, mixed>
*
* @api
*/
public function getProduct(string $productId): array
{
return $this->signedRequest('GET', "/api/v3/brokerage/products/{$productId}");
}
/**
* Get best bid/ask for multiple product IDs in one request.
*
* @param array<int, string> $productIds e.g. ['BTC-EUR', 'ETH-EUR']
* @return array<string, mixed>
*/
public function getBestBidAsk(array $productIds): array
{
$params = [];
foreach ($productIds as $productId) {
$params['product_ids'][] = $productId;
}
return $this->signedRequest('GET', '/api/v3/brokerage/best_bid_ask', $params);
}
/**
* Execute a signed JWT request with retry on rate limiting.
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
private function signedRequest(string $method, string $path, array $params = []): array
{
return retry(
self::RETRY_BACKOFF_MS,
function () use ($method, $path, $params) {
$jwt = $this->buildJwt($method, $path);
$request = $this->client($jwt);
$url = $path;
if (! empty($params)) {
$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
// Coinbase expects repeated keys without numeric indices: product_ids=A&product_ids=B.
$query = preg_replace('/%5B\d+%5D=/', '=', $query);
$url .= '?'.$query;
}
$response = match (strtoupper($method)) {
'GET' => $request->get($url),
default => throw new \InvalidArgumentException("Unsupported HTTP method: {$method}"),
};
$response->throw();
return $response->json();
},
when: fn (Exception $e) => $e instanceof RequestException && $e->response->status() === 429,
);
}
/**
* Build a Coinbase CDP JWT for a single request.
*
* Claims: sub=keyName, iss=cdp, nbf=now, exp=now+120, uri="METHOD host/path".
* Header includes kid=keyName and a random nonce.
*/
private function buildJwt(string $method, string $path): string
{
$now = time();
$payload = [
'sub' => $this->keyName,
'iss' => 'cdp',
'nbf' => $now,
'exp' => $now + self::JWT_TTL_SECONDS,
'uri' => strtoupper($method).' '.self::HOST.$path,
];
$headers = ['nonce' => bin2hex(random_bytes(16))];
return JWT::encode($payload, $this->privateKey, $this->algorithm(), $this->keyName, $headers);
}
private function client(string $jwt): PendingRequest
{
return Http::baseUrl(self::BASE_URL)
->withToken($jwt)
->acceptJson()
->throw(function ($response) {
Log::error('Coinbase API error', [
'status' => $response->status(),
'body' => $response->json(),
]);
});
}
}

View File

@ -117,6 +117,21 @@ class BankingConnectionFactory extends Factory
]);
}
public function coinbase(): static
{
return $this->state(fn (array $attributes) => [
'provider' => 'coinbase',
'authorization_id' => null,
'session_id' => null,
'api_token' => 'organizations/org-'.fake()->uuid().'/apiKeys/key-'.fake()->uuid(),
'api_secret' => "-----BEGIN EC PRIVATE KEY-----\nFAKEKEY\n-----END EC PRIVATE KEY-----",
'aspsp_name' => 'Coinbase',
'aspsp_country' => 'ES',
'aspsp_logo' => 'https://whisper.money/storage/banks/logos/coinbase.png',
'valid_until' => null,
]);
}
public function error(): static
{
return $this->state(fn (array $attributes) => [

View File

@ -9458,5 +9458,9 @@
{
"name": "Bitpanda",
"logo": "https://whisper.money/storage/banks/logos/7Y6gl0gaFH1mStJMcUQ9VpgzX1kduyumm0dDhGlf.png"
},
{
"name": "Coinbase",
"logo": "https://whisper.money/storage/banks/logos/coinbase.png"
}
]

View File

@ -83,6 +83,9 @@
"AI requires sending your data to external\\n servers": "La IA requiere enviar tus datos a servidores externos",
"API Key": "Clave API",
"API Key Management": "Gestión de Claves API",
"API Key Name": "Nombre de la Clave API",
"API Keys": "Claves API",
"App Key ID": "ID de la Clave API",
"API Management": "Gestión de API",
"API Secret": "Secreto API",
"API Token": "Token API",
@ -343,6 +346,9 @@
"Connect in seconds": "Conecta en segundos",
"Connect your Binance account using your API Key and Secret.": "Conecta tu cuenta de Binance usando tu Clave API y Secreto.",
"Connect your Bitpanda account using your API Key.": "Conecta tu cuenta de Bitpanda usando tu Clave API.",
"Connect your Coinbase account using a CDP API key.": "Conecta tu cuenta de Coinbase usando una clave API de CDP.",
"Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under": "Crea una clave API de CDP (Ed25519 recomendado) en la Coinbase Developer Platform en",
"Create a CDP API key in the Coinbase Developer Platform under": "Crea una clave API de CDP en la Coinbase Developer Platform en",
"Connect your Indexa Capital account using your API token.": "Conecta tu cuenta de Indexa Capital usando tu token API.",
"Connect your bank accounts": "Conecta tus cuentas bancarias",
"Connect your bank accounts, savings, investments, and more — all in a single dashboard. No more switching between apps or losing track of accounts.": "Conecta tus cuentas bancarias, ahorros, inversiones y más, todo en un único panel. Sin más cambiar entre apps ni perder el control de tus cuentas.",
@ -509,6 +515,8 @@
"Enter transactions yourself or import a CSV file": "Introduce transacciones tú mismo o importa un archivo CSV",
"Enter your API Key and Secret to connect your Binance account.": "Introduce tu Clave API y Secreto para conectar tu cuenta de Binance.",
"Enter your API Key to connect your Bitpanda account.": "Introduce tu Clave API para conectar tu cuenta de Bitpanda.",
"Enter your CDP App Key ID and Secret to connect your Coinbase account.": "Introduce el ID de tu Clave API de CDP y el Secreto para conectar tu cuenta de Coinbase.",
"Enter your CDP API key name and private key to connect your Coinbase account.": "Introduce el nombre de tu clave API de CDP y la clave privada para conectar tu cuenta de Coinbase.",
"Enter your API token to connect your Indexa Capital account.": "Introduce tu token API para conectar tu cuenta de Indexa Capital.",
"Enter your details below to create your account": "Ingresa tus datos a continuación para crear tu cuenta",
"Enter your email and password below to log in": "Ingresa tu correo electrónico y contraseña a continuación para iniciar sesión",
@ -1006,6 +1014,8 @@
"Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information.": "Política de privacidad de Whisper Money. Aprende cómo recopilamos, usamos y protegemos tu información personal.",
"Privacy policy for Whisper Money. Learn how we collect, use, and protect your personal information. Your data is never shared with third parties.": "Política de privacidad de Whisper Money. Aprende cómo recopilamos, usamos y protegemos tu información personal. Tus datos nunca se comparten con terceros.",
"Private & Secure": "Privado y seguro",
"Private Key": "Clave Privada",
"Secret": "Secreto",
"Private by Design": "Privado por Diseño",
"Pro Monthly": "Pro Mensual",
"Pro Plan Active": "Plan Pro Activo",
@ -1430,6 +1440,7 @@
"Uruguayan Peso": "Peso uruguayo",
"Usage Information:": "Información de Uso:",
"Use Defaults": "Usar Valores Predeterminados",
"Use a view-only key.": "Usa una clave de solo lectura.",
"Use a strong password (minimum 12 characters). This password will encrypt your data.": "Usa una contraseña fuerte (mínimo 12 caracteres). Esta contraseña encriptará tus datos.",
"Use a strong password (minimum 12 characters). This\\n password will encrypt your data.": "Usa una contraseña segura (mínimo 12 caracteres). Esta contraseña cifrará tus datos.",
"Use code **:code** to get **80% off** your first period (monthly or yearly!)": "Usa el código **:code** para obtener **80% de descuento** en tu primer período (¡mensual o anual!)",

View File

@ -8,6 +8,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
@ -21,6 +22,8 @@ import type {
EnableBankingInstitution,
} from '@/types/banking';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import type { SharedData } from '@/types';
import { useCallback, useEffect, useMemo, useState } from 'react';
const COUNTRIES = [
@ -65,6 +68,13 @@ const BITPANDA_INSTITUTION: EnableBankingInstitution = {
maximum_consent_validity: null,
};
const COINBASE_INSTITUTION: EnableBankingInstitution = {
name: 'Coinbase',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/coinbase.png',
maximum_consent_validity: null,
};
interface ConnectAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
@ -87,6 +97,7 @@ export function ConnectAccountDialog({
onOpenChange,
connections = [],
}: ConnectAccountDialogProps) {
const { features } = usePage<SharedData>().props;
const [step, setStep] = useState<Step>('country');
const [country, setCountry] = useState<string>('');
const [institutions, setInstitutions] = useState<
@ -105,6 +116,8 @@ export function ConnectAccountDialog({
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [bitpandaApiKey, setBitpandaApiKey] = useState('');
const [coinbaseKeyName, setCoinbaseKeyName] = useState('');
const [coinbasePrivateKey, setCoinbasePrivateKey] = useState('');
const isIndexaCapital = useMemo(
() => selectedBank?.name === 'Indexa Capital',
@ -121,6 +134,11 @@ export function ConnectAccountDialog({
[selectedBank],
);
const isCoinbase = useMemo(
() => selectedBank?.name === 'Coinbase',
[selectedBank],
);
const resetState = useCallback(() => {
setStep('country');
setCountry('');
@ -135,6 +153,8 @@ export function ConnectAccountDialog({
setApiKey('');
setApiSecret('');
setBitpandaApiKey('');
setCoinbaseKeyName('');
setCoinbasePrivateKey('');
}, []);
useEffect(() => {
@ -184,10 +204,10 @@ export function ConnectAccountDialog({
const hasProvider = (provider: string) =>
connections.some((c) => c.provider === provider);
const extraInstitutions = [
BINANCE_INSTITUTION,
BITPANDA_INSTITUTION,
];
const extraInstitutions = [BINANCE_INSTITUTION, BITPANDA_INSTITUTION];
if (features.coinbase) {
extraInstitutions.push(COINBASE_INSTITUTION);
}
if (countryCode === 'ES') {
extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION);
}
@ -200,6 +220,9 @@ export function ConnectAccountDialog({
if (institution.name === 'Bitpanda') {
return !hasProvider('bitpanda');
}
if (institution.name === 'Coinbase') {
return !hasProvider('coinbase');
}
if (institution.name === 'Indexa Capital') {
return !hasProvider('indexacapital');
}
@ -230,7 +253,9 @@ export function ConnectAccountDialog({
? '/open-banking/binance/connect'
: isIndexaCapital
? '/open-banking/indexa-capital/connect'
: '/open-banking/authorize';
: isCoinbase
? '/open-banking/coinbase/connect'
: '/open-banking/authorize';
const body = isBitpanda
? { api_key: bitpandaApiKey, country: country }
@ -238,11 +263,17 @@ export function ConnectAccountDialog({
? { api_key: apiKey, api_secret: apiSecret, country: country }
: isIndexaCapital
? { api_token: apiToken }
: {
aspsp_name: selectedBank.name,
country: country,
logo: selectedBank.logo,
};
: isCoinbase
? {
api_key_name: coinbaseKeyName,
private_key: coinbasePrivateKey,
country: country,
}
: {
aspsp_name: selectedBank.name,
country: country,
logo: selectedBank.logo,
};
const response = await fetch(url, {
method: 'POST',
@ -288,6 +319,7 @@ export function ConnectAccountDialog({
!isIndexaCapital &&
!isBinance &&
!isBitpanda &&
!isCoinbase &&
__(
'You will be redirected to your bank to authorize access.',
)}
@ -306,6 +338,11 @@ export function ConnectAccountDialog({
__(
'Enter your API Key to connect your Bitpanda account.',
)}
{step === 'confirm' &&
isCoinbase &&
__(
'Enter your CDP App Key ID and Secret to connect your Coinbase account.',
)}
</DialogDescription>
</DialogHeader>
@ -424,9 +461,13 @@ export function ConnectAccountDialog({
? __(
'Connect your Indexa Capital account using your API token.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
: isCoinbase
? __(
'Connect your Coinbase account using a CDP API key.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
</p>
</div>
</div>
@ -553,6 +594,59 @@ export function ConnectAccountDialog({
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(e.target.value)
}
className="mt-1 font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(
e.target.value,
)
}
rows={6}
className="mt-1 font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
. {__('Use a view-only key.')}
</p>
</div>
)}
<div className="flex justify-end gap-2">
<Button
variant="outline"
@ -567,7 +661,10 @@ export function ConnectAccountDialog({
isSubmitting ||
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey)
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName ||
!coinbasePrivateKey))
}
>
{isSubmitting

View File

@ -2,6 +2,7 @@ import { BankLogo } from '@/components/bank-logo';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
@ -15,6 +16,8 @@ import type {
EnableBankingInstitution,
} from '@/types/banking';
import { __ } from '@/utils/i18n';
import { usePage } from '@inertiajs/react';
import type { SharedData } from '@/types';
import { ArrowLeft } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
@ -60,6 +63,13 @@ const BITPANDA_INSTITUTION: EnableBankingInstitution = {
maximum_consent_validity: null,
};
const COINBASE_INSTITUTION: EnableBankingInstitution = {
name: 'Coinbase',
country: 'ALL',
logo: 'https://whisper.money/storage/banks/logos/coinbase.png',
maximum_consent_validity: null,
};
type Step = 'country' | 'bank' | 'confirm';
function getCsrfToken(): string {
@ -80,6 +90,7 @@ export function ConnectAccountInline({
onBack,
connections = [],
}: ConnectAccountInlineProps) {
const { features } = usePage<SharedData>().props;
const [step, setStep] = useState<Step>('country');
const { trigger } = useWebHaptics();
const [country, setCountry] = useState<string>('');
@ -99,6 +110,8 @@ export function ConnectAccountInline({
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [bitpandaApiKey, setBitpandaApiKey] = useState('');
const [coinbaseKeyName, setCoinbaseKeyName] = useState('');
const [coinbasePrivateKey, setCoinbasePrivateKey] = useState('');
const isIndexaCapital = useMemo(
() => selectedBank?.name === 'Indexa Capital',
@ -112,6 +125,10 @@ export function ConnectAccountInline({
() => selectedBank?.name === 'Bitpanda',
[selectedBank],
);
const isCoinbase = useMemo(
() => selectedBank?.name === 'Coinbase',
[selectedBank],
);
useEffect(() => {
if (searchQuery) {
@ -168,10 +185,10 @@ export function ConnectAccountInline({
const hasProvider = (provider: string) =>
connections.some((c) => c.provider === provider);
const extraInstitutions = [
BINANCE_INSTITUTION,
BITPANDA_INSTITUTION,
];
const extraInstitutions = [BINANCE_INSTITUTION, BITPANDA_INSTITUTION];
if (features.coinbase) {
extraInstitutions.push(COINBASE_INSTITUTION);
}
if (countryCode === 'ES') {
extraInstitutions.push(INDEXA_CAPITAL_INSTITUTION);
}
@ -184,6 +201,9 @@ export function ConnectAccountInline({
if (institution.name === 'Bitpanda') {
return !hasProvider('bitpanda');
}
if (institution.name === 'Coinbase') {
return !hasProvider('coinbase');
}
if (institution.name === 'Indexa Capital') {
return !hasProvider('indexacapital');
}
@ -216,7 +236,9 @@ export function ConnectAccountInline({
? '/open-banking/binance/connect'
: isIndexaCapital
? '/open-banking/indexa-capital/connect'
: '/open-banking/authorize';
: isCoinbase
? '/open-banking/coinbase/connect'
: '/open-banking/authorize';
const body = isBitpanda
? { api_key: bitpandaApiKey, country }
@ -224,11 +246,17 @@ export function ConnectAccountInline({
? { api_key: apiKey, api_secret: apiSecret, country }
: isIndexaCapital
? { api_token: apiToken }
: {
aspsp_name: selectedBank.name,
country,
logo: selectedBank.logo,
};
: isCoinbase
? {
api_key_name: coinbaseKeyName,
private_key: coinbasePrivateKey,
country,
}
: {
aspsp_name: selectedBank.name,
country,
logo: selectedBank.logo,
};
const response = await fetch(url, {
method: 'POST',
@ -398,9 +426,13 @@ export function ConnectAccountInline({
? __(
'Connect your Indexa Capital account using your API token.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
: isCoinbase
? __(
'Connect your Coinbase account using a CDP API key.',
)
: __(
'You will be redirected to authorize access to your account data.',
)}
</p>
</div>
</div>
@ -513,6 +545,57 @@ export function ConnectAccountInline({
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(e.target.value)
}
className="font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(e.target.value)
}
rows={6}
className="font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
. {__('Use a view-only key.')}
</p>
</div>
)}
<Button
className="w-full"
size="lg"
@ -521,7 +604,9 @@ export function ConnectAccountInline({
isSubmitting ||
(isIndexaCapital && !apiToken) ||
(isBinance && (!apiKey || !apiSecret)) ||
(isBitpanda && !bitpandaApiKey)
(isBitpanda && !bitpandaApiKey) ||
(isCoinbase &&
(!coinbaseKeyName || !coinbasePrivateKey))
}
>
{isSubmitting ? __('Connecting...') : __('Connect')}

View File

@ -9,6 +9,7 @@ import {
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import type { BankingConnection } from '@/types/banking';
import { __ } from '@/utils/i18n';
import { router } from '@inertiajs/react';
@ -29,11 +30,14 @@ export function UpdateCredentialsDialog({
const [apiToken, setApiToken] = useState('');
const [apiKey, setApiKey] = useState('');
const [apiSecret, setApiSecret] = useState('');
const [coinbaseKeyName, setCoinbaseKeyName] = useState('');
const [coinbasePrivateKey, setCoinbasePrivateKey] = useState('');
const [error, setError] = useState<string | null>(null);
const isIndexaCapital = connection.provider === 'indexacapital';
const isBinance = connection.provider === 'binance';
const isBitpanda = connection.provider === 'bitpanda';
const isCoinbase = connection.provider === 'coinbase';
const isValid = isIndexaCapital
? apiToken.length > 0
@ -41,7 +45,9 @@ export function UpdateCredentialsDialog({
? apiKey.length > 0 && apiSecret.length > 0
: isBitpanda
? apiKey.length > 0
: false;
: isCoinbase
? coinbaseKeyName.length > 0 && coinbasePrivateKey.length > 0
: false;
function handleSubmit() {
setIsSubmitting(true);
@ -51,7 +57,12 @@ export function UpdateCredentialsDialog({
? { api_token: apiToken }
: isBinance
? { api_key: apiKey, api_secret: apiSecret }
: { api_key: apiKey };
: isCoinbase
? {
api_key_name: coinbaseKeyName,
private_key: coinbasePrivateKey,
}
: { api_key: apiKey };
router.patch(
`/settings/connections/${connection.id}/credentials`,
@ -67,6 +78,8 @@ export function UpdateCredentialsDialog({
errors.api_token ??
errors.api_key ??
errors.api_secret ??
errors.api_key_name ??
errors.private_key ??
__(
'Failed to update credentials. Please try again.',
),
@ -83,6 +96,8 @@ export function UpdateCredentialsDialog({
setApiToken('');
setApiKey('');
setApiSecret('');
setCoinbaseKeyName('');
setCoinbasePrivateKey('');
setError(null);
}
@ -216,6 +231,57 @@ export function UpdateCredentialsDialog({
</p>
</div>
)}
{isCoinbase && (
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="update-coinbase-key-name">
{__('App Key ID')}
</Label>
<Input
id="update-coinbase-key-name"
type="text"
value={coinbaseKeyName}
onChange={(e) =>
setCoinbaseKeyName(e.target.value)
}
className="font-mono text-xs"
placeholder="00000000-0000-0000-0000-000000000000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="update-coinbase-private-key">
{__('Secret')}
</Label>
<Textarea
id="update-coinbase-private-key"
value={coinbasePrivateKey}
onChange={(e) =>
setCoinbasePrivateKey(e.target.value)
}
rows={6}
className="font-mono text-xs"
placeholder={
'Paste your CDP API key secret'
}
/>
</div>
<p className="text-xs text-muted-foreground">
{__(
'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
)}{' '}
<a
href="https://portal.cdp.coinbase.com/access/api"
target="_blank"
rel="noopener noreferrer"
className="underline"
>
{__('API Keys')}
</a>
.
</p>
</div>
)}
</div>
<DialogFooter>

View File

@ -126,7 +126,7 @@ export default function ConnectionsPage({ connections }: Props) {
}
function isApiKeyProvider(connection: BankingConnection): boolean {
return ['indexacapital', 'binance', 'bitpanda'].includes(
return ['indexacapital', 'binance', 'bitpanda', 'coinbase'].includes(
connection.provider,
);
}

View File

@ -41,6 +41,7 @@ export interface NavDivider {
export interface Features {
cashflow: boolean;
coinbase: boolean;
}
export interface Flash {

View File

@ -10,6 +10,7 @@ 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\CoinbaseController;
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
use App\Http\Controllers\OpenBanking\InstitutionController;
use App\Http\Controllers\RealEstateDetailController;
@ -133,6 +134,8 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function
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::post('coinbase/connect', [CoinbaseController::class, 'store'])
->name('open-banking.coinbase.connect');
});
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {

View File

@ -0,0 +1,143 @@
<?php
use App\Models\Account;
use App\Models\BankingConnection;
use App\Models\User;
use App\Services\Banking\CoinbaseBalanceSyncService;
use App\Services\Banking\CoinbaseClient;
use Illuminate\Support\Facades\Http;
function ecPrivateKeyForCoinbase(): string
{
$key = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
openssl_pkey_export($key, $pem);
return $pem;
}
test('syncs coinbase balance with crypto and fiat wallets', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->coinbase()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'coinbase-portfolio',
'currency_code' => 'EUR',
]);
Http::fake([
'api.coinbase.com/api/v3/brokerage/accounts*' => Http::response([
'accounts' => [
[
'uuid' => 'cb-1',
'name' => 'BTC',
'currency' => 'BTC',
'available_balance' => ['value' => '1.0', 'currency' => 'BTC'],
'hold' => ['value' => '0', 'currency' => 'BTC'],
'active' => true,
'type' => 'ACCOUNT_TYPE_CRYPTO',
],
[
'uuid' => 'cb-2',
'name' => 'EUR',
'currency' => 'EUR',
'available_balance' => ['value' => '500.00', 'currency' => 'EUR'],
'hold' => ['value' => '0', 'currency' => 'EUR'],
'active' => true,
'type' => 'ACCOUNT_TYPE_FIAT',
],
],
'has_next' => false,
'cursor' => '',
'size' => 2,
]),
'api.coinbase.com/api/v3/brokerage/best_bid_ask*' => Http::response([
'pricebooks' => [
[
'product_id' => 'BTC-EUR',
'bids' => [['price' => '49900.00', 'size' => '1']],
'asks' => [['price' => '50100.00', 'size' => '1']],
],
],
]),
]);
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
$service = app(CoinbaseBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(1);
// 1 BTC * 50000 (mid of 49900/50100) EUR + 500 EUR fiat = 50500 EUR → 5_050_000 cents
$balance = $account->balances()->first();
expect($balance->balance)->toBe(5_050_000);
expect($balance->balance_date->toDateString())->toBe(now()->toDateString());
});
test('treats usd stablecoins as usd when valuing', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'USD']);
$connection = BankingConnection::factory()->coinbase()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => 'coinbase-portfolio',
'currency_code' => 'USD',
]);
Http::fake([
'api.coinbase.com/api/v3/brokerage/accounts*' => Http::response([
'accounts' => [
[
'uuid' => 'cb-1',
'name' => 'USDC',
'currency' => 'USDC',
'available_balance' => ['value' => '1000.00', 'currency' => 'USDC'],
'hold' => ['value' => '0', 'currency' => 'USDC'],
'active' => true,
'type' => 'ACCOUNT_TYPE_CRYPTO',
],
],
'has_next' => false,
'cursor' => '',
'size' => 1,
]),
'api.coinbase.com/api/v3/brokerage/best_bid_ask*' => Http::response([
'pricebooks' => [],
]),
]);
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
$service = app(CoinbaseBalanceSyncService::class);
$service->sync($account, $client);
$balance = $account->balances()->first();
expect($balance->balance)->toBe(100_000); // 1000 USD → 100000 cents
});
test('skips sync when external_account_id is missing', function () {
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
$connection = BankingConnection::factory()->coinbase()->create([
'user_id' => $user->id,
]);
$account = Account::factory()->connected()->create([
'user_id' => $user->id,
'banking_connection_id' => $connection->id,
'external_account_id' => null,
'currency_code' => 'EUR',
]);
$client = new CoinbaseClient('organizations/org/apiKeys/key', ecPrivateKeyForCoinbase());
$service = app(CoinbaseBalanceSyncService::class);
$service->sync($account, $client);
expect($account->balances()->count())->toBe(0);
Http::assertNothingSent();
});

View File

@ -0,0 +1,129 @@
<?php
use App\Enums\BankingConnectionStatus;
use App\Models\BankingConnection;
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
function generateTestEcPrivateKey(): string
{
$key = openssl_pkey_new([
'private_key_type' => OPENSSL_KEYTYPE_EC,
'curve_name' => 'prime256v1',
]);
openssl_pkey_export($key, $pem);
return $pem;
}
test('users can connect a coinbase account with valid credentials', function () {
Queue::fake();
$user = User::factory()->onboarded()->create(['currency_code' => 'EUR']);
Http::fake([
'api.coinbase.com/api/v3/brokerage/accounts*' => Http::response([
'accounts' => [
[
'uuid' => 'cb-acc-1',
'name' => 'BTC Wallet',
'currency' => 'BTC',
'available_balance' => ['value' => '0.5', 'currency' => 'BTC'],
'hold' => ['value' => '0', 'currency' => 'BTC'],
'active' => true,
'type' => 'ACCOUNT_TYPE_CRYPTO',
],
],
'has_next' => false,
'cursor' => '',
'size' => 1,
]),
]);
$response = $this->actingAs($user)->postJson('/open-banking/coinbase/connect', [
'api_key_name' => 'organizations/org-uuid/apiKeys/key-uuid',
'private_key' => generateTestEcPrivateKey(),
'country' => 'ES',
]);
$response->assertOk();
$response->assertJsonStructure(['redirect_url', 'connection_id']);
$connection = BankingConnection::where('user_id', $user->id)->where('provider', 'coinbase')->first();
expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
expect($connection->pending_accounts_data)->toHaveCount(1);
expect($connection->pending_accounts_data[0]['uid'])->toBe('coinbase-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('invalid coinbase credentials return 422', function () {
$user = User::factory()->onboarded()->create();
Http::fake([
'api.coinbase.com/api/v3/brokerage/accounts*' => Http::response(['error' => 'unauthorized'], 401),
]);
$response = $this->actingAs($user)->postJson('/open-banking/coinbase/connect', [
'api_key_name' => 'organizations/org-uuid/apiKeys/key-uuid',
'private_key' => generateTestEcPrivateKey(),
'country' => 'ES',
]);
$response->assertUnprocessable();
$response->assertJsonFragment(['message' => 'Invalid API credentials or failed to connect to Coinbase.']);
$this->assertDatabaseMissing('banking_connections', [
'user_id' => $user->id,
'provider' => 'coinbase',
]);
});
test('coinbase request validates api_key_name format', function () {
$user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->postJson('/open-banking/coinbase/connect', [
'api_key_name' => 'not-a-valid-format',
'private_key' => generateTestEcPrivateKey(),
'country' => 'ES',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['api_key_name']);
});
test('coinbase request validates private_key length', function () {
$user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->postJson('/open-banking/coinbase/connect', [
'api_key_name' => 'organizations/org-uuid/apiKeys/key-uuid',
'private_key' => 'short',
'country' => 'ES',
]);
$response->assertUnprocessable();
$response->assertJsonValidationErrors(['private_key']);
});
test('free tier users cannot connect a coinbase account after onboarding when subscriptions are enabled', function () {
config(['subscriptions.enabled' => true]);
$user = User::factory()->onboarded()->create();
$response = $this->actingAs($user)->postJson('/open-banking/coinbase/connect', [
'api_key_name' => 'organizations/org-uuid/apiKeys/key-uuid',
'private_key' => generateTestEcPrivateKey(),
'country' => 'ES',
]);
$response->assertPaymentRequired();
});