diff --git a/assets/images/simple-illustrations/simple-illustration__bank-check.svg b/assets/images/simple-illustrations/simple-illustration__bank-check.svg new file mode 100644 index 000000000000..124f8509d240 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__bank-check.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/simple-illustrations/simple-illustration__bank-x.svg b/assets/images/simple-illustrations/simple-illustration__bank-x.svg new file mode 100644 index 000000000000..4b325b976614 --- /dev/null +++ b/assets/images/simple-illustrations/simple-illustration__bank-x.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7a54e5e399d7..0595464b62ae 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -812,6 +812,7 @@ const CONST = { PRODUCT_UPDATE_SEPTEMBER_2026: 'productUpdateSeptember2026', }, BANK_ACCOUNT: { + PLAID_WRONG_ACCOUNT_ERROR_FIELD: 'plaidWrongAccount', BENEFICIAL_OWNER_INFO_STEP: { BENEFICIAL_OWNER_DATA: { BENEFICIAL_OWNER_KEYS: 'beneficialOwnerKeys', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 5c92ffbfef9b..7f23aca72e49 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -232,6 +232,11 @@ const DYNAMIC_ROUTES = { entryScreens: [SCREENS.SETTINGS.WALLET.ROOT, SCREENS.WORKSPACE.WORKFLOWS], getRoute: (bankAccountID: number | undefined) => `global-reimbursements/${bankAccountID}` as const, }, + BANK_ACCOUNT_LINK_PLAID: { + path: ':bankAccountID/link-plaid', + entryScreens: [SCREENS.SETTINGS.WALLET.ROOT], + getRoute: (bankAccountID: string) => `${bankAccountID}/link-plaid` as const, + }, BANK_ACCOUNT_VERIFY_ACCOUNT: { path: 'verify-bank-account', entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 566a6691e307..b77de806221a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -210,6 +210,7 @@ const SCREENS = { SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account', EDIT_BANK_ACCOUNT_NICKNAME: 'Settings_Wallet_Edit_Bank_Account_Nickname', DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO: 'Dynamic_Settings_Wallet_Wise_Global_Reimbursements_Intro', + DYNAMIC_BANK_ACCOUNT_LINK_PLAID: 'Dynamic_Settings_Wallet_BankAccount_LinkPlaid', TRAVEL_CVV: 'Settings_Wallet_Travel_CVV', TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount', PERSONAL_CARD_DETAILS: 'Settings_Wallet_Personal_Card_Details', diff --git a/src/components/ConnectionStatusMessage.tsx b/src/components/ConnectionStatusMessage.tsx index 2110d8ab74b5..98ae82aca480 100644 --- a/src/components/ConnectionStatusMessage.tsx +++ b/src/components/ConnectionStatusMessage.tsx @@ -22,6 +22,11 @@ type ConnectionStatusMessageProps = { statusTone?: 'default' | 'success' | 'danger'; onLinkPress?: ComponentProps['onLinkPress']; shouldIncludeHorizontalPadding?: boolean; + + /** + * Accessible name for the action button + */ + actionAccessibilityLabel?: string; }; function ConnectionStatusMessage({ @@ -32,6 +37,7 @@ function ConnectionStatusMessage({ statusTone = 'default', onLinkPress, shouldIncludeHorizontalPadding = true, + actionAccessibilityLabel, }: ConnectionStatusMessageProps) { const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); const theme = useTheme(); @@ -48,15 +54,23 @@ function ConnectionStatusMessage({ } const shouldShowActionButton = !!actionText && !!onActionPress; const isDangerStatus = statusTone === 'danger'; - const messageTag = isDangerStatus ? 'rbr' : 'muted-text-label'; + const isSuccessStatus = statusTone === 'success'; + let messageTag; + if (isDangerStatus) { + messageTag = 'rbr'; + } else if (isSuccessStatus) { + messageTag = 'gbr'; + } else { + messageTag = 'muted-text-label'; + } const messageHTML = `<${messageTag}>${message ?? ''}`; const messageContent = ( - {isDangerStatus && ( + {(isDangerStatus || isSuccessStatus) && ( )} @@ -71,11 +85,12 @@ function ConnectionStatusMessage({ const actionButton = shouldShowActionButton ? ( diff --git a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx index cf86bad4c113..b515a4f28aad 100755 --- a/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx +++ b/src/components/HTMLEngineProvider/BaseHTMLEngineProvider.tsx @@ -90,6 +90,16 @@ function BaseHTMLEngineProvider({textSelectable = false, children, enableExperim }, contentModel: HTMLContentModel.block, }), + gbr: HTMLElementModel.fromCustomModel({ + tagName: 'gbr', + getMixedUAStyles: (tnode) => { + if (tnode.attributes.issmall === undefined) { + return {...styles.formError, ...styles.mb0, ...styles.badgeSuccessText}; + } + return {...styles.formError, ...styles.mb0, ...styles.textMicro, ...styles.badgeSuccessText}; + }, + contentModel: HTMLContentModel.block, + }), 'muted-link': HTMLElementModel.fromCustomModel({ tagName: 'muted-link', mixedUAStyles: {...styles.subTextFileUpload, ...styles.textSupporting}, diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx index 96628aedea33..fc9fa0756c6d 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/RBRRenderer.tsx @@ -25,7 +25,7 @@ function RBRRenderer({tnode, style}: CustomRendererProps) { numberOfLines={shouldShowEllipsis ? 1 : 0} ellipsizeMode="tail" key={props.key} - style={[styles.textLabelError, flattenStyle, isSmall ? styles.textMicro : {}]} + style={[styles.textLabelError, styles.badgeDangerText, flattenStyle, isSmall ? styles.textMicro : {}]} > {props.childElement} diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index 8ccb44d73fe1..4849bdd5c11d 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -115,7 +115,9 @@ import AiBot from '@assets/images/simple-illustrations/simple-illustration__ai-b import Alert from '@assets/images/simple-illustrations/simple-illustration__alert.svg'; import Members from '@assets/images/simple-illustrations/simple-illustration__approval-members.svg'; import Approval from '@assets/images/simple-illustrations/simple-illustration__approval.svg'; +import BankCheck from '@assets/images/simple-illustrations/simple-illustration__bank-check.svg'; import ShareBank from '@assets/images/simple-illustrations/simple-illustration__bank-share.svg'; +import BankX from '@assets/images/simple-illustrations/simple-illustration__bank-x.svg'; import BankCoin from '@assets/images/simple-illustrations/simple-illustration__bankcoin.svg'; import Binoculars from '@assets/images/simple-illustrations/simple-illustration__binoculars.svg'; import BlueShield from '@assets/images/simple-illustrations/simple-illustration__blueshield.svg'; @@ -358,7 +360,9 @@ const Illustrations = { AiBot, Building, BuildingCross, + BankCheck, BankCoin, + BankX, Coins, CardDenied, CardIntoWallet, diff --git a/src/languages/de.ts b/src/languages/de.ts index b46fc8738572..de6a7da1c5bc 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2854,6 +2854,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Bitte bestätigen Sie Testtransaktionen', accountRequiresAttention: 'Dieses Konto erfordert Ihre Aufmerksamkeit', unlock: 'Entsperren', + plaidBrokenReconnect: 'Beheben Sie Ihre Plaid-Verbindung für ein genaueres Limit Ihrer Expensify Karte', + plaidConnectForLimit: 'Mit Plaid verbinden, um ein genaueres Limit für Ihre Expensify Karte zu erhalten', + connect: 'Verbinden', + }, + linkPlaid: { + title: 'Mit Plaid verbinden', + successHeading: 'Erfolgreiche Verbindung', + successDescription: 'Danke, dass Sie Plaid verbunden haben!', + failureHeading: 'Verbindung fehlgeschlagen', + failureDescription: 'Bitte versuchen Sie es erneut oder wenden Sie sich an Concierge.', + wrongAccountHeading: 'Falsches Bankkonto', + wrongAccountDescription: 'Bitte wählen Sie das passende Bankkonto in Plaid aus.', }, wiseGlobalReimbursementsIntro: { title: 'Globale Rückerstattungen einrichten?', diff --git a/src/languages/el.ts b/src/languages/el.ts index 1eb71ef7ca54..a259e15ef5c9 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -2910,6 +2910,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Παρακαλούμε επιβεβαιώστε τις δοκιμαστικές συναλλαγές', accountRequiresAttention: 'Αυτός ο λογαριασμός χρειάζεται προσοχή', unlock: 'Ξεκλείδωμα', + plaidBrokenReconnect: 'Διορθώστε τη σύνδεσή σας με το Plaid για πιο ακριβές όριο στην Κάρτα Expensify', + plaidConnectForLimit: 'Συνδεθείτε με το Plaid για πιο ακριβές όριο στην Κάρτα Expensify', + connect: 'Σύνδεση', + }, + linkPlaid: { + title: 'Σύνδεση με το Plaid', + successHeading: 'Επιτυχής σύνδεση', + successDescription: 'Ευχαριστούμε που συνδεθήκατε με το Plaid!', + failureHeading: 'Ανεπιτυχής σύνδεση', + failureDescription: 'Παρακαλούμε δοκιμάστε ξανά ή επικοινωνήστε με το Concierge.', + wrongAccountHeading: 'Λάθος τραπεζικός λογαριασμός', + wrongAccountDescription: 'Παρακαλούμε επιλέξτε τον αντίστοιχο τραπεζικό λογαριασμό στο Plaid.', }, wiseGlobalReimbursementsIntro: { title: 'Να ρυθμιστούν οι παγκόσμιες αποζημιώσεις;', diff --git a/src/languages/en.ts b/src/languages/en.ts index ae40aaf45841..e331e62d95d6 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2985,6 +2985,18 @@ const translations = { confirmTestTransactions: 'Please confirm test transactions', accountRequiresAttention: 'This account requires attention', unlock: 'Unlock', + plaidBrokenReconnect: 'Fix your Plaid connection for a more accurate Expensify Card limit', + plaidConnectForLimit: 'Connect to Plaid for a more accurate Expensify Card limit', + connect: 'Connect', + }, + linkPlaid: { + title: 'Connect to Plaid', + successHeading: 'Successful connection', + successDescription: 'Thanks for connecting to Plaid!', + failureHeading: 'Unsuccessful connection', + failureDescription: 'Please try again or reach out to Concierge.', + wrongAccountHeading: 'Wrong bank account', + wrongAccountDescription: 'Please select the matching bank account in Plaid.', }, assignedCards: 'Cards', assignedCardsDescription: 'Transactions from assigned cards sync automatically.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 206a8d0429ad..8df69c573d85 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2759,6 +2759,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Confirma las transacciones de prueba', accountRequiresAttention: 'Esta cuenta requiere atención', unlock: 'Desbloquear', + plaidBrokenReconnect: 'Repara tu conexión con Plaid para tener un límite de la Tarjeta Expensify más preciso', + plaidConnectForLimit: 'Conéctate a Plaid para tener un límite más preciso en la Tarjeta Expensify', + connect: 'Conectar', + }, + linkPlaid: { + title: 'Conectar con Plaid', + successHeading: 'Conexión correcta', + successDescription: '¡Gracias por conectarte a Plaid!', + failureHeading: 'Conexión fallida', + failureDescription: 'Por favor, inténtalo de nuevo o contacta con Concierge.', + wrongAccountHeading: 'Cuenta bancaria incorrecta', + wrongAccountDescription: 'Selecciona la cuenta bancaria correspondiente en Plaid.', }, wiseGlobalReimbursementsIntro: { title: '¿Configurar reembolsos globales?', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 13810352929c..8a395e335a1e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2859,6 +2859,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Veuillez confirmer les transactions de test', accountRequiresAttention: 'Ce compte nécessite une attention particulière', unlock: 'Déverrouiller', + plaidBrokenReconnect: 'Corrigez votre connexion Plaid pour un plafond de Carte Expensify plus précis', + plaidConnectForLimit: 'Connectez Plaid pour une limite de Carte Expensify plus précise', + connect: 'Connecter', + }, + linkPlaid: { + title: 'Se connecter à Plaid', + successHeading: 'Connexion réussie', + successDescription: 'Merci de vous être connecté à Plaid !', + failureHeading: 'Connexion infructueuse', + failureDescription: 'Veuillez réessayer ou contactez Concierge.', + wrongAccountHeading: 'Mauvais compte bancaire', + wrongAccountDescription: 'Veuillez sélectionner le compte bancaire correspondant dans Plaid.', }, wiseGlobalReimbursementsIntro: { title: 'Configurer les remboursements globaux ?', diff --git a/src/languages/it.ts b/src/languages/it.ts index 7fa79f709757..c783856990ee 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2843,6 +2843,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Conferma le transazioni di prova', accountRequiresAttention: 'Questo conto richiede attenzione', unlock: 'Sblocca', + plaidBrokenReconnect: 'Sistema la connessione Plaid per un limite della Carta Expensify più preciso', + plaidConnectForLimit: 'Connettiti a Plaid per un limite della Carta Expensify più preciso', + connect: 'Connetti', + }, + linkPlaid: { + title: 'Connetti a Plaid', + successHeading: 'Connessione riuscita', + successDescription: 'Grazie per esserti connesso a Plaid!', + failureHeading: 'Connessione non riuscita', + failureDescription: 'Riprova oppure contatta Concierge.', + wrongAccountHeading: 'Conto bancario errato', + wrongAccountDescription: 'Seleziona il conto bancario corrispondente in Plaid.', }, wiseGlobalReimbursementsIntro: { title: 'Configurare i rimborsi globali?', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index e3c76e4b3392..6ee70003bc1e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2822,6 +2822,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'テスト取引を確認してください', accountRequiresAttention: 'このアカウントには対応が必要です', unlock: 'ロック解除', + plaidBrokenReconnect: 'より正確な Expensify カードの限度額のために Plaid 接続を修正しましょう', + plaidConnectForLimit: 'Plaid に接続して、より正確な Expensify カードの利用限度額を設定しましょう', + connect: '接続', + }, + linkPlaid: { + title: 'Plaid に接続', + successHeading: '接続が成功しました', + successDescription: 'Plaid に接続していただきありがとうございます!', + failureHeading: '接続に失敗しました', + failureDescription: 'もう一度お試しいただくか、Concierge へお問い合わせください。', + wrongAccountHeading: '誤った銀行口座', + wrongAccountDescription: 'Plaid で一致する銀行口座を選択してください。', }, wiseGlobalReimbursementsIntro: { title: 'グローバル払い戻しを設定しますか?', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 0de492600d27..3dfe47d4d94e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2847,6 +2847,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Bevestig testtransacties alsjeblieft', accountRequiresAttention: 'Deze account vereist aandacht', unlock: 'Ontgrendelen', + plaidBrokenReconnect: 'Herstel je Plaid-verbinding voor een nauwkeurigere limiet op je Expensify Kaart', + plaidConnectForLimit: 'Maak verbinding met Plaid voor een nauwkeuriger limiet voor je Expensify Kaart', + connect: 'Verbinding maken', + }, + linkPlaid: { + title: 'Verbinden met Plaid', + successHeading: 'Verbinding geslaagd', + successDescription: 'Bedankt dat je verbinding hebt gemaakt met Plaid!', + failureHeading: 'Mislukte verbinding', + failureDescription: 'Probeer het opnieuw of neem contact op met Concierge.', + wrongAccountHeading: 'Verkeerde bankrekening', + wrongAccountDescription: 'Selecteer de overeenkomende bankrekening in Plaid.', }, wiseGlobalReimbursementsIntro: { title: 'Wereldwijde terugbetalingen instellen?', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index afc2ef292e3e..f84d610faa7e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2883,6 +2883,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Potwierdź proszę transakcje testowe', accountRequiresAttention: 'To konto wymaga uwagi', unlock: 'Odblokuj', + plaidBrokenReconnect: 'Napraw połączenie z Plaid, aby limit Karty Expensify był dokładniejszy', + plaidConnectForLimit: 'Połącz się z Plaid, aby uzyskać dokładniejszy limit Karty Expensify', + connect: 'Połącz', + }, + linkPlaid: { + title: 'Połącz z Plaid', + successHeading: 'Połączono pomyślnie', + successDescription: 'Dzięki za połączenie z Plaid!', + failureHeading: 'Nieudane połączenie', + failureDescription: 'Spróbuj ponownie lub skontaktuj się z Concierge.', + wrongAccountHeading: 'Nieprawidłowe konto bankowe', + wrongAccountDescription: 'Wybierz pasujące konto bankowe w Plaid.', }, wiseGlobalReimbursementsIntro: { title: 'Skonfigurować globalne zwroty kosztów?', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index bf61af514119..fc34c97ef5ab 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2838,6 +2838,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Confirme as transações de teste', accountRequiresAttention: 'Esta conta precisa de atenção', unlock: 'Desbloquear', + plaidBrokenReconnect: 'Corrija sua conexão com o Plaid para ter um limite do Cartão Expensify mais preciso', + plaidConnectForLimit: 'Conecte-se ao Plaid para um limite de Cartão Expensify mais preciso', + connect: 'Conectar', + }, + linkPlaid: { + title: 'Conectar ao Plaid', + successHeading: 'Conexão bem-sucedida', + successDescription: 'Obrigado por se conectar ao Plaid!', + failureHeading: 'Conexão sem sucesso', + failureDescription: 'Tente novamente ou entre em contato com o Concierge.', + wrongAccountHeading: 'Conta bancária incorreta', + wrongAccountDescription: 'Selecione a conta bancária correspondente no Plaid.', }, wiseGlobalReimbursementsIntro: { title: 'Configurar reembolsos globais?', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 238d15ce72cd..603cc58e3d6b 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2748,6 +2748,18 @@ const translations: TranslationDeepObject = { confirmTestTransactions: '请确认测试交易', accountRequiresAttention: '此账户需要处理', unlock: '解锁', + plaidBrokenReconnect: '修复您的 Plaid 连接以获得更准确的 Expensify 卡额度', + plaidConnectForLimit: '连接 Plaid,以获得更精确的 Expensify 卡额度', + connect: '连接', + }, + linkPlaid: { + title: '连接到 Plaid', + successHeading: '连接成功', + successDescription: '感谢你连接 Plaid!', + failureHeading: '连接失败', + failureDescription: '请重试,或联系 Concierge。', + wrongAccountHeading: '银行账户错误', + wrongAccountDescription: '请在 Plaid 中选择对应的银行账户。', }, wiseGlobalReimbursementsIntro: {title: '设置全球报销?', subtitle: '按当地货币向全球成员付款。', getStarted: '开始使用', headerTitle: '全球报销'}, wiseGlobalReimbursementsBanner: {title: '向全球付款', subtitle: '在您的美元账户中启用全球报销。'}, diff --git a/src/libs/API/parameters/LinkPlaidToBankAccountParams.ts b/src/libs/API/parameters/LinkPlaidToBankAccountParams.ts new file mode 100644 index 000000000000..426834a0bfe6 --- /dev/null +++ b/src/libs/API/parameters/LinkPlaidToBankAccountParams.ts @@ -0,0 +1,5 @@ +type LinkPlaidToBankAccountParams = { + bankAccountID: number; + publicToken: string; +}; +export default LinkPlaidToBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index bfda6d857670..a59c2850c70f 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -22,6 +22,7 @@ export type {default as BeginSignInParams} from './BeginSignInParams'; export type {default as CloseAccountParams} from './CloseAccountParams'; export type {default as RevokeDeviceParams} from './RevokeDeviceParams'; export type {default as ConnectBankAccountParams} from './ConnectBankAccountParams'; +export type {default as LinkPlaidToBankAccountParams} from './LinkPlaidToBankAccountParams'; export type {default as OpenDuplicatePolicyPageParams} from './OpenDuplicatePolicyPageParams'; export type {default as ConnectPolicyToAccountingIntegrationParams} from './ConnectPolicyToAccountingIntegrationParams'; export type {default as ConnectPolicyToNetSuiteOAuthParams} from './ConnectPolicyToNetSuiteOAuthParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 990de2711950..0fe344cde453 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -123,6 +123,7 @@ const WRITE_COMMANDS = { ADD_ATTACHMENT: 'AddAttachment', ADD_TEXT_AND_ATTACHMENT: 'AddTextAndAttachment', CONNECT_BANK_ACCOUNT_WITH_PLAID: 'ConnectBankAccountWithPlaid', + LINK_PLAID_TO_BANK_ACCOUNT: 'LinkPlaidToBankAccount', ADD_PERSONAL_BANK_ACCOUNT: 'AddPersonalBankAccount', CREATE_COLLECT_ONLY_DEPOSIT_ACCOUNT: 'CreateCollectOnlyDepositAccount', UPDATE_PERSONAL_BANK_ACCOUNT_INFO: 'UpdatePersonalBankAccountInfo', @@ -912,6 +913,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.CREATE_APP_REPORT]: Parameters.CreateAppReportParams; [WRITE_COMMANDS.ADD_TEXT_AND_ATTACHMENT]: Parameters.AddCommentOrAttachmentParams; [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID]: Parameters.ConnectBankAccountParams; + [WRITE_COMMANDS.LINK_PLAID_TO_BANK_ACCOUNT]: Parameters.LinkPlaidToBankAccountParams; [WRITE_COMMANDS.ADD_PERSONAL_BANK_ACCOUNT]: Parameters.AddPersonalBankAccountParams; [WRITE_COMMANDS.CREATE_COLLECT_ONLY_DEPOSIT_ACCOUNT]: Parameters.CreateCollectOnlyDepositAccountParams; [WRITE_COMMANDS.UPDATE_PERSONAL_BANK_ACCOUNT_INFO]: Parameters.UpdatePersonalBankAccountInfoParams; diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 1ed341db2d52..2d13d6aec151 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -20,9 +20,11 @@ type KYBVerificationResponses = NonNullable['externalA type BankAccountConnectionStatus = { labelKey: TranslationPaths; tone: 'default' | 'success' | 'danger'; + badgeTone?: 'default' | 'success' | 'danger'; messageKey?: TranslationPaths; actionKey?: TranslationPaths; requiresUnlockHandler?: boolean; + requiresPlaidHandler?: boolean; tooltipKey?: TranslationPaths; brickRoadIndicator?: ValueOf; }; @@ -69,6 +71,50 @@ function hasBankAccountAllowDebit(accountData: AccountData | undefined): boolean return !!accountData.allowDebit; } +function isConnectedViaPlaid(accountData: AccountData | undefined): boolean { + if (!accountData) { + return false; + } + + return !!accountData.plaidAccountID || !!accountData.additionalData?.plaidAccountID; +} + +function hasBrokenPlaidConnection(accountData: AccountData | undefined): boolean { + if (!accountData) { + return false; + } + + return !!accountData.additionalData?.verifications?.externalApiResponses?.plaidAssets?.needsFixing; +} + +/** + * Whether the bank account can be connected via Plaid: + * - Provisioned as the Expensify Card settlement account; or + * - Linked policy is on Expensify Card waitlist (NVP_EXPENSIFY_ON_CARD_WAITLIST) + */ +function canLinkPlaid(bankAccount: {isExpensifyCardSettlementAccount?: boolean; accountData?: AccountData} | null | undefined, cardOnWaitlistPolicyIDs: Set | undefined): boolean { + if (!bankAccount) { + return false; + } + + if (bankAccount.isExpensifyCardSettlementAccount || bankAccount.accountData?.isExpensifyCardSettlementAccount) { + return true; + } + + const policyID = bankAccount.accountData?.additionalData?.policyID; + if (policyID && cardOnWaitlistPolicyIDs?.has(policyID)) { + return true; + } + + for (const id of bankAccount.accountData?.policyIDs ?? []) { + if (cardOnWaitlistPolicyIDs?.has(id)) { + return true; + } + } + + return false; +} + function getIncompleteBankAccountStatus(): BankAccountConnectionStatus { return { labelKey: 'walletPage.bankAccountStatus.incomplete', @@ -88,7 +134,36 @@ function getIncompleteBankAccountStatus(): BankAccountConnectionStatus { * ReimbursementAccountPage, which routes a PENDING account to the validation (test transaction) step only when the * currency is USD. An absent currency is treated as USD, matching BankAccount.getCurrency(). */ -function getBankAccountConnectionStatus(state: string | undefined, currency?: string): BankAccountConnectionStatus | undefined { +function getBankAccountConnectionStatus(accountData: AccountData | undefined, currency?: string, canBankAccountLinkPlaid = false): BankAccountConnectionStatus | undefined { + if (!accountData) { + return undefined; + } + + const {state, type, additionalData: {country = CONST.COUNTRY.US} = {}} = accountData; + if (canBankAccountLinkPlaid && country === CONST.COUNTRY.US && state === CONST.BANK_ACCOUNT.STATE.OPEN && type === CONST.BANK_ACCOUNT.TYPE.BUSINESS) { + if (hasBrokenPlaidConnection(accountData)) { + return { + requiresPlaidHandler: true, + labelKey: 'walletPage.bankAccountStatus.active', + messageKey: 'walletPage.bankAccountStatus.plaidBrokenReconnect', + actionKey: 'common.actionBadge.fix', + tone: 'danger', + badgeTone: 'success', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }; + } + if (!isConnectedViaPlaid(accountData)) { + return { + requiresPlaidHandler: true, + labelKey: 'walletPage.bankAccountStatus.active', + messageKey: 'walletPage.bankAccountStatus.plaidConnectForLimit', + actionKey: 'walletPage.bankAccountStatus.connect', + tone: 'success', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.INFO, + }; + } + } + if (state === CONST.BANK_ACCOUNT.STATE.PENDING && !!currency && currency !== CONST.CURRENCY.USD) { return getIncompleteBankAccountStatus(); } @@ -128,6 +203,7 @@ function getBankAccountConnectionStatus(state: string | undefined, currency?: st return undefined; } } + /** * A BUSINESS account in a state that has actually been usable for paying expenses (anything other than SETUP / VERIFYING / PENDING). * Used by the search picker, the autocomplete suggestions, and the advanced-filter visibility gate so all three surfaces accept and count the same set of accounts. @@ -218,6 +294,10 @@ function hasPartiallySetupBankAccount(bankAccountList: OnyxEntry isBankAccountPartiallySetup(bankAccount?.accountData?.state)); } +function hasBrokenPlaidConnectionBankAccount(bankAccountList: OnyxEntry): boolean { + return Object.values(bankAccountList ?? {}).some((bankAccount) => hasBrokenPlaidConnection(bankAccount?.accountData)); +} + const PERSONAL_INFO_STEP = { NAME: 1, ADDRESS: 2, @@ -373,10 +453,14 @@ export { getDefaultCompanyWebsite, getBankAccountState, hasBankAccountAllowDebit, + isConnectedViaPlaid, + hasBrokenPlaidConnection, + canLinkPlaid, getBankAccountConnectionStatus, getRequiredKYBDocuments, getLastFourDigits, hasPartiallySetupBankAccount, + hasBrokenPlaidConnectionBankAccount, hasPersonalBankAccountMissingInfo, isBankAccountPartiallySetup, isUserAddressVerificationRequired, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index f7f3e066fbe1..3388a7ec671f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -499,6 +499,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/Wallet/ShareBankAccount/ShareBankAccount').default, [SCREENS.SETTINGS.WALLET.EDIT_BANK_ACCOUNT_NICKNAME]: () => require('../../../../pages/settings/Wallet/EditBankAccountNicknamePage').default, [SCREENS.SETTINGS.WALLET.DYNAMIC_WISE_GLOBAL_REIMBURSEMENTS_INTRO]: () => require('../../../../pages/settings/Wallet/WiseGlobalReimbursementsIntroPage').default, + [SCREENS.SETTINGS.WALLET.DYNAMIC_BANK_ACCOUNT_LINK_PLAID]: () => require('../../../../pages/settings/Wallet/LinkPlaidToBankAccountPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVPage').default, [SCREENS.SETTINGS.WALLET.TRAVEL_CVV_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/TravelCVVPage/TravelCVVVerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_FIX_CONNECTION]: () => require('@pages/settings/Wallet/PersonalCards/FixPersonalCardConnectionPage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 8e9275f7786c..f0f8c0616842 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -265,6 +265,7 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_WALLET_EDIT_BANK_ACCOUNT_NICKNAME.route, exact: true, }, + [SCREENS.SETTINGS.WALLET.DYNAMIC_BANK_ACCOUNT_LINK_PLAID]: DYNAMIC_ROUTES.BANK_ACCOUNT_LINK_PLAID.path, [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_ADD_NEW]: { path: ROUTES.SETTINGS_WALLET_PERSONAL_CARD_ADD_NEW, exact: true, diff --git a/src/libs/Navigation/paramLists/AccountSettingsParamList.ts b/src/libs/Navigation/paramLists/AccountSettingsParamList.ts index 6def8fbda1c3..03e6bd0f0ba1 100644 --- a/src/libs/Navigation/paramLists/AccountSettingsParamList.ts +++ b/src/libs/Navigation/paramLists/AccountSettingsParamList.ts @@ -198,6 +198,9 @@ type AccountSettingsParamList = { }; [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_WARNING]: undefined; [SCREENS.SETTINGS.WALLET.PERSONAL_CARD_UPGRADE]: undefined; + [SCREENS.SETTINGS.WALLET.DYNAMIC_BANK_ACCOUNT_LINK_PLAID]: { + bankAccountID: string; + }; [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index b5e617f090f8..0d1482e708d2 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -9,6 +9,7 @@ import type { ConnectBankAccountParams, CreateCollectOnlyDepositAccountParams, DeletePaymentBankAccountParams, + LinkPlaidToBankAccountParams, EnableGlobalReimbursementsForUSDBankAccountParams, FinishCorpayBankAccountOnboardingParams, OpenReimbursementAccountPageParams, @@ -537,6 +538,38 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc return true; } +/** + * Link (or re-link/fix) an existing verified Business Bank Account to Plaid. + */ +function linkPlaidToBankAccount(bankAccountID: number, publicToken: string) { + const parameters: LinkPlaidToBankAccountParams = {bankAccountID, publicToken}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: {[bankAccountID]: {isLoading: true, errors: null, errorFields: {[CONST.BANK_ACCOUNT.PLAID_WRONG_ACCOUNT_ERROR_FIELD]: null}}}, + }, + ], + finallyData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: {[bankAccountID]: {isLoading: false}}, + }, + ], + }; + + API.write(WRITE_COMMANDS.LINK_PLAID_TO_BANK_ACCOUNT, parameters, onyxData); +} + +function clearLinkPlaidBankAccountErrors(bankAccountID: number) { + return Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, { + [bankAccountID]: {isLoading: false, errors: null, errorFields: {[CONST.BANK_ACCOUNT.PLAID_WRONG_ACCOUNT_ERROR_FIELD]: null}}, + }); +} + /** * Adds a bank account via Plaid * @@ -2020,6 +2053,9 @@ export { openPlaidView, connectBankAccountManually, connectBankAccountWithPlaid, + clearPlaid, + linkPlaidToBankAccount, + clearLinkPlaidBankAccountErrors, createCorpayBankAccount, deletePaymentBankAccount, handlePlaidError, diff --git a/src/libs/actions/PaymentMethods.ts b/src/libs/actions/PaymentMethods.ts index c757e569c662..5fd2d6c66343 100644 --- a/src/libs/actions/PaymentMethods.ts +++ b/src/libs/actions/PaymentMethods.ts @@ -12,7 +12,7 @@ import type { UpdateBillingCurrencyParams, } from '@libs/API/parameters'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; -import {getLastFourDigits} from '@libs/BankAccountUtils'; +import {getLastFourDigits, hasBrokenPlaidConnectionBankAccount} from '@libs/BankAccountUtils'; import * as CardUtils from '@libs/CardUtils'; import GoogleTagManager from '@libs/GoogleTagManager'; import Log from '@libs/Log'; @@ -430,7 +430,8 @@ function hasPaymentMethodError( session?: OnyxEntry, policies?: OnyxCollection, ): boolean { - const hasBankOrFundError = Object.values({...(bankList ?? {}), ...(fundList ?? {})}).some((item) => Object.keys(item?.errors ?? {}).length > 0); + const hasBankOrFundError = + Object.values({...(bankList ?? {}), ...(fundList ?? {})}).some((item) => Object.keys(item?.errors ?? {}).length > 0) || hasBrokenPlaidConnectionBankAccount(bankList); const currentUserLogin = session?.email; const cardsWithErrors = Object.values(cardList ?? {}).filter((card) => Object.keys(card?.errors ?? {}).length > 0); diff --git a/src/pages/settings/Wallet/LinkPlaidToBankAccountPage.tsx b/src/pages/settings/Wallet/LinkPlaidToBankAccountPage.tsx new file mode 100644 index 000000000000..52267d84a73d --- /dev/null +++ b/src/pages/settings/Wallet/LinkPlaidToBankAccountPage.tsx @@ -0,0 +1,198 @@ +/** + * Page for linking (or re-linking/fixing) an existing verified Business Bank Account to Plaid. + * Reached from the Wallet page when the account is provisioned for the Expensify Card or its + * policy is on the Expensify Card waitlist. + */ +import ActivityIndicator from '@components/ActivityIndicator'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import ConfirmationPage from '@components/ConfirmationPage'; +import ConfirmationPageDefault from '@components/ConfirmationPage/composed/ConfirmationPageDefault'; +import FixedFooter from '@components/FixedFooter'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import PlaidLink from '@components/PlaidLink'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; + +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useBeforeRemove from '@hooks/useBeforeRemove'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearLinkPlaidBankAccountErrors, clearPlaid, linkPlaidToBankAccount} from '@libs/actions/BankAccounts'; +import {openPlaidBankLogin} from '@libs/actions/Plaid'; +import {hasBrokenPlaidConnection, isConnectedViaPlaid} from '@libs/BankAccountUtils'; +import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; +import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; +import Log from '@libs/Log'; + +import Navigation from '@navigation/Navigation'; +import type {PlatformStackScreenProps} from '@navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Route} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +import React, {useEffect} from 'react'; +import {View} from 'react-native'; + +type LinkPlaidToBankAccountPageProps = PlatformStackScreenProps; + +type LinkPlaidToBankAccountInnerProps = { + /** ID of the bank account being (re)linked to Plaid */ + bankAccountID: number; + + /** Route to navigate back to when the flow is done, cancelled, or exited */ + backPath: Route; +}; + +function LinkPlaidToBankAccountInner({bankAccountID, backPath}: LinkPlaidToBankAccountInnerProps) { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const illustrations = useMemoizedLazyIllustrations(['BankCheck', 'BankX']); + + const [plaidLinkToken] = useOnyx(ONYXKEYS.RAM_ONLY_PLAID_LINK_TOKEN); + const [isPlaidDisabled] = useOnyx(ONYXKEYS.IS_PLAID_DISABLED); + const [bankAccount] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: (list) => list?.[bankAccountID]}); + + const latestErrorMessage = getLatestErrorMessage(bankAccount); + const isWrongAccountError = !isEmptyObject(getLatestErrorField(bankAccount, CONST.BANK_ACCOUNT.PLAID_WRONG_ACCOUNT_ERROR_FIELD)); + const isSuccess = + !bankAccount?.isLoading && !latestErrorMessage && !isWrongAccountError && isConnectedViaPlaid(bankAccount?.accountData) && !hasBrokenPlaidConnection(bankAccount?.accountData); + + useEffect(() => { + openPlaidBankLogin(false, bankAccountID); + }, [bankAccountID]); + + useBeforeRemove(() => { + clearLinkPlaidBankAccountErrors(bankAccountID); + clearPlaid(); + }); + + let announcement = ''; + if (isPlaidDisabled) { + announcement = translate('bankAccount.error.tooManyAttempts'); + } else if (isSuccess) { + announcement = translate('walletPage.linkPlaid.successHeading'); + } else if (isWrongAccountError) { + announcement = translate('walletPage.linkPlaid.wrongAccountHeading'); + } else if (latestErrorMessage) { + announcement = translate('walletPage.linkPlaid.failureHeading'); + } + useAccessibilityAnnouncement(announcement, !!announcement, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true}); + + if (isPlaidDisabled) { + return ( + + {translate('bankAccount.error.tooManyAttempts')} + + ); + } + + if (isSuccess) { + return ( + + Navigation.goBack(backPath)} + buttonText={translate('common.buttonConfirm')} + containerStyle={styles.h100} + /> + + ); + } + + if (isWrongAccountError) { + return ( + + Navigation.goBack(backPath)} + buttonText={translate('common.buttonConfirm')} + containerStyle={styles.h100} + /> + + ); + } + + if (latestErrorMessage) { + return ( + + + + + {translate('walletPage.linkPlaid.failureHeading')} + + + + + + Navigation.goBack(backPath)} + /> + + + + ); + } + + if (plaidLinkToken) { + return ( + { + Log.info('[PlaidLink] Success!'); + linkPlaidToBankAccount(bankAccountID, publicToken); + }} + onError={(error) => Log.hmmm('[LinkPlaidToBankAccount] PlaidLink error: ', error?.message)} + onEvent={() => {}} + onExit={() => Navigation.goBack(backPath)} + receivedRedirectURI={getPlaidOAuthReceivedRedirectURI()} + /> + ); + } + + return ( + + + + ); +} + +function LinkPlaidToBankAccountPage({route}: LinkPlaidToBankAccountPageProps) { + const {translate} = useLocalize(); + const bankAccountID = Number(route.params?.bankAccountID); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.BANK_ACCOUNT_LINK_PLAID.path); + + return ( + + Navigation.goBack(backPath)} + /> + + + + + ); +} + +export default LinkPlaidToBankAccountPage; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 490ccaa37893..3291c06c64ef 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -14,7 +14,7 @@ import useOnyx from '@hooks/useOnyx'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; -import {getBankAccountConnectionStatus, getBankAccountState, isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; +import {getBankAccountConnectionStatus, getBankAccountState, canLinkPlaid, isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; import type {BankAccountConnectionStatus} from '@libs/BankAccountUtils'; import {getAssignedCardFeedAccess, getPolicyIDsNamedByCardFeeds} from '@libs/CardFeedUtils'; import { @@ -66,6 +66,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import {isActingAsDelegateSelector, isUserValidatedSelector} from '@selectors/Account'; +import cardOnWaitlistPolicyIDsSelector from '@selectors/CardOnWaitlist'; import {createPoliciesForAssignedCardsSelector} from '@selectors/Policy'; import {FlashList} from '@shopify/flash-list'; import lodashSortBy from 'lodash/sortBy'; @@ -219,6 +220,7 @@ function PaymentMethodList({ const [policiesForAssignedCards] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { selector: (policies: OnyxCollection) => policiesForAssignedCardsSelectorFactory(policies), }); + const [cardOnWaitlistPolicyIDs] = useOnyx(ONYXKEYS.COLLECTION.NVP_EXPENSIFY_ON_CARD_WAITLIST, {selector: cardOnWaitlistPolicyIDsSelector}); // Temporarily disabled because P2P debit cards are disabled. // const [fundList = getEmptyObject()] = useOnyx(ONYXKEYS.FUND_LIST); @@ -232,9 +234,11 @@ function PaymentMethodList({ onActionPress: (e: GestureResponderEvent | KeyboardEvent | undefined) => void, onUnlockPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void, isPendingDelete = false, + onPlaidPress?: () => void, ): PaymentMethodItem['connectionStatus'] => ({ statusText: translate(status.labelKey), statusTone: status.tone, + statusBadgeTone: status.badgeTone, tooltipText: status.tooltipKey ? translate(status.tooltipKey) : undefined, message: status.messageKey ? translate(status.messageKey) : undefined, actionText: status.actionKey ? translate(status.actionKey) : undefined, @@ -245,6 +249,10 @@ function PaymentMethodList({ (onUnlockPress ?? onActionPress)(undefined); return; } + if (onPlaidPress) { + onPlaidPress(); + return; + } onActionPress(undefined); }, }); @@ -600,10 +608,11 @@ function PaymentMethodList({ }; const existingBrickRoadIndicator = (paymentMethod as Partial).brickRoadIndicator; const isMissingPersonalInfo = isPersonalBankAccountMissingInfo(paymentMethod.accountData); + const canBankAccountLinkPlaid = canLinkPlaid(paymentMethod, cardOnWaitlistPolicyIDs); // `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData. const bankAccountCurrency = ('bankCurrency' in paymentMethod ? paymentMethod.bankCurrency : undefined) || paymentMethod.accountData?.additionalData?.currency; const bankConnectionStatus = - shouldShowConnectionStatus && !isMissingPersonalInfo ? getBankAccountConnectionStatus(getBankAccountState(paymentMethod.accountData), bankAccountCurrency) : undefined; + shouldShowConnectionStatus && !isMissingPersonalInfo ? getBankAccountConnectionStatus(paymentMethod.accountData, bankAccountCurrency, canBankAccountLinkPlaid) : undefined; const paymentMethodPress = (e: GestureResponderEvent | KeyboardEvent | undefined) => pressHandler({ event: e, @@ -616,6 +625,11 @@ function PaymentMethodList({ event: e, ...paymentMethodData, })); + const methodID = paymentMethod.methodID; + const onPlaidPress = + bankConnectionStatus?.requiresPlaidHandler && methodID !== undefined + ? () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.BANK_ACCOUNT_LINK_PLAID.getRoute(methodID.toString()))) + : undefined; return { ...paymentMethod, @@ -635,6 +649,7 @@ function PaymentMethodList({ paymentMethodPress, paymentMethodThreeDotsPress, paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + onPlaidPress, ) : undefined, }; diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index 1e4e354d2acb..b32ab1740b69 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -49,6 +49,7 @@ import PendingDigitalWalletApprovalRow from './PendingDigitalWalletApprovalRow'; type ConnectionStatusDetails = { statusText: string; statusTone?: 'default' | 'success' | 'danger'; + statusBadgeTone?: 'default' | 'success' | 'danger'; tooltipText?: string; message?: string; actionText?: string; @@ -222,7 +223,7 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems return ( ); @@ -290,6 +291,8 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems }; const menuItem = ; + const actionAccessibilityLabel = connectionStatus?.actionText ? `${connectionStatus.actionText} ${item.title ?? ''}, ${item.description}` : undefined; + return ( dismissError(item) : undefined} @@ -308,6 +311,7 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems .exfy`). + */ +function cardOnWaitlistPolicyIDsSelector(collection: OnyxCollection): Set { + const policyIDs = new Set(); + for (const entry of Object.values(collection ?? {})) { + const policyID = entry?.domainName ? getPolicyIDFromDomainName(entry.domainName) : undefined; + if (policyID) { + policyIDs.add(policyID); + } + } + return policyIDs; +} + +export default cardOnWaitlistPolicyIDsSelector; diff --git a/src/types/onyx/AccountData.ts b/src/types/onyx/AccountData.ts index 4fedf67c3bb8..e471764e167d 100644 --- a/src/types/onyx/AccountData.ts +++ b/src/types/onyx/AccountData.ts @@ -51,6 +51,9 @@ type AccountData = { /** List of policies this account is linked to */ policyIDs?: string[]; + + /** Determines if the bank account is an Expensify Card settlement account */ + isExpensifyCardSettlementAccount?: boolean; }; export default AccountData; diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts index 94436dce38a2..83afae076dc6 100644 --- a/src/types/onyx/BankAccount.ts +++ b/src/types/onyx/BankAccount.ts @@ -37,12 +37,6 @@ type BankAccountAdditionalData = { /** The business's Wise onboarding as every holder of the account sees it: the status only, the answers are the admin's */ wise?: Pick; - /** Verification statuses returned by the backend, mirroring how Classic determines status */ - verifications?: { - /** Present once the account is set up with Corpay for Global Reimbursement */ - corpay?: Record; - }; - /** First name of the bank account owner (OldDot field name) */ firstName?: string; @@ -69,6 +63,21 @@ type BankAccountAdditionalData = { /** Phone number of the bank account owner */ companyPhone?: string; + + /** External API verification results attached to the bank account */ + verifications?: { + /** Responses from external verification APIs */ + externalApiResponses?: { + /** Plaid assets verification state */ + plaidAssets?: { + /** Whether the Plaid connection is broken and requires re-linking */ + needsFixing?: boolean; + }; + }; + + /** Present once the account is set up with Corpay for Global Reimbursement */ + corpay?: Record; + }; }; /** Model of bank account */ @@ -108,8 +117,14 @@ type BankAccount = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** Country code related to the bank account */ bankCountry: string; + /** Whether an async action on this bank account is in flight (e.g. Plaid connect/fix) */ + isLoading?: boolean; + /** Any additional error message to show */ errors?: OnyxCommon.Errors; + + /** Errors keyed by the field they belong to */ + errorFields?: OnyxCommon.ErrorFields; }>; /** Record of bank accounts, indexed by bankAccountID */ diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index 0ae2198202af..2f494b319aaf 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -10,6 +10,7 @@ import { hasBankAccountAllowDebit, hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo, + canLinkPlaid, isBankAccountPartiallySetup, isBankAccountEligibleForGlobalReimbursements, isPersonalBankAccountMissingInfo, @@ -346,15 +347,17 @@ describe('BankAccountUtils', () => { }); describe('getBankAccountConnectionStatus', () => { + const accountFor = (state: string | undefined): AccountData => ({state}) as AccountData; + it('maps OPEN bank accounts to Active without an RBR', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.OPEN)).toEqual({ + expect(getBankAccountConnectionStatus(accountFor(CONST.BANK_ACCOUNT.STATE.OPEN))).toEqual({ labelKey: 'walletPage.bankAccountStatus.active', tone: 'success', }); }); it('maps SETUP bank accounts to Incomplete with the finish action', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.SETUP)).toEqual({ + expect(getBankAccountConnectionStatus(accountFor(CONST.BANK_ACCOUNT.STATE.SETUP))).toEqual({ labelKey: 'walletPage.bankAccountStatus.incomplete', messageKey: 'walletPage.bankAccountStatus.finishAddingBankAccount', actionKey: 'walletPage.bankAccountStatus.finish', @@ -364,7 +367,7 @@ describe('BankAccountUtils', () => { }); it('maps PENDING bank accounts to Pending with the confirm action', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING)).toEqual({ + expect(getBankAccountConnectionStatus(accountFor(CONST.BANK_ACCOUNT.STATE.PENDING))).toEqual({ labelKey: 'walletPage.bankAccountStatus.pending', messageKey: 'walletPage.bankAccountStatus.confirmTestTransactions', actionKey: 'common.confirm', @@ -374,7 +377,7 @@ describe('BankAccountUtils', () => { }); it('maps VERIFYING bank accounts to Verifying with only a tooltip', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.VERIFYING)).toEqual({ + expect(getBankAccountConnectionStatus(accountFor(CONST.BANK_ACCOUNT.STATE.VERIFYING))).toEqual({ labelKey: 'walletPage.bankAccountStatus.verifying', tooltipKey: 'walletPage.bankAccountStatus.reviewingDocumentation', tone: 'default', @@ -382,7 +385,7 @@ describe('BankAccountUtils', () => { }); it('maps LOCKED bank accounts to Locked with the unlock action', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.LOCKED)).toEqual({ + expect(getBankAccountConnectionStatus(accountFor(CONST.BANK_ACCOUNT.STATE.LOCKED))).toEqual({ labelKey: 'common.locked', messageKey: 'walletPage.bankAccountStatus.accountRequiresAttention', actionKey: 'walletPage.bankAccountStatus.unlock', @@ -393,7 +396,7 @@ describe('BankAccountUtils', () => { }); it.each([CONST.CURRENCY.USD, undefined])('keeps the confirm action for a PENDING account in currency "%s"', (currency) => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING, currency)).toEqual( + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.PENDING}, currency)).toEqual( expect.objectContaining({ labelKey: 'walletPage.bankAccountStatus.pending', actionKey: 'common.confirm', @@ -402,7 +405,7 @@ describe('BankAccountUtils', () => { }); it.each(['GBP', 'EUR', 'AUD'])('maps a PENDING account in currency "%s" to Incomplete, since only USD accounts have test transactions', (currency) => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING, currency)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.PENDING}, currency)).toEqual({ labelKey: 'walletPage.bankAccountStatus.incomplete', messageKey: 'walletPage.bankAccountStatus.finishAddingBankAccount', actionKey: 'walletPage.bankAccountStatus.finish', @@ -414,12 +417,127 @@ describe('BankAccountUtils', () => { it.each([CONST.BANK_ACCOUNT.STATE.OPEN, CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.VERIFYING, CONST.BANK_ACCOUNT.STATE.LOCKED])( 'is unaffected by a non-USD currency in state "%s"', (state) => { - expect(getBankAccountConnectionStatus(state, 'GBP')).toEqual(getBankAccountConnectionStatus(state)); + expect(getBankAccountConnectionStatus({state}, 'GBP')).toEqual(getBankAccountConnectionStatus({state})); }, ); it.each([undefined, '', 'UNKNOWN'])('returns undefined for unsupported state "%s"', (state) => { - expect(getBankAccountConnectionStatus(state)).toBeUndefined(); + expect(getBankAccountConnectionStatus(accountFor(state))).toBeUndefined(); + }); + + it('returns undefined when accountData is undefined', () => { + expect(getBankAccountConnectionStatus(undefined)).toBeUndefined(); + }); + + describe('Plaid Connect / Fix branches', () => { + const openBusinessUS: AccountData = { + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {country: CONST.COUNTRY.US}, + } as AccountData; + + it('returns Connect branch (requiresPlaidHandler) when card-eligible US business is OPEN with no Plaid connection', () => { + expect(getBankAccountConnectionStatus(openBusinessUS, CONST.CURRENCY.USD, true)).toEqual({ + requiresPlaidHandler: true, + labelKey: 'walletPage.bankAccountStatus.active', + messageKey: 'walletPage.bankAccountStatus.plaidConnectForLimit', + actionKey: 'walletPage.bankAccountStatus.connect', + tone: 'success', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.INFO, + }); + }); + + it('returns Fix branch (requiresPlaidHandler) when card-eligible US business is OPEN with a broken Plaid connection', () => { + const accountData = { + ...openBusinessUS, + additionalData: { + ...openBusinessUS.additionalData, + plaidAccountID: 'plaid-123', + verifications: {externalApiResponses: {plaidAssets: {needsFixing: true}}}, + }, + } as AccountData; + expect(getBankAccountConnectionStatus(accountData, CONST.CURRENCY.USD, true)).toEqual({ + requiresPlaidHandler: true, + labelKey: 'walletPage.bankAccountStatus.active', + messageKey: 'walletPage.bankAccountStatus.plaidBrokenReconnect', + actionKey: 'common.actionBadge.fix', + tone: 'danger', + badgeTone: 'success', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }); + }); + + it('falls through to plain Active when isCardEligible is false', () => { + expect(getBankAccountConnectionStatus(openBusinessUS, CONST.CURRENCY.USD, false)).toEqual({ + labelKey: 'walletPage.bankAccountStatus.active', + tone: 'success', + }); + }); + + it('falls through to plain Active for non-US country', () => { + const accountData = { + ...openBusinessUS, + additionalData: {country: 'CA'}, + } as AccountData; + expect(getBankAccountConnectionStatus(accountData, CONST.CURRENCY.USD, true)).toEqual({ + labelKey: 'walletPage.bankAccountStatus.active', + tone: 'success', + }); + }); + + it('defaults to US when country is undefined', () => { + const accountData = { + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {}, + } as AccountData; + expect(getBankAccountConnectionStatus(accountData, CONST.CURRENCY.USD, true)?.requiresPlaidHandler).toBe(true); + }); + + it('falls through to plain Active for personal accounts', () => { + const accountData = { + type: CONST.BANK_ACCOUNT.TYPE.PERSONAL, + state: CONST.BANK_ACCOUNT.STATE.OPEN, + additionalData: {country: CONST.COUNTRY.US}, + } as AccountData; + expect(getBankAccountConnectionStatus(accountData, CONST.CURRENCY.USD, true)).toEqual({ + labelKey: 'walletPage.bankAccountStatus.active', + tone: 'success', + }); + }); + + it('does not render Connect when a plaidAccountID exists and needsFixing is false', () => { + const accountData = { + ...openBusinessUS, + additionalData: {...openBusinessUS.additionalData, plaidAccountID: 'plaid-123'}, + } as AccountData; + expect(getBankAccountConnectionStatus(accountData, CONST.CURRENCY.USD, true)).toEqual({ + labelKey: 'walletPage.bankAccountStatus.active', + tone: 'success', + }); + }); + }); + }); + + describe('canLinkPlaid', () => { + it('returns false when bank account is undefined', () => { + expect(canLinkPlaid(undefined, undefined)).toBe(false); + }); + + it('returns true when the top-level isExpensifyCardSettlementAccount is set', () => { + expect(canLinkPlaid({isExpensifyCardSettlementAccount: true, accountData: {}}, undefined)).toBe(true); + }); + + it('returns true when accountData.isExpensifyCardSettlementAccount is set', () => { + expect(canLinkPlaid({accountData: {isExpensifyCardSettlementAccount: true}}, undefined)).toBe(true); + }); + + it('returns true when any of the account policies is on the waitlist', () => { + expect(canLinkPlaid({accountData: {additionalData: {policyID: 'A'}, policyIDs: ['B']}}, new Set(['B']))).toBe(true); + }); + + it('returns false when no policies match a waitlist entry and settlement flag is unset', () => { + expect(canLinkPlaid({accountData: {additionalData: {policyID: 'A'}, policyIDs: ['B']}}, new Set(['C']))).toBe(false); }); }); diff --git a/tests/unit/useAccountIndicatorChecksTest.ts b/tests/unit/useAccountIndicatorChecksTest.ts index 61b0033fcd17..8730a0d69b16 100644 --- a/tests/unit/useAccountIndicatorChecksTest.ts +++ b/tests/unit/useAccountIndicatorChecksTest.ts @@ -73,6 +73,54 @@ describe('useAccountIndicatorChecks', () => { expect(result.current.accountStatus).toBe(CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR); }); + it('returns HAS_PAYMENT_METHOD_ERROR when a bank account has a broken Plaid connection', async () => { + await act(async () => { + await Onyx.multiSet( + createMock({ + [ONYXKEYS.USER_WALLET]: {}, + [ONYXKEYS.BANK_ACCOUNT_LIST]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 12345: { + methodID: 12345, + accountData: {additionalData: {verifications: {externalApiResponses: {plaidAssets: {needsFixing: true}}}}}, + }, + }, + [ONYXKEYS.SESSION]: {email: userID}, + }), + ); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => useAccountIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.accountStatus).toBe(CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR); + }); + + it('does not return HAS_PAYMENT_METHOD_ERROR when the Plaid connection is healthy', async () => { + await act(async () => { + await Onyx.multiSet( + createMock({ + [ONYXKEYS.USER_WALLET]: {}, + [ONYXKEYS.BANK_ACCOUNT_LIST]: { + // eslint-disable-next-line @typescript-eslint/naming-convention + 12345: { + methodID: 12345, + accountData: {additionalData: {verifications: {externalApiResponses: {plaidAssets: {needsFixing: false}}}}}, + }, + }, + [ONYXKEYS.SESSION]: {email: userID}, + }), + ); + await waitForBatchedUpdatesWithAct(); + }); + + const {result} = renderHook(() => useAccountIndicatorChecks()); + await waitForBatchedUpdatesWithAct(); + + expect(result.current.accountStatus).not.toBe(CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR); + }); + it('returns HAS_REIMBURSEMENT_ACCOUNT_ERRORS when reimbursement account has errors', async () => { await act(async () => { await Onyx.multiSet( diff --git a/tests/unit/useAccountTabIndicatorStatusTest.ts b/tests/unit/useAccountTabIndicatorStatusTest.ts index 331f4c360498..625f4a8dc7f4 100644 --- a/tests/unit/useAccountTabIndicatorStatusTest.ts +++ b/tests/unit/useAccountTabIndicatorStatusTest.ts @@ -8,6 +8,7 @@ import {defaultTheme} from '@styles/theme'; import CONST from '@src/CONST'; import initOnyxDerivedValues from '@src/libs/actions/OnyxDerived'; import ONYXKEYS from '@src/ONYXKEYS'; +import type IndicatorStatus from '@src/types/utils/IndicatorStatus'; import type {OnyxMultiSetInput} from 'react-native-onyx'; import type {IndicatorTestCase} from 'tests/utils/IndicatorTestUtils'; @@ -37,6 +38,13 @@ const accountCardFeedTestCases = { }, } as const satisfies Record<'admin' | 'employee', IndicatorTestCase>; +// Shares HAS_PAYMENT_METHOD_ERROR with hasPaymentMethodError, so the mock discriminates the two by name. +const brokenPlaidConnectionTestCase = { + name: 'has a bank account with a broken Plaid connection', + indicatorColor: defaultTheme.danger, + status: CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR, +} as const satisfies IndicatorTestCase; + const TEST_CASES = { hasUserWalletErrors: { name: 'has user wallet errors', @@ -48,6 +56,7 @@ const TEST_CASES = { indicatorColor: defaultTheme.danger, status: CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR, }, + hasBrokenPlaidConnection: brokenPlaidConnectionTestCase, hasReimbursementAccountErrors: { name: 'has reimbursement account errors', indicatorColor: defaultTheme.danger, @@ -89,6 +98,16 @@ const TEST_CASES = { const policyKey = `${ONYXKEYS.COLLECTION.POLICY}1` as const; +const getMockBankAccountData = (name: string, status: IndicatorStatus | undefined) => { + if (status === CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT) { + return {state: CONST.BANK_ACCOUNT.STATE.LOCKED}; + } + if (name === brokenPlaidConnectionTestCase.name) { + return {additionalData: {verifications: {externalApiResponses: {plaidAssets: {needsFixing: true}}}}}; + } + return undefined; +}; + const getMockForTestCase = ({name, status}: IndicatorTestCase) => ({ [ONYXKEYS.BANK_ACCOUNT_LIST]: { @@ -96,17 +115,12 @@ const getMockForTestCase = ({name, status}: IndicatorTestCase) => 12345: { methodID: 12345, errors: - status === CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR + status === CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR && name !== brokenPlaidConnectionTestCase.name ? { error: 'Something went wrong', } : undefined, - accountData: - status === CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT - ? { - state: CONST.BANK_ACCOUNT.STATE.LOCKED, - } - : undefined, + accountData: getMockBankAccountData(name, status), }, }, [ONYXKEYS.USER_WALLET]: { diff --git a/tests/unit/useIndicatorStatusTest.tsx b/tests/unit/useIndicatorStatusTest.tsx index 731ab2f7ce3d..be636b603f8c 100644 --- a/tests/unit/useIndicatorStatusTest.tsx +++ b/tests/unit/useIndicatorStatusTest.tsx @@ -9,6 +9,7 @@ import CONST from '@src/CONST'; import initWithOnyxDerivedValues from '@src/libs/actions/OnyxDerived'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; +import type IndicatorStatus from '@src/types/utils/IndicatorStatus'; import type {OnyxMultiSetInput} from 'react-native-onyx'; @@ -41,6 +42,13 @@ const cardFeedErrorTestCases = { }, } as const satisfies Record<'admin' | 'employee', IndicatorTestCase>; +const brokenPlaidConnectionTestCase = { + name: 'has a bank account with a broken Plaid connection', + indicatorColor: defaultTheme.danger, + status: CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR, + indicatorPolicyID: undefined, +} as const satisfies IndicatorTestCase; + const TEST_CASES = { hasPolicyErrors: { name: 'has policy errors', @@ -78,6 +86,7 @@ const TEST_CASES = { status: CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR, indicatorPolicyID: undefined, }, + hasBrokenPlaidConnection: brokenPlaidConnectionTestCase, hasSubscriptionErrors: { name: 'has subscription errors', indicatorColor: defaultTheme.danger, @@ -153,6 +162,16 @@ const TEST_CASES_NON_ADMIN = { hasEmployeeCardFeedErrors: cardFeedErrorTestCases.employee, } as const satisfies Record; +const getMockBankAccountData = (name: string, status: IndicatorStatus | undefined) => { + if (status === CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT) { + return {state: CONST.BANK_ACCOUNT.STATE.LOCKED}; + } + if (name === brokenPlaidConnectionTestCase.name) { + return {additionalData: {verifications: {externalApiResponses: {plaidAssets: {needsFixing: true}}}}}; + } + return undefined; +}; + const getMockForTestCase = ({name, status}: IndicatorTestCase, isAdmin: boolean) => createMock({ [`${ONYXKEYS.COLLECTION.POLICY}1` as const]: { @@ -219,17 +238,12 @@ const getMockForTestCase = ({name, status}: IndicatorTestCase, isAdmin: boolean) 12345: { methodID: 12345, errors: - status === CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR + status === CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR && name !== brokenPlaidConnectionTestCase.name ? { error: 'Something went wrong', } : undefined, - accountData: - status === CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT - ? { - state: CONST.BANK_ACCOUNT.STATE.LOCKED, - } - : undefined, + accountData: getMockBankAccountData(name, status), }, }, [ONYXKEYS.USER_WALLET]: {