From bb6895ce91a768a31e08dbb75e53df8c19281611 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Wed, 17 Jun 2026 19:20:08 +0430 Subject: [PATCH 01/61] feat: show connection status and last sync time for bank accounts and cards --- src/languages/en.ts | 22 +++ src/libs/BankAccountUtils.ts | 50 ++++++- .../settings/Wallet/PaymentMethodList.tsx | 104 +++++++++------ .../settings/Wallet/PaymentMethodListItem.tsx | 77 ++++++++++- .../workflows/WorkspaceWorkflowsPage.tsx | 126 +++++++++++------- 5 files changed, 288 insertions(+), 91 deletions(-) diff --git a/src/languages/en.ts b/src/languages/en.ts index 08010d9e5caf..1c929c945ba2 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2573,6 +2573,28 @@ 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: 'Please fix this connection in', + companyCardsLink: '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.', expensifyCard: 'Expensify Card', diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index 15c284aca46d..72953cf3fec7 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -1,6 +1,8 @@ import {Str} from 'expensify-common'; import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; 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'; @@ -9,6 +11,15 @@ import type {ACHData} from '@src/types/onyx/ReimbursementAccount'; /** 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; + tooltipKey?: TranslationPaths; + brickRoadIndicator?: ValueOf; +}; + function getDefaultCompanyWebsite(session: OnyxEntry, account: OnyxEntry, shouldShowPublicDomain = false): string { return account?.isFromPublicDomain && !shouldShowPublicDomain ? '' : `https://www.${Str.extractEmailDomain(session?.email ?? '')}`; } @@ -21,6 +32,42 @@ 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 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: + case undefined: + 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', + tone: 'danger', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }; + default: + return undefined; + } +} + function doesPolicyHavePartiallySetupBankAccount(bankAccountList: OnyxEntry, policyID: string) { if (!bankAccountList) { return false; @@ -175,6 +222,7 @@ function getRequiredKYBDocuments(externalApiResponses: KYBVerificationResponses) export { getDefaultCompanyWebsite, + getBankAccountConnectionStatus, getRequiredKYBDocuments, getLastFourDigits, hasPartiallySetupBankAccount, @@ -187,4 +235,4 @@ export { getCompletedStepsForBankAccount, PERSONAL_INFO_STEP, }; -export type {KYBVerificationResponses}; +export type {BankAccountConnectionStatus, KYBVerificationResponses}; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index ba053e86dbcd..74474ed21759 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -20,7 +20,8 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; +import {getBankAccountConnectionStatus, isPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; +import type {BankAccountConnectionStatus} from '@libs/BankAccountUtils'; import { getAssignedCardSortKey, getCardFeedIcon, @@ -40,7 +41,7 @@ import { import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods} from '@libs/PaymentUtils'; -import {getDescriptionForPolicyDomainCard} from '@libs/PolicyUtils'; +import {getDescriptionForPolicyDomainCard, isPolicyAdmin} from '@libs/PolicyUtils'; import {getTravelInvoicingCard, isTravelCVVEligible} from '@libs/TravelInvoicingUtils'; import colors from '@styles/theme/colors'; import variables from '@styles/variables'; @@ -147,6 +148,10 @@ function isPaymentMethodActive(actionPaymentMethodType: string, activePaymentMet return paymentMethod.accountType === actionPaymentMethodType && paymentMethod.methodID === activePaymentMethodID; } +function getPolicyIDFromDomainName(domainName: string): string | undefined { + return domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]; +} + function keyExtractor(item: PaymentMethod | string) { if (typeof item === 'string') { return item; @@ -180,7 +185,7 @@ function PaymentMethodList({ onThreeDotsMenuPress, }: PaymentMethodListProps) { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, datetimeToRelative} = useLocalize(); const {isOffline} = useNetwork(); const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plus', 'ThreeDots', 'LuggageWithLines']); const illustrations = useThemeIllustrations(); @@ -210,6 +215,21 @@ 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: status.actionKey === 'walletPage.bankAccountStatus.unlock' ? (onUnlockPress ?? onActionPress) : onActionPress, + }); + const computeFilteredPaymentMethods = (): Array => { if (shouldShowAssignedCards) { const assignedCards = Object.values(isLoadingCardList ? {} : (cardList ?? {})) @@ -230,6 +250,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 = getPolicyIDFromDomainName(card.domainName); + const policyForCard = policyIDForCard ? policiesForAssignedCards?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDForCard.toUpperCase()}`] : undefined; + const isAdminForCardPolicy = isPolicyAdmin(policyForCard); let icon; if (isUserPersonalCard && isCSVCard) { @@ -262,6 +285,24 @@ function PaymentMethodList({ brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; } + const isCardBroken = isCardConnectionBroken(card); + const shouldShowCardConnectionMessage = isCardBroken || shouldShowRBR; + const cardLastSyncText = card.lastScrape ? translate('walletPage.cardLastSynced', datetimeToRelative(card.lastScrape)) : translate('walletPage.cardNeverSynced'); + const cardConnectionStatus: PaymentMethodItem['connectionStatus'] = { + statusText: translate(isCardInactive(card) ? 'walletPage.cardStatus.inactive' : 'walletPage.cardStatus.active'), + statusTone: shouldShowCardConnectionMessage ? 'danger' : isCardInactive(card) ? 'default' : 'success', + message: shouldShowCardConnectionMessage + ? translate(!isUserPersonalCard && isAdminForCardPolicy ? 'walletPage.cardStatus.fixConnectionIn' : isUserPersonalCard ? 'walletPage.cardStatus.fixConnection' : 'walletPage.cardStatus.askAdminToFixConnection') + : undefined, + actionText: isUserPersonalCard && shouldShowCardConnectionMessage ? translate('walletPage.cardStatus.fixConnection') : undefined, + onActionPress: isUserPersonalCard && shouldShowCardConnectionMessage ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_FIX_CONNECTION.getRoute(String(card.cardID))) : undefined, + linkText: !isUserPersonalCard && isAdminForCardPolicy && shouldShowCardConnectionMessage ? translate('walletPage.cardStatus.companyCardsLink') : undefined, + onLinkPress: + !isUserPersonalCard && isAdminForCardPolicy && policyIDForCard && shouldShowCardConnectionMessage + ? () => Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyIDForCard)) + : undefined, + }; + if (!isExpensifyCard(card)) { const lastFourPAN = lastFourNumbersFromCardName(card.cardName); const plaidUrl = getPlaidInstitutionIconUrl(card.bank); @@ -298,7 +339,8 @@ function PaymentMethodList({ key: card.cardID.toString(), plaidUrl, title: cardTitle, - description: isCSVImportCard ? translate('cardPage.csvCardDescription') : cardDescription, + description: appendCardLastSync(isCSVImportCard ? translate('cardPage.csvCardDescription') : cardDescription, cardLastSyncText), + connectionStatus: cardConnectionStatus, interactive: !isDisabled, disabled: isDisabled, shouldShowRightIcon, @@ -318,33 +360,14 @@ function PaymentMethodList({ continue; } - const isAdminIssuedVirtualCard = !!card?.nameValuePairs?.issuedBy && !!card?.nameValuePairs?.isVirtual; - // Travel cards are handled by the dedicated travelCardGrouped section below if (isTravelCard(card)) { 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) { - 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}; - if ( - card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.DOMAIN || - card.fraud === CONST.EXPENSIFY_CARD.FRAUD_TYPES.INDIVIDUAL || - Object.keys(assignedCardsGroupedItem.errors).length > 0 - ) { - assignedCardsGroupedItem.brickRoadIndicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - } - continue; - } - const pressHandler = onPress as CardPressHandler; - // The card shouldn't be grouped or it's domain group doesn't exist yet + // Do not group assigned cards by domain. Each card needs its own status, last sync, and RBR state. const cardDescription = card?.nameValuePairs?.issuedBy && card?.lastFourPAN ? `${card?.lastFourPAN} ${CONST.DOT_SEPARATOR} ${getDescriptionForPolicyDomainCard(card.domainName, policiesForAssignedCards)}` @@ -353,7 +376,8 @@ function PaymentMethodList({ key: card.cardID.toString(), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing title: card?.nameValuePairs?.cardTitle || card.bank, - description: cardDescription, + description: appendCardLastSync(cardDescription, cardLastSyncText), + connectionStatus: cardConnectionStatus, onPress: () => Navigation.navigate(ROUTES.SETTINGS_WALLET_DOMAIN_CARD.getRoute(String(card.cardID))), onThreeDotsMenuPress: (e: GestureResponderEvent | KeyboardEvent | undefined) => pressHandler({ @@ -368,7 +392,6 @@ function PaymentMethodList({ cardID: card.cardID, }), cardID: card.cardID, - isGroupedCardDomain: !isAdminIssuedVirtualCard, shouldShowRightIcon: true, interactive: !isDisabled, disabled: isDisabled, @@ -456,28 +479,33 @@ function PaymentMethodList({ description: paymentMethod.description, }; const isMissingPersonalInfo = isPersonalBankAccountMissingInfo(paymentMethod.accountData); + const bankConnectionStatus = isMissingPersonalInfo ? undefined : getBankAccountConnectionStatus(paymentMethod.accountData?.state); + 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: bankConnectionStatus?.brickRoadIndicator, + connectionStatus: bankConnectionStatus ? mapBankStatusToRowStatus(bankConnectionStatus, paymentMethodPress, paymentMethodThreeDotsPress) : undefined, }; }); return combinedPaymentMethods; diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index c14c2008872f..f1fb99b774cd 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -9,7 +9,9 @@ import OfflineWithFeedback from '@components/OfflineWithFeedback'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import PressableWithFeedback from '@components/Pressable/PressableWithFeedback'; import Text from '@components/Text'; +import TextLink from '@components/TextLink'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; +import Tooltip from '@components/Tooltip'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -28,6 +30,18 @@ import type PaymentMethod from '@src/types/onyx/PaymentMethod'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type IconAsset from '@src/types/utils/IconAsset'; +type ConnectionStatusDetails = { + statusText: string; + statusTone?: 'default' | 'success' | 'danger'; + tooltipText?: string; + message?: string; + actionText?: string; + onActionPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void; + isActionDisabled?: boolean; + linkText?: string; + onLinkPress?: (e: GestureResponderEvent | KeyboardEvent) => void; +}; + type PaymentMethodItem = PaymentMethod & { key?: string; title?: string; @@ -50,6 +64,7 @@ type PaymentMethodItem = PaymentMethod & { isCardFrozen?: boolean; /** Whether the personal bank account is missing required personal info (name, address, phone) */ isMissingPersonalInfo?: boolean; + connectionStatus?: ConnectionStatusDetails; } & BankIcon; type PaymentMethodListItemProps = { @@ -146,7 +161,9 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems }; let badgeText; - if (isInLockedState) { + if (item.connectionStatus) { + badgeText = undefined; + } else if (isInLockedState) { badgeText = translate('common.locked'); } else if (isNeedingAction) { badgeText = translate('common.review'); @@ -155,12 +172,31 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems } let badgeIcon; - if (isInLockedState) { + if (!item.connectionStatus && isInLockedState) { badgeIcon = icons.DotIndicator; } // Card state pills (below title, next to description) const descriptionAddon = useMemo(() => { + if (item.connectionStatus) { + const badge = ( + + ); + + return item.connectionStatus.tooltipText ? ( + + {badge} + + ) : ( + badge + ); + } if (isNeedingAction && shouldShowDefaultBadge) { return ( { + if (!item.connectionStatus?.message) { + return null; + } + + return ( + + + {item.connectionStatus.message} + {!!item.connectionStatus.actionText && !!item.connectionStatus.onActionPress && ( + <> + {' '} + {} : item.connectionStatus.onActionPress} + > + {item.connectionStatus.actionText} + + + )} + {!!item.connectionStatus.linkText && !!item.connectionStatus.onLinkPress && ( + <> + {' '} + {item.connectionStatus.linkText} + + )} + + + ); + }; return ( + {renderStatusMessage()} {isChaseAccountConnectedViaPlaid && ( void}) { + const styles = useThemeStyles(); + + return ( + + + {message} + {!!actionText && !!onActionPress && ( + <> + {' '} + {actionText} + + )} + + + ); +} + function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { useWorkspaceDocumentTitle(policy?.name, 'workspace.common.workflows'); const {translate, localeCompare} = useLocalize(); const styles = useThemeStyles(); const theme = useTheme(); const illustrations = useMemoizedLazyIllustrations(['Workflows']); - const expensifyIcons = useMemoizedLazyExpensifyIcons(['DotIndicator', 'Info', 'Plus']); + const expensifyIcons = useMemoizedLazyExpensifyIcons(['Info', 'Plus']); // We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply a correct padding style // eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth const {shouldUseNarrowLayout, isSmallScreenWidth} = useResponsiveLayout(); @@ -326,8 +346,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const accountData = isBankAccountFullySetup ? policy?.achAccount : bankAccountConnectedToWorkspace?.accountData; const bankTitle = addressName.includes(CONST.MASKED_PAN_PREFIX) ? bankName : addressName; const bankAccountID = isBankAccountFullySetup ? policy?.achAccount?.bankAccountID : bankAccountConnectedToWorkspace?.methodID; - const state = isBankAccountFullySetup ? (policy?.achAccount?.state ?? '') : (bankAccountConnectedToWorkspace?.accountData?.state ?? ''); - const isAccountInSetupState = isBankAccountPartiallySetup(state); + const state = isBankAccountFullySetup ? policy?.achAccount?.state : bankAccountConnectedToWorkspace?.accountData?.state; const isBusinessBankAccountLocked = state === CONST.BANK_ACCOUNT.STATE.LOCKED; const shouldShowBankAccount = (!!isBankAccountFullySetup || !!bankAccountConnectedToWorkspace) && policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; @@ -339,17 +358,28 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const hasReimburserError = !!policy?.errorFields?.reimburser; const hasApprovalError = !!policy?.errorFields?.approvalMode; const hasDelayedSubmissionError = !!(policy?.errorFields?.autoReporting ?? policy?.errorFields?.autoReportingFrequency); - - const getBadgeText = (accountState: string | undefined) => { - switch (accountState) { - case CONST.BANK_ACCOUNT.STATE.SETUP: - return translate('common.actionRequired'); - case CONST.BANK_ACCOUNT.STATE.LOCKED: - return translate('common.locked'); - default: - return undefined; - } - }; + const bankConnectionStatus = getBankAccountConnectionStatus(state); + const bankConnectionBrickRoadIndicator = + bankConnectionStatus?.brickRoadIndicator ?? (state === CONST.BANK_ACCOUNT.STATE.VERIFYING ? undefined : hasReimburserError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); + const bankConnectionStatusBadge = bankConnectionStatus ? ( + + ) : undefined; + const bankConnectionStatusAddon = + bankConnectionStatus?.tooltipKey && bankConnectionStatusBadge ? ( + + {bankConnectionStatusBadge} + + ) : ( + bankConnectionStatusBadge + ); + const bankConnectionMessage = bankConnectionStatus?.messageKey ? translate(bankConnectionStatus.messageKey) : undefined; + const bankConnectionActionText = bankConnectionStatus?.actionKey ? translate(bankConnectionStatus.actionKey) : undefined; const updateWorkspaceCurrencyPrompt = ( @@ -365,6 +395,30 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { return undefined; }; + const handleBankAccountPress = () => { + if (isAccountLocked) { + showLockedAccountModal(); + return; + } + // User who is reimburser can initiate unlocking process + if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && isUserReimburser) { + pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); + navigateToConciergeChat(conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, betas); + return; + } + + // User who is not reimburser can't initiate unlocking process but can connect new account + if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && !isUserReimburser) { + // If user has existing accounts and no bank account setup in progress we should show screen to choose an existing account + if (hasValidExistingAccounts && !shouldShowContinueModal) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(route.params.policyID)); + return; + } + } + + navigateToBankAccountRoute({policyID: route.params.policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID)}); + }; + return [ { title: translate('workflowsPage.submissionFrequency'), @@ -594,33 +648,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { { - if (isAccountLocked) { - showLockedAccountModal(); - return; - } - // User who is reimburser can initiate unlocking process - if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && isUserReimburser) { - pressLockedBankAccount(bankAccountID, translate, conciergeReportID ?? undefined, delegateAccountID); - navigateToConciergeChat(conciergeReportID ?? undefined, introSelected, currentUserAccountID, isSelfTourViewed, betas); - return; - } - - // User who is not reimburser can't initiate unlocking process but can connect new account - if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && !isUserReimburser) { - // If user has existing accounts and no bank account setup in progress we should show screen to choose an existing account - if (hasValidExistingAccounts && !shouldShowContinueModal) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(route.params.policyID)); - return; - } - } - - navigateToBankAccountRoute({policyID: route.params.policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID)}); - } - : undefined - } + onPress={canWritePayments ? handleBankAccountPress : undefined} displayInDefaultIconColor icon={bankIcon.icon} iconHeight={bankIcon.iconHeight ?? bankIcon.iconSize} @@ -629,17 +657,22 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { titleStyle={isBankAccountPendingDelete ? styles.offlineFeedbackDeleted : undefined} descriptionTextStyle={isBankAccountPendingDelete ? styles.offlineFeedbackDeleted : undefined} disabled={isOffline || !isPolicyAdmin} - badgeText={getBadgeText(accountData?.state)} + descriptionAddon={bankConnectionStatusAddon} sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.BANK_ACCOUNT} - badgeIcon={isAccountInSetupState || (isBusinessBankAccountLocked && isPolicyAdmin) ? expensifyIcons.DotIndicator : undefined} - isBadgeSuccess={isAccountInSetupState} isBadgeError={isBusinessBankAccountLocked && isPolicyAdmin} shouldShowRightIcon={canWritePayments} interactive={canWritePayments} shouldGreyOutWhenDisabled={!policy?.pendingFields?.reimbursementChoice} wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]} - brickRoadIndicator={hasReimburserError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + brickRoadIndicator={bankConnectionBrickRoadIndicator} /> + {!!bankConnectionMessage && ( + + )} ) : ( canWritePayments && ( @@ -744,7 +777,6 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { shouldUseNarrowLayout, expensifyIcons.Info, expensifyIcons.Plus, - expensifyIcons.DotIndicator, theme.textSupporting, accountManagerReportID, filteredApprovalWorkflows.length, From 987214eef9ebc1dfb373b6ce49de7e0e632ced0d Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Thu, 18 Jun 2026 17:25:48 +0430 Subject: [PATCH 02/61] prettier fix --- src/pages/settings/Wallet/PaymentMethodList.tsx | 13 +++++++++++-- src/pages/settings/Wallet/PaymentMethodListItem.tsx | 6 +----- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 74474ed21759..1434a979a158 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -292,10 +292,19 @@ function PaymentMethodList({ statusText: translate(isCardInactive(card) ? 'walletPage.cardStatus.inactive' : 'walletPage.cardStatus.active'), statusTone: shouldShowCardConnectionMessage ? 'danger' : isCardInactive(card) ? 'default' : 'success', message: shouldShowCardConnectionMessage - ? translate(!isUserPersonalCard && isAdminForCardPolicy ? 'walletPage.cardStatus.fixConnectionIn' : isUserPersonalCard ? 'walletPage.cardStatus.fixConnection' : 'walletPage.cardStatus.askAdminToFixConnection') + ? translate( + !isUserPersonalCard && isAdminForCardPolicy + ? 'walletPage.cardStatus.fixConnectionIn' + : isUserPersonalCard + ? 'walletPage.cardStatus.fixConnection' + : 'walletPage.cardStatus.askAdminToFixConnection', + ) : undefined, actionText: isUserPersonalCard && shouldShowCardConnectionMessage ? translate('walletPage.cardStatus.fixConnection') : undefined, - onActionPress: isUserPersonalCard && shouldShowCardConnectionMessage ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_FIX_CONNECTION.getRoute(String(card.cardID))) : undefined, + onActionPress: + isUserPersonalCard && shouldShowCardConnectionMessage + ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_FIX_CONNECTION.getRoute(String(card.cardID))) + : undefined, linkText: !isUserPersonalCard && isAdminForCardPolicy && shouldShowCardConnectionMessage ? translate('walletPage.cardStatus.companyCardsLink') : undefined, onLinkPress: !isUserPersonalCard && isAdminForCardPolicy && policyIDForCard && shouldShowCardConnectionMessage diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index f1fb99b774cd..9e9a5c1262f5 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -241,11 +241,7 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems {!!item.connectionStatus.actionText && !!item.connectionStatus.onActionPress && ( <> {' '} - {} : item.connectionStatus.onActionPress} - > - {item.connectionStatus.actionText} - + {} : item.connectionStatus.onActionPress}>{item.connectionStatus.actionText} )} {!!item.connectionStatus.linkText && !!item.connectionStatus.onLinkPress && ( From d7ca6d14bd475d47b61805a3c6e1e8521cc15d25 Mon Sep 17 00:00:00 2001 From: Maruf Sharifi Date: Fri, 19 Jun 2026 15:55:45 +0430 Subject: [PATCH 03/61] fix wallet connection status row UI --- .../settings/Wallet/PaymentMethodList.tsx | 60 +++++++++---- .../settings/Wallet/PaymentMethodListItem.tsx | 86 +++++++++++++++---- 2 files changed, 111 insertions(+), 35 deletions(-) diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 1434a979a158..ee3f45ac9206 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -152,6 +152,15 @@ function getPolicyIDFromDomainName(domainName: string): string | undefined { return domainName.match(CONST.REGEX.EXPENSIFY_POLICY_DOMAIN_NAME)?.[1]; } +function getBankAccountState(accountData: PaymentMethod['accountData']): string | undefined { + if (typeof accountData !== 'object' || accountData === null) { + return undefined; + } + + const state = (accountData as Record).state; + return typeof state === 'string' ? state : undefined; +} + function keyExtractor(item: PaymentMethod | string) { if (typeof item === 'string') { return item; @@ -227,7 +236,13 @@ function PaymentMethodList({ tooltipText: status.tooltipKey ? translate(status.tooltipKey) : undefined, message: status.messageKey ? translate(status.messageKey) : undefined, actionText: status.actionKey ? translate(status.actionKey) : undefined, - onActionPress: status.actionKey === 'walletPage.bankAccountStatus.unlock' ? (onUnlockPress ?? onActionPress) : onActionPress, + onActionPress: () => { + if (status.actionKey === 'walletPage.bankAccountStatus.unlock') { + (onUnlockPress ?? onActionPress)(undefined); + return; + } + onActionPress(undefined); + }, }); const computeFilteredPaymentMethods = (): Array => { @@ -286,21 +301,32 @@ function PaymentMethodList({ } const isCardBroken = isCardConnectionBroken(card); - const shouldShowCardConnectionMessage = isCardBroken || shouldShowRBR; + const isCardInactiveState = isCardInactive(card); + const shouldShowCardConnectionMessage = isCardBroken || shouldShowRBR || isCardInactiveState; const cardLastSyncText = card.lastScrape ? translate('walletPage.cardLastSynced', datetimeToRelative(card.lastScrape)) : translate('walletPage.cardNeverSynced'); + let cardStatusTone: NonNullable['statusTone'] = 'success'; + if (shouldShowCardConnectionMessage) { + cardStatusTone = 'danger'; + } else if (isCardInactiveState) { + cardStatusTone = 'default'; + } + let cardConnectionMessage: string | undefined; + if (shouldShowCardConnectionMessage) { + let messageKey: 'walletPage.cardStatus.fixConnectionIn' | 'walletPage.cardStatus.fixConnection' | 'walletPage.cardStatus.askAdminToFixConnection'; + if (!isUserPersonalCard && isAdminForCardPolicy) { + messageKey = 'walletPage.cardStatus.fixConnectionIn'; + } else if (isUserPersonalCard) { + messageKey = 'walletPage.cardStatus.fixConnection'; + } else { + messageKey = 'walletPage.cardStatus.askAdminToFixConnection'; + } + cardConnectionMessage = translate(messageKey); + } const cardConnectionStatus: PaymentMethodItem['connectionStatus'] = { - statusText: translate(isCardInactive(card) ? 'walletPage.cardStatus.inactive' : 'walletPage.cardStatus.active'), - statusTone: shouldShowCardConnectionMessage ? 'danger' : isCardInactive(card) ? 'default' : 'success', - message: shouldShowCardConnectionMessage - ? translate( - !isUserPersonalCard && isAdminForCardPolicy - ? 'walletPage.cardStatus.fixConnectionIn' - : isUserPersonalCard - ? 'walletPage.cardStatus.fixConnection' - : 'walletPage.cardStatus.askAdminToFixConnection', - ) - : undefined, - actionText: isUserPersonalCard && shouldShowCardConnectionMessage ? translate('walletPage.cardStatus.fixConnection') : undefined, + statusText: translate(isCardInactiveState ? 'walletPage.cardStatus.inactive' : 'walletPage.cardStatus.active'), + statusTone: cardStatusTone, + message: cardConnectionMessage, + actionText: isUserPersonalCard && shouldShowCardConnectionMessage ? translate('common.actionBadge.fix') : undefined, onActionPress: isUserPersonalCard && shouldShowCardConnectionMessage ? () => Navigation.navigate(ROUTES.SETTINGS_WALLET_PERSONAL_CARD_FIX_CONNECTION.getRoute(String(card.cardID))) @@ -354,7 +380,7 @@ function PaymentMethodList({ disabled: isDisabled, shouldShowRightIcon, shouldShowThreeDotsMenu: !isUserPersonalCard, - errors: isUserPersonalCard ? undefined : card.errors, + errors: undefined, canDismissError: false, pendingAction: card.pendingAction, brickRoadIndicator, @@ -404,7 +430,7 @@ function PaymentMethodList({ shouldShowRightIcon: true, interactive: !isDisabled, disabled: isDisabled, - errors: card.errors, + errors: undefined, canDismissError: true, pendingAction: card.pendingAction, brickRoadIndicator, @@ -488,7 +514,7 @@ function PaymentMethodList({ description: paymentMethod.description, }; const isMissingPersonalInfo = isPersonalBankAccountMissingInfo(paymentMethod.accountData); - const bankConnectionStatus = isMissingPersonalInfo ? undefined : getBankAccountConnectionStatus(paymentMethod.accountData?.state); + const bankConnectionStatus = isMissingPersonalInfo ? undefined : getBankAccountConnectionStatus(getBankAccountState(paymentMethod.accountData)); const paymentMethodPress = (e: GestureResponderEvent | KeyboardEvent | undefined) => pressHandler({ event: e, diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index 9e9a5c1262f5..23f209cbd2b6 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -1,8 +1,10 @@ +import type {KeyboardEvent as ReactKeyboardEvent} from 'react'; import React, {useMemo, useRef} from 'react'; import type {GestureResponderEvent, StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Badge from '@components/Badge'; +import Button from '@components/Button'; import Icon from '@components/Icon'; import MenuItem from '@components/MenuItem'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; @@ -36,10 +38,10 @@ type ConnectionStatusDetails = { tooltipText?: string; message?: string; actionText?: string; - onActionPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void; + onActionPress?: (e: GestureResponderEvent | ReactKeyboardEvent | undefined) => void; isActionDisabled?: boolean; linkText?: string; - onLinkPress?: (e: GestureResponderEvent | KeyboardEvent) => void; + onLinkPress?: (e: GestureResponderEvent | ReactKeyboardEvent) => void; }; type PaymentMethodItem = PaymentMethod & { @@ -114,11 +116,28 @@ 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 getBankAccountState(accountData: PaymentMethodItem['accountData']): string | undefined { + if (typeof accountData !== 'object' || accountData === null) { + return undefined; + } + + const state = (accountData as Record).state; + return typeof state === 'string' ? state : undefined; +} + +function hasBankAccountAllowDebit(accountData: PaymentMethodItem['accountData']): boolean { + if (typeof accountData !== 'object' || accountData === null) { + return false; + } + + return !!(accountData as Record).allowDebit; } function isAccountNeedingAction(account: PaymentMethodItem) { @@ -230,21 +249,26 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems }, [isNeedingAction, shouldShowDefaultBadge, item.connectionStatus, item.isCardFrozen, item.isInactive, icons.FreezeCard, styles.ml0, styles.mr1, translate]); const renderStatusMessage = () => { - if (!item.connectionStatus?.message) { + if (!item.connectionStatus?.message && !item.connectionStatus?.actionText) { return null; } - return ( - - - {item.connectionStatus.message} - {!!item.connectionStatus.actionText && !!item.connectionStatus.onActionPress && ( - <> - {' '} - {} : item.connectionStatus.onActionPress}>{item.connectionStatus.actionText} - - )} - {!!item.connectionStatus.linkText && !!item.connectionStatus.onLinkPress && ( + const statusMessageRowPadding = {paddingLeft: 32, paddingRight: 32}; + const shouldShowActionButton = !!item.connectionStatus?.actionText && !!item.connectionStatus?.onActionPress; + const isDangerStatus = item.connectionStatus?.statusTone === 'danger'; + const messageContent = ( + + {isDangerStatus && ( + + + + )} + + {item.connectionStatus?.message} + {!!item.connectionStatus?.linkText && !!item.connectionStatus?.onLinkPress && ( <> {' '} {item.connectionStatus.linkText} @@ -253,6 +277,32 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems ); + + const actionButton = shouldShowActionButton ? ( +