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" + /> +
+
+ +