diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index 21102c4e1fc6..1973b0ffc7f3 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -991,6 +991,7 @@ const CONST = {
BULK_EDIT: 'bulkEdit',
BULK_EDIT_WORKSPACES: 'bulkEditWorkspaces',
SUBMIT_2026: 'submit2026',
+ WALLET_CONNECTION_STATUS: 'walletConnectionStatus',
BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay',
WORKSPACE_ROOMS_PAGE: 'workspaceRoomsPage',
VENDOR_MATCHING: 'vendorMatching',
diff --git a/src/components/ConnectionStatusBadge.tsx b/src/components/ConnectionStatusBadge.tsx
new file mode 100644
index 000000000000..60991a362ad0
--- /dev/null
+++ b/src/components/ConnectionStatusBadge.tsx
@@ -0,0 +1,39 @@
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+import {View} from 'react-native';
+
+import Badge from './Badge';
+import Tooltip from './Tooltip';
+
+type ConnectionStatusBadgeProps = {
+ text: string;
+ tone?: 'default' | 'success' | 'danger';
+ tooltipText?: string;
+};
+
+function ConnectionStatusBadge({text, tone = 'default', tooltipText}: ConnectionStatusBadgeProps) {
+ const styles = useThemeStyles();
+
+ const badge = (
+
+ );
+
+ if (!tooltipText) {
+ return badge;
+ }
+
+ return (
+
+ {badge}
+
+ );
+}
+
+export default ConnectionStatusBadge;
diff --git a/src/components/ConnectionStatusMessage.tsx b/src/components/ConnectionStatusMessage.tsx
new file mode 100644
index 000000000000..acd4d1e82e2e
--- /dev/null
+++ b/src/components/ConnectionStatusMessage.tsx
@@ -0,0 +1,89 @@
+import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
+import useResponsiveLayout from '@hooks/useResponsiveLayout';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import type {ComponentProps} from 'react';
+
+import React from 'react';
+import {View} from 'react-native';
+
+import Button from './Button';
+import Icon from './Icon';
+import RenderHTML from './RenderHTML';
+
+type ConnectionStatusMessageProps = {
+ message?: string;
+ actionText?: string;
+ onActionPress?: () => void;
+ isActionDisabled?: boolean;
+ statusTone?: 'default' | 'success' | 'danger';
+ onLinkPress?: ComponentProps['onLinkPress'];
+ shouldIncludeHorizontalPadding?: boolean;
+};
+
+function ConnectionStatusMessage({
+ message,
+ actionText,
+ onActionPress,
+ isActionDisabled = false,
+ statusTone = 'default',
+ onLinkPress,
+ shouldIncludeHorizontalPadding = true,
+}: ConnectionStatusMessageProps) {
+ const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']);
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {shouldUseNarrowLayout} = useResponsiveLayout();
+
+ if (!message && !actionText) {
+ return null;
+ }
+
+ let statusMessageRowPadding;
+ if (shouldIncludeHorizontalPadding) {
+ statusMessageRowPadding = shouldUseNarrowLayout ? styles.ph5 : styles.ph8;
+ }
+ const shouldShowActionButton = !!actionText && !!onActionPress;
+ const isDangerStatus = statusTone === 'danger';
+ const messageTag = isDangerStatus ? 'rbr' : 'muted-text-label';
+ const messageHTML = `<${messageTag}>${message ?? ''}${messageTag}>`;
+ const messageContent = (
+
+ {isDangerStatus && (
+
+
+
+ )}
+
+
+
+
+ );
+
+ const actionButton = shouldShowActionButton ? (
+
+ ) : null;
+
+ return (
+
+ {messageContent}
+ {actionButton}
+
+ );
+}
+
+export default ConnectionStatusMessage;
diff --git a/src/languages/de.ts b/src/languages/de.ts
index 38a5c417ff7d..e593993b70e7 100644
--- a/src/languages/de.ts
+++ b/src/languages/de.ts
@@ -2603,6 +2603,27 @@ const translations: TranslationDeepObject = {
description: 'Verwende diese Karte für deine Expensify Travel-Buchungen. Sie wird beim Bezahlen als “Travel Card” angezeigt.',
},
chaseAccountNumberDifferent: 'Warum ist meine Kontonummer anders?',
+ cardLastSynced: (relativeDate: string) => `Synchronisiert am ${relativeDate}`,
+ cardNeverSynced: 'Nie synchronisiert',
+ cardStatus: {
+ active: 'Aktiv',
+ inactive: 'Inaktiv',
+ fixConnection: 'Bitte beheben Sie diese Verbindung',
+ fixConnectionIn: (companyCardsRoute: string) => `Bitte beheben Sie diese Verbindung in Firmenkarten`,
+ askAdminToFixConnection: 'Bitte bitten Sie eine(n) Admin, diese Verbindung zu reparieren',
+ },
+ bankAccountStatus: {
+ active: 'Aktiv',
+ incomplete: 'Unvollständig',
+ pending: 'Ausstehend',
+ verifying: 'Wird überprüft',
+ reviewingDocumentation: 'Wir prüfen Ihre Unterlagen',
+ finishAddingBankAccount: 'Hinzufügen des Bankkontos abschließen',
+ finish: 'Fertig',
+ confirmTestTransactions: 'Bitte bestätigen Sie Testtransaktionen',
+ accountRequiresAttention: 'Dieses Konto erfordert Ihre Aufmerksamkeit',
+ unlock: 'Entsperren',
+ },
},
cardPage: {
expensifyCard: 'Expensify Karte',
diff --git a/src/languages/en.ts b/src/languages/en.ts
index dd5997cfc8ed..60ed9806f65d 100644
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -2652,6 +2652,27 @@ const translations = {
addBankAccountToSendAndReceive: 'Add a bank account to make or receive payments.',
addDebitOrCreditCard: 'Add debit or credit card',
cardInactive: 'Inactive',
+ cardLastSynced: (relativeDate: string) => `Synced ${relativeDate}`,
+ cardNeverSynced: 'Never synced',
+ cardStatus: {
+ active: 'Active',
+ inactive: 'Inactive',
+ fixConnection: 'Please fix this connection',
+ fixConnectionIn: (companyCardsRoute: string) => `Please fix this connection in company cards`,
+ askAdminToFixConnection: 'Please ask an admin to fix this connection',
+ },
+ bankAccountStatus: {
+ active: 'Active',
+ incomplete: 'Incomplete',
+ pending: 'Pending',
+ verifying: 'Verifying',
+ reviewingDocumentation: "We're reviewing your documentation",
+ finishAddingBankAccount: 'Finish adding bank account',
+ finish: 'Finish',
+ confirmTestTransactions: 'Please confirm test transactions',
+ accountRequiresAttention: 'This account requires attention',
+ unlock: 'Unlock',
+ },
assignedCards: 'Cards',
assignedCardsDescription: 'Transactions from assigned cards sync automatically.',
addVirtualCardPersonalDetails: {
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 2e2751fa1254..9ea6758e22d4 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -2478,6 +2478,27 @@ const translations: TranslationDeepObject = {
description: 'Usa esta tarjeta para tus reservas de Expensify Travel. Aparecerá como “Travel Card” al pagar.',
},
chaseAccountNumberDifferent: '¿Por qué es diferente el número de mi cuenta?',
+ cardLastSynced: (relativeDate: string) => `Sincronizado ${relativeDate}`,
+ cardNeverSynced: 'Nunca sincronizado',
+ cardStatus: {
+ active: 'Activo',
+ inactive: 'Inactivo',
+ fixConnection: 'Por favor, corrige esta conexión',
+ fixConnectionIn: (companyCardsRoute) => `Por favor, corrige esta conexión en tarjetas de la empresa`,
+ askAdminToFixConnection: 'Pídele a un administrador que solucione esta conexión',
+ },
+ bankAccountStatus: {
+ active: 'Activo',
+ incomplete: 'Incompleto',
+ pending: 'Pendiente',
+ verifying: 'Verificando',
+ reviewingDocumentation: 'Estamos revisando tu documentación',
+ finishAddingBankAccount: 'Terminar de añadir cuenta bancaria',
+ finish: 'Finalizar',
+ confirmTestTransactions: 'Confirma las transacciones de prueba',
+ accountRequiresAttention: 'Esta cuenta requiere atención',
+ unlock: 'Desbloquear',
+ },
},
cardPage: {
expensifyCard: 'Tarjeta Expensify',
diff --git a/src/languages/fr.ts b/src/languages/fr.ts
index c629abc9b71a..c3ebf9915833 100644
--- a/src/languages/fr.ts
+++ b/src/languages/fr.ts
@@ -2608,6 +2608,27 @@ const translations: TranslationDeepObject = {
description: 'Utilisez cette carte pour vos réservations Expensify Travel. Elle apparaîtra comme “Travel Card” lors du paiement.',
},
chaseAccountNumberDifferent: 'Pourquoi mon numéro de compte est-il différent ?',
+ cardLastSynced: (relativeDate: string) => `Synchronisé le ${relativeDate}`,
+ cardNeverSynced: 'Jamais synchronisé',
+ cardStatus: {
+ active: 'Actif',
+ inactive: 'Inactif',
+ fixConnection: 'Veuillez corriger cette connexion',
+ fixConnectionIn: (companyCardsRoute: string) => `Veuillez corriger cette connexion dans cartes d’entreprise`,
+ askAdminToFixConnection: 'Veuillez demander à un administrateur de corriger cette connexion',
+ },
+ bankAccountStatus: {
+ active: 'Actif',
+ incomplete: 'Incomplet',
+ pending: 'En attente',
+ verifying: 'Vérification en cours',
+ reviewingDocumentation: 'Nous examinons votre documentation',
+ finishAddingBankAccount: 'Terminer l’ajout du compte bancaire',
+ finish: 'Terminer',
+ confirmTestTransactions: 'Veuillez confirmer les transactions de test',
+ accountRequiresAttention: 'Ce compte nécessite une attention particulière',
+ unlock: 'Déverrouiller',
+ },
},
cardPage: {
expensifyCard: 'Carte Expensify',
diff --git a/src/languages/it.ts b/src/languages/it.ts
index 6d0e525d96f0..45429e987cf6 100644
--- a/src/languages/it.ts
+++ b/src/languages/it.ts
@@ -2598,6 +2598,27 @@ const translations: TranslationDeepObject = {
description: 'Usa questa carta per le tue prenotazioni con Expensify Travel. Verrà visualizzata come “Travel Card” al momento del pagamento.',
},
chaseAccountNumberDifferent: 'Perché il mio numero di conto è diverso?',
+ cardLastSynced: (relativeDate: string) => `Sincronizzato il ${relativeDate}`,
+ cardNeverSynced: 'Mai sincronizzato',
+ cardStatus: {
+ active: 'Attiva',
+ inactive: 'Inattivo',
+ fixConnection: 'Sistema questa connessione per favore',
+ fixConnectionIn: (companyCardsRoute: string) => `Correggi questa connessione in carte aziendali`,
+ askAdminToFixConnection: 'Chiedi a un amministratore di correggere questa connessione',
+ },
+ bankAccountStatus: {
+ active: 'Attiva',
+ incomplete: 'Incompleto',
+ pending: 'In sospeso',
+ verifying: 'Verifica in corso',
+ reviewingDocumentation: 'Stiamo esaminando la tua documentazione',
+ finishAddingBankAccount: 'Completa l’aggiunta del conto bancario',
+ finish: 'Fine',
+ confirmTestTransactions: 'Conferma le transazioni di prova',
+ accountRequiresAttention: 'Questo conto richiede attenzione',
+ unlock: 'Sblocca',
+ },
},
cardPage: {
expensifyCard: 'Carta Expensify',
diff --git a/src/languages/ja.ts b/src/languages/ja.ts
index fa6dd6c61c46..500d27aa132c 100644
--- a/src/languages/ja.ts
+++ b/src/languages/ja.ts
@@ -2572,6 +2572,27 @@ const translations: TranslationDeepObject = {
description: 'このカードをExpensify Travelでの予約に使用してください。チェックアウト時には「Travel Card」と表示されます。',
},
chaseAccountNumberDifferent: '口座番号が異なるのはなぜですか?',
+ cardLastSynced: (relativeDate: string) => `${relativeDate} に同期しました`,
+ cardNeverSynced: '未同期',
+ cardStatus: {
+ active: 'アクティブ',
+ inactive: '無効',
+ fixConnection: 'この接続を修正してください',
+ fixConnectionIn: (companyCardsRoute: string) => `この接続を会社カードで修正してください`,
+ askAdminToFixConnection: '管理者にこの接続の修正を依頼してください',
+ },
+ bankAccountStatus: {
+ active: 'アクティブ',
+ incomplete: '未完了',
+ pending: '保留中',
+ verifying: '確認中',
+ reviewingDocumentation: '書類を審査しています',
+ finishAddingBankAccount: '銀行口座の追加を完了',
+ finish: '完了',
+ confirmTestTransactions: 'テスト取引を確認してください',
+ accountRequiresAttention: 'このアカウントには対応が必要です',
+ unlock: 'ロック解除',
+ },
},
cardPage: {
expensifyCard: 'Expensify カード',
diff --git a/src/languages/nl.ts b/src/languages/nl.ts
index 48358b1a0428..9c722a42fce8 100644
--- a/src/languages/nl.ts
+++ b/src/languages/nl.ts
@@ -2597,6 +2597,27 @@ const translations: TranslationDeepObject = {
description: 'Gebruik deze kaart voor je Expensify Travel-boekingen. Hij wordt weergegeven als “Travel Card” bij het afrekenen.',
},
chaseAccountNumberDifferent: 'Waarom is mijn rekeningnummer anders?',
+ cardLastSynced: (relativeDate: string) => `Gesynchroniseerd op ${relativeDate}`,
+ cardNeverSynced: 'Nooit gesynchroniseerd',
+ cardStatus: {
+ active: 'Actief',
+ inactive: 'Inactief',
+ fixConnection: 'Repareer deze verbinding alsjeblieft',
+ fixConnectionIn: (companyCardsRoute: string) => `Repareer deze verbinding in bedrijfskaarten`,
+ askAdminToFixConnection: 'Vraag een/beheerder om deze verbinding te herstellen',
+ },
+ bankAccountStatus: {
+ active: 'Actief',
+ incomplete: 'Onvolledig',
+ pending: 'In behandeling',
+ verifying: 'Bevestigen',
+ reviewingDocumentation: 'We beoordelen je documentatie',
+ finishAddingBankAccount: 'Bankrekening toevoegen afronden',
+ finish: 'Afronden',
+ confirmTestTransactions: 'Bevestig testtransacties alsjeblieft',
+ accountRequiresAttention: 'Deze account vereist aandacht',
+ unlock: 'Ontgrendelen',
+ },
},
cardPage: {
expensifyCard: 'Expensify Kaart',
diff --git a/src/languages/pl.ts b/src/languages/pl.ts
index 15aeecbc3554..efcf4ae0de8a 100644
--- a/src/languages/pl.ts
+++ b/src/languages/pl.ts
@@ -2588,6 +2588,27 @@ const translations: TranslationDeepObject = {
description: 'Użyj tej karty do rezerwacji w Expensify Travel. Podczas płatności będzie wyświetlana jako “Travel Card”.',
},
chaseAccountNumberDifferent: 'Dlaczego mój numer konta jest inny?',
+ cardLastSynced: (relativeDate: string) => `Zsynchronizowano ${relativeDate}`,
+ cardNeverSynced: 'Nigdy nie zsynchronizowano',
+ cardStatus: {
+ active: 'Aktywne',
+ inactive: 'Nieaktywne',
+ fixConnection: 'Napraw to połączenie',
+ fixConnectionIn: (companyCardsRoute: string) => `Napraw to połączenie w firmowe karty`,
+ askAdminToFixConnection: 'Poproś administratora o naprawienie tego połączenia',
+ },
+ bankAccountStatus: {
+ active: 'Aktywne',
+ incomplete: 'Nieukończone',
+ pending: 'Oczekujące',
+ verifying: 'Weryfikowanie',
+ reviewingDocumentation: 'Sprawdzamy twoją dokumentację',
+ finishAddingBankAccount: 'Dokończ dodawanie konta bankowego',
+ finish: 'Zakończ',
+ confirmTestTransactions: 'Potwierdź proszę transakcje testowe',
+ accountRequiresAttention: 'To konto wymaga uwagi',
+ unlock: 'Odblokuj',
+ },
},
cardPage: {
expensifyCard: 'Karta Expensify',
diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts
index e4c7ecd38a2e..8bb084a0110a 100644
--- a/src/languages/pt-BR.ts
+++ b/src/languages/pt-BR.ts
@@ -2589,6 +2589,27 @@ const translations: TranslationDeepObject = {
description: 'Use este cartão para suas reservas no Expensify Travel. Ele aparecerá como “Travel Card” no checkout.',
},
chaseAccountNumberDifferent: 'Por que meu número de conta é diferente?',
+ cardLastSynced: (relativeDate: string) => `Sincronizado em ${relativeDate}`,
+ cardNeverSynced: 'Nunca sincronizado',
+ cardStatus: {
+ active: 'Ativo',
+ inactive: 'Inativo',
+ fixConnection: 'Corrija esta conexão',
+ fixConnectionIn: (companyCardsRoute: string) => `Corrija esta conexão em cartões corporativos`,
+ askAdminToFixConnection: 'Peça para um administrador corrigir essa conexão',
+ },
+ bankAccountStatus: {
+ active: 'Ativo',
+ incomplete: 'Incompleto',
+ pending: 'Pendente',
+ verifying: 'Verificando',
+ reviewingDocumentation: 'Estamos analisando sua documentação',
+ finishAddingBankAccount: 'Concluir adição da conta bancária',
+ finish: 'Concluir',
+ confirmTestTransactions: 'Confirme as transações de teste',
+ accountRequiresAttention: 'Esta conta precisa de atenção',
+ unlock: 'Desbloquear',
+ },
},
cardPage: {
expensifyCard: 'Cartão Expensify',
diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts
index 99fdc6e70841..1e37e64af2ff 100644
--- a/src/languages/zh-hans.ts
+++ b/src/languages/zh-hans.ts
@@ -2508,6 +2508,27 @@ const translations: TranslationDeepObject = {
unshareErrorModalTitle: '无法取消共享银行账户',
travelCVV: {title: '旅行 CVV', subtitle: '预订差旅时使用此选项', description: '使用此卡预订 Expensify Travel 行程。结账时它会显示为“Travel Card”。'},
chaseAccountNumberDifferent: '为什么我的账户号码不同?',
+ cardLastSynced: (relativeDate: string) => `已同步于 ${relativeDate}`,
+ cardNeverSynced: '从未同步',
+ cardStatus: {
+ active: '活跃',
+ inactive: '未激活',
+ fixConnection: '请修复此连接',
+ fixConnectionIn: (companyCardsRoute: string) => `请在公司卡中修复此连接`,
+ askAdminToFixConnection: '请联系管理员修复此连接',
+ },
+ bankAccountStatus: {
+ active: '活跃',
+ incomplete: '未完成',
+ pending: '待处理',
+ verifying: '正在验证',
+ reviewingDocumentation: '我们正在审核您的证明文件',
+ finishAddingBankAccount: '完成添加银行账户',
+ finish: '完成',
+ confirmTestTransactions: '请确认测试交易',
+ accountRequiresAttention: '此账户需要处理',
+ unlock: '解锁',
+ },
},
cardPage: {
expensifyCard: 'Expensify 卡',
diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts
index 68f598698e30..3e2f168571b7 100644
--- a/src/libs/BankAccountUtils.ts
+++ b/src/libs/BankAccountUtils.ts
@@ -1,16 +1,28 @@
import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
import INPUT_IDS from '@src/types/form/ReimbursementAccountForm';
import type * as OnyxTypes from '@src/types/onyx';
import type AccountData from '@src/types/onyx/AccountData';
import type {ACHData} from '@src/types/onyx/ReimbursementAccount';
import type {OnyxEntry} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
import {Str} from 'expensify-common';
/** Responses of the additional KYB verification checks, hinting at which documents the user still needs to upload */
type KYBVerificationResponses = NonNullable['externalApiResponses'];
+type BankAccountConnectionStatus = {
+ labelKey: TranslationPaths;
+ tone: 'default' | 'success' | 'danger';
+ messageKey?: TranslationPaths;
+ actionKey?: TranslationPaths;
+ requiresUnlockHandler?: boolean;
+ tooltipKey?: TranslationPaths;
+ brickRoadIndicator?: ValueOf;
+};
+
function getDefaultCompanyWebsite(session: OnyxEntry, account: OnyxEntry, shouldShowPublicDomain = false): string {
return account?.isFromPublicDomain && !shouldShowPublicDomain ? '' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`;
}
@@ -37,6 +49,64 @@ function isBankAccountPartiallySetup(state: string | undefined) {
return state === CONST.BANK_ACCOUNT.STATE.SETUP || state === CONST.BANK_ACCOUNT.STATE.VERIFYING || state === CONST.BANK_ACCOUNT.STATE.PENDING;
}
+function getBankAccountState(accountData: AccountData | undefined): string | undefined {
+ if (!accountData) {
+ return undefined;
+ }
+
+ return accountData.state;
+}
+
+function hasBankAccountAllowDebit(accountData: AccountData | undefined): boolean {
+ if (!accountData) {
+ return false;
+ }
+
+ return !!accountData.allowDebit;
+}
+
+function getBankAccountConnectionStatus(state: string | undefined): BankAccountConnectionStatus | undefined {
+ switch (state) {
+ case CONST.BANK_ACCOUNT.STATE.OPEN:
+ return {
+ labelKey: 'walletPage.bankAccountStatus.active',
+ tone: 'success',
+ };
+ case CONST.BANK_ACCOUNT.STATE.SETUP:
+ return {
+ labelKey: 'walletPage.bankAccountStatus.incomplete',
+ messageKey: 'walletPage.bankAccountStatus.finishAddingBankAccount',
+ actionKey: 'walletPage.bankAccountStatus.finish',
+ tone: 'danger',
+ brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
+ };
+ case CONST.BANK_ACCOUNT.STATE.PENDING:
+ return {
+ labelKey: 'walletPage.bankAccountStatus.pending',
+ messageKey: 'walletPage.bankAccountStatus.confirmTestTransactions',
+ actionKey: 'common.confirm',
+ tone: 'danger',
+ brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
+ };
+ case CONST.BANK_ACCOUNT.STATE.VERIFYING:
+ return {
+ labelKey: 'walletPage.bankAccountStatus.verifying',
+ tooltipKey: 'walletPage.bankAccountStatus.reviewingDocumentation',
+ tone: 'default',
+ };
+ case CONST.BANK_ACCOUNT.STATE.LOCKED:
+ return {
+ labelKey: 'common.locked',
+ messageKey: 'walletPage.bankAccountStatus.accountRequiresAttention',
+ actionKey: 'walletPage.bankAccountStatus.unlock',
+ requiresUnlockHandler: true,
+ tone: 'danger',
+ brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
+ };
+ default:
+ 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.
@@ -201,6 +271,9 @@ export {
getBankAccountSearchLabel,
isFilterableBankAccount,
getDefaultCompanyWebsite,
+ getBankAccountState,
+ hasBankAccountAllowDebit,
+ getBankAccountConnectionStatus,
getRequiredKYBDocuments,
getLastFourDigits,
hasPartiallySetupBankAccount,
@@ -213,4 +286,4 @@ export {
getCompletedStepsForBankAccount,
PERSONAL_INFO_STEP,
};
-export type {KYBVerificationResponses};
+export type {BankAccountConnectionStatus, KYBVerificationResponses};
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index a1cce79deae2..1ef0f72a10a5 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -120,6 +120,25 @@ type CompanyCardBankIcons = Record;
const CUSTOM_FEED_PREFIXES = [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD, CONST.COMPANY_CARD.FEED_BANK_NAME.VISA, CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX];
+type CardConnectionStatusDisplay = {
+ statusKey: TranslationPaths;
+ statusTone: 'success' | 'danger';
+ messageKey?: TranslationPaths;
+ actionKey?: TranslationPaths;
+ shouldUsePersonalCardFix?: boolean;
+ shouldUseCompanyCardsLink?: boolean;
+};
+
+type CardConnectionStatusDisplayParams = {
+ shouldShowConnectionStatus: boolean;
+ isCardBroken: boolean;
+ shouldShowRBR: boolean;
+ isCardInactive: boolean;
+ isPersonalCard: boolean;
+ isAdminForCardPolicy: boolean;
+ policyID?: string;
+};
+
const feedNamesMapping = {
[CONST.COMPANY_CARD.FEED_BANK_NAME.CSV]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.CSV,
[CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: CONST.COMPANY_CARDS.CARD_TYPE_NAMES.VISA,
@@ -1348,6 +1367,44 @@ function isCardConnectionBroken(card: Card): boolean {
return !!card.lastScrapeResult && !CONST.COMPANY_CARDS.BROKEN_CONNECTION_IGNORED_STATUSES.includes(card.lastScrapeResult);
}
+function getCardConnectionStatusDisplay({
+ shouldShowConnectionStatus,
+ isCardBroken,
+ shouldShowRBR,
+ isCardInactive: isCardInactiveStatus,
+ isPersonalCard: isPersonalCardStatus,
+ isAdminForCardPolicy,
+ policyID,
+}: CardConnectionStatusDisplayParams): CardConnectionStatusDisplay | undefined {
+ if (!shouldShowConnectionStatus) {
+ return undefined;
+ }
+
+ const shouldShowMessage = isCardBroken || shouldShowRBR || isCardInactiveStatus;
+ const shouldUsePersonalCardFix = shouldShowMessage && isPersonalCardStatus;
+ const shouldUseCompanyCardsLink = shouldShowMessage && !isPersonalCardStatus && isAdminForCardPolicy && !!policyID;
+ let messageKey: TranslationPaths | undefined;
+
+ if (shouldShowMessage) {
+ if (shouldUseCompanyCardsLink) {
+ messageKey = 'walletPage.cardStatus.fixConnectionIn';
+ } else if (isPersonalCardStatus) {
+ messageKey = 'walletPage.cardStatus.fixConnection';
+ } else {
+ messageKey = 'walletPage.cardStatus.askAdminToFixConnection';
+ }
+ }
+
+ return {
+ statusKey: shouldShowMessage ? 'walletPage.cardStatus.inactive' : 'walletPage.cardStatus.active',
+ statusTone: shouldShowMessage ? 'danger' : 'success',
+ messageKey,
+ actionKey: shouldUsePersonalCardFix ? 'common.actionBadge.fix' : undefined,
+ shouldUsePersonalCardFix,
+ shouldUseCompanyCardsLink,
+ };
+}
+
/**
* Check whether a broken card connection has been unresolved long enough that we should stop
* actively prompting the user (remove the time-sensitive task and the RBR). The error itself is
@@ -2067,6 +2124,7 @@ export {
getCSVFeedType,
getFeedType,
isCardConnectionBroken,
+ getCardConnectionStatusDisplay,
isBrokenConnectionPastDismissThreshold,
isSmartLimitEnabled,
lastFourNumbersFromCardName,
diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts
index bb6a660ce059..812a44bb32ea 100644
--- a/src/libs/PolicyUtils.ts
+++ b/src/libs/PolicyUtils.ts
@@ -2760,11 +2760,13 @@ function getMostFrequentEmailDomain(acceptedDomains: string[], policy?: Policy)
return mostFrequent.domain;
}
+const getPolicyIDFromDomainName = (domainName: string): string | undefined => domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]?.toUpperCase();
+
const getDescriptionForPolicyDomainCard = (domainName: string, policies: OnyxCollection): string => {
// A domain name containing a policyID indicates that this is a workspace feed
- const policyID = domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1];
+ const policyID = getPolicyIDFromDomainName(domainName);
if (policyID) {
- const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID.toUpperCase()}`];
+ const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
return policy?.name ?? domainName;
}
return domainName;
@@ -3050,6 +3052,7 @@ export {
canModifyPlan,
getAdminsPrivateEmailDomains,
getMostFrequentEmailDomain,
+ getPolicyIDFromDomainName,
getDescriptionForPolicyDomainCard,
getManagerAccountID,
isPreferredExporter,
diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx
index 3d93e6cc5b59..b299ab09a588 100644
--- a/src/pages/settings/Wallet/PaymentMethodList.tsx
+++ b/src/pages/settings/Wallet/PaymentMethodList.tsx
@@ -5,6 +5,7 @@ import Text from '@components/Text';
import useCardFeedErrors from '@hooks/useCardFeedErrors';
import {useCompanyCardFeedIcons} from '@hooks/useCompanyCardIcons';
+import useEnvironment from '@hooks/useEnvironment';
import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
@@ -12,13 +13,16 @@ import useOnyx from '@hooks/useOnyx';
import useThemeIllustrations from '@hooks/useThemeIllustrations';
import useThemeStyles from '@hooks/useThemeStyles';
-import {isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils';
+import {getBankAccountConnectionStatus, getBankAccountState, isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils';
+import type {BankAccountConnectionStatus} from '@libs/BankAccountUtils';
import {
getAssignedCardSortKey,
+ getCardConnectionStatusDisplay,
getCardFeedIcon,
getCardFeedWithDomainID,
getPlaidInstitutionIconUrl,
isActionableVirtualExpensifyCard,
+ isBrokenConnectionPastDismissThreshold,
isCardConnectionBroken,
isCardFrozen,
isCardInactive,
@@ -34,7 +38,7 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea
import Navigation from '@libs/Navigation/Navigation';
import {formatPaymentMethods} from '@libs/PaymentUtils';
import {areAddressAndPersonalDetailsMissing} from '@libs/PersonalDetailsUtils';
-import {getDescriptionForPolicyDomainCard} from '@libs/PolicyUtils';
+import {getDescriptionForPolicyDomainCard, getPolicyIDFromDomainName, isPolicyAdmin} from '@libs/PolicyUtils';
import {getTravelInvoicingCard, isTravelCVVEligible} from '@libs/TravelInvoicingUtils';
import colors from '@styles/theme/colors';
@@ -100,6 +104,9 @@ type PaymentMethodListProps = {
/** Whether the assigned cards should be shown on the list */
shouldShowAssignedCards?: boolean;
+ /** Whether connection statuses and sync details should be shown */
+ shouldShowConnectionStatus?: boolean;
+
/** Whether the right icon should be shown in PaymentMethodItem */
shouldShowRightIcon?: boolean;
@@ -175,6 +182,7 @@ function PaymentMethodList({
shouldShowAddBankAccount = true,
addBankAccountItemStyle,
shouldShowAssignedCards = false,
+ shouldShowConnectionStatus = false,
shouldSkipDefaultAccountValidation = false,
onListContentSizeChange = () => {},
style = {},
@@ -194,7 +202,8 @@ function PaymentMethodList({
onThreeDotsMenuPress,
}: PaymentMethodListProps) {
const styles = useThemeStyles();
- const {translate} = useLocalize();
+ const {translate, datetimeToRelative} = useLocalize();
+ const {environmentURL} = useEnvironment();
const {isOffline} = useNetwork();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus', 'ThreeDots', 'LuggageWithLines']);
const illustrations = useThemeIllustrations();
@@ -202,7 +211,9 @@ function PaymentMethodList({
const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {
selector: isUserValidatedSelector,
});
- const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isActingAsDelegateSelector});
+ const [isActingAsDelegate] = useOnyx(ONYXKEYS.ACCOUNT, {
+ selector: isActingAsDelegateSelector,
+ });
const [customCardNames] = useOnyx(ONYXKEYS.NVP_EXPENSIFY_COMPANY_CARDS_CUSTOM_NAMES);
const [bankAccountList = getEmptyObject(), bankAccountListResult] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
@@ -225,6 +236,27 @@ function PaymentMethodList({
const {shouldShowRbrForFeedNameWithDomainID} = useCardFeedErrors();
const shouldShowListFooterComponent = shouldShowAddBankAccount;
+ const appendCardLastSync = (description: string | undefined, lastSyncText: string) => [description, lastSyncText].filter(Boolean).join(` ${CONST.DOT_SEPARATOR} `);
+
+ const mapBankStatusToRowStatus = (
+ status: BankAccountConnectionStatus,
+ onActionPress: (e: GestureResponderEvent | KeyboardEvent | undefined) => void,
+ onUnlockPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void,
+ ): PaymentMethodItem['connectionStatus'] => ({
+ statusText: translate(status.labelKey),
+ statusTone: status.tone,
+ tooltipText: status.tooltipKey ? translate(status.tooltipKey) : undefined,
+ message: status.messageKey ? translate(status.messageKey) : undefined,
+ actionText: status.actionKey ? translate(status.actionKey) : undefined,
+ onActionPress: () => {
+ if (status.requiresUnlockHandler) {
+ (onUnlockPress ?? onActionPress)(undefined);
+ return;
+ }
+ onActionPress(undefined);
+ },
+ });
+
const computeFilteredPaymentMethods = (): Array => {
if (shouldShowAssignedCards) {
const assignedCards = Object.values(isLoadingCardList ? {} : (cardList ?? {}))
@@ -246,6 +278,9 @@ function PaymentMethodList({
const isUserPersonalCard = isPersonalCard(card);
const isCSVCard = card.bank === CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD || card.bank.includes(CONST.COMPANY_CARD.FEED_BANK_NAME.CSV);
const assignedCardsGrouped = isUserPersonalCard ? personalCardsGrouped : companyCardsGrouped;
+ const policyIDForCard = shouldShowConnectionStatus && card.domainName ? getPolicyIDFromDomainName(card.domainName) : undefined;
+ const policyForCard = policyIDForCard ? policiesForAssignedCards?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDForCard}`] : undefined;
+ const isAdminForCardPolicy = shouldShowConnectionStatus ? isPolicyAdmin(policyForCard) : false;
let icon;
if (isUserPersonalCard && isCSVCard) {
@@ -258,7 +293,7 @@ function PaymentMethodList({
if (card.fundID) {
const feedNameWithDomainID = getCardFeedWithDomainID(card.bank, card.fundID);
shouldShowRBR = shouldShowRbrForFeedNameWithDomainID[feedNameWithDomainID];
- } else if (card.bank !== CONST.PERSONAL_CARDS.BANK_NAME.CSV) {
+ } else if ((!shouldShowConnectionStatus || !isUserPersonalCard) && card.bank !== CONST.PERSONAL_CARDS.BANK_NAME.CSV) {
// Don't show red dot for CSV imported cards without fundID
shouldShowRBR = true;
}
@@ -278,6 +313,56 @@ function PaymentMethodList({
brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
}
+ const isCardBroken = isCardConnectionBroken(card) && !isBrokenConnectionPastDismissThreshold(card);
+ const isCardInactiveState = isCardInactive(card);
+ const cardConnectionStatusDisplay = getCardConnectionStatusDisplay({
+ shouldShowConnectionStatus,
+ isCardBroken,
+ shouldShowRBR,
+ isCardInactive: isCardInactiveState,
+ isPersonalCard: isUserPersonalCard,
+ isAdminForCardPolicy,
+ policyID: policyIDForCard,
+ });
+ const shouldShowCardConnectionMessage = !!cardConnectionStatusDisplay?.messageKey;
+ const shouldShowCardErrorMessages = !shouldShowCardConnectionMessage || !!card.pendingAction;
+ const shouldShowCardLastSync = shouldShowConnectionStatus && !isExpensifyCard(card) && !isCSVCard;
+ let cardLastSyncText: string | undefined;
+ if (shouldShowCardLastSync) {
+ if (card.lastScrape) {
+ cardLastSyncText = translate('walletPage.cardLastSynced', datetimeToRelative(card.lastScrape.replace(' ', 'T')));
+ } else {
+ cardLastSyncText = translate('walletPage.cardNeverSynced');
+ }
+ }
+ let cardConnectionStatus: PaymentMethodItem['connectionStatus'];
+ if (cardConnectionStatusDisplay) {
+ let cardConnectionMessage: string | undefined;
+ if (cardConnectionStatusDisplay.shouldUseCompanyCardsLink && policyIDForCard) {
+ cardConnectionMessage = translate('walletPage.cardStatus.fixConnectionIn', `${environmentURL}/${ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyIDForCard)}`);
+ } else if (cardConnectionStatusDisplay.shouldUsePersonalCardFix) {
+ cardConnectionMessage = translate('walletPage.cardStatus.fixConnection');
+ } else if (cardConnectionStatusDisplay.messageKey) {
+ cardConnectionMessage = translate('walletPage.cardStatus.askAdminToFixConnection');
+ }
+
+ cardConnectionStatus = {
+ statusText: translate(cardConnectionStatusDisplay.statusKey),
+ statusTone: cardConnectionStatusDisplay.statusTone,
+ message: cardConnectionMessage,
+ actionText: cardConnectionStatusDisplay.actionKey ? translate(cardConnectionStatusDisplay.actionKey) : undefined,
+ onActionPress: cardConnectionStatusDisplay.shouldUsePersonalCardFix
+ ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_FIX_CONNECTION.getRoute(String(card.cardID)))
+ : undefined,
+ onLinkPress:
+ cardConnectionStatusDisplay.shouldUseCompanyCardsLink && policyIDForCard
+ ? () => {
+ Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyIDForCard));
+ }
+ : undefined,
+ };
+ }
+
if (!isExpensifyCard(card)) {
const lastFourPAN = lastFourNumbersFromCardName(card.cardName);
const plaidUrl = getPlaidInstitutionIconUrl(card.bank);
@@ -310,16 +395,26 @@ function PaymentMethodList({
cardID: card.cardID,
});
+ let itemDescription = cardDescription;
+ if (isCSVImportCard) {
+ itemDescription = translate('cardPage.csvCardDescription');
+ }
+ if (shouldShowConnectionStatus && cardLastSyncText) {
+ itemDescription = appendCardLastSync(itemDescription, cardLastSyncText);
+ }
+
assignedCardsGrouped.push({
key: card.cardID.toString(),
plaidUrl,
title: cardTitle,
- description: isCSVImportCard ? translate('cardPage.csvCardDescription') : cardDescription,
+ description: itemDescription,
+ connectionStatus: cardConnectionStatus,
interactive: !isDisabled,
disabled: isDisabled,
shouldShowRightIcon,
shouldShowThreeDotsMenu: !isUserPersonalCard,
errors: isUserPersonalCard ? undefined : card.errors,
+ shouldShowErrorMessages: !isUserPersonalCard && shouldShowCardErrorMessages,
canDismissError: false,
pendingAction: card.pendingAction,
brickRoadIndicator,
@@ -341,12 +436,14 @@ function PaymentMethodList({
continue;
}
- // The card should be grouped to a specific domain and such domain already exists in a assignedCardsGrouped
- if (assignedCardsGrouped.some((item) => item.isGroupedCardDomain && item.description === card.domainName) && !isAdminIssuedVirtualCard) {
+ if (!shouldShowConnectionStatus && assignedCardsGrouped.some((item) => item.isGroupedCardDomain && item.description === card.domainName) && !isAdminIssuedVirtualCard) {
const domainGroupIndex = assignedCardsGrouped.findIndex((item) => item.isGroupedCardDomain && item.description === card.domainName);
const assignedCardsGroupedItem = assignedCardsGrouped.at(domainGroupIndex);
if (domainGroupIndex >= 0 && assignedCardsGroupedItem) {
- assignedCardsGroupedItem.errors = {...assignedCardsGrouped.at(domainGroupIndex)?.errors, ...card.errors};
+ assignedCardsGroupedItem.errors = {
+ ...assignedCardsGrouped.at(domainGroupIndex)?.errors,
+ ...card.errors,
+ };
if (
card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN ||
card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL ||
@@ -359,17 +456,23 @@ function PaymentMethodList({
}
const pressHandler = onPress as CardPressHandler;
-
- // The card shouldn't be grouped or it's domain group doesn't exist yet
const cardDescription =
card?.nameValuePairs?.issuedBy && card?.lastFourPAN
? `${card?.lastFourPAN} ${CONST.DOT_SEPARATOR} ${getDescriptionForPolicyDomainCard(card.domainName, policiesForAssignedCards)}`
: getDescriptionForPolicyDomainCard(card.domainName, policiesForAssignedCards);
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
+ const cardTitle = card?.nameValuePairs?.cardTitle || card.bank;
+
+ let itemDescription = cardDescription;
+ if (shouldShowConnectionStatus && cardLastSyncText) {
+ itemDescription = appendCardLastSync(cardDescription, cardLastSyncText);
+ }
+
assignedCardsGrouped.push({
key: card.cardID.toString(),
- // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
- title: card?.nameValuePairs?.cardTitle || card.bank,
- description: cardDescription,
+ title: cardTitle,
+ description: itemDescription,
+ connectionStatus: cardConnectionStatus,
onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(String(card.cardID))),
onThreeDotsMenuPress: (e: GestureResponderEvent | KeyboardEvent | undefined) =>
pressHandler({
@@ -389,6 +492,7 @@ function PaymentMethodList({
interactive: !isDisabled,
disabled: isDisabled,
errors: card.errors,
+ shouldShowErrorMessages: shouldShowCardErrorMessages,
canDismissError: true,
pendingAction: card.pendingAction,
brickRoadIndicator,
@@ -452,7 +556,8 @@ function PaymentMethodList({
if (excludeStates?.length) {
combinedPaymentMethods = combinedPaymentMethods.filter((paymentMethod) => {
const account = paymentMethod as BankAccount;
- return !excludeStates.includes(account.accountData?.state as ValueOf);
+ const bankAccountState = getBankAccountState(account.accountData) as ValueOf | undefined;
+ return !bankAccountState || !excludeStates.includes(bankAccountState);
});
}
@@ -473,29 +578,35 @@ function PaymentMethodList({
methodID: paymentMethod.methodID,
description: paymentMethod.description,
};
+ const existingBrickRoadIndicator = (paymentMethod as Partial).brickRoadIndicator;
const isMissingPersonalInfo = isPersonalBankAccountMissingInfo(paymentMethod.accountData);
+ const bankConnectionStatus = shouldShowConnectionStatus && !isMissingPersonalInfo ? getBankAccountConnectionStatus(getBankAccountState(paymentMethod.accountData)) : undefined;
+ const paymentMethodPress = (e: GestureResponderEvent | KeyboardEvent | undefined) =>
+ pressHandler({
+ event: e,
+ ...paymentMethodData,
+ });
+ const paymentMethodThreeDotsPress =
+ onThreeDotsMenuPress &&
+ ((e: GestureResponderEvent | KeyboardEvent | undefined) =>
+ onThreeDotsMenuPress({
+ event: e,
+ ...paymentMethodData,
+ }));
return {
...paymentMethod,
title: paymentMethod.title?.includes(CONST.MASKED_PAN_PREFIX) ? paymentMethod.accountData?.additionalData?.bankName : paymentMethod.title,
- onPress: (e: GestureResponderEvent) =>
- pressHandler({
- event: e,
- ...paymentMethodData,
- }),
- onThreeDotsMenuPress: onThreeDotsMenuPress
- ? (e: GestureResponderEvent) =>
- onThreeDotsMenuPress({
- event: e,
- ...paymentMethodData,
- })
- : undefined,
+ onPress: paymentMethodPress,
+ onThreeDotsMenuPress: paymentMethodThreeDotsPress,
disabled: paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE,
isMethodActive,
iconRight: itemIconRight ?? expensifyIcons.ThreeDots,
shouldShowRightIcon,
canDismissError: true,
isMissingPersonalInfo,
+ brickRoadIndicator: shouldShowConnectionStatus ? (bankConnectionStatus?.brickRoadIndicator ?? existingBrickRoadIndicator) : existingBrickRoadIndicator,
+ connectionStatus: bankConnectionStatus ? mapBankStatusToRowStatus(bankConnectionStatus, paymentMethodPress, paymentMethodThreeDotsPress) : undefined,
};
});
return combinedPaymentMethods;
@@ -563,7 +674,14 @@ function PaymentMethodList({
};
return (
-
+
data={itemsToRender}
renderItem={renderItem}
diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx
index 06d4cc38136d..c747de3bcc4b 100644
--- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx
+++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx
@@ -1,10 +1,14 @@
import Badge from '@components/Badge';
import Button from '@components/Button';
+import ConnectionStatusBadge from '@components/ConnectionStatusBadge';
+import ConnectionStatusMessage from '@components/ConnectionStatusMessage';
+import Hoverable from '@components/Hoverable';
import Icon from '@components/Icon';
import MenuItem from '@components/MenuItem';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import type {PopoverMenuItem} from '@components/PopoverMenu';
import PressableWithFeedback from '@components/Pressable/PressableWithFeedback';
+import type RenderHTML from '@components/RenderHTML';
import Text from '@components/Text';
import ThreeDotsMenu from '@components/ThreeDotsMenu';
@@ -15,7 +19,7 @@ import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
import {openExternalLink} from '@libs/actions/Link';
-import {isBankAccountPartiallySetup} from '@libs/BankAccountUtils';
+import {getBankAccountState, hasBankAccountAllowDebit, isBankAccountPartiallySetup} from '@libs/BankAccountUtils';
import Log from '@libs/Log';
import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute';
import Navigation from '@libs/Navigation/Navigation';
@@ -33,12 +37,24 @@ import type PaymentMethod from '@src/types/onyx/PaymentMethod';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
+import type {ComponentProps} from 'react';
import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native';
import type {ValueOf} from 'type-fest';
import React, {useMemo, useRef} from 'react';
import {View} from 'react-native';
+type ConnectionStatusDetails = {
+ statusText: string;
+ statusTone?: 'default' | 'success' | 'danger';
+ tooltipText?: string;
+ message?: string;
+ actionText?: string;
+ onActionPress?: () => void;
+ isActionDisabled?: boolean;
+ onLinkPress?: ComponentProps['onLinkPress'];
+};
+
type PaymentMethodItem = PaymentMethod & {
key?: string;
title?: string;
@@ -61,6 +77,8 @@ type PaymentMethodItem = PaymentMethod & {
isCardFrozen?: boolean;
/** Whether the personal bank account is missing required personal info (name, address, phone) */
isMissingPersonalInfo?: boolean;
+ connectionStatus?: ConnectionStatusDetails;
+ shouldShowErrorMessages?: boolean;
/** Whether to show the "Add details" CTA row below a virtual Expensify Card when personal details are missing */
shouldShowMissingPersonalDetailsAction?: boolean;
} & BankIcon;
@@ -112,11 +130,11 @@ function dismissError(item: PaymentMethodItem) {
}
function isAccountInSetupState(account: PaymentMethodItem) {
- return !!(account.accountData && 'state' in account.accountData && isBankAccountPartiallySetup(account.accountData.state));
+ return isBankAccountPartiallySetup(getBankAccountState(account.accountData));
}
function isBusinessBankAccountLocked(account: PaymentMethodItem) {
- return account.accountData && 'state' in account.accountData && account.accountData.state === CONST.BANK_ACCOUNT.STATE.LOCKED && account.accountData.allowDebit;
+ return getBankAccountState(account.accountData) === CONST.BANK_ACCOUNT.STATE.LOCKED && hasBankAccountAllowDebit(account.accountData);
}
function isAccountNeedingAction(account: PaymentMethodItem) {
@@ -134,6 +152,7 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems
const isInLockedState = isBusinessBankAccountLocked(item);
const showThreeDotsMenu = item.shouldShowThreeDotsMenu !== false && !!threeDotsMenuItems && !isInLockedState;
const isNeedingAction = isAccountNeedingAction(item);
+ const connectionStatus = item.connectionStatus;
// Check if this is a Chase personal bank account connected via Plaid
const isChaseAccountConnectedViaPlaid =
@@ -158,8 +177,10 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems
}
};
- let badgeText;
- if (isInLockedState) {
+ let badgeText: string | undefined;
+ if (connectionStatus) {
+ badgeText = shouldShowDefaultBadge ? translate('paymentMethodList.defaultPaymentMethod') : undefined;
+ } else if (isInLockedState) {
badgeText = translate('common.locked');
} else if (isNeedingAction) {
badgeText = translate('common.review');
@@ -167,8 +188,8 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems
badgeText = translate('paymentMethodList.defaultPaymentMethod');
}
- let badgeIcon;
- if (isInLockedState) {
+ let badgeIcon: IconAsset | undefined;
+ if (!item.connectionStatus && isInLockedState) {
badgeIcon = icons.DotIndicator;
}
@@ -194,6 +215,15 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems
/>
);
}
+ if (item.connectionStatus) {
+ return (
+
+ );
+ }
if (item.isInactive) {
return (
+
+
+ ) : undefined;
+ const menuItemProps: React.ComponentProps = {
+ onPress: handleRowPress,
+ title: item.title,
+ description: item.description,
+ descriptionAddon,
+ icon: item.icon,
+ plaidUrl: item.plaidUrl,
+ disabled: item.disabled,
+ iconType: item.plaidUrl ? CONST.ICON_TYPE_PLAID : CONST.ICON_TYPE_ICON,
+ displayInDefaultIconColor: !item.iconFill,
+ iconHeight: item.iconHeight ?? item.iconSize,
+ iconWidth: item.iconWidth ?? item.iconSize,
+ iconStyles: item.iconStyles,
+ iconFill: item.iconFill,
+ badgeText,
+ badgeIcon,
+ wrapperStyle: [styles.paymentMethod, listItemStyle],
+ iconRight: isNeedingAction ? undefined : item.iconRight,
+ shouldShowRightIcon: !showThreeDotsMenu && item.shouldShowRightIcon,
+ shouldShowRightComponent: showThreeDotsMenu,
+ rightComponent,
+ interactive: item.interactive,
+ success: item.isMethodActive,
+ ...(connectionStatus
+ ? {
+ shouldRemoveBackground: true,
+ shouldRemoveHoverBackground: true,
+ brickRoadIndicator: connectionStatus.message ? undefined : item.brickRoadIndicator,
+ }
+ : {
+ isBadgeSuccess: isNeedingAction ? true : undefined,
+ isBadgeError: isInLockedState,
+ brickRoadIndicator: item.brickRoadIndicator,
+ }),
+ };
+ const menuItem = ;
return (
-
) : (
canWritePayments && (
@@ -869,9 +926,9 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
promptConfigureApprovalsInHR,
isDEWEnabled,
shouldUseNarrowLayout,
+ expensifyIcons.DotIndicator,
expensifyIcons.Info,
expensifyIcons.Plus,
- expensifyIcons.DotIndicator,
theme.textSupporting,
accountManagerReportID,
filteredApprovalWorkflows.length,
@@ -904,6 +961,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) {
confirmCurrencyChangeAndHideModal,
delegateAccountID,
canAccessSubmit2026Features,
+ canAccessWalletConnectionStatusFeatures,
canWriteApprovals,
canWritePayments,
canWriteWorkflows,
diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts
index 3a15511e36b6..f7ecf9ce9b31 100644
--- a/src/selectors/Policy.ts
+++ b/src/selectors/Policy.ts
@@ -1,7 +1,7 @@
import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections';
import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy';
// eslint-disable-next-line no-restricted-imports -- isPaidGroupPolicy is intentional: copy-settings targets are billing/paid-only (Collect/Control), so free group plans like Submit must be excluded (see createCopySettingsEligibleTargetsSelector).
-import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils';
+import {getActiveAdminWorkspaces, getOwnedPaidPolicies, getPolicyIDFromDomainName, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils';
import {getDefaultAvatarURL} from '@libs/UserAvatarUtils';
import CONST from '@src/CONST';
@@ -149,12 +149,7 @@ const createAllPolicyReportFieldsSelector = (policies: OnyxCollection, l
};
const createPoliciesForDomainCardsSelector = (domainNames: string[]) => {
- const policyIDs = new Set(
- domainNames
- .map((domainName) => domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1])
- .filter((policyID): policyID is string => !!policyID)
- .map((policyID) => policyID.toUpperCase()),
- );
+ const policyIDs = new Set(domainNames.map(getPolicyIDFromDomainName).filter((policyID): policyID is string => !!policyID));
return (policies: OnyxCollection) => {
if (policyIDs.size === 0) {
@@ -360,7 +355,12 @@ const createAdminPoliciesSelector =
if (!isCurrentPolicy && (policy.type === CONST.POLICY.TYPE.PERSONAL || policy.role !== CONST.POLICY.ROLE.ADMIN)) {
return acc;
}
- acc[key] = {id: policy.id, name: policy.name, avatarURL: policy.avatarURL, created: policy.created};
+ acc[key] = {
+ id: policy.id,
+ name: policy.name,
+ avatarURL: policy.avatarURL,
+ created: policy.created,
+ };
return acc;
}, {});
};
diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts
index 72bf5f835cdc..8a123ffb66a3 100644
--- a/tests/unit/BankAccountUtilsTest.ts
+++ b/tests/unit/BankAccountUtilsTest.ts
@@ -1,8 +1,11 @@
import {
+ getBankAccountConnectionStatus,
+ getBankAccountState,
getCompletedStepsForBankAccount,
getDefaultCompanyWebsite,
getLastFourDigits,
getRequiredKYBDocuments,
+ hasBankAccountAllowDebit,
hasPartiallySetupBankAccount,
hasPersonalBankAccountMissingInfo,
isBankAccountPartiallySetup,
@@ -246,6 +249,82 @@ describe('BankAccountUtils', () => {
});
});
+ describe('getBankAccountState', () => {
+ it('returns the state from accountData', () => {
+ expect(getBankAccountState({state: CONST.BANK_ACCOUNT.STATE.OPEN} as AccountData)).toBe(CONST.BANK_ACCOUNT.STATE.OPEN);
+ });
+
+ it('returns undefined when accountData is undefined', () => {
+ expect(getBankAccountState(undefined)).toBeUndefined();
+ });
+ });
+
+ describe('hasBankAccountAllowDebit', () => {
+ it('returns true when accountData allows debit', () => {
+ expect(hasBankAccountAllowDebit({allowDebit: true} as AccountData)).toBe(true);
+ });
+
+ it('returns false when accountData does not allow debit', () => {
+ expect(hasBankAccountAllowDebit({allowDebit: false} as AccountData)).toBe(false);
+ });
+
+ it('returns false when accountData is undefined', () => {
+ expect(hasBankAccountAllowDebit(undefined)).toBe(false);
+ });
+ });
+
+ describe('getBankAccountConnectionStatus', () => {
+ it('maps OPEN bank accounts to Active without an RBR', () => {
+ expect(getBankAccountConnectionStatus(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({
+ labelKey: 'walletPage.bankAccountStatus.incomplete',
+ messageKey: 'walletPage.bankAccountStatus.finishAddingBankAccount',
+ actionKey: 'walletPage.bankAccountStatus.finish',
+ tone: 'danger',
+ brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
+ });
+ });
+
+ it('maps PENDING bank accounts to Pending with the confirm action', () => {
+ expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING)).toEqual({
+ labelKey: 'walletPage.bankAccountStatus.pending',
+ messageKey: 'walletPage.bankAccountStatus.confirmTestTransactions',
+ actionKey: 'common.confirm',
+ tone: 'danger',
+ brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
+ });
+ });
+
+ it('maps VERIFYING bank accounts to Verifying with only a tooltip', () => {
+ expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.VERIFYING)).toEqual({
+ labelKey: 'walletPage.bankAccountStatus.verifying',
+ tooltipKey: 'walletPage.bankAccountStatus.reviewingDocumentation',
+ tone: 'default',
+ });
+ });
+
+ it('maps LOCKED bank accounts to Locked with the unlock action', () => {
+ expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.LOCKED)).toEqual({
+ labelKey: 'common.locked',
+ messageKey: 'walletPage.bankAccountStatus.accountRequiresAttention',
+ actionKey: 'walletPage.bankAccountStatus.unlock',
+ requiresUnlockHandler: true,
+ tone: 'danger',
+ brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
+ });
+ });
+
+ it.each([undefined, '', 'UNKNOWN'])('returns undefined for unsupported state "%s"', (state) => {
+ expect(getBankAccountConnectionStatus(state)).toBeUndefined();
+ });
+ });
+
describe('hasPartiallySetupBankAccount', () => {
it('returns true when at least one account is in SETUP state', () => {
const bankAccountList = {
diff --git a/tests/unit/CardUtilsTest.ts b/tests/unit/CardUtilsTest.ts
index 3369130a1c2f..56c46bc5366d 100644
--- a/tests/unit/CardUtilsTest.ts
+++ b/tests/unit/CardUtilsTest.ts
@@ -22,6 +22,7 @@ import {
getBankCardDetailsImage,
getBankName,
getBrokenConnectionUrlToFixPersonalCard,
+ getCardConnectionStatusDisplay,
getCardDescription,
getCardDescriptionForSearchTable,
getCardFeedIcon,
@@ -4658,3 +4659,74 @@ describe('getCompanyCardCustomName', () => {
expect(getCompanyCardCustomName('9999', sharedCardCustomNames, customCardNames)).toBeUndefined();
});
});
+
+describe('getCardConnectionStatusDisplay', () => {
+ const defaultParams = {
+ shouldShowConnectionStatus: true,
+ isCardBroken: false,
+ shouldShowRBR: false,
+ isCardInactive: false,
+ isPersonalCard: false,
+ isAdminForCardPolicy: false,
+ policyID: undefined,
+ };
+
+ it('returns undefined when connection status is disabled', () => {
+ expect(getCardConnectionStatusDisplay({...defaultParams, shouldShowConnectionStatus: false})).toBeUndefined();
+ });
+
+ it('returns an active success status for a healthy card', () => {
+ expect(getCardConnectionStatusDisplay(defaultParams)).toEqual({
+ statusKey: 'walletPage.cardStatus.active',
+ statusTone: 'success',
+ messageKey: undefined,
+ actionKey: undefined,
+ shouldUsePersonalCardFix: false,
+ shouldUseCompanyCardsLink: false,
+ });
+ });
+
+ it('returns the personal-card fix action for a broken personal card', () => {
+ expect(getCardConnectionStatusDisplay({...defaultParams, isCardBroken: true, isPersonalCard: true})).toEqual({
+ statusKey: 'walletPage.cardStatus.inactive',
+ statusTone: 'danger',
+ messageKey: 'walletPage.cardStatus.fixConnection',
+ actionKey: 'common.actionBadge.fix',
+ shouldUsePersonalCardFix: true,
+ shouldUseCompanyCardsLink: false,
+ });
+ });
+
+ it('returns the company-cards link message for an admin company card', () => {
+ expect(getCardConnectionStatusDisplay({...defaultParams, shouldShowRBR: true, isAdminForCardPolicy: true, policyID: 'ABC123'})).toEqual({
+ statusKey: 'walletPage.cardStatus.inactive',
+ statusTone: 'danger',
+ messageKey: 'walletPage.cardStatus.fixConnectionIn',
+ actionKey: undefined,
+ shouldUsePersonalCardFix: false,
+ shouldUseCompanyCardsLink: true,
+ });
+ });
+
+ it('returns the ask-admin message for a non-admin company card', () => {
+ expect(getCardConnectionStatusDisplay({...defaultParams, isCardInactive: true, policyID: 'ABC123'})).toEqual({
+ statusKey: 'walletPage.cardStatus.inactive',
+ statusTone: 'danger',
+ messageKey: 'walletPage.cardStatus.askAdminToFixConnection',
+ actionKey: undefined,
+ shouldUsePersonalCardFix: false,
+ shouldUseCompanyCardsLink: false,
+ });
+ });
+
+ it('does not show a company-cards link without a policy ID', () => {
+ expect(getCardConnectionStatusDisplay({...defaultParams, shouldShowRBR: true, isAdminForCardPolicy: true})).toEqual({
+ statusKey: 'walletPage.cardStatus.inactive',
+ statusTone: 'danger',
+ messageKey: 'walletPage.cardStatus.askAdminToFixConnection',
+ actionKey: undefined,
+ shouldUsePersonalCardFix: false,
+ shouldUseCompanyCardsLink: false,
+ });
+ });
+});
diff --git a/tests/unit/PolicyUtilsTest.ts b/tests/unit/PolicyUtilsTest.ts
index c857ba817d9f..7fcbad82b166 100644
--- a/tests/unit/PolicyUtilsTest.ts
+++ b/tests/unit/PolicyUtilsTest.ts
@@ -30,6 +30,7 @@ import {
getMatchingVendors,
getPolicyBrickRoadIndicatorStatus,
getPolicyByCustomUnitID,
+ getPolicyIDFromDomainName,
getRateDisplayValue,
getSubmitToAccountID,
getSubmitToEmail,
@@ -4045,3 +4046,25 @@ describe('getDefaultChatEnabledPolicy', () => {
expect(getDefaultChatEnabledPolicy([teamPolicy, corporatePolicy], submitPolicy)).toBeUndefined();
});
});
+
+describe('getPolicyIDFromDomainName', () => {
+ it('extracts the policy ID from an Expensify workspace-feed domain name', () => {
+ expect(getPolicyIDFromDomainName('expensify-policyA1B2C3.exfy')).toBe('A1B2C3');
+ });
+
+ it('converts the extracted policy ID to uppercase', () => {
+ expect(getPolicyIDFromDomainName(`expensify-policy${'a1b2c3'}.exfy`)).toBe('A1B2C3');
+ });
+
+ it('returns undefined for a domain name that is not a workspace feed', () => {
+ expect(getPolicyIDFromDomainName('example.com')).toBeUndefined();
+ });
+
+ it('returns undefined for an empty string', () => {
+ expect(getPolicyIDFromDomainName('')).toBeUndefined();
+ });
+
+ it('returns undefined when the extension is missing', () => {
+ expect(getPolicyIDFromDomainName('expensify-policyA1B2C3')).toBeUndefined();
+ });
+});