From 202ba02e1f855769cbfb672bf9885dc5163a1e2b Mon Sep 17 00:00:00 2001 From: Dread Date: Wed, 15 Jul 2026 21:44:53 -0700 Subject: [PATCH 1/8] feat(mobile): Plaid Link SDK cutover for Bridge external accounts [ENG-524] Match backend flash#446, which replaced the hosted Plaid linkUrl with a linkToken + server-side public_token exchange. - Add react-native-plaid-link-sdk (12.8.3, classic imperative API; no Expo dep). - Schema snapshot: BridgeExternalAccountLink gains linkToken; linkUrl is now nullable + @deprecated. Add bridgeExchangePlaidPublicToken mutation + input/ payload types. Regen codegen. - GraphQL docs: select linkToken in BridgeAddExternalAccount; add BridgeExchangePlaidPublicToken mutation. - TopupCashout.checkBridgeKyc: open Plaid Link with linkToken, then call bridgeExchangePlaidPublicToken({ linkToken, publicToken }) on success. The linked account arrives asynchronously via Bridge's webhook. - Additive fallback: no linkToken (older backend) -> existing linkUrl WebView; BRIDGE_PLAID_NOT_AVAILABLE -> manual bank entry (unchanged). - jest mock for the native SDK. Follow-ups: OAuth-bank redirect config (iOS associated domains + AppDelegate handler, Android intent-filter; needs a Plaid-dashboard redirect URI) and device/simulator verification. Do not merge before flash#446 ships. Co-Authored-By: Claude Opus 4.8 (1M context) --- __mocks__/react-native-plaid-link-sdk.js | 10 +++ app/graphql/front-end-mutations.ts | 11 +++ app/graphql/generated.gql | 13 ++++ app/graphql/generated.ts | 68 ++++++++++++++++++- app/graphql/public-schema.graphql | 14 +++- .../topup-cashout-flow/TopupCashout.tsx | 59 +++++++++++++++- package.json | 1 + yarn.lock | 5 ++ 8 files changed, 176 insertions(+), 5 deletions(-) create mode 100644 __mocks__/react-native-plaid-link-sdk.js 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/app/graphql/front-end-mutations.ts b/app/graphql/front-end-mutations.ts index 3e29e6f70..e66cc9707 100644 --- a/app/graphql/front-end-mutations.ts +++ b/app/graphql/front-end-mutations.ts @@ -248,6 +248,7 @@ gql` bridgeAddExternalAccount { externalAccount { expiresAt + linkToken linkUrl } errors { @@ -257,6 +258,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..346f797c9 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -86,6 +86,7 @@ mutation BridgeAddExternalAccount { bridgeAddExternalAccount { externalAccount { expiresAt + linkToken linkUrl __typename } @@ -137,6 +138,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..a2b3fec05 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, readonly linkUrl?: string | null } | 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,6 +4581,7 @@ export const BridgeAddExternalAccountDocument = gql` bridgeAddExternalAccount { externalAccount { expiresAt + linkToken linkUrl } errors { @@ -4589,6 +4616,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/screens/topup-cashout-flow/TopupCashout.tsx b/app/screens/topup-cashout-flow/TopupCashout.tsx index d4e0bea86..bd49ceb96 100644 --- a/app/screens/topup-cashout-flow/TopupCashout.tsx +++ b/app/screens/topup-cashout-flow/TopupCashout.tsx @@ -22,6 +22,7 @@ import ArrowUp from "@app/assets/icons/arrow-up-from-bracket.svg" import { AccountLevel, useBridgeAddExternalAccountMutation, + useBridgeExchangePlaidPublicTokenMutation, useBridgeExternalAccountsQuery, useBridgeInitiateKycMutation, useBridgeKycStatusQuery, @@ -29,6 +30,10 @@ import { import { useActivityIndicator, useTransferFlags } from "@app/hooks" import { useLevel } from "@app/graphql/level-context" +// Plaid Link SDK — opens the native Plaid flow with a linkToken from the backend. +import { create, open } from "react-native-plaid-link-sdk" +import type { LinkExit, LinkSuccess } from "react-native-plaid-link-sdk" + type Props = StackScreenProps const TopupCashout: React.FC = ({ navigation }) => { @@ -51,6 +56,7 @@ const TopupCashout: React.FC = ({ navigation }) => { const [initiateBridgeKyc] = useBridgeInitiateKycMutation() const [addExternalAccount] = useBridgeAddExternalAccountMutation() + const [exchangePlaidPublicToken] = useBridgeExchangePlaidPublicTokenMutation() const { data: kycStatusData, refetch: refetchKycStatus } = useBridgeKycStatusQuery({ fetchPolicy: "cache-and-network", @@ -111,6 +117,48 @@ const TopupCashout: React.FC = ({ navigation }) => { [currentLevel, navigation], ) + const openPlaidLink = useCallback( + (linkToken: string) => { + 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("Error", errors[0].message) + return + } + + // The linked account is provisioned asynchronously via Bridge's webhook. + await refetchExternalAccounts() + Alert.alert( + "Bank connected", + "Your bank is being linked and will appear here shortly.", + ) + } catch (err) { + toggleActivityIndicator(false) + Alert.alert("Error", "Failed to link your bank. Please try again.") + } + }, + onExit: (exit: LinkExit) => { + // Plaid reports a real failure here, distinct from a plain user cancel. + if (exit.error) { + Alert.alert("Error", exit.error.errorMessage || "Bank linking was cancelled.") + } + }, + }) + }, + [exchangePlaidPublicToken, refetchExternalAccounts, toggleActivityIndicator], + ) + const checkBridgeKyc = useCallback( async (type: "topup" | "settle") => { if (!bridgeEnabled) return @@ -144,8 +192,14 @@ const TopupCashout: React.FC = ({ navigation }) => { return } - const linkUrl = res.data?.bridgeAddExternalAccount?.externalAccount?.linkUrl - if (linkUrl) { + const externalAccount = res.data?.bridgeAddExternalAccount?.externalAccount + const linkToken = externalAccount?.linkToken + const linkUrl = externalAccount?.linkUrl + if (linkToken) { + // Preferred path: open Plaid Link with the SDK, then exchange the public token. + openPlaidLink(linkToken) + } else if (linkUrl) { + // Deprecated hosted-URL fallback for backends that don't return a linkToken yet. navigation.navigate("BridgeExternalAccountWebView", { linkUrl }) } else { Alert.alert("Error", "Failed to get external account link. Please try again.") @@ -161,6 +215,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" From e81f4f57f000c757c616fe7d18afbc167dd98415 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 08:37:40 -0700 Subject: [PATCH 2/8] refactor(plaid): extract usePlaidLink hook, localize copy, test the flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inline review pass (house standards from the ENG-513/516 cycle): - Plaid Link + public_token exchange moves from TopupCashout into a usePlaidLink hook (same extraction pattern as useBridgeKyc), taking onLinked for the webhook-async refetch — and becoming testable. - All five new user-facing strings localized (PlaidLink.* keys, en + 23 locales); the onExit fallback no longer mislabels a real Plaid failure as "cancelled". - 5 tests over the flow using the SDK manual mock: exchange payload + variables, payload-error and thrown-error paths (indicator always cleared, no refetch), exit-error alert with/without Plaid's message, silent user cancel. Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 144 ++++++++++++++++++ app/hooks/index.ts | 1 + app/hooks/use-plaid-link.ts | 70 +++++++++ app/i18n/en/index.ts | 6 + app/i18n/i18n-types.ts | 36 +++++ app/i18n/raw-i18n/source/en.json | 6 + app/i18n/raw-i18n/translations/af.json | 6 + app/i18n/raw-i18n/translations/ar.json | 6 + app/i18n/raw-i18n/translations/ca.json | 6 + app/i18n/raw-i18n/translations/cs.json | 6 + app/i18n/raw-i18n/translations/da.json | 6 + app/i18n/raw-i18n/translations/de.json | 6 + app/i18n/raw-i18n/translations/el.json | 6 + app/i18n/raw-i18n/translations/es.json | 6 + app/i18n/raw-i18n/translations/fr.json | 6 + app/i18n/raw-i18n/translations/hr.json | 6 + app/i18n/raw-i18n/translations/hu.json | 6 + app/i18n/raw-i18n/translations/hy.json | 6 + app/i18n/raw-i18n/translations/it.json | 6 + app/i18n/raw-i18n/translations/ms.json | 6 + app/i18n/raw-i18n/translations/nl.json | 6 + app/i18n/raw-i18n/translations/pt.json | 6 + app/i18n/raw-i18n/translations/qu.json | 6 + app/i18n/raw-i18n/translations/sr.json | 6 + app/i18n/raw-i18n/translations/sw.json | 6 + app/i18n/raw-i18n/translations/th.json | 6 + app/i18n/raw-i18n/translations/tr.json | 6 + app/i18n/raw-i18n/translations/vi.json | 6 + app/i18n/raw-i18n/translations/xh.json | 6 + .../topup-cashout-flow/TopupCashout.tsx | 51 +------ 30 files changed, 405 insertions(+), 47 deletions(-) create mode 100644 __tests__/hooks/use-plaid-link.spec.tsx create mode 100644 app/hooks/use-plaid-link.ts diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx new file mode 100644 index 000000000..76779deae --- /dev/null +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -0,0 +1,144 @@ +/** + * 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. + * - onSuccess exchanges { linkToken, publicToken } server-side, then runs + * onLinked (webhook-async refetch) and shows the "appears shortly" copy. + * - Exchange payload errors and thrown errors alert and never run onLinked; + * the activity indicator is always cleared. + * - onExit alerts only for real Plaid failures — plain user cancel is 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?: { errorMessage?: string } }) => void +} + +const openLinkAndGetHandlers = (onLinked?: jest.Mock): PlaidHandlers => { + const { result } = renderHook(() => usePlaidLink({ onLinked })) + act(() => result.current.openPlaidLink("link-token-1")) + expect(create).toHaveBeenCalledWith({ token: "link-token-1" }) + expect(open).toHaveBeenCalledTimes(1) + return (open as jest.Mock).mock.calls[0][0] +} + +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.", + ) + // Indicator on for the exchange, off before the alert + expect(mockToggleActivityIndicator).toHaveBeenNthCalledWith(1, true) + expect(mockToggleActivityIndicator).toHaveBeenNthCalledWith(2, false) + }) + + it("surfaces exchange payload errors and does not refetch", async () => { + const onLinked = jest.fn() + mockExchange.mockResolvedValue({ + data: { + bridgeExchangePlaidPublicToken: { + errors: [{ code: "BRIDGE_INVALID_PLAID_TOKEN", message: "Invalid token" }], + }, + }, + }) + + const handlers = openLinkAndGetHandlers(onLinked) + await act(() => handlers.onSuccess({ publicToken: "public-token-1" })) + + expect(alertSpy).toHaveBeenCalledWith("Error", "Invalid token") + expect(onLinked).not.toHaveBeenCalled() + expect(mockToggleActivityIndicator).toHaveBeenLastCalledWith(false) + }) + + it("alerts generically and clears the indicator when the exchange throws", async () => { + const onLinked = jest.fn() + mockExchange.mockRejectedValue(new Error("network down")) + + const handlers = openLinkAndGetHandlers(onLinked) + await act(() => handlers.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) + }) + + it("alerts on a real Plaid exit error, using its message when present", () => { + const handlers = openLinkAndGetHandlers() + + act(() => handlers.onExit({ error: { errorMessage: "INSTITUTION_DOWN" } })) + expect(alertSpy).toHaveBeenCalledWith("Error", "INSTITUTION_DOWN") + + alertSpy.mockClear() + act(() => handlers.onExit({ error: { errorMessage: "" } })) + expect(alertSpy).toHaveBeenCalledWith( + "Error", + "Bank linking failed. Please try again.", + ) + }) + + it("stays silent on a plain user cancel (exit without error)", () => { + const handlers = openLinkAndGetHandlers() + + act(() => handlers.onExit({})) + expect(alertSpy).not.toHaveBeenCalled() + }) +}) 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..e9747bb6d --- /dev/null +++ b/app/hooks/use-plaid-link.ts @@ -0,0 +1,70 @@ +import { useCallback } 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() + + const openPlaidLink = useCallback( + (linkToken: string) => { + 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 + } + + await onLinked?.() + Alert.alert(LL.PlaidLink.connectedTitle(), LL.PlaidLink.connectedBody()) + } catch (err) { + toggleActivityIndicator(false) + Alert.alert(LL.common.error(), LL.PlaidLink.exchangeFailed()) + } + }, + onExit: (exit: LinkExit) => { + // Plaid reports a real failure here — a plain user cancel carries no + // error and stays silent. + if (exit.error) { + Alert.alert( + LL.common.error(), + exit.error.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 bd49ceb96..126d55f78 100644 --- a/app/screens/topup-cashout-flow/TopupCashout.tsx +++ b/app/screens/topup-cashout-flow/TopupCashout.tsx @@ -22,18 +22,14 @@ import ArrowUp from "@app/assets/icons/arrow-up-from-bracket.svg" import { AccountLevel, useBridgeAddExternalAccountMutation, - useBridgeExchangePlaidPublicTokenMutation, useBridgeExternalAccountsQuery, useBridgeInitiateKycMutation, 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" -// Plaid Link SDK — opens the native Plaid flow with a linkToken from the backend. -import { create, open } from "react-native-plaid-link-sdk" -import type { LinkExit, LinkSuccess } from "react-native-plaid-link-sdk" - type Props = StackScreenProps const TopupCashout: React.FC = ({ navigation }) => { @@ -56,7 +52,6 @@ const TopupCashout: React.FC = ({ navigation }) => { const [initiateBridgeKyc] = useBridgeInitiateKycMutation() const [addExternalAccount] = useBridgeAddExternalAccountMutation() - const [exchangePlaidPublicToken] = useBridgeExchangePlaidPublicTokenMutation() const { data: kycStatusData, refetch: refetchKycStatus } = useBridgeKycStatusQuery({ fetchPolicy: "cache-and-network", @@ -117,47 +112,9 @@ const TopupCashout: React.FC = ({ navigation }) => { [currentLevel, navigation], ) - const openPlaidLink = useCallback( - (linkToken: string) => { - 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("Error", errors[0].message) - return - } - - // The linked account is provisioned asynchronously via Bridge's webhook. - await refetchExternalAccounts() - Alert.alert( - "Bank connected", - "Your bank is being linked and will appear here shortly.", - ) - } catch (err) { - toggleActivityIndicator(false) - Alert.alert("Error", "Failed to link your bank. Please try again.") - } - }, - onExit: (exit: LinkExit) => { - // Plaid reports a real failure here, distinct from a plain user cancel. - if (exit.error) { - Alert.alert("Error", exit.error.errorMessage || "Bank linking was cancelled.") - } - }, - }) - }, - [exchangePlaidPublicToken, refetchExternalAccounts, toggleActivityIndicator], - ) + // 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") => { From 8ad57c0146d6802c48795a3571c96ff153fd7d23 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 09:16:57 -0700 Subject: [PATCH 3/8] fix(plaid): iOS cancel false-error, session reentrancy, honest success reporting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review round 1 (5 confirmed findings): - BLOCKING: on iOS the bridge ALWAYS embeds an error object with empty-string fields in the Link exit payload, so `if (exit.error)` alerted "Bank linking failed" on every plain user cancel (Android omits the key and stayed silent). Real failures are now detected by content (errorCode/errorMessage/displayMessage non-empty), and the alert prefers Plaid's user-facing displayMessage. - In-flight session guard: the SDK's native module is a singleton — a double-tap ran create()/open() twice, cross-wiring stored callbacks and {linkToken, publicToken} pairs. Second call is now a no-op until the session terminates. - A rejection from the best-effort onLinked refetch was reported as "Failed to link your bank" AFTER a successful exchange — inviting users to re-link and create duplicate external accounts at Bridge. Success is now decided by the exchange alone. - The linkUrl fallback branch was unreachable dead code (the linkToken-selecting operation fails validation entirely on a pre-flash#446 backend) and that same failure stranded the activity spinner: addExternalAccount is now try/caught, the branch and the dead linkUrl selection are removed (codegen regenerated). The hosted-webview screen stays registered as an operational escape hatch until on-device SDK verification. - Spec: 8 tests — refetch-rejection still succeeds, refetch-before- alert ordering pinned, in-flight guard + re-arm after exit and after completion, content-based exit errors, both platform cancel shapes. Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 109 ++++++++++++++++-- app/graphql/front-end-mutations.ts | 1 - app/graphql/generated.gql | 1 - app/graphql/generated.ts | 3 +- app/hooks/use-plaid-link.ts | 44 ++++++- .../topup-cashout-flow/TopupCashout.tsx | 43 +++---- 6 files changed, 159 insertions(+), 42 deletions(-) diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx index 76779deae..c7b3b48d9 100644 --- a/__tests__/hooks/use-plaid-link.spec.tsx +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -3,12 +3,16 @@ * * Contract under test: * - openPlaidLink creates the Plaid session with the backend's linkToken and - * opens the native UI. - * - onSuccess exchanges { linkToken, publicToken } server-side, then runs - * onLinked (webhook-async refetch) and shows the "appears shortly" copy. + * 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 real Plaid failures — plain user cancel is silent. + * - 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" @@ -46,15 +50,23 @@ import { usePlaidLink } from "@app/hooks/use-plaid-link" type PlaidHandlers = { onSuccess: (success: { publicToken: string }) => Promise - onExit: (exit: { error?: { errorMessage?: string } }) => void + 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 } = renderHook(() => usePlaidLink({ onLinked })) + const { result } = renderPlaidLink(onLinked) act(() => result.current.openPlaidLink("link-token-1")) expect(create).toHaveBeenCalledWith({ token: "link-token-1" }) expect(open).toHaveBeenCalledTimes(1) - return (open as jest.Mock).mock.calls[0][0] + return lastHandlers() } describe("usePlaidLink", () => { @@ -83,11 +95,37 @@ describe("usePlaidLink", () => { "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 and does not refetch", async () => { const onLinked = jest.fn() mockExchange.mockResolvedValue({ @@ -121,24 +159,71 @@ describe("usePlaidLink", () => { expect(mockToggleActivityIndicator).toHaveBeenLastCalledWith(false) }) - it("alerts on a real Plaid exit error, using its message when present", () => { + 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: { errorMessage: "INSTITUTION_DOWN" } })) - expect(alertSpy).toHaveBeenCalledWith("Error", "INSTITUTION_DOWN") + 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: { errorMessage: "" } })) + act(() => handlers.onExit({ error: { errorCode: "-1" } })) expect(alertSpy).toHaveBeenCalledWith( "Error", "Bank linking failed. Please try again.", ) }) - it("stays silent on a plain user cancel (exit without error)", () => { + 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 e66cc9707..7db5a49d5 100644 --- a/app/graphql/front-end-mutations.ts +++ b/app/graphql/front-end-mutations.ts @@ -249,7 +249,6 @@ gql` externalAccount { expiresAt linkToken - linkUrl } errors { code diff --git a/app/graphql/generated.gql b/app/graphql/generated.gql index 346f797c9..a23899d61 100644 --- a/app/graphql/generated.gql +++ b/app/graphql/generated.gql @@ -87,7 +87,6 @@ mutation BridgeAddExternalAccount { externalAccount { expiresAt linkToken - linkUrl __typename } errors { diff --git a/app/graphql/generated.ts b/app/graphql/generated.ts index a2b3fec05..f8bf455c3 100644 --- a/app/graphql/generated.ts +++ b/app/graphql/generated.ts @@ -2704,7 +2704,7 @@ 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 linkToken: string, readonly linkUrl?: string | null } | 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; @@ -4582,7 +4582,6 @@ export const BridgeAddExternalAccountDocument = gql` externalAccount { expiresAt linkToken - linkUrl } errors { code diff --git a/app/hooks/use-plaid-link.ts b/app/hooks/use-plaid-link.ts index e9747bb6d..74426bcff 100644 --- a/app/hooks/use-plaid-link.ts +++ b/app/hooks/use-plaid-link.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react" +import { useCallback, useRef } from "react" import { Alert } from "react-native" import { create, @@ -26,8 +26,17 @@ export const usePlaidLink = ({ onLinked }: { onLinked?: () => unknown } = {}) => 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) => { @@ -44,20 +53,43 @@ export const usePlaidLink = ({ onLinked }: { onLinked?: () => unknown } = {}) => return } - await onLinked?.() + // 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) => { - // Plaid reports a real failure here — a plain user cancel carries no - // error and stays silent. - if (exit.error) { + 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(), - exit.error.errorMessage || LL.PlaidLink.linkFailed(), + // displayMessage is Plaid's user-facing copy; errorMessage is + // developer-facing and only a fallback. + exitError.displayMessage || + exitError.errorMessage || + LL.PlaidLink.linkFailed(), ) } }, diff --git a/app/screens/topup-cashout-flow/TopupCashout.tsx b/app/screens/topup-cashout-flow/TopupCashout.tsx index 126d55f78..44eed036a 100644 --- a/app/screens/topup-cashout-flow/TopupCashout.tsx +++ b/app/screens/topup-cashout-flow/TopupCashout.tsx @@ -135,30 +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 externalAccount = res.data?.bridgeAddExternalAccount?.externalAccount - const linkToken = externalAccount?.linkToken - const linkUrl = externalAccount?.linkUrl - if (linkToken) { - // Preferred path: open Plaid Link with the SDK, then exchange the public token. - openPlaidLink(linkToken) - } else if (linkUrl) { - // Deprecated hosted-URL fallback for backends that don't return a linkToken yet. - 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.") } } From dd5337995c6a9bd78e82e0f288a1a57fabcf0cec Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 11:00:59 -0700 Subject: [PATCH 4/8] test(plaid): pin session-guard re-arm on both exchange-failure paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review round 3: the guard's release after a failed exchange was enforced solely by the finally block, and both natural finally-removal mutants (re-arm at end-of-try, re-arm after try/catch) passed the whole suite — a "redundant finally" refactor would strand users on a latched guard after any failure until remount. The payload-error and thrown-exchange tests now retry and assert a second session opens; each assertion kills one mutant (verified empirically by the reviewer). Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx index c7b3b48d9..2cc2218e7 100644 --- a/__tests__/hooks/use-plaid-link.spec.tsx +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -126,7 +126,7 @@ describe("usePlaidLink", () => { ) }) - it("surfaces exchange payload errors and does not refetch", async () => { + it("surfaces exchange payload errors, does not refetch, and re-arms for retry", async () => { const onLinked = jest.fn() mockExchange.mockResolvedValue({ data: { @@ -136,20 +136,27 @@ describe("usePlaidLink", () => { }, }) - const handlers = openLinkAndGetHandlers(onLinked) - await act(() => handlers.onSuccess({ publicToken: "public-token-1" })) + 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 and clears the indicator when the exchange throws", async () => { + 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 handlers = openLinkAndGetHandlers(onLinked) - await act(() => handlers.onSuccess({ publicToken: "public-token-1" })) + const { result } = renderPlaidLink(onLinked) + act(() => result.current.openPlaidLink("link-token-1")) + await act(() => lastHandlers().onSuccess({ publicToken: "public-token-1" })) expect(alertSpy).toHaveBeenCalledWith( "Error", @@ -157,6 +164,11 @@ describe("usePlaidLink", () => { ) 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", () => { From 611e4a20f707e66b16b7651d93c5fe019f4fd55a Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 13:39:28 -0700 Subject: [PATCH 5/8] test(plaid): pin guard release on the real-error exit path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review round 4: the branch-scoped release mutant (guard freed only on silent-cancel exits) survived all 8 tests — a real Plaid error (bank down, the case users retry) would latch the guard and dead the link button until remount. The error-exit test now retries and asserts a second session opens. (Round 4 also corrected the PR body's graphql-check claim — the one deprecated-field note is pre-existing and unrelated to linkUrl.) Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx index 2cc2218e7..3462f4a91 100644 --- a/__tests__/hooks/use-plaid-link.spec.tsx +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -201,10 +201,11 @@ describe("usePlaidLink", () => { }) it("alerts on a real Plaid exit error, preferring Plaid's user-facing message", () => { - const handlers = openLinkAndGetHandlers() + const { result } = renderPlaidLink() + act(() => result.current.openPlaidLink("link-token-1")) act(() => - handlers.onExit({ + lastHandlers().onExit({ error: { errorCode: "INSTITUTION_ERROR", errorMessage: "developer detail", @@ -217,8 +218,13 @@ describe("usePlaidLink", () => { "Your bank is temporarily unavailable.", ) + // Real-error exits are exactly where users retry (the bank was down) — + // the guard must release on this termination path too. + act(() => result.current.openPlaidLink("link-token-2")) + expect(create).toHaveBeenCalledTimes(2) + alertSpy.mockClear() - act(() => handlers.onExit({ error: { errorCode: "-1" } })) + act(() => lastHandlers().onExit({ error: { errorCode: "-1" } })) expect(alertSpy).toHaveBeenCalledWith( "Error", "Bank linking failed. Please try again.", From 2a42e2874234b7cd1e31dd63b688674dce5ef823 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 14:07:05 -0700 Subject: [PATCH 6/8] test(plaid): pin settle-order of the refetch with a deferred promise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review round 5: the ordering assertion compared mock invocationCallOrder — call order, not settle order — so the fire-and-forget mutant (onLinked?.()?.catch()) passed the whole suite while shipping "Bank connected" over a still-stale account list. The new test holds the refetch open on a deferred promise, asserts no success alert while it is pending (with onLinked already called, so the window is proven reached), then resolves and asserts the alert. Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 45 ++++++++++++++++++++++--- 1 file changed, 41 insertions(+), 4 deletions(-) diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx index 3462f4a91..c123633b5 100644 --- a/__tests__/hooks/use-plaid-link.spec.tsx +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -95,15 +95,52 @@ describe("usePlaidLink", () => { "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("waits for the refetch to SETTLE before announcing success", async () => { + // Pins settle order, not call order: a fire-and-forget refetch + // (`onLinked?.()?.catch(...)`) calls onLinked at the same point but + // alerts while the refetch is still in flight — the user would dismiss + // "Bank connected" onto a stale account list. + let resolveRefetch!: () => void + const onLinked = jest.fn( + () => + new Promise((resolve) => { + resolveRefetch = resolve + }), + ) + mockExchange.mockResolvedValue({ + data: { bridgeExchangePlaidPublicToken: { errors: [] } }, + }) + + const { result } = renderPlaidLink(onLinked) + act(() => result.current.openPlaidLink("link-token-1")) + + let pending!: Promise + act(() => { + pending = lastHandlers().onSuccess({ publicToken: "public-token-1" }) + }) + // Flush past the exchange await and into the refetch + await act(async () => { + for (let i = 0; i < 10; i += 1) { + await Promise.resolve() + } + }) + expect(onLinked).toHaveBeenCalledTimes(1) + // Refetch still pending → no success alert yet + expect(alertSpy).not.toHaveBeenCalled() + + resolveRefetch() + await act(() => pending) + expect(alertSpy).toHaveBeenCalledWith( + "Bank connected", + "Your bank is being linked and will appear here shortly.", + ) + }) + 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 From 8a0bf256d9af27b699411e559d59fd0e75d7cb99 Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 18:33:25 -0700 Subject: [PATCH 7/8] test(plaid): pin the errorMessage rung of the exit-alert copy chain Adversarial-review round 6: deleting the errorMessage fallback term passed all 9 tests, so a "simplification" would silently swap Plaid's actionable developer message (display_message is commonly null on real errors) for the generic fallback. New exit case pins the middle rung with the iOS empty-string displayMessage shape. Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx index c123633b5..cd43971ee 100644 --- a/__tests__/hooks/use-plaid-link.spec.tsx +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -260,6 +260,22 @@ describe("usePlaidLink", () => { act(() => result.current.openPlaidLink("link-token-2")) expect(create).toHaveBeenCalledTimes(2) + // Middle rung: no display copy (empty string, the iOS bridge shape) — + // Plaid's developer message is still more actionable than the generic + // fallback and must not be skipped over. + alertSpy.mockClear() + act(() => + lastHandlers().onExit({ + error: { + errorCode: "ITEM_LOGIN_REQUIRED", + errorMessage: "developer detail", + displayMessage: "", + }, + }), + ) + expect(alertSpy).toHaveBeenCalledWith("Error", "developer detail") + act(() => result.current.openPlaidLink("link-token-3")) + alertSpy.mockClear() act(() => lastHandlers().onExit({ error: { errorCode: "-1" } })) expect(alertSpy).toHaveBeenCalledWith( From ecea10aa7674bff3457d539de2aae45d9500c3bf Mon Sep 17 00:00:00 2001 From: Dread Date: Fri, 17 Jul 2026 21:39:36 -0700 Subject: [PATCH 8/8] =?UTF-8?q?test(plaid):=20pin=20create()-before-open()?= =?UTF-8?q?=20=E2=80=94=20the=20SDK's=20hard=20native=20precondition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial-review round 8: an order-inverting mutant passed all 9 tests, yet open()-before-create() is a total outage on device (Android throws LinkException, iOS error-exits "Create was not called."). The shared helper now asserts invocation order (flip-detectable, unlike the vacuous ordering assertion removed in round 5) and the guard-re-arm test pins the second session's pair too. Co-Authored-By: Claude Fable 5 --- __tests__/hooks/use-plaid-link.spec.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/__tests__/hooks/use-plaid-link.spec.tsx b/__tests__/hooks/use-plaid-link.spec.tsx index cd43971ee..edb133629 100644 --- a/__tests__/hooks/use-plaid-link.spec.tsx +++ b/__tests__/hooks/use-plaid-link.spec.tsx @@ -66,6 +66,12 @@ const openLinkAndGetHandlers = (onLinked?: jest.Mock): PlaidHandlers => { act(() => result.current.openPlaidLink("link-token-1")) expect(create).toHaveBeenCalledWith({ token: "link-token-1" }) expect(open).toHaveBeenCalledTimes(1) + // create() BEFORE open() is the SDK's one hard native precondition: + // Android throws LinkException("Create must be called before open."), + // iOS short-circuits open() into an error exit ("Create was not called."). + expect((create as jest.Mock).mock.invocationCallOrder[0]).toBeLessThan( + (open as jest.Mock).mock.invocationCallOrder[0], + ) return lastHandlers() } @@ -222,6 +228,10 @@ describe("usePlaidLink", () => { act(() => result.current.openPlaidLink("link-token-2")) expect(create).toHaveBeenCalledTimes(2) expect(create).toHaveBeenLastCalledWith({ token: "link-token-2" }) + // create-before-open holds on the retry session too + expect((create as jest.Mock).mock.invocationCallOrder[1]).toBeLessThan( + (open as jest.Mock).mock.invocationCallOrder[1], + ) }) it("re-arms after a completed session (onSuccess settled)", async () => {