feat: add Plaid provider for US bank sync
Adds full Plaid integration as a new banking provider: Backend: - Add Plaid to BankingProvider enum (credential fields, account type, usesApiKey) - Add Plaid to TransactionSource enum - PlaidClient — wraps Plaid REST API (link/token/create, /item/public_token/exchange, /transactions/sync, /accounts/balance/get, /accounts/get) - PlaidController — createLinkToken + store (exchange public_token for access_token, create connection with pending accounts) - PlaidTransactionSyncService — sync via /transactions/sync endpoint - PlaidBalanceSyncService — sync via /accounts/balance/get - PlaidSyncer — orchestrator, registered in BankingConnectionSyncerFactory - plaid config entry in config/services.php - Routes for /open-banking/plaid/link-token and /open-banking/plaid/connect Frontend: - Plaid entry in CONNECT_PROVIDERS registry - usesSdk flag on ConnectProvider type for SDK-based providers - Plaid Link flow in useConnectFlow hook (CDN-based Plaid Link SDK) - ProviderCredentialFields shows description text for SDK providers Tests: - PlaidControllerTest (link token, connect, invalid token, validation) - BankingConnectionSyncerFactoryTest updated for PlaidSyncer
This commit is contained in:
parent
7d750fa1a8
commit
30a79b6883
|
|
@ -12,6 +12,7 @@ enum BankingProvider: string
|
|||
case Coinbase = 'coinbase';
|
||||
case InteractiveBrokers = 'interactivebrokers';
|
||||
case Wise = 'wise';
|
||||
case Plaid = 'plaid';
|
||||
case EnableBanking = 'enablebanking';
|
||||
|
||||
/**
|
||||
|
|
@ -20,7 +21,10 @@ enum BankingProvider: string
|
|||
*/
|
||||
public function usesApiKey(): bool
|
||||
{
|
||||
return $this !== self::EnableBanking;
|
||||
return match ($this) {
|
||||
self::EnableBanking => false,
|
||||
default => true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -30,7 +34,7 @@ enum BankingProvider: string
|
|||
{
|
||||
return match ($this) {
|
||||
self::IndexaCapital, self::Binance, self::Bitpanda, self::Coinbase, self::InteractiveBrokers => AccountType::Investment,
|
||||
self::Wise, self::EnableBanking => AccountType::Checking,
|
||||
self::Wise, self::EnableBanking, self::Plaid => AccountType::Checking,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -67,6 +71,7 @@ enum BankingProvider: string
|
|||
new CredentialField('query_id', 'api_secret', ['required', 'string', 'min:3']),
|
||||
],
|
||||
self::EnableBanking => [],
|
||||
self::Plaid => [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,4 +8,5 @@ enum TransactionSource: string
|
|||
case Imported = 'imported';
|
||||
case EnableBanking = 'enablebanking';
|
||||
case Wise = 'wise';
|
||||
case Plaid = 'plaid';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,155 @@
|
|||
<?php
|
||||
|
||||
namespace App\Http\Controllers\OpenBanking;
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Enums\BankingProvider;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\CreatesAccountsFromPending;
|
||||
use App\Http\Controllers\OpenBanking\Concerns\HandlesSubscriptionGate;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\Bank;
|
||||
use App\Services\AccountUserCurrencyService;
|
||||
use App\Services\Banking\PlaidClient;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlaidController extends Controller
|
||||
{
|
||||
use CreatesAccountsFromPending;
|
||||
use HandlesSubscriptionGate;
|
||||
|
||||
/**
|
||||
* Create a Plaid Link token for the frontend SDK.
|
||||
*/
|
||||
public function createLinkToken(Request $request): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
try {
|
||||
$client = new PlaidClient;
|
||||
$result = $client->createLinkToken((string) $user->id);
|
||||
|
||||
return response()->json([
|
||||
'link_token' => $result['link_token'],
|
||||
'expiration' => $result['expiration'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to create Plaid link token', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Failed to create Plaid Link token. Please check your Plaid configuration.',
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a Plaid public_token for an access_token and create a connection.
|
||||
*/
|
||||
public function store(Request $request, AccountUserCurrencyService $accountUserCurrencyService): JsonResponse
|
||||
{
|
||||
$user = auth()->user();
|
||||
|
||||
if ($this->shouldBlockOpenBankingAccess($user)) {
|
||||
return $this->subscribeJsonResponse();
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'public_token' => ['required', 'string', 'min:10'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$client = new PlaidClient;
|
||||
$exchange = $client->exchangePublicToken($request->public_token);
|
||||
|
||||
$accessToken = $exchange['access_token'];
|
||||
$itemId = $exchange['item_id'];
|
||||
|
||||
// Fetch accounts from Plaid to build pending accounts
|
||||
$clientWithAccess = new PlaidClient($accessToken, $itemId);
|
||||
$plaidAccounts = $clientWithAccess->getAccounts();
|
||||
|
||||
$pendingAccounts = $this->buildPendingAccounts($plaidAccounts['accounts'] ?? []);
|
||||
|
||||
if ($pendingAccounts === []) {
|
||||
return response()->json(['message' => 'No accounts found for this Plaid Link token.'], 422);
|
||||
}
|
||||
|
||||
$bank = Bank::firstOrCreate(
|
||||
['name' => 'Plaid', 'user_id' => null],
|
||||
['name' => 'Plaid', 'logo' => null],
|
||||
);
|
||||
|
||||
$connection = $user->bankingConnections()->create([
|
||||
'provider' => BankingProvider::Plaid,
|
||||
'api_token' => $accessToken,
|
||||
'api_secret' => $itemId,
|
||||
'aspsp_name' => 'Plaid',
|
||||
'aspsp_country' => 'US',
|
||||
'aspsp_logo' => $bank->logo,
|
||||
'status' => BankingConnectionStatus::AwaitingMapping,
|
||||
'pending_accounts_data' => $pendingAccounts,
|
||||
]);
|
||||
|
||||
if (! $user->isOnboarded()) {
|
||||
$this->createAccountsFromPending($user, $connection, $accountUserCurrencyService);
|
||||
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,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to connect Plaid', [
|
||||
'user_id' => $user->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Failed to connect Plaid. Please try again.',
|
||||
], 422);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{account_id?: string, name?: string, official_name?: string, type?: string, subtype?: string, balances?: array{iso_currency_code?: string}}> $plaidAccounts
|
||||
* @return array<int, array{uid: string, currency: string, name: string}>
|
||||
*/
|
||||
private function buildPendingAccounts(array $plaidAccounts): array
|
||||
{
|
||||
$pending = [];
|
||||
|
||||
foreach ($plaidAccounts as $account) {
|
||||
$accountId = $account['account_id'] ?? null;
|
||||
|
||||
if ($accountId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$currency = $account['balances']['iso_currency_code'] ?? 'USD';
|
||||
$name = $account['official_name'] ?? $account['name'] ?? 'Plaid Account';
|
||||
|
||||
$pending[] = [
|
||||
'uid' => $accountId,
|
||||
'currency' => $currency,
|
||||
'name' => $name,
|
||||
];
|
||||
}
|
||||
|
||||
return $pending;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking;
|
||||
|
||||
use App\Models\Account;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlaidBalanceSyncService
|
||||
{
|
||||
/**
|
||||
* Sync today's balance for a Plaid account via /accounts/balance/get.
|
||||
*
|
||||
* The account's `external_account_id` is the Plaid `account_id`.
|
||||
*/
|
||||
public function sync(Account $account, PlaidClient $client): void
|
||||
{
|
||||
if (! $account->external_account_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$result = $client->getBalances();
|
||||
|
||||
$plaidAccount = collect($result['accounts'] ?? [])
|
||||
->firstWhere('account_id', $account->external_account_id);
|
||||
|
||||
if ($plaidAccount === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$currentBalance = $plaidAccount['balances']['current'] ?? null;
|
||||
|
||||
if ($currentBalance === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$amountCents = (int) round((float) $currentBalance * 100);
|
||||
|
||||
$account->balances()->updateOrCreate(
|
||||
['balance_date' => now()->toDateString()],
|
||||
['balance' => $amountCents],
|
||||
);
|
||||
|
||||
Log::info('Synced Plaid balance', [
|
||||
'account_id' => $account->id,
|
||||
'external_account_id' => $account->external_account_id,
|
||||
'balance' => $amountCents,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,148 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking;
|
||||
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
use Illuminate\Http\Client\RequestException;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlaidClient
|
||||
{
|
||||
private const ENV_URLS = [
|
||||
'sandbox' => 'https://sandbox.plaid.com',
|
||||
'development' => 'https://development.plaid.com',
|
||||
'production' => 'https://production.plaid.com',
|
||||
];
|
||||
|
||||
private ?string $accessToken = null;
|
||||
|
||||
private string $baseUrl;
|
||||
private string $clientId;
|
||||
private string $secret;
|
||||
|
||||
public function __construct(
|
||||
?string $accessToken = null,
|
||||
public readonly ?string $itemId = null,
|
||||
) {
|
||||
$this->accessToken = $accessToken;
|
||||
$this->clientId = config('services.plaid.client_id');
|
||||
$this->secret = config('services.plaid.secret');
|
||||
$env = config('services.plaid.env', 'sandbox');
|
||||
$this->baseUrl = self::ENV_URLS[$env] ?? self::ENV_URLS['sandbox'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a link token for Plaid Link frontend SDK.
|
||||
*
|
||||
* @param string $clientUserId Your internal user ID (opaque to Plaid).
|
||||
* @param array $countryCodes e.g. ['US', 'CA']
|
||||
* @return array{link_token: string, expiration: string}
|
||||
*/
|
||||
public function createLinkToken(string $clientUserId, array $countryCodes = ['US']): array
|
||||
{
|
||||
$response = $this->client()->post('/link/token/create', [
|
||||
'user' => [
|
||||
'client_user_id' => $clientUserId,
|
||||
],
|
||||
'client_name' => 'Whisper Money',
|
||||
'products' => ['transactions'],
|
||||
'country_codes' => $countryCodes,
|
||||
'language' => 'en',
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Exchange a Plaid Link public_token for a permanent access_token.
|
||||
*
|
||||
* @return array{access_token: string, item_id: string, request_id: string}
|
||||
*/
|
||||
public function exchangePublicToken(string $publicToken): array
|
||||
{
|
||||
$response = $this->client()->post('/item/public_token/exchange', [
|
||||
'public_token' => $publicToken,
|
||||
]);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch transaction updates for an item using Plaid's /transactions/sync.
|
||||
*
|
||||
* @return array{added: array, modified: array, removed: array, next_cursor: string|null, has_more: bool}
|
||||
*/
|
||||
public function syncTransactions(?string $cursor = null): array
|
||||
{
|
||||
$payload = [];
|
||||
|
||||
if ($cursor !== null) {
|
||||
$payload['cursor'] = $cursor;
|
||||
}
|
||||
|
||||
$response = $this->client()->post('/transactions/sync', $payload);
|
||||
|
||||
$response->throw();
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch account balances.
|
||||
*
|
||||
* @return array{accounts: array, item: array, request_id: string}
|
||||
*/
|
||||
public function getBalances(): array
|
||||
{
|
||||
$response = $this->client()->post('/accounts/balance/get');
|
||||
|
||||
$response->throw();
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch account details.
|
||||
*
|
||||
* @return array{accounts: array, item: array, request_id: string}
|
||||
*/
|
||||
public function getAccounts(): array
|
||||
{
|
||||
$response = $this->client()->post('/accounts/get');
|
||||
|
||||
$response->throw();
|
||||
|
||||
return $response->json();
|
||||
}
|
||||
|
||||
private function client(): PendingRequest
|
||||
{
|
||||
$payload = [
|
||||
'client_id' => $this->clientId,
|
||||
'secret' => $this->secret,
|
||||
];
|
||||
|
||||
if ($this->accessToken !== null) {
|
||||
$payload['access_token'] = $this->accessToken;
|
||||
}
|
||||
|
||||
return Http::baseUrl($this->baseUrl)
|
||||
->withOptions(['json' => $payload])
|
||||
->acceptJson()
|
||||
->throw(function ($response, RequestException $exception) {
|
||||
$body = $response->json();
|
||||
$errorCode = $body['error_code'] ?? null;
|
||||
|
||||
Log::warning('Plaid API error', [
|
||||
'status' => $response->status(),
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $body['error_message'] ?? null,
|
||||
]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking;
|
||||
|
||||
use App\Enums\TransactionSource;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Database\UniqueConstraintViolationException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PlaidTransactionSyncService
|
||||
{
|
||||
/**
|
||||
* Sync transactions for a Plaid account via /transactions/sync.
|
||||
*
|
||||
* The account's `external_account_id` is the Plaid `account_id`.
|
||||
*
|
||||
* @return int Number of new transactions created
|
||||
*/
|
||||
public function sync(Account $account, PlaidClient $client, string $dateFrom, string $dateTo): int
|
||||
{
|
||||
if (! $account->external_account_id) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$cursor = null;
|
||||
$created = 0;
|
||||
$hasMore = true;
|
||||
|
||||
do {
|
||||
$result = $client->syncTransactions($cursor);
|
||||
$added = $result['added'] ?? [];
|
||||
$hasMore = $result['has_more'] ?? false;
|
||||
$cursor = $result['next_cursor'] ?? null;
|
||||
|
||||
foreach ($added as $transaction) {
|
||||
if (($transaction['account_id'] ?? '') !== $account->external_account_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$parsed = $this->parseTransaction($transaction);
|
||||
|
||||
if ($parsed === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($this->importTransaction($account, $transaction, $parsed)) {
|
||||
$created++;
|
||||
}
|
||||
}
|
||||
} while ($hasMore);
|
||||
|
||||
Log::info('Synced Plaid transactions', [
|
||||
'account_id' => $account->id,
|
||||
'external_account_id' => $account->external_account_id,
|
||||
'new_transactions' => $created,
|
||||
'date_from' => $dateFrom,
|
||||
'date_to' => $dateTo,
|
||||
]);
|
||||
|
||||
return $created;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Plaid transaction into normalized amount + description.
|
||||
*
|
||||
* @return array{amount_cents: int, currency: string, description: string}|null
|
||||
*/
|
||||
private function parseTransaction(array $transaction): ?array
|
||||
{
|
||||
$amount = (float) ($transaction['amount'] ?? 0);
|
||||
|
||||
if ($amount === 0.0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$amountCents = (int) round($amount * 100);
|
||||
|
||||
$currency = $transaction['iso_currency_code'] ?? 'USD';
|
||||
|
||||
// Prefer merchant_name, fall back to name
|
||||
$description = $transaction['merchant_name']
|
||||
?? $transaction['name']
|
||||
?? 'Plaid Transaction';
|
||||
|
||||
return [
|
||||
'amount_cents' => $amountCents,
|
||||
'currency' => $currency,
|
||||
'description' => $description,
|
||||
];
|
||||
}
|
||||
|
||||
private function importTransaction(Account $account, array $transaction, array $parsed): bool
|
||||
{
|
||||
$externalId = $transaction['transaction_id'] ?? null;
|
||||
$fingerprint = $this->fingerprint($transaction, $parsed);
|
||||
|
||||
$exists = $account->transactions()
|
||||
->withTrashed()
|
||||
->where(function ($query) use ($fingerprint, $externalId) {
|
||||
$query->where('dedup_fingerprint', $fingerprint);
|
||||
|
||||
if ($externalId !== null) {
|
||||
$query->orWhere('external_transaction_id', $externalId);
|
||||
}
|
||||
})
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$transactionDate = $transaction['date'] ?? now()->toDateString();
|
||||
|
||||
try {
|
||||
$account->transactions()->create([
|
||||
'user_id' => $account->user_id,
|
||||
'description' => $parsed['description'],
|
||||
'description_iv' => null,
|
||||
'original_description' => $parsed['description'],
|
||||
'transaction_date' => $transactionDate,
|
||||
'amount' => $parsed['amount_cents'],
|
||||
'currency_code' => $parsed['currency'],
|
||||
'notes' => null,
|
||||
'notes_iv' => null,
|
||||
'source' => TransactionSource::Plaid,
|
||||
'external_transaction_id' => $externalId,
|
||||
'dedup_fingerprint' => $fingerprint,
|
||||
'raw_data' => $transaction,
|
||||
]);
|
||||
} catch (UniqueConstraintViolationException) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function fingerprint(array $transaction, array $parsed): string
|
||||
{
|
||||
$id = $transaction['transaction_id'] ?? null;
|
||||
|
||||
if ($id !== null) {
|
||||
return 'fp_'.hash('sha256', implode("\x1f", ['plaid_transaction_id', $id]));
|
||||
}
|
||||
|
||||
return 'fp_'.hash('sha256', implode("\x1f", [
|
||||
$transaction['date'] ?? '',
|
||||
(string) $parsed['amount_cents'],
|
||||
$parsed['currency'],
|
||||
$parsed['description'],
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ class BankingConnectionSyncerFactory
|
|||
BankingProvider::Bitpanda => BitpandaSyncer::class,
|
||||
BankingProvider::Coinbase => CoinbaseSyncer::class,
|
||||
BankingProvider::InteractiveBrokers => InteractiveBrokersSyncer::class,
|
||||
BankingProvider::Plaid => PlaidSyncer::class,
|
||||
BankingProvider::EnableBanking => EnableBankingSyncer::class,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services\Banking\Sync;
|
||||
|
||||
use App\Models\BankingConnection;
|
||||
use App\Services\Banking\PlaidBalanceSyncService;
|
||||
use App\Services\Banking\PlaidClient;
|
||||
use App\Services\Banking\PlaidTransactionSyncService;
|
||||
|
||||
class PlaidSyncer extends AbstractBankingConnectionSyncer
|
||||
{
|
||||
public function __construct(
|
||||
private PlaidTransactionSyncService $transactionSync,
|
||||
private PlaidBalanceSyncService $balanceSync,
|
||||
) {}
|
||||
|
||||
public function sync(BankingConnection $connection, bool $isFirstSync): array
|
||||
{
|
||||
$dateFrom = $isFirstSync
|
||||
? now()->subYear()->toDateString()
|
||||
: ($connection->last_synced_at?->toDateString() ?? now()->subMonth()->toDateString());
|
||||
$dateTo = now()->toDateString();
|
||||
|
||||
$client = new PlaidClient($connection->api_token, $connection->api_secret);
|
||||
|
||||
$connection->load('accounts');
|
||||
|
||||
$transactionsPerAccount = [];
|
||||
|
||||
foreach ($connection->accounts as $account) {
|
||||
$count = $this->transactionSync->sync($account, $client, $dateFrom, $dateTo);
|
||||
$this->balanceSync->sync($account, $client);
|
||||
$transactionsPerAccount[$account->name] = $count;
|
||||
}
|
||||
|
||||
return [
|
||||
'transactions_synced' => array_sum($transactionsPerAccount),
|
||||
'transactions_per_account' => $transactionsPerAccount,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -48,4 +48,10 @@ return [
|
|||
'ai_cohort_webhook_url' => env('DISCORD_AI_COHORT_WEBHOOK_URL'),
|
||||
],
|
||||
|
||||
'plaid' => [
|
||||
'client_id' => env('PLAID_CLIENT_ID'),
|
||||
'secret' => env('PLAID_SECRET'),
|
||||
'env' => env('PLAID_ENV', 'sandbox'),
|
||||
],
|
||||
|
||||
];
|
||||
|
|
|
|||
|
|
@ -174,11 +174,147 @@ export function useConnectFlow(connections: BankingConnection[]) {
|
|||
[connections],
|
||||
);
|
||||
|
||||
const postToBackend = useCallback(
|
||||
async (
|
||||
url: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<{ redirect_url: string } | null> => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
(data as { message?: string }).message ||
|
||||
'Failed to start authorization',
|
||||
);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handlePlaidLink = useCallback(async () => {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Get link token from backend
|
||||
const linkTokenResult = await postToBackend(
|
||||
'/open-banking/plaid/link-token',
|
||||
{},
|
||||
);
|
||||
|
||||
if (!linkTokenResult) {
|
||||
throw new Error('Failed to create Plaid link token');
|
||||
}
|
||||
|
||||
// linkTokenResult might just be { link_token, expiration } — not a redirect response
|
||||
// Re-fetch to get the link token directly
|
||||
const tokenResponse = await fetch(
|
||||
'/open-banking/plaid/link-token',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
throw new Error('Failed to create Plaid link token');
|
||||
}
|
||||
|
||||
const tokenData = await tokenResponse.json();
|
||||
const linkToken = tokenData.link_token;
|
||||
|
||||
if (!linkToken) {
|
||||
throw new Error('No link token returned from Plaid');
|
||||
}
|
||||
|
||||
// 2. Open Plaid Link
|
||||
const config: PlaidLinkConfig = {
|
||||
token: linkToken,
|
||||
onSuccess: async (publicToken: string) => {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await postToBackend(
|
||||
'/open-banking/plaid/connect',
|
||||
{ public_token: publicToken },
|
||||
);
|
||||
|
||||
if (result?.redirect_url) {
|
||||
window.location.href = result.redirect_url;
|
||||
}
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: __('Failed to connect. Please try again.'),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
},
|
||||
onExit: () => {
|
||||
setIsSubmitting(false);
|
||||
},
|
||||
};
|
||||
|
||||
const plaid = (window as unknown as PlaidWindow).Plaid;
|
||||
if (plaid) {
|
||||
plaid.create(config).open();
|
||||
} else {
|
||||
// Load Plaid Link script dynamically
|
||||
const script = document.createElement('script');
|
||||
script.src =
|
||||
'https://cdn.plaid.com/link/v2/stable/link-initialize.js';
|
||||
script.onload = () => {
|
||||
const plaidLoaded = (window as unknown as PlaidWindow)
|
||||
.Plaid;
|
||||
if (plaidLoaded) {
|
||||
plaidLoaded.create(config).open();
|
||||
}
|
||||
};
|
||||
script.onerror = () => {
|
||||
setError(
|
||||
__('Failed to load Plaid. Please try again.'),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: __('Failed to connect. Please try again.'),
|
||||
);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [postToBackend, __]);
|
||||
|
||||
const handleAuthorize = useCallback(async () => {
|
||||
if (!selectedBank) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Plaid uses its own SDK flow
|
||||
if (provider?.usesSdk) {
|
||||
await handlePlaidLink();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
|
|
@ -198,25 +334,11 @@ export function useConnectFlow(connections: BankingConnection[]) {
|
|||
logo: selectedBank.logo,
|
||||
};
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-XSRF-TOKEN': getCsrfToken(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await postToBackend(url, body);
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(
|
||||
data.message || 'Failed to start authorization',
|
||||
);
|
||||
if (result?.redirect_url) {
|
||||
window.location.href = result.redirect_url;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
window.location.href = data.redirect_url;
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
|
|
@ -225,7 +347,15 @@ export function useConnectFlow(connections: BankingConnection[]) {
|
|||
);
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [selectedBank, provider, credentials, country]);
|
||||
}, [
|
||||
selectedBank,
|
||||
provider,
|
||||
credentials,
|
||||
country,
|
||||
postToBackend,
|
||||
handlePlaidLink,
|
||||
__,
|
||||
]);
|
||||
|
||||
const canSubmit =
|
||||
!isSubmitting &&
|
||||
|
|
@ -259,3 +389,19 @@ export function useConnectFlow(connections: BankingConnection[]) {
|
|||
clearBankSelection,
|
||||
};
|
||||
}
|
||||
|
||||
interface PlaidLinkConfig {
|
||||
token: string;
|
||||
onSuccess: (publicToken: string, metadata?: unknown) => void;
|
||||
onExit?: (error?: unknown, metadata?: unknown) => void;
|
||||
onLoad?: () => void;
|
||||
onEvent?: (eventName: string, metadata: unknown) => void;
|
||||
}
|
||||
|
||||
interface PlaidStatic {
|
||||
create: (config: PlaidLinkConfig) => { open: () => void; destroy: () => void };
|
||||
}
|
||||
|
||||
interface PlaidWindow {
|
||||
Plaid: PlaidStatic | undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export type ConnectProvider = {
|
|||
cardDescription: string;
|
||||
fields: CredentialField[];
|
||||
help: { before: string; href: string; link: string; after?: string };
|
||||
/** Uses a frontend SDK (Plaid Link) instead of credential fields. */
|
||||
usesSdk?: boolean;
|
||||
};
|
||||
|
||||
export const CONNECT_PROVIDERS: ConnectProvider[] = [
|
||||
|
|
@ -237,6 +239,27 @@ export const CONNECT_PROVIDERS: ConnectProvider[] = [
|
|||
link: 'Performance & Reports → Flex Queries',
|
||||
},
|
||||
},
|
||||
{
|
||||
providerKey: 'plaid',
|
||||
institution: {
|
||||
name: 'Plaid',
|
||||
country: 'US',
|
||||
logo: null,
|
||||
maximum_consent_validity: null,
|
||||
},
|
||||
endpoint: '/open-banking/plaid/connect',
|
||||
onlyCountry: 'US',
|
||||
usesSdk: true,
|
||||
headerDescription: 'Connect your US bank account through Plaid.',
|
||||
cardDescription:
|
||||
'Use Plaid to securely connect your US bank account.',
|
||||
fields: [],
|
||||
help: {
|
||||
before: 'Plaid securely connects to over 12,000 US financial institutions.',
|
||||
href: 'https://plaid.com',
|
||||
link: 'Learn more about Plaid',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** Find a provider by the selected institution name. */
|
||||
|
|
@ -268,6 +291,9 @@ export function isProviderComplete(
|
|||
provider: ConnectProvider,
|
||||
values: Record<string, string>,
|
||||
): boolean {
|
||||
if (provider.usesSdk) {
|
||||
return true;
|
||||
}
|
||||
return provider.fields.every((f) => (values[f.key] ?? '').length > 0);
|
||||
}
|
||||
|
||||
|
|
@ -303,6 +329,19 @@ export function ProviderCredentialFields({
|
|||
onChange: (key: string, value: string) => void;
|
||||
idPrefix?: string;
|
||||
}) {
|
||||
if (provider.usesSdk) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{__(
|
||||
'You will be redirected to Plaid to securely connect your bank account.',
|
||||
)}
|
||||
</p>
|
||||
<ProviderHelp help={provider.help} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{provider.fields.map((field) => {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ use App\Http\Controllers\OpenBanking\ConnectionAccountController;
|
|||
use App\Http\Controllers\OpenBanking\IndexaCapitalController;
|
||||
use App\Http\Controllers\OpenBanking\InstitutionController;
|
||||
use App\Http\Controllers\OpenBanking\InteractiveBrokersController;
|
||||
use App\Http\Controllers\OpenBanking\PlaidController;
|
||||
use App\Http\Controllers\OpenBanking\WiseController;
|
||||
use App\Http\Controllers\RealEstateDetailController;
|
||||
use App\Http\Controllers\ReEvaluateTransactionRulesController;
|
||||
|
|
@ -184,6 +185,10 @@ Route::middleware(['auth', 'verified'])->prefix('open-banking')->group(function
|
|||
->name('open-banking.wise.connect');
|
||||
Route::post('interactive-brokers/connect', [InteractiveBrokersController::class, 'store'])
|
||||
->name('open-banking.interactive-brokers.connect');
|
||||
Route::post('plaid/link-token', [PlaidController::class, 'createLinkToken'])
|
||||
->name('open-banking.plaid.link-token');
|
||||
Route::post('plaid/connect', [PlaidController::class, 'store'])
|
||||
->name('open-banking.plaid.connect');
|
||||
});
|
||||
|
||||
Route::middleware(['auth', 'verified', 'onboarded', 'subscribed'])->group(function () {
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ use App\Services\Banking\Sync\CoinbaseSyncer;
|
|||
use App\Services\Banking\Sync\EnableBankingSyncer;
|
||||
use App\Services\Banking\Sync\IndexaCapitalSyncer;
|
||||
use App\Services\Banking\Sync\InteractiveBrokersSyncer;
|
||||
use App\Services\Banking\Sync\PlaidSyncer;
|
||||
use App\Services\Banking\Sync\WiseSyncer;
|
||||
|
||||
dataset('providers', [
|
||||
|
|
@ -19,6 +20,7 @@ dataset('providers', [
|
|||
'bitpanda' => [BankingProvider::Bitpanda, BitpandaSyncer::class],
|
||||
'coinbase' => [BankingProvider::Coinbase, CoinbaseSyncer::class],
|
||||
'interactivebrokers' => [BankingProvider::InteractiveBrokers, InteractiveBrokersSyncer::class],
|
||||
'plaid' => [BankingProvider::Plaid, PlaidSyncer::class],
|
||||
'enablebanking' => [BankingProvider::EnableBanking, EnableBankingSyncer::class],
|
||||
]);
|
||||
|
||||
|
|
@ -51,5 +53,7 @@ it('notifies on auth failure for every API-key provider but not EnableBanking',
|
|||
->and(app(InteractiveBrokersSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
|
||||
->and(app(InteractiveBrokersSyncer::class)->expires())->toBeFalse()
|
||||
->and(app(WiseSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
|
||||
->and(app(PlaidSyncer::class)->notifiesOnAuthFailure())->toBeTrue()
|
||||
->and(app(PlaidSyncer::class)->expires())->toBeFalse()
|
||||
->and(app(EnableBankingSyncer::class)->notifiesOnAuthFailure())->toBeFalse();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\BankingConnectionStatus;
|
||||
use App\Jobs\SyncBankingConnectionJob;
|
||||
use App\Models\BankingConnection;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
function fakePlaidApi(): void
|
||||
{
|
||||
Http::fake(function (Illuminate\Http\Client\Request $request) {
|
||||
$url = $request->url();
|
||||
$body = $request->data();
|
||||
|
||||
// Link token creation
|
||||
if (str_contains($url, '/link/token/create')) {
|
||||
return Http::response([
|
||||
'link_token' => 'link-sandbox-abc123def',
|
||||
'expiration' => '2026-07-08T00:00:00Z',
|
||||
'request_id' => 'req_link_token',
|
||||
]);
|
||||
}
|
||||
|
||||
// Public token exchange
|
||||
if (str_contains($url, '/item/public_token/exchange')) {
|
||||
return Http::response([
|
||||
'access_token' => 'access-sandbox-xyz789',
|
||||
'item_id' => 'item_abc123',
|
||||
'request_id' => 'req_exchange',
|
||||
]);
|
||||
}
|
||||
|
||||
// Get accounts
|
||||
if (str_contains($url, '/accounts/get')) {
|
||||
return Http::response([
|
||||
'accounts' => [
|
||||
[
|
||||
'account_id' => 'plaid_acc_checking_1',
|
||||
'name' => 'Plaid Checking',
|
||||
'official_name' => 'Plaid Premier Checking',
|
||||
'type' => 'depository',
|
||||
'subtype' => 'checking',
|
||||
'balances' => [
|
||||
'current' => 1250.00,
|
||||
'iso_currency_code' => 'USD',
|
||||
],
|
||||
],
|
||||
[
|
||||
'account_id' => 'plaid_acc_savings_1',
|
||||
'name' => 'Plaid Savings',
|
||||
'official_name' => null,
|
||||
'type' => 'depository',
|
||||
'subtype' => 'savings',
|
||||
'balances' => [
|
||||
'current' => 5000.00,
|
||||
'iso_currency_code' => 'USD',
|
||||
],
|
||||
],
|
||||
],
|
||||
'item' => ['item_id' => 'item_abc123'],
|
||||
'request_id' => 'req_accounts',
|
||||
]);
|
||||
}
|
||||
|
||||
return Http::response([], 404);
|
||||
});
|
||||
}
|
||||
|
||||
test('creates a link token from plaid', function () {
|
||||
fakePlaidApi();
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/open-banking/plaid/link-token');
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['link_token', 'expiration']);
|
||||
});
|
||||
|
||||
test('connecting plaid with valid public token creates pending accounts', function () {
|
||||
Queue::fake();
|
||||
fakePlaidApi();
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/open-banking/plaid/connect', [
|
||||
'public_token' => 'public-sandbox-valid-token-12345',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertJsonStructure(['redirect_url', 'connection_id']);
|
||||
|
||||
$connection = BankingConnection::where('user_id', $user->id)->where('provider', 'plaid')->first();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::AwaitingMapping);
|
||||
|
||||
$uids = collect($connection->pending_accounts_data)->pluck('uid');
|
||||
|
||||
expect($uids)->toContain('plaid_acc_checking_1', 'plaid_acc_savings_1');
|
||||
expect($connection->pending_accounts_data)->toHaveCount(2);
|
||||
|
||||
// Assert access token is stored encrypted
|
||||
expect($connection->api_token)->not->toBeNull();
|
||||
|
||||
// No accounts created yet for an onboarded user (mapping step does that).
|
||||
$this->assertDatabaseMissing('accounts', [
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
]);
|
||||
});
|
||||
|
||||
test('connecting plaid during onboarding auto-creates accounts', function () {
|
||||
Queue::fake();
|
||||
fakePlaidApi();
|
||||
|
||||
$user = User::factory()->create(); // not onboarded
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/open-banking/plaid/connect', [
|
||||
'public_token' => 'public-sandbox-valid-token-12345',
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
|
||||
$connection = BankingConnection::where('user_id', $user->id)->where('provider', 'plaid')->first();
|
||||
expect($connection->status)->toBe(BankingConnectionStatus::Active);
|
||||
|
||||
foreach (['plaid_acc_checking_1', 'plaid_acc_savings_1'] as $uid) {
|
||||
$this->assertDatabaseHas('accounts', [
|
||||
'user_id' => $user->id,
|
||||
'banking_connection_id' => $connection->id,
|
||||
'external_account_id' => $uid,
|
||||
]);
|
||||
}
|
||||
|
||||
Queue::assertPushed(SyncBankingConnectionJob::class);
|
||||
});
|
||||
|
||||
test('plaid connect with invalid public token returns 422', function () {
|
||||
Http::fake([
|
||||
'sandbox.plaid.com/item/public_token/exchange' => Http::response(
|
||||
['error_code' => 'INVALID_PUBLIC_TOKEN', 'error_message' => 'provided public token is not in a valid format'],
|
||||
400,
|
||||
),
|
||||
]);
|
||||
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/open-banking/plaid/connect', [
|
||||
'public_token' => 'invalid-token',
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonFragment(['message' => 'Failed to connect Plaid. Please try again.']);
|
||||
|
||||
$this->assertDatabaseMissing('banking_connections', [
|
||||
'user_id' => $user->id,
|
||||
'provider' => 'plaid',
|
||||
]);
|
||||
});
|
||||
|
||||
test('plaid connect requires public_token', function () {
|
||||
$user = User::factory()->onboarded()->create();
|
||||
|
||||
$response = $this->actingAs($user)->postJson('/open-banking/plaid/connect', []);
|
||||
|
||||
$response->assertStatus(422);
|
||||
$response->assertJsonValidationErrors(['public_token']);
|
||||
});
|
||||
Loading…
Reference in New Issue