Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions __mocks__/react-native-plaid-link-sdk.js
Original file line number Diff line number Diff line change
@@ -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" },
}
11 changes: 11 additions & 0 deletions app/graphql/front-end-mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ gql`
bridgeAddExternalAccount {
externalAccount {
expiresAt
linkToken
linkUrl
}
errors {
Expand All @@ -257,6 +258,16 @@ gql`
}
}

mutation BridgeExchangePlaidPublicToken($input: BridgeExchangePlaidPublicTokenInput!) {
bridgeExchangePlaidPublicToken(input: $input) {
errors {
code
message
}
message
}
}

mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) {
bridgeRequestWithdrawal(input: $input) {
withdrawal {
Expand Down
13 changes: 13 additions & 0 deletions app/graphql/generated.gql
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ mutation BridgeAddExternalAccount {
bridgeAddExternalAccount {
externalAccount {
expiresAt
linkToken
linkUrl
__typename
}
Expand Down Expand Up @@ -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 {
Expand Down
68 changes: 66 additions & 2 deletions app/graphql/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,17 @@ export type BridgeCreateVirtualAccountPayload = {
readonly virtualAccount?: Maybe<BridgeVirtualAccount>;
};

export type BridgeExchangePlaidPublicTokenInput = {
readonly linkToken: Scalars['String']['input'];
readonly publicToken: Scalars['String']['input'];
};

export type BridgeExchangePlaidPublicTokenPayload = {
readonly __typename: 'BridgeExchangePlaidPublicTokenPayload';
readonly errors: ReadonlyArray<Error>;
readonly message?: Maybe<Scalars['String']['output']>;
};

export type BridgeExternalAccount = {
readonly __typename: 'BridgeExternalAccount';
readonly accountNumberLast4: Scalars['String']['output'];
Expand All @@ -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<Scalars['String']['output']>;
};

export type BridgeInitiateKycInput = {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1243,6 +1257,11 @@ export type MutationBridgeCreateExternalAccountArgs = {
};


export type MutationBridgeExchangePlaidPublicTokenArgs = {
input: BridgeExchangePlaidPublicTokenInput;
};


export type MutationBridgeInitiateKycArgs = {
input: BridgeInitiateKycInput;
};
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -4555,6 +4581,7 @@ export const BridgeAddExternalAccountDocument = gql`
bridgeAddExternalAccount {
externalAccount {
expiresAt
linkToken
linkUrl
}
errors {
Expand Down Expand Up @@ -4589,6 +4616,43 @@ export function useBridgeAddExternalAccountMutation(baseOptions?: Apollo.Mutatio
export type BridgeAddExternalAccountMutationHookResult = ReturnType<typeof useBridgeAddExternalAccountMutation>;
export type BridgeAddExternalAccountMutationResult = Apollo.MutationResult<BridgeAddExternalAccountMutation>;
export type BridgeAddExternalAccountMutationOptions = Apollo.BaseMutationOptions<BridgeAddExternalAccountMutation, BridgeAddExternalAccountMutationVariables>;
export const BridgeExchangePlaidPublicTokenDocument = gql`
mutation BridgeExchangePlaidPublicToken($input: BridgeExchangePlaidPublicTokenInput!) {
bridgeExchangePlaidPublicToken(input: $input) {
errors {
code
message
}
message
}
}
`;
export type BridgeExchangePlaidPublicTokenMutationFn = Apollo.MutationFunction<BridgeExchangePlaidPublicTokenMutation, BridgeExchangePlaidPublicTokenMutationVariables>;

/**
* __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<BridgeExchangePlaidPublicTokenMutation, BridgeExchangePlaidPublicTokenMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<BridgeExchangePlaidPublicTokenMutation, BridgeExchangePlaidPublicTokenMutationVariables>(BridgeExchangePlaidPublicTokenDocument, options);
}
export type BridgeExchangePlaidPublicTokenMutationHookResult = ReturnType<typeof useBridgeExchangePlaidPublicTokenMutation>;
export type BridgeExchangePlaidPublicTokenMutationResult = Apollo.MutationResult<BridgeExchangePlaidPublicTokenMutation>;
export type BridgeExchangePlaidPublicTokenMutationOptions = Apollo.BaseMutationOptions<BridgeExchangePlaidPublicTokenMutation, BridgeExchangePlaidPublicTokenMutationVariables>;
export const BridgeRequestWithdrawalDocument = gql`
mutation BridgeRequestWithdrawal($input: BridgeRequestWithdrawalInput!) {
bridgeRequestWithdrawal(input: $input) {
Expand Down
14 changes: 13 additions & 1 deletion app/graphql/public-schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand All @@ -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 {
Expand Down Expand Up @@ -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!
Expand Down
59 changes: 57 additions & 2 deletions app/screens/topup-cashout-flow/TopupCashout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,18 @@ 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 { 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<RootStackParamList, "TopupCashout">

const TopupCashout: React.FC<Props> = ({ navigation }) => {
Expand All @@ -51,6 +56,7 @@ const TopupCashout: React.FC<Props> = ({ navigation }) => {

const [initiateBridgeKyc] = useBridgeInitiateKycMutation()
const [addExternalAccount] = useBridgeAddExternalAccountMutation()
const [exchangePlaidPublicToken] = useBridgeExchangePlaidPublicTokenMutation()

const { data: kycStatusData, refetch: refetchKycStatus } = useBridgeKycStatusQuery({
fetchPolicy: "cache-and-network",
Expand Down Expand Up @@ -111,6 +117,48 @@ const TopupCashout: React.FC<Props> = ({ 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
Expand Down Expand Up @@ -144,8 +192,14 @@ const TopupCashout: React.FC<Props> = ({ 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.")
Expand All @@ -161,6 +215,7 @@ const TopupCashout: React.FC<Props> = ({ navigation }) => {
externalAccountsData?.bridgeExternalAccounts,
kycStatusData?.bridgeKycStatus,
navigation,
openPlaidLink,
toggleActivityIndicator,
],
)
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading