diff --git a/app/Features/CoinbaseIntegration.php b/app/Features/CoinbaseIntegration.php
new file mode 100644
index 00000000..5238e23c
--- /dev/null
+++ b/app/Features/CoinbaseIntegration.php
@@ -0,0 +1,19 @@
+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,
+ ]);
+ }
+}
diff --git a/app/Http/Controllers/OpenBanking/ConnectionController.php b/app/Http/Controllers/OpenBanking/ConnectionController.php
index b1bebf1f..9c2adb07 100644
--- a/app/Http/Controllers/OpenBanking/ConnectionController.php
+++ b/app/Http/Controllers/OpenBanking/ConnectionController.php
@@ -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) {
diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php
index 3bfb9ee8..2f872f17 100644
--- a/app/Http/Middleware/HandleInertiaRequests.php
+++ b/app/Http/Middleware/HandleInertiaRequests.php
@@ -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,
];
}
diff --git a/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php b/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php
new file mode 100644
index 00000000..05433830
--- /dev/null
+++ b/app/Http/Requests/OpenBanking/ConnectCoinbaseRequest.php
@@ -0,0 +1,36 @@
+>
+ */
+ 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
+ */
+ 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.',
+ ];
+ }
+}
diff --git a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php
index b0663b61..ce072fe5 100644
--- a/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php
+++ b/app/Http/Requests/OpenBanking/UpdateConnectionCredentialsRequest.php
@@ -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 => [],
};
}
diff --git a/app/Jobs/SyncBankingConnectionJob.php b/app/Jobs/SyncBankingConnectionJob.php
index e5b2e39e..93c0e7e4 100644
--- a/app/Jobs/SyncBankingConnectionJob.php
+++ b/app/Jobs/SyncBankingConnectionJob.php
@@ -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
*/
@@ -454,6 +470,7 @@ class SyncBankingConnectionJob implements ShouldBeUnique, ShouldQueue
{
return $connection->isIndexaCapital()
|| $connection->isBinance()
- || $connection->isBitpanda();
+ || $connection->isBitpanda()
+ || $connection->isCoinbase();
}
}
diff --git a/app/Models/BankingConnection.php b/app/Models/BankingConnection.php
index 9de57f38..7d841c08 100644
--- a/app/Models/BankingConnection.php
+++ b/app/Models/BankingConnection.php
@@ -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';
diff --git a/app/Services/Banking/CoinbaseBalanceSyncService.php b/app/Services/Banking/CoinbaseBalanceSyncService.php
new file mode 100644
index 00000000..f2020605
--- /dev/null
+++ b/app/Services/Banking/CoinbaseBalanceSyncService.php
@@ -0,0 +1,211 @@
+ 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> $coinbaseAccounts
+ * @return array{0: float, 1: array}
+ */
+ 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 $assets
+ * @return array
+ */
+ 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 $cryptoAssets
+ * @param array $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);
+ }
+}
diff --git a/app/Services/Banking/CoinbaseClient.php b/app/Services/Banking/CoinbaseClient.php
new file mode 100644
index 00000000..9867d49b
--- /dev/null
+++ b/app/Services/Banking/CoinbaseClient.php
@@ -0,0 +1,181 @@
+ 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
+ */
+ 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>
+ */
+ 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
+ *
+ * @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 $productIds e.g. ['BTC-EUR', 'ETH-EUR']
+ * @return array
+ */
+ 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 $params
+ * @return array
+ */
+ 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(),
+ ]);
+ });
+ }
+}
diff --git a/database/factories/BankingConnectionFactory.php b/database/factories/BankingConnectionFactory.php
index 6c29f2cc..ad7445c3 100644
--- a/database/factories/BankingConnectionFactory.php
+++ b/database/factories/BankingConnectionFactory.php
@@ -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) => [
diff --git a/database/seeders/data/banks.json b/database/seeders/data/banks.json
index 5f7ddb0e..4c6753a8 100644
--- a/database/seeders/data/banks.json
+++ b/database/seeders/data/banks.json
@@ -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"
}
]
diff --git a/lang/es.json b/lang/es.json
index 0779dbbb..3e8ccda4 100644
--- a/lang/es.json
+++ b/lang/es.json
@@ -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!)",
diff --git a/resources/js/components/open-banking/connect-account-dialog.tsx b/resources/js/components/open-banking/connect-account-dialog.tsx
index ed505170..0654cfb5 100644
--- a/resources/js/components/open-banking/connect-account-dialog.tsx
+++ b/resources/js/components/open-banking/connect-account-dialog.tsx
@@ -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().props;
const [step, setStep] = useState('country');
const [country, setCountry] = useState('');
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.',
+ )}
@@ -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.',
+ )}
@@ -553,6 +594,59 @@ export function ConnectAccountDialog({
)}
+ {isCoinbase && (
+
+
+
+
+ setCoinbaseKeyName(e.target.value)
+ }
+ className="mt-1 font-mono text-xs"
+ placeholder="00000000-0000-0000-0000-000000000000"
+ />
+
+
+
+
+
+ {__(
+ 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
+ )}{' '}
+
+ {__('API Keys')}
+
+ . {__('Use a view-only key.')}
+
+
+ )}
+
@@ -513,6 +545,57 @@ export function ConnectAccountInline({
)}
+ {isCoinbase && (
+
+
+
+
+ setCoinbaseKeyName(e.target.value)
+ }
+ className="font-mono text-xs"
+ placeholder="00000000-0000-0000-0000-000000000000"
+ />
+
+
+
+
+
+ {__(
+ 'Create a CDP API key (Ed25519 recommended) in the Coinbase Developer Platform under',
+ )}{' '}
+
+ {__('API Keys')}
+
+ . {__('Use a view-only key.')}
+
+
+ )}
+