diff --git a/__mocks__/react-native-plaid-link-sdk.js b/__mocks__/react-native-plaid-link-sdk.js new file mode 100644 index 000000000..a11814941 --- /dev/null +++ b/__mocks__/react-native-plaid-link-sdk.js @@ -0,0 +1,10 @@ +// Manual mock — react-native-plaid-link-sdk is a native module and cannot load under jest. +module.exports = { + __esModule: true, + create: jest.fn(), + open: jest.fn(), + dismissLink: jest.fn(), + usePlaidEmitter: jest.fn(), + LinkLogLevel: { DEBUG: "debug", INFO: "info", WARN: "warn", ERROR: "error" }, + LinkIOSPresentationStyle: { MODAL: "MODAL", FULL_SCREEN: "FULL_SCREEN" }, +} diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx new file mode 100644 index 000000000..2cc2218e7 --- /dev/null +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -0,0 +1,241 @@ +/** + * usePlaidLink — Plaid Link + server-side public_token exchange (ENG-524). + * + * Contract under test: + * - openPlaidLink creates the Plaid session with the backend's linkToken and + * opens the native UI; a second call while a session is in flight is a + * no-op (the SDK's native module is a singleton — see the hook comment). + * - onSuccess exchanges { linkToken, publicToken } server-side; once the + * exchange succeeds the link IS established: the onLinked refetch is + * best-effort and its failure must never be reported as a link failure. + * - Exchange payload errors and thrown errors alert and never run onLinked; + * the activity indicator is always cleared. + * - onExit alerts only for a REAL Plaid failure, detected by error CONTENT: + * the iOS bridge always embeds an all-empty error object on a plain user + * cancel (Android omits it) — both cancel shapes must stay silent. + */ + +import { Alert } from "react-native" +import { renderHook, act } from "@testing-library/react-hooks" +// Auto-mocked by __mocks__/react-native-plaid-link-sdk.js (native module) +import { create, open } from "react-native-plaid-link-sdk" + +const mockExchange = jest.fn() +jest.mock("@app/graphql/generated", () => ({ + useBridgeExchangePlaidPublicTokenMutation: () => [mockExchange], +})) + +const mockToggleActivityIndicator = jest.fn() +jest.mock("@app/hooks/useActivityIndicator", () => ({ + useActivityIndicator: () => ({ + toggleActivityIndicator: mockToggleActivityIndicator, + }), +})) + +jest.mock("@app/i18n/i18n-react", () => ({ + useI18nContext: () => ({ + LL: { + common: { error: () => "Error" }, + PlaidLink: { + connectedTitle: () => "Bank connected", + connectedBody: () => "Your bank is being linked and will appear here shortly.", + exchangeFailed: () => "Failed to link your bank. Please try again.", + linkFailed: () => "Bank linking failed. Please try again.", + }, + }, + }), +})) + +import { usePlaidLink } from "@app/hooks/use-plaid-link" + +type PlaidHandlers = { + onSuccess: (success: { publicToken: string }) => Promise + onExit: (exit: { + error?: { errorCode?: string; errorMessage?: string; displayMessage?: string } + }) => void +} + +const renderPlaidLink = (onLinked?: jest.Mock) => + renderHook(() => usePlaidLink({ onLinked })) + +const lastHandlers = (): PlaidHandlers => + (open as jest.Mock).mock.calls[(open as jest.Mock).mock.calls.length - 1][0] + +const openLinkAndGetHandlers = (onLinked?: jest.Mock): PlaidHandlers => { + const { result } = renderPlaidLink(onLinked) + act(() => result.current.openPlaidLink("link-token-1")) + expect(create).toHaveBeenCalledWith({ token: "link-token-1" }) + expect(open).toHaveBeenCalledTimes(1) + return lastHandlers() +} + +describe("usePlaidLink", () => { + const alertSpy = jest.spyOn(Alert, "alert") + + beforeEach(() => { + jest.clearAllMocks() + }) + + it("exchanges the public token server-side, refetches, and confirms", async () => { + const onLinked = jest.fn().mockResolvedValue(undefined) + mockExchange.mockResolvedValue({ + data: { bridgeExchangePlaidPublicToken: { errors: [], message: "ok" } }, + }) + + const handlers = openLinkAndGetHandlers(onLinked) + await act(() => handlers.onSuccess({ publicToken: "public-token-1" })) + + expect(mockExchange).toHaveBeenCalledWith({ + variables: { + input: { linkToken: "link-token-1", publicToken: "public-token-1" }, + }, + }) + expect(onLinked).toHaveBeenCalledTimes(1) + expect(alertSpy).toHaveBeenCalledWith( + "Bank connected", + "Your bank is being linked and will appear here shortly.", + ) + // Refetch settles before the user is told the account will appear + expect(onLinked.mock.invocationCallOrder[0]).toBeLessThan( + alertSpy.mock.invocationCallOrder[0], + ) + // Indicator on for the exchange, off before the alert + expect(mockToggleActivityIndicator).toHaveBeenNthCalledWith(1, true) + expect(mockToggleActivityIndicator).toHaveBeenNthCalledWith(2, false) + }) + + it("still reports success when the best-effort refetch rejects", async () => { + // The exchange succeeded — the webhook will provision the account. A + // refetch failure reported as a link failure makes users re-link and + // create duplicate external accounts. + const onLinked = jest.fn().mockRejectedValue(new Error("network blip")) + mockExchange.mockResolvedValue({ + data: { bridgeExchangePlaidPublicToken: { errors: [] } }, + }) + + const handlers = openLinkAndGetHandlers(onLinked) + await act(() => handlers.onSuccess({ publicToken: "public-token-1" })) + + expect(alertSpy).toHaveBeenCalledWith( + "Bank connected", + "Your bank is being linked and will appear here shortly.", + ) + expect(alertSpy).not.toHaveBeenCalledWith( + "Error", + "Failed to link your bank. Please try again.", + ) + }) + + it("surfaces exchange payload errors, does not refetch, and re-arms for retry", async () => { + const onLinked = jest.fn() + mockExchange.mockResolvedValue({ + data: { + bridgeExchangePlaidPublicToken: { + errors: [{ code: "BRIDGE_INVALID_PLAID_TOKEN", message: "Invalid token" }], + }, + }, + }) + + const { result } = renderPlaidLink(onLinked) + act(() => result.current.openPlaidLink("link-token-1")) + await act(() => lastHandlers().onSuccess({ publicToken: "public-token-1" })) + + expect(alertSpy).toHaveBeenCalledWith("Error", "Invalid token") + expect(onLinked).not.toHaveBeenCalled() + expect(mockToggleActivityIndicator).toHaveBeenLastCalledWith(false) + + // The guard must release on the payload-error early-return path, or the + // user's retry after "Invalid token" silently no-ops until remount. + act(() => result.current.openPlaidLink("link-token-2")) + expect(create).toHaveBeenCalledTimes(2) + }) + + it("alerts generically, clears the indicator, and re-arms when the exchange throws", async () => { + const onLinked = jest.fn() + mockExchange.mockRejectedValue(new Error("network down")) + + const { result } = renderPlaidLink(onLinked) + act(() => result.current.openPlaidLink("link-token-1")) + await act(() => lastHandlers().onSuccess({ publicToken: "public-token-1" })) + + expect(alertSpy).toHaveBeenCalledWith( + "Error", + "Failed to link your bank. Please try again.", + ) + expect(onLinked).not.toHaveBeenCalled() + expect(mockToggleActivityIndicator).toHaveBeenLastCalledWith(false) + + // "Please try again" must actually work: the guard releases on the + // thrown-exchange path too. + act(() => result.current.openPlaidLink("link-token-2")) + expect(create).toHaveBeenCalledTimes(2) + }) + + it("ignores a second openPlaidLink while a session is in flight, and re-arms after exit", () => { + const { result } = renderPlaidLink() + act(() => result.current.openPlaidLink("link-token-1")) + act(() => result.current.openPlaidLink("link-token-2")) + + // Second call is a no-op — one native session only + expect(create).toHaveBeenCalledTimes(1) + expect(open).toHaveBeenCalledTimes(1) + + // Session terminated (user cancel) → a new session may open + act(() => lastHandlers().onExit({})) + act(() => result.current.openPlaidLink("link-token-2")) + expect(create).toHaveBeenCalledTimes(2) + expect(create).toHaveBeenLastCalledWith({ token: "link-token-2" }) + }) + + it("re-arms after a completed session (onSuccess settled)", async () => { + mockExchange.mockResolvedValue({ + data: { bridgeExchangePlaidPublicToken: { errors: [] } }, + }) + const { result } = renderPlaidLink() + + act(() => result.current.openPlaidLink("link-token-1")) + await act(() => lastHandlers().onSuccess({ publicToken: "public-token-1" })) + + act(() => result.current.openPlaidLink("link-token-2")) + expect(create).toHaveBeenCalledTimes(2) + }) + + it("alerts on a real Plaid exit error, preferring Plaid's user-facing message", () => { + const handlers = openLinkAndGetHandlers() + + act(() => + handlers.onExit({ + error: { + errorCode: "INSTITUTION_ERROR", + errorMessage: "developer detail", + displayMessage: "Your bank is temporarily unavailable.", + }, + }), + ) + expect(alertSpy).toHaveBeenCalledWith( + "Error", + "Your bank is temporarily unavailable.", + ) + + alertSpy.mockClear() + act(() => handlers.onExit({ error: { errorCode: "-1" } })) + expect(alertSpy).toHaveBeenCalledWith( + "Error", + "Bank linking failed. Please try again.", + ) + }) + + it("stays silent on user cancel — both platform shapes", () => { + const handlers = openLinkAndGetHandlers() + + // Android: no error key at all + act(() => handlers.onExit({})) + // iOS: always-present error object with all-empty fields + act(() => + handlers.onExit({ + error: { errorCode: "", errorMessage: "", displayMessage: "" }, + }), + ) + expect(alertSpy).not.toHaveBeenCalled() + }) +}) diff --git a/app/graphql/front-end-mutations.ts b/app/graphql/front-end-mutations.ts index 3e29e6f70..7db5a49d5 100644 --- a/app/graphql/front-end-mutations.ts +++ b/app/graphql/front-end-mutations.ts @@ -248,7 +248,7 @@ gql` bridgeAddExternalAccount { externalAccount { expiresAt - linkUrl + linkToken } errors { code @@ -257,6 +257,16 @@ gql` } } + mutation BridgeExchangePlaidPublicToken($input: BridgeExchangePlaidPublicTokenInput!) { + bridgeExchangePlaidPublicToken(input: $input) { + errors { + code + message + } + message + } + } + mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { bridgeRequestWithdrawal(input: $input) { withdrawal { diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index 25e7ad01a..a23899d61 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -86,7 +86,7 @@ mutation BridgeAddExternalAccount { bridgeAddExternalAccount { externalAccount { expiresAt - linkUrl + linkToken __typename } errors { @@ -137,6 +137,18 @@ mutation BridgeCreateExternalAccount($input: BridgeCreateExternalAccountInput!) } } +mutation BridgeExchangePlaidPublicToken($input: BridgeExchangePlaidPublicTokenInput!) { + bridgeExchangePlaidPublicToken(input: $input) { + errors { + code + message + __typename + } + message + __typename + } +} + mutation BridgeInitiateKyc($input: BridgeInitiateKycInput!) { bridgeInitiateKyc(input: $input) { errors { diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index d7bc63f90..f8bf455c3 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -402,6 +402,17 @@ export type BridgeCreateVirtualAccountPayload = { readonly virtualAccount?: Maybe; }; +export type BridgeExchangePlaidPublicTokenInput = { + readonly linkToken: Scalars['String']['input']; + readonly publicToken: Scalars['String']['input']; +}; + +export type BridgeExchangePlaidPublicTokenPayload = { + readonly __typename: 'BridgeExchangePlaidPublicTokenPayload'; + readonly errors: ReadonlyArray; + readonly message?: Maybe; +}; + export type BridgeExternalAccount = { readonly __typename: 'BridgeExternalAccount'; readonly accountNumberLast4: Scalars['String']['output']; @@ -413,7 +424,9 @@ export type BridgeExternalAccount = { export type BridgeExternalAccountLink = { readonly __typename: 'BridgeExternalAccountLink'; readonly expiresAt: Scalars['String']['output']; - readonly linkUrl: Scalars['String']['output']; + readonly linkToken: Scalars['String']['output']; + /** @deprecated Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null. */ + readonly linkUrl?: Maybe; }; export type BridgeInitiateKycInput = { @@ -1066,6 +1079,7 @@ export type Mutation = { readonly bridgeCancelWithdrawalRequest: BridgeCancelWithdrawalRequestPayload; readonly bridgeCreateExternalAccount: BridgeCreateExternalAccountPayload; readonly bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload; + readonly bridgeExchangePlaidPublicToken: BridgeExchangePlaidPublicTokenPayload; readonly bridgeInitiateKyc: BridgeInitiateKycPayload; readonly bridgeInitiateWithdrawal: BridgeInitiateWithdrawalPayload; readonly bridgeRequestWithdrawal: BridgeRequestWithdrawalPayload; @@ -1243,6 +1257,11 @@ export type MutationBridgeCreateExternalAccountArgs = { }; +export type MutationBridgeExchangePlaidPublicTokenArgs = { + input: BridgeExchangePlaidPublicTokenInput; +}; + + export type MutationBridgeInitiateKycArgs = { input: BridgeInitiateKycInput; }; @@ -2685,7 +2704,14 @@ export type BridgeInitiateKycMutation = { readonly __typename: 'Mutation', reado export type BridgeAddExternalAccountMutationVariables = Exact<{ [key: string]: never; }>; -export type BridgeAddExternalAccountMutation = { readonly __typename: 'Mutation', readonly bridgeAddExternalAccount: { readonly __typename: 'BridgeAddExternalAccountPayload', readonly externalAccount?: { readonly __typename: 'BridgeExternalAccountLink', readonly expiresAt: string, readonly linkUrl: string } | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly code?: string | null, readonly message: string }> } }; +export type BridgeAddExternalAccountMutation = { readonly __typename: 'Mutation', readonly bridgeAddExternalAccount: { readonly __typename: 'BridgeAddExternalAccountPayload', readonly externalAccount?: { readonly __typename: 'BridgeExternalAccountLink', readonly expiresAt: string, readonly linkToken: string } | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly code?: string | null, readonly message: string }> } }; + +export type BridgeExchangePlaidPublicTokenMutationVariables = Exact<{ + input: BridgeExchangePlaidPublicTokenInput; +}>; + + +export type BridgeExchangePlaidPublicTokenMutation = { readonly __typename: 'Mutation', readonly bridgeExchangePlaidPublicToken: { readonly __typename: 'BridgeExchangePlaidPublicTokenPayload', readonly message?: string | null, readonly errors: ReadonlyArray<{ readonly __typename: 'GraphQLApplicationError', readonly code?: string | null, readonly message: string }> } }; export type BridgeRequestWithdrawalMutationVariables = Exact<{ input: BridgeRequestWithdrawalInput; @@ -4555,7 +4581,7 @@ export const BridgeAddExternalAccountDocument = gql` bridgeAddExternalAccount { externalAccount { expiresAt - linkUrl + linkToken } errors { code @@ -4589,6 +4615,43 @@ export function useBridgeAddExternalAccountMutation(baseOptions?: Apollo.Mutatio export type BridgeAddExternalAccountMutationHookResult = ReturnType; export type BridgeAddExternalAccountMutationResult = Apollo.MutationResult; export type BridgeAddExternalAccountMutationOptions = Apollo.BaseMutationOptions; +export const BridgeExchangePlaidPublicTokenDocument = gql` + mutation BridgeExchangePlaidPublicToken($input: BridgeExchangePlaidPublicTokenInput!) { + bridgeExchangePlaidPublicToken(input: $input) { + errors { + code + message + } + message + } +} + `; +export type BridgeExchangePlaidPublicTokenMutationFn = Apollo.MutationFunction; + +/** + * __useBridgeExchangePlaidPublicTokenMutation__ + * + * To run a mutation, you first call `useBridgeExchangePlaidPublicTokenMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useBridgeExchangePlaidPublicTokenMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [bridgeExchangePlaidPublicTokenMutation, { data, loading, error }] = useBridgeExchangePlaidPublicTokenMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useBridgeExchangePlaidPublicTokenMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(BridgeExchangePlaidPublicTokenDocument, options); + } +export type BridgeExchangePlaidPublicTokenMutationHookResult = ReturnType; +export type BridgeExchangePlaidPublicTokenMutationResult = Apollo.MutationResult; +export type BridgeExchangePlaidPublicTokenMutationOptions = Apollo.BaseMutationOptions; export const BridgeRequestWithdrawalDocument = gql` mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) { bridgeRequestWithdrawal(input: $input) { diff --git a/app/graphql/public-schema.graphql b/app/graphql/public-schema.graphql index aa3759bac..cdf6a02b4 100644 --- a/app/graphql/public-schema.graphql +++ b/app/graphql/public-schema.graphql @@ -329,6 +329,16 @@ type BridgeCreateVirtualAccountPayload { virtualAccount: BridgeVirtualAccount } +input BridgeExchangePlaidPublicTokenInput { + linkToken: String! + publicToken: String! +} + +type BridgeExchangePlaidPublicTokenPayload { + errors: [Error!]! + message: String +} + type BridgeExternalAccount { accountNumberLast4: String! bankName: String! @@ -338,7 +348,8 @@ type BridgeExternalAccount { type BridgeExternalAccountLink { expiresAt: String! - linkUrl: String! + linkToken: String! + linkUrl: String @deprecated(reason: "Use linkToken with the Plaid Link SDK. Hosted-URL linking is being retired; this field is best-effort and may be null.") } input BridgeInitiateKycInput { @@ -1078,6 +1089,7 @@ type Mutation { bridgeCancelWithdrawalRequest(input: BridgeCancelWithdrawalRequestInput!): BridgeCancelWithdrawalRequestPayload! bridgeCreateExternalAccount(input: BridgeCreateExternalAccountInput!): BridgeCreateExternalAccountPayload! bridgeCreateVirtualAccount: BridgeCreateVirtualAccountPayload! + bridgeExchangePlaidPublicToken(input: BridgeExchangePlaidPublicTokenInput!): BridgeExchangePlaidPublicTokenPayload! bridgeInitiateKyc(input: BridgeInitiateKycInput!): BridgeInitiateKycPayload! bridgeInitiateWithdrawal(input: BridgeInitiateWithdrawalInput!): BridgeInitiateWithdrawalPayload! bridgeRequestWithdrawal(input: BridgeRequestWithdrawalInput!): BridgeRequestWithdrawalPayload! diff --git a/app/hooks/index.ts b/app/hooks/index.ts index 8850d1b58..5606a657b 100644 --- a/app/hooks/index.ts +++ b/app/hooks/index.ts @@ -8,5 +8,6 @@ export * from "./useIbexFee" export * from "./useFlashcard" export * from "./useSwap" export * from "./use-transfer-flags" +export * from "./use-plaid-link" export * from "./use-unauthed-price-conversion" export * from "./useAccountUpgrade" diff --git a/app/hooks/use-plaid-link.ts b/app/hooks/use-plaid-link.ts new file mode 100644 index 000000000..74426bcff --- /dev/null +++ b/app/hooks/use-plaid-link.ts @@ -0,0 +1,102 @@ +import { useCallback, useRef } from "react" +import { Alert } from "react-native" +import { + create, + open, + type LinkExit, + type LinkSuccess, +} from "react-native-plaid-link-sdk" + +import { useBridgeExchangePlaidPublicTokenMutation } from "@app/graphql/generated" +import { useI18nContext } from "@app/i18n/i18n-react" + +import { useActivityIndicator } from "./useActivityIndicator" + +/** + * Plaid Link flow for Bridge external accounts (ENG-524, backend flash#446). + * + * Opens the native Plaid Link UI with a backend-issued linkToken and performs + * the server-side public_token exchange on success — the Bridge Api-Key never + * leaves the backend. The linked account is provisioned asynchronously via + * Bridge's webhook: callers pass `onLinked` to refetch their account list, + * and the success copy tells the user the account will appear shortly. + */ +export const usePlaidLink = ({ onLinked }: { onLinked?: () => unknown } = {}) => { + const { LL } = useI18nContext() + const { toggleActivityIndicator } = useActivityIndicator() + const [exchangePlaidPublicToken] = useBridgeExchangePlaidPublicTokenMutation() + + // The SDK's native module is a singleton: a second create()/open() while a + // session is up replaces its handler and stored JS callbacks, cross-wiring + // {linkToken, publicToken} pairs or dropping callbacks entirely. Guard from + // openPlaidLink until the session terminates (onSuccess settles or onExit). + const sessionInFlight = useRef(false) + + const openPlaidLink = useCallback( + (linkToken: string) => { + if (sessionInFlight.current) return + sessionInFlight.current = true + + create({ token: linkToken }) + open({ + onSuccess: async (success: LinkSuccess) => { + toggleActivityIndicator(true) + try { + const res = await exchangePlaidPublicToken({ + variables: { input: { linkToken, publicToken: success.publicToken } }, + }) + toggleActivityIndicator(false) + + const errors = res.data?.bridgeExchangePlaidPublicToken?.errors + if (errors && errors.length > 0) { + Alert.alert(LL.common.error(), errors[0].message) + return + } + + // The exchange succeeded — the link IS established and the webhook + // will provision the account. The refetch is best-effort: its + // failure (flaky network, unmounted screen) must never be reported + // as a failed bank link, or users re-link and create duplicates. + try { + await onLinked?.() + } catch { + // best-effort — the next screen focus refetches anyway + } + Alert.alert(LL.PlaidLink.connectedTitle(), LL.PlaidLink.connectedBody()) + } catch (err) { + toggleActivityIndicator(false) + Alert.alert(LL.common.error(), LL.PlaidLink.exchangeFailed()) + } finally { + sessionInFlight.current = false + } + }, + onExit: (exit: LinkExit) => { + sessionInFlight.current = false + // A plain user cancel must stay silent — but the iOS bridge ALWAYS + // embeds an `error` object (with empty-string fields) in the exit + // payload, while Android omits it. Presence is meaningless on iOS; + // detect a real failure by content. Real Plaid errors carry a + // non-empty errorCode on both platforms. + const exitError = exit.error + const isRealError = Boolean( + exitError && + (exitError.errorCode || exitError.errorMessage || exitError.displayMessage), + ) + if (isRealError && exitError) { + Alert.alert( + LL.common.error(), + // displayMessage is Plaid's user-facing copy; errorMessage is + // developer-facing and only a fallback. + exitError.displayMessage || + exitError.errorMessage || + LL.PlaidLink.linkFailed(), + ) + } + }, + }) + }, + [exchangePlaidPublicToken, onLinked, toggleActivityIndicator, LL], + ) + + return { openPlaidLink } +} diff --git a/app/i18n/en/index.ts b/app/i18n/en/index.ts index 6cf5d3b84..e97dd59bb 100644 --- a/app/i18n/en/index.ts +++ b/app/i18n/en/index.ts @@ -636,6 +636,12 @@ const en: BaseTranslation = { viewInGoogleMaps: "View in Google Maps", getDirections: "Get Directions", }, + PlaidLink: { + connectedTitle: "Bank connected", + connectedBody: "Your bank is being linked and will appear here shortly.", + exchangeFailed: "Failed to link your bank. Please try again.", + linkFailed: "Bank linking failed. Please try again.", + }, HomeScreen: { cashout: "Cash Out", receive: "Receive", diff --git a/app/i18n/i18n-types.ts b/app/i18n/i18n-types.ts index 375dfcc87..2e52eee97 100644 --- a/app/i18n/i18n-types.ts +++ b/app/i18n/i18n-types.ts @@ -2017,6 +2017,24 @@ type RootTranslation = { */ getDirections: string } + PlaidLink: { + /** + * B​a​n​k​ ​c​o​n​n​e​c​t​e​d + */ + connectedTitle: string + /** + * Y​o​u​r​ ​b​a​n​k​ ​i​s​ ​b​e​i​n​g​ ​l​i​n​k​e​d​ ​a​n​d​ ​w​i​l​l​ ​a​p​p​e​a​r​ ​h​e​r​e​ ​s​h​o​r​t​l​y​. + */ + connectedBody: string + /** + * F​a​i​l​e​d​ ​t​o​ ​l​i​n​k​ ​y​o​u​r​ ​b​a​n​k​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + exchangeFailed: string + /** + * B​a​n​k​ ​l​i​n​k​i​n​g​ ​f​a​i​l​e​d​.​ ​P​l​e​a​s​e​ ​t​r​y​ ​a​g​a​i​n​. + */ + linkFailed: string + } HomeScreen: { /** * C​a​s​h​ ​O​u​t @@ -7927,6 +7945,24 @@ export type TranslationFunctions = { */ getDirections: () => LocalizedString } + PlaidLink: { + /** + * Bank connected + */ + connectedTitle: () => LocalizedString + /** + * Your bank is being linked and will appear here shortly. + */ + connectedBody: () => LocalizedString + /** + * Failed to link your bank. Please try again. + */ + exchangeFailed: () => LocalizedString + /** + * Bank linking failed. Please try again. + */ + linkFailed: () => LocalizedString + } HomeScreen: { /** * Cash Out diff --git a/app/i18n/raw-i18n/source/en.json b/app/i18n/raw-i18n/source/en.json index 4d517beb4..8102d5b31 100644 --- a/app/i18n/raw-i18n/source/en.json +++ b/app/i18n/raw-i18n/source/en.json @@ -596,6 +596,12 @@ "viewInGoogleMaps": "View in Google Maps", "getDirections": "Get Directions" }, + "PlaidLink": { + "connectedTitle": "Bank connected", + "connectedBody": "Your bank is being linked and will appear here shortly.", + "exchangeFailed": "Failed to link your bank. Please try again.", + "linkFailed": "Bank linking failed. Please try again." + }, "HomeScreen": { "cashout": "Cash Out", "receive": "Receive", diff --git a/app/i18n/raw-i18n/translations/af.json b/app/i18n/raw-i18n/translations/af.json index 01a91cc51..d7b35593e 100644 --- a/app/i18n/raw-i18n/translations/af.json +++ b/app/i18n/raw-i18n/translations/af.json @@ -1673,5 +1673,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Bank gekoppel", + "connectedBody": "Jou bank word gekoppel en sal binnekort hier verskyn.", + "exchangeFailed": "Kon nie jou bank koppel nie. Probeer asseblief weer.", + "linkFailed": "Bankkoppeling het misluk. Probeer asseblief weer." } } diff --git a/app/i18n/raw-i18n/translations/ar.json b/app/i18n/raw-i18n/translations/ar.json index 5281c2f22..14a90aae7 100644 --- a/app/i18n/raw-i18n/translations/ar.json +++ b/app/i18n/raw-i18n/translations/ar.json @@ -1678,5 +1678,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "تم ربط البنك", + "connectedBody": "يتم ربط بنكك وسيظهر هنا قريبًا.", + "exchangeFailed": "فشل ربط البنك. يرجى المحاولة مرة أخرى.", + "linkFailed": "فشل ربط البنك. يرجى المحاولة مرة أخرى." } } diff --git a/app/i18n/raw-i18n/translations/ca.json b/app/i18n/raw-i18n/translations/ca.json index b92c79d94..4219b5e9a 100644 --- a/app/i18n/raw-i18n/translations/ca.json +++ b/app/i18n/raw-i18n/translations/ca.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banc connectat", + "connectedBody": "El teu banc s'està vinculant i apareixerà aquí aviat.", + "exchangeFailed": "No s'ha pogut vincular el teu banc. Torna-ho a provar.", + "linkFailed": "La vinculació del banc ha fallat. Torna-ho a provar." } } diff --git a/app/i18n/raw-i18n/translations/cs.json b/app/i18n/raw-i18n/translations/cs.json index 375e28419..abfbe1665 100644 --- a/app/i18n/raw-i18n/translations/cs.json +++ b/app/i18n/raw-i18n/translations/cs.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banka připojena", + "connectedBody": "Vaše banka se propojuje a brzy se zde zobrazí.", + "exchangeFailed": "Propojení banky se nezdařilo. Zkuste to prosím znovu.", + "linkFailed": "Propojení banky selhalo. Zkuste to prosím znovu." } } diff --git a/app/i18n/raw-i18n/translations/da.json b/app/i18n/raw-i18n/translations/da.json index d8bdea752..02c06f233 100644 --- a/app/i18n/raw-i18n/translations/da.json +++ b/app/i18n/raw-i18n/translations/da.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Bank forbundet", + "connectedBody": "Din bank bliver tilknyttet og vises her snart.", + "exchangeFailed": "Kunne ikke tilknytte din bank. Prøv venligst igen.", + "linkFailed": "Banktilknytning mislykkedes. Prøv venligst igen." } } diff --git a/app/i18n/raw-i18n/translations/de.json b/app/i18n/raw-i18n/translations/de.json index 4416e6b54..8de195aa6 100644 --- a/app/i18n/raw-i18n/translations/de.json +++ b/app/i18n/raw-i18n/translations/de.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Bank verbunden", + "connectedBody": "Deine Bank wird verknüpft und erscheint hier in Kürze.", + "exchangeFailed": "Deine Bank konnte nicht verknüpft werden. Bitte versuche es erneut.", + "linkFailed": "Bankverknüpfung fehlgeschlagen. Bitte versuche es erneut." } } diff --git a/app/i18n/raw-i18n/translations/el.json b/app/i18n/raw-i18n/translations/el.json index 90f3d452e..58640db7a 100644 --- a/app/i18n/raw-i18n/translations/el.json +++ b/app/i18n/raw-i18n/translations/el.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Η τράπεζα συνδέθηκε", + "connectedBody": "Η τράπεζά σου συνδέεται και θα εμφανιστεί εδώ σύντομα.", + "exchangeFailed": "Η σύνδεση της τράπεζας απέτυχε. Δοκίμασε ξανά.", + "linkFailed": "Η σύνδεση της τράπεζας απέτυχε. Δοκίμασε ξανά." } } diff --git a/app/i18n/raw-i18n/translations/es.json b/app/i18n/raw-i18n/translations/es.json index b968c0a3d..38373b0b1 100644 --- a/app/i18n/raw-i18n/translations/es.json +++ b/app/i18n/raw-i18n/translations/es.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banco conectado", + "connectedBody": "Tu banco se está vinculando y aparecerá aquí pronto.", + "exchangeFailed": "No se pudo vincular tu banco. Inténtalo de nuevo.", + "linkFailed": "La vinculación del banco falló. Inténtalo de nuevo." } } diff --git a/app/i18n/raw-i18n/translations/fr.json b/app/i18n/raw-i18n/translations/fr.json index c0bf2496c..48202f9b6 100644 --- a/app/i18n/raw-i18n/translations/fr.json +++ b/app/i18n/raw-i18n/translations/fr.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banque connectée", + "connectedBody": "Votre banque est en cours de liaison et apparaîtra ici sous peu.", + "exchangeFailed": "Impossible de lier votre banque. Veuillez réessayer.", + "linkFailed": "La liaison bancaire a échoué. Veuillez réessayer." } } diff --git a/app/i18n/raw-i18n/translations/hr.json b/app/i18n/raw-i18n/translations/hr.json index ba5f9b3b5..0dc8fe054 100644 --- a/app/i18n/raw-i18n/translations/hr.json +++ b/app/i18n/raw-i18n/translations/hr.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banka povezana", + "connectedBody": "Vaša banka se povezuje i uskoro će se pojaviti ovdje.", + "exchangeFailed": "Povezivanje banke nije uspjelo. Pokušajte ponovno.", + "linkFailed": "Povezivanje banke nije uspjelo. Pokušajte ponovno." } } diff --git a/app/i18n/raw-i18n/translations/hu.json b/app/i18n/raw-i18n/translations/hu.json index 304f01950..6d2bf3f9d 100644 --- a/app/i18n/raw-i18n/translations/hu.json +++ b/app/i18n/raw-i18n/translations/hu.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Bank összekapcsolva", + "connectedBody": "A bankod összekapcsolása folyamatban van, hamarosan itt megjelenik.", + "exchangeFailed": "Nem sikerült összekapcsolni a bankodat. Kérjük, próbáld újra.", + "linkFailed": "A bank összekapcsolása nem sikerült. Kérjük, próbáld újra." } } diff --git a/app/i18n/raw-i18n/translations/hy.json b/app/i18n/raw-i18n/translations/hy.json index b8b88c91b..c81b35caf 100644 --- a/app/i18n/raw-i18n/translations/hy.json +++ b/app/i18n/raw-i18n/translations/hy.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Բանկը միացված է", + "connectedBody": "Ձեր բանկը կապվում է և շուտով կհայտնվի այստեղ։", + "exchangeFailed": "Չհաջողվեց կապել ձեր բանկը։ Խնդրում ենք փորձել կրկին։", + "linkFailed": "Բանկի կապումը ձախողվեց։ Խնդրում ենք փորձել կրկին։" } } diff --git a/app/i18n/raw-i18n/translations/it.json b/app/i18n/raw-i18n/translations/it.json index a05844ef4..e2e6e37dc 100644 --- a/app/i18n/raw-i18n/translations/it.json +++ b/app/i18n/raw-i18n/translations/it.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banca collegata", + "connectedBody": "La tua banca è in fase di collegamento e apparirà qui a breve.", + "exchangeFailed": "Impossibile collegare la tua banca. Riprova.", + "linkFailed": "Collegamento della banca non riuscito. Riprova." } } diff --git a/app/i18n/raw-i18n/translations/ms.json b/app/i18n/raw-i18n/translations/ms.json index 49cfb530f..282d9ad35 100644 --- a/app/i18n/raw-i18n/translations/ms.json +++ b/app/i18n/raw-i18n/translations/ms.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Bank disambungkan", + "connectedBody": "Bank anda sedang dipautkan dan akan muncul di sini tidak lama lagi.", + "exchangeFailed": "Gagal memautkan bank anda. Sila cuba lagi.", + "linkFailed": "Pemautan bank gagal. Sila cuba lagi." } } diff --git a/app/i18n/raw-i18n/translations/nl.json b/app/i18n/raw-i18n/translations/nl.json index 0e15f8e07..419a52d93 100644 --- a/app/i18n/raw-i18n/translations/nl.json +++ b/app/i18n/raw-i18n/translations/nl.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Bank gekoppeld", + "connectedBody": "Je bank wordt gekoppeld en verschijnt hier binnenkort.", + "exchangeFailed": "Kon je bank niet koppelen. Probeer het opnieuw.", + "linkFailed": "Bankkoppeling mislukt. Probeer het opnieuw." } } diff --git a/app/i18n/raw-i18n/translations/pt.json b/app/i18n/raw-i18n/translations/pt.json index 8b9335106..b7366fe4f 100644 --- a/app/i18n/raw-i18n/translations/pt.json +++ b/app/i18n/raw-i18n/translations/pt.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banco conectado", + "connectedBody": "Seu banco está sendo vinculado e aparecerá aqui em breve.", + "exchangeFailed": "Não foi possível vincular seu banco. Tente novamente.", + "linkFailed": "A vinculação do banco falhou. Tente novamente." } } diff --git a/app/i18n/raw-i18n/translations/qu.json b/app/i18n/raw-i18n/translations/qu.json index 69542dfcd..9ebabeaa3 100644 --- a/app/i18n/raw-i18n/translations/qu.json +++ b/app/i18n/raw-i18n/translations/qu.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banco tinkisqa", + "connectedBody": "Bancoyki tinkichkan, kaypi rikhurinqa ratolla.", + "exchangeFailed": "Mana atirqachu bancoykita tinkiy. Ama hina kaspa, wakmanta ruray.", + "linkFailed": "Banco tinkiy mana atisqa. Ama hina kaspa, wakmanta ruray." } } diff --git a/app/i18n/raw-i18n/translations/sr.json b/app/i18n/raw-i18n/translations/sr.json index ee740956c..e0b4a2dbf 100644 --- a/app/i18n/raw-i18n/translations/sr.json +++ b/app/i18n/raw-i18n/translations/sr.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Банка повезана", + "connectedBody": "Ваша банка се повезује и ускоро ће се појавити овде.", + "exchangeFailed": "Повезивање банке није успело. Покушајте поново.", + "linkFailed": "Повезивање банке није успело. Покушајте поново." } } diff --git a/app/i18n/raw-i18n/translations/sw.json b/app/i18n/raw-i18n/translations/sw.json index b6449c08a..456bbce84 100644 --- a/app/i18n/raw-i18n/translations/sw.json +++ b/app/i18n/raw-i18n/translations/sw.json @@ -1678,5 +1678,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Benki imeunganishwa", + "connectedBody": "Benki yako inaunganishwa na itaonekana hapa hivi karibuni.", + "exchangeFailed": "Imeshindwa kuunganisha benki yako. Tafadhali jaribu tena.", + "linkFailed": "Kuunganisha benki kumeshindwa. Tafadhali jaribu tena." } } diff --git a/app/i18n/raw-i18n/translations/th.json b/app/i18n/raw-i18n/translations/th.json index 7d7c5a001..a2a38cde2 100644 --- a/app/i18n/raw-i18n/translations/th.json +++ b/app/i18n/raw-i18n/translations/th.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "เชื่อมต่อธนาคารแล้ว", + "connectedBody": "กำลังเชื่อมโยงธนาคารของคุณ และจะแสดงที่นี่ในไม่ช้า", + "exchangeFailed": "ไม่สามารถเชื่อมโยงธนาคารของคุณได้ กรุณาลองใหม่อีกครั้ง", + "linkFailed": "การเชื่อมโยงธนาคารล้มเหลว กรุณาลองใหม่อีกครั้ง" } } diff --git a/app/i18n/raw-i18n/translations/tr.json b/app/i18n/raw-i18n/translations/tr.json index 5bf21a7e5..0e1fc0da0 100644 --- a/app/i18n/raw-i18n/translations/tr.json +++ b/app/i18n/raw-i18n/translations/tr.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Banka bağlandı", + "connectedBody": "Bankanız bağlanıyor ve kısa süre içinde burada görünecek.", + "exchangeFailed": "Bankanız bağlanamadı. Lütfen tekrar deneyin.", + "linkFailed": "Banka bağlantısı başarısız oldu. Lütfen tekrar deneyin." } } diff --git a/app/i18n/raw-i18n/translations/vi.json b/app/i18n/raw-i18n/translations/vi.json index d2ed7b90d..678642e31 100644 --- a/app/i18n/raw-i18n/translations/vi.json +++ b/app/i18n/raw-i18n/translations/vi.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Đã kết nối ngân hàng", + "connectedBody": "Ngân hàng của bạn đang được liên kết và sẽ sớm xuất hiện tại đây.", + "exchangeFailed": "Không thể liên kết ngân hàng của bạn. Vui lòng thử lại.", + "linkFailed": "Liên kết ngân hàng thất bại. Vui lòng thử lại." } } diff --git a/app/i18n/raw-i18n/translations/xh.json b/app/i18n/raw-i18n/translations/xh.json index 30fe869bc..66480afe6 100644 --- a/app/i18n/raw-i18n/translations/xh.json +++ b/app/i18n/raw-i18n/translations/xh.json @@ -1672,5 +1672,11 @@ "genericError": "Something went wrong. Please try again.", "alreadyLinkedTitle": "Bank Account Already Linked", "alreadyLinkedMessage": "This bank account is already linked to your profile." + }, + "PlaidLink": { + "connectedTitle": "Ibhanki iqhagamshelwe", + "connectedBody": "Ibhanki yakho iyaqhagamshelwa kwaye iza kubonakala apha kungekudala.", + "exchangeFailed": "Ayikwazanga ukuqhagamshela ibhanki yakho. Nceda uzame kwakhona.", + "linkFailed": "Ukuqhagamshelwa kwebhanki akuphumelelanga. Nceda uzame kwakhona." } } diff --git a/app/screens/topup-cashout-flow/TopupCashout.tsx b/app/screens/topup-cashout-flow/TopupCashout.tsx index d4e0bea86..44eed036a 100644 --- a/app/screens/topup-cashout-flow/TopupCashout.tsx +++ b/app/screens/topup-cashout-flow/TopupCashout.tsx @@ -27,6 +27,7 @@ import { useBridgeKycStatusQuery, } from "@app/graphql/generated" import { useActivityIndicator, useTransferFlags } from "@app/hooks" +import { usePlaidLink } from "@app/hooks/use-plaid-link" import { useLevel } from "@app/graphql/level-context" type Props = StackScreenProps @@ -111,6 +112,10 @@ const TopupCashout: React.FC = ({ navigation }) => { [currentLevel, navigation], ) + // Plaid Link + server-side public_token exchange (ENG-524); the linked + // account arrives asynchronously via Bridge's webhook, so refetch on link. + const { openPlaidLink } = usePlaidLink({ onLinked: refetchExternalAccounts }) + const checkBridgeKyc = useCallback( async (type: "topup" | "settle") => { if (!bridgeEnabled) return @@ -130,24 +135,33 @@ const TopupCashout: React.FC = ({ navigation }) => { navigation.navigate("CashoutDetails", { type: "bridge" }) } else { toggleActivityIndicator(true) - const res = await addExternalAccount() - toggleActivityIndicator(false) + try { + const res = await addExternalAccount() + toggleActivityIndicator(false) - const errors = res.data?.bridgeAddExternalAccount?.errors - if (errors && errors.length > 0) { - // If Plaid Link is unavailable, offer manual bank entry as fallback - if (errors[0].code === "BRIDGE_PLAID_NOT_AVAILABLE") { - navigation.navigate("BridgeAddExternalAccount") + const errors = res.data?.bridgeAddExternalAccount?.errors + if (errors && errors.length > 0) { + // If Plaid Link is unavailable, offer manual bank entry as fallback + if (errors[0].code === "BRIDGE_PLAID_NOT_AVAILABLE") { + navigation.navigate("BridgeAddExternalAccount") + return + } + Alert.alert("Error", errors[0].message) return } - Alert.alert("Error", errors[0].message) - return - } - const linkUrl = res.data?.bridgeAddExternalAccount?.externalAccount?.linkUrl - if (linkUrl) { - navigation.navigate("BridgeExternalAccountWebView", { linkUrl }) - } else { + const linkToken = + res.data?.bridgeAddExternalAccount?.externalAccount?.linkToken + if (linkToken) { + openPlaidLink(linkToken) + } else { + Alert.alert( + "Error", + "Failed to get external account link. Please try again.", + ) + } + } catch (err) { + toggleActivityIndicator(false) Alert.alert("Error", "Failed to get external account link. Please try again.") } } @@ -161,6 +175,7 @@ const TopupCashout: React.FC = ({ navigation }) => { externalAccountsData?.bridgeExternalAccounts, kycStatusData?.bridgeKycStatus, navigation, + openPlaidLink, toggleActivityIndicator, ], ) diff --git a/package.json b/package.json index 4e921be0e..7939e435a 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "react-native-modal": "^14.0.0-rc.1", "react-native-nfc-manager": "^3.14.8", "react-native-pager-view": "6.9.1", + "react-native-plaid-link-sdk": "12.8.3", "react-native-qrcode-svg": "^6.2.0", "react-native-randombytes": "^3.6.1", "react-native-rate": "^1.2.12", diff --git a/yarn.lock b/yarn.lock index e88d41206..9c1938605 100644 --- a/yarn.lock +++ b/yarn.lock @@ -20654,6 +20654,11 @@ react-native-pager-view@6.9.1: resolved "https://registry.yarnpkg.com/react-native-pager-view/-/react-native-pager-view-6.9.1.tgz#a9e6d9323935cc2ae1d46d7816b66f76dc3eff8e" integrity sha512-uUT0MMMbNtoSbxe9pRvdJJKEi9snjuJ3fXlZhG8F2vVMOBJVt/AFtqMPUHu9yMflmqOr08PewKzj9EPl/Yj+Gw== +react-native-plaid-link-sdk@12.8.3: + version "12.8.3" + resolved "https://registry.yarnpkg.com/react-native-plaid-link-sdk/-/react-native-plaid-link-sdk-12.8.3.tgz#b43c48e70a3f82813a77b66278d9c3a38e46628d" + integrity sha512-vdIETjkMncTFxqPFN8+kWyzI3bjEa2Z86DOevI++h/JAGQBgaVy3fQHggEIhfVLr2g+U1TsfrmHqCOOqy/qcGw== + react-native-qrcode-svg@^6.2.0: version "6.3.21" resolved "https://registry.yarnpkg.com/react-native-qrcode-svg/-/react-native-qrcode-svg-6.3.21.tgz#af873cf8e5b9fc68315a2c267ff6563d55c56abb"