diff --git a/CHANGELOG.md b/CHANGELOG.md index 528a8ebe91..9412140c72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## Unreleased +**Fixes** +* [Fixed] Manual bank-account entry in ConnectAccountOnboarding now creates an external account on iOS/Android by collecting a bank-account token instead of only Financial Connections accounts. + ## 0.67.0 - 2026-06-09 **Changes** * Updated Stripe iOS SDK from 25.16.0 to 25.17.0. diff --git a/etc/stripe-react-native.api.md b/etc/stripe-react-native.api.md index df1e64484e..09839eeea5 100644 --- a/etc/stripe-react-native.api.md +++ b/etc/stripe-react-native.api.md @@ -1013,6 +1013,7 @@ export const collectBankAccountToken: (clientSecret: string, params?: CollectBan type CollectBankAccountTokenParams = { style?: UserInterfaceStyle; onEvent?: (event: FinancialConnectionsEvent) => void; + connectedAccountId?: string; }; // @public diff --git a/src/connect/EmbeddedComponent.tsx b/src/connect/EmbeddedComponent.tsx index 7f4581fb55..a6edeb8b7e 100644 --- a/src/connect/EmbeddedComponent.tsx +++ b/src/connect/EmbeddedComponent.tsx @@ -26,6 +26,7 @@ import type { StripeConnectInitParams, } from './connectTypes'; import type { FinancialConnections } from '../types'; +import { FinancialConnectionsSheetError } from '../types/FinancialConnections'; import { ComponentAnalyticsClient } from './analytics/ComponentAnalyticsClient'; const DEVELOPMENT_MODE = false; @@ -457,7 +458,7 @@ export function EmbeddedComponent(props: EmbeddedComponentProps) { id: string, result: { session?: FinancialConnections.Session; - token?: FinancialConnections.BankAccountToken; + token?: ReturnType | null; error?: { code: string; message: string; @@ -580,14 +581,21 @@ export function EmbeddedComponent(props: EmbeddedComponentProps) { cleanup, }; - // Call native Financial Connections - NativeStripeSdk.collectFinancialConnectionsAccounts(clientSecret, { + NativeStripeSdk.collectBankAccountToken(clientSecret, { connectedAccountId, }) - .then(({ session, error }) => { + .then(({ session, token, error }) => { cleanup(); if (error) { + if (error.code === FinancialConnectionsSheetError.Canceled) { + handleFinancialConnectionsResult(id, { + session: undefined, + token: undefined, + error: undefined, + }); + return; + } handleFinancialConnectionsResult(id, { session: undefined, token: undefined, @@ -598,21 +606,18 @@ export function EmbeddedComponent(props: EmbeddedComponentProps) { type: error.type, }, }); - } else if (session) { - // Note: collectFinancialConnectionsAccounts doesn't return a token - // Only collectBankAccountToken returns both session and token + } else if (token || session) { handleFinancialConnectionsResult(id, { session, - token: undefined, + token: token ? toStripeJsBankAccountToken(token) : null, error: undefined, }); } else { - // Defensive: should never happen handleFinancialConnectionsResult(id, { error: { code: 'UnexpectedError', message: - 'No session or error returned from Financial Connections', + 'No session, token, or error returned from Financial Connections', }, }); } @@ -818,6 +823,38 @@ function withDefaultFontFamily(appearance: any) { }; } +// connect-js expects the Stripe.js API-shaped (snake_case) token, matching what the +// native iOS/Android Connect SDKs send. RN's FinancialConnections token is camelCase. +/** @internal Exported for testing only. */ +export function toStripeJsBankAccountToken( + token: FinancialConnections.BankAccountToken +) { + const ba = token.bankAccount; + return { + id: token.id, + object: 'token' as const, + type: 'bank_account' as const, + used: token.used, + livemode: token.livemode, + created: token.created, + bank_account: ba + ? { + id: ba.id, + object: 'bank_account' as const, + account_holder_name: ba.accountHolderName, + account_holder_type: ba.accountHolderType, + bank_name: ba.bankName, + country: ba.country, + currency: ba.currency, + fingerprint: ba.fingerprint, + last4: ba.last4, + routing_number: ba.routingNumber, + status: ba.status, + } + : null, + }; +} + // Validates that a URL is well-formed and uses http or https protocol function isValidUrl(url: string): boolean { try { diff --git a/src/connect/__tests__/EmbeddedComponent.test.tsx b/src/connect/__tests__/EmbeddedComponent.test.tsx index e74e6cc202..35372a94be 100644 --- a/src/connect/__tests__/EmbeddedComponent.test.tsx +++ b/src/connect/__tests__/EmbeddedComponent.test.tsx @@ -23,7 +23,10 @@ jest.mock('../../specs/NativeStripeSdkModule', () => import React from 'react'; import { render, waitFor, act } from '@testing-library/react-native'; import { Platform, AppState } from 'react-native'; -import { EmbeddedComponent } from '../EmbeddedComponent'; +import { + EmbeddedComponent, + toStripeJsBankAccountToken, +} from '../EmbeddedComponent'; import { loadConnectAndInitialize, ConnectComponentsProvider, @@ -338,4 +341,103 @@ describe('EmbeddedComponent', () => { expect(contextValue.appearance.variables.fontFamily).toBe('CustomFont'); }); }); + + describe('toStripeJsBankAccountToken', () => { + it('maps camelCase token to snake_case Stripe.js shape', () => { + const result = toStripeJsBankAccountToken({ + id: 'tok_123', + livemode: false, + used: false, + type: 'BankAccount', + created: 1000000, + bankAccount: { + id: 'ba_123', + bankName: 'Test Bank', + accountHolderName: 'John Doe', + accountHolderType: 'Individual', + currency: 'usd', + country: 'US', + routingNumber: '110000000', + status: 'New', + fingerprint: 'fp_123', + last4: '6789', + }, + }); + + expect(result).toEqual({ + id: 'tok_123', + object: 'token', + type: 'bank_account', + used: false, + livemode: false, + created: 1000000, + bank_account: { + id: 'ba_123', + object: 'bank_account', + account_holder_name: 'John Doe', + account_holder_type: 'Individual', + bank_name: 'Test Bank', + country: 'US', + currency: 'usd', + fingerprint: 'fp_123', + last4: '6789', + routing_number: '110000000', + status: 'New', + }, + }); + }); + + it('returns null bank_account when bankAccount is null', () => { + const result = toStripeJsBankAccountToken({ + id: 'tok_456', + livemode: true, + used: true, + type: 'BankAccount', + created: 2000000, + bankAccount: null, + }); + + expect(result.id).toBe('tok_456'); + expect(result.object).toBe('token'); + expect(result.type).toBe('bank_account'); + expect(result.livemode).toBe(true); + expect(result.bank_account).toBeNull(); + }); + + it('preserves null fields in bank_account', () => { + const result = toStripeJsBankAccountToken({ + id: 'tok_789', + livemode: false, + used: false, + type: 'BankAccount', + created: null, + bankAccount: { + id: 'ba_789', + bankName: null, + accountHolderName: null, + accountHolderType: null, + currency: null, + country: null, + routingNumber: null, + status: null, + fingerprint: null, + last4: null, + }, + }); + + expect(result.bank_account).toEqual({ + id: 'ba_789', + object: 'bank_account', + account_holder_name: null, + account_holder_type: null, + bank_name: null, + country: null, + currency: null, + fingerprint: null, + last4: null, + routing_number: null, + status: null, + }); + }); + }); }); diff --git a/src/types/PaymentMethod.ts b/src/types/PaymentMethod.ts index 4e5da350b3..1de25caeed 100644 --- a/src/types/PaymentMethod.ts +++ b/src/types/PaymentMethod.ts @@ -304,4 +304,6 @@ export type CollectBankAccountTokenParams = { style?: UserInterfaceStyle; /** An optional event listener to receive @type {FinancialConnectionEvent} for specific events during the process of a user connecting their financial accounts. */ onEvent?: (event: FinancialConnectionsEvent) => void; + /** Optional connected account ID. Used for Stripe Connect embedded components. */ + connectedAccountId?: string; };