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 ?? ''}${messageTag}>`;
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]: {