diff --git a/app/Enums/BankingProvider.php b/app/Enums/BankingProvider.php index 28faca15..9226ba54 100644 --- a/app/Enums/BankingProvider.php +++ b/app/Enums/BankingProvider.php @@ -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 => [], }; } diff --git a/app/Enums/TransactionSource.php b/app/Enums/TransactionSource.php index 0a8169bb..2fc17e5e 100644 --- a/app/Enums/TransactionSource.php +++ b/app/Enums/TransactionSource.php @@ -8,4 +8,5 @@ enum TransactionSource: string case Imported = 'imported'; case EnableBanking = 'enablebanking'; case Wise = 'wise'; + case Plaid = 'plaid'; } diff --git a/app/Http/Controllers/OpenBanking/PlaidController.php b/app/Http/Controllers/OpenBanking/PlaidController.php new file mode 100644 index 00000000..97bae8da --- /dev/null +++ b/app/Http/Controllers/OpenBanking/PlaidController.php @@ -0,0 +1,155 @@ +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 $plaidAccounts + * @return array + */ + 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; + } +} diff --git a/app/Services/Banking/PlaidBalanceSyncService.php b/app/Services/Banking/PlaidBalanceSyncService.php new file mode 100644 index 00000000..ac35db4b --- /dev/null +++ b/app/Services/Banking/PlaidBalanceSyncService.php @@ -0,0 +1,49 @@ +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, + ]); + } +} diff --git a/app/Services/Banking/PlaidClient.php b/app/Services/Banking/PlaidClient.php new file mode 100644 index 00000000..36067404 --- /dev/null +++ b/app/Services/Banking/PlaidClient.php @@ -0,0 +1,148 @@ + '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, + ]); + }); + } +} diff --git a/app/Services/Banking/PlaidTransactionSyncService.php b/app/Services/Banking/PlaidTransactionSyncService.php new file mode 100644 index 00000000..29355569 --- /dev/null +++ b/app/Services/Banking/PlaidTransactionSyncService.php @@ -0,0 +1,152 @@ +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'], + ])); + } +} diff --git a/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php b/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php index 3a9970a8..c44d2d25 100644 --- a/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php +++ b/app/Services/Banking/Sync/BankingConnectionSyncerFactory.php @@ -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, }; diff --git a/app/Services/Banking/Sync/PlaidSyncer.php b/app/Services/Banking/Sync/PlaidSyncer.php new file mode 100644 index 00000000..c59f3994 --- /dev/null +++ b/app/Services/Banking/Sync/PlaidSyncer.php @@ -0,0 +1,41 @@ +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, + ]; + } +} diff --git a/config/services.php b/config/services.php index 78a201ba..83b8a0eb 100644 --- a/config/services.php +++ b/config/services.php @@ -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'), + ], + ]; diff --git a/resources/js/hooks/use-connect-flow.ts b/resources/js/hooks/use-connect-flow.ts index fdc69505..9912dd9b 100644 --- a/resources/js/hooks/use-connect-flow.ts +++ b/resources/js/hooks/use-connect-flow.ts @@ -174,11 +174,147 @@ export function useConnectFlow(connections: BankingConnection[]) { [connections], ); + const postToBackend = useCallback( + async ( + url: string, + body: Record, + ): 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; +} diff --git a/resources/js/lib/connect-providers.tsx b/resources/js/lib/connect-providers.tsx index 55b341d6..5e8d7362 100644 --- a/resources/js/lib/connect-providers.tsx +++ b/resources/js/lib/connect-providers.tsx @@ -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, ): 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 ( +
+

+ {__( + 'You will be redirected to Plaid to securely connect your bank account.', + )} +

+ +
+ ); + } + return (
{provider.fields.map((field) => { diff --git a/routes/web.php b/routes/web.php index 69fba6b0..e3ace8ca 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 () { diff --git a/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php b/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php index 9eed673b..7b45ca0a 100644 --- a/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php +++ b/tests/Feature/OpenBanking/BankingConnectionSyncerFactoryTest.php @@ -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(); }); diff --git a/tests/Feature/OpenBanking/PlaidControllerTest.php b/tests/Feature/OpenBanking/PlaidControllerTest.php new file mode 100644 index 00000000..b3312759 --- /dev/null +++ b/tests/Feature/OpenBanking/PlaidControllerTest.php @@ -0,0 +1,168 @@ +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']); +});