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:
parent
a09339d3c0
commit
e71a743a0a
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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 => [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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) => [
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
11
lang/es.json
11
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!)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ export interface NavDivider {
|
|||
|
||||
export interface Features {
|
||||
cashflow: boolean;
|
||||
coinbase: boolean;
|
||||
}
|
||||
|
||||
export interface Flash {
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in New Issue