diff --git a/assets/images/laptop-with-second-screen-bank.svg b/assets/images/laptop-with-second-screen-bank.svg new file mode 100644 index 000000000000..fa1233238e66 --- /dev/null +++ b/assets/images/laptop-with-second-screen-bank.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/images/scissors-cutting-money.svg b/assets/images/scissors-cutting-money.svg new file mode 100644 index 000000000000..882d9559013f --- /dev/null +++ b/assets/images/scissors-cutting-money.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 01fda140b479..99eef06a1ffa 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -923,6 +923,7 @@ const CONST = { OPEN: 'OPEN', DELETED: 'DELETED', LOCKED: 'LOCKED', + VALIDATION_FAILED: 'VALIDATION_FAILED', }, MAX_LENGTH: { FULL_SSN: 9, @@ -960,6 +961,10 @@ const CONST = { 'resultcode.ssn.issued.prior.to.dob', ], }, + NOC_CODE: { + INSUFFICIENT_FUNDS: ['R01', 'R09'] as string[], + DEBIT_BLOCKED: ['R05', 'R06', 'R07', 'R08', 'R10', 'R11', 'R16', 'R23', 'R29'] as string[], + }, }, CORPAY_DOCUMENT: { ALLOWED_FILE_TYPES: ['pdf', 'jpg', 'jpeg', 'png'], @@ -1139,6 +1144,7 @@ const CONST = { OPEN: 'OPEN', DELETED: 'DELETED', LOCKED: 'LOCKED', + VALIDATION_FAILED: 'VALIDATION_FAILED', }, }, ENABLE_GLOBAL_REIMBURSEMENTS: { @@ -9174,6 +9180,7 @@ const CONST = { HAS_MERGE_HR_SETUP_NEEDED: 'hasMergeHRSetupNeeded', HAS_HR_CONNECTION_ERROR: 'hasHRConnectionError', HAS_APPROVAL_WORKFLOW_NON_MEMBER_APPROVER: 'hasApprovalWorkflowNonMemberApprover', + HAS_VALIDATION_FAILED_BANK_ACCOUNT: 'hasValidationFailedBankAccount', }, DEBUG: { diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 6d8f0867ef1d..91a7fcdc05c0 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -227,6 +227,20 @@ const DYNAMIC_ROUTES = { }), queryParams: ['bankCountry', 'bankCurrency'], }, + FIX_BANK_ACCOUNT: { + path: 'fix-bank-account/:bankAccountID', + entryScreens: [ + SCREENS.SETTINGS.WALLET.ROOT, + SCREENS.WORKSPACE.WORKFLOWS, + SCREENS.CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT_ROOT, + SCREENS.RIGHT_MODAL.SEARCH_REPORT, + SCREENS.RIGHT_MODAL.EXPENSE_REPORT, + SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT, + SCREENS.REPORT, + SCREENS.SEARCH.ROOT, + ], + getRoute: (bankAccountID: string) => `fix-bank-account/${bankAccountID}` as const, + }, BANK_ACCOUNT_VERIFY_ACCOUNT: { path: 'verify-bank-account', entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT], diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 9905d3ad4829..d92164c05d67 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -100,6 +100,7 @@ const SCREENS = { EMAIL_ISSUE: 'Settings_Email_Issue', DYNAMIC_VERIFY_ACCOUNT: 'Dynamic_Verify_Account', DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT: 'Dynamic_Add_Bank_Account_Verify_Account', + DYNAMIC_FIX_BANK_ACCOUNT: 'Dynamic_Fix_Bank_Account', DYNAMIC_EXIT_SURVEY_CONFIRM: 'Dynamic_ExitSurvey_Confirm', DYNAMIC_EXIT_SURVEY_REASON: 'Dynamic_ExitSurvey_Reason', DYNAMIC_KEYBOARD_SHORTCUTS: 'Dynamic_Keyboard_Shortcuts', diff --git a/src/components/ConnectionStatusMessage.tsx b/src/components/ConnectionStatusMessage.tsx index 2110d8ab74b5..6a84c0e24388 100644 --- a/src/components/ConnectionStatusMessage.tsx +++ b/src/components/ConnectionStatusMessage.tsx @@ -22,6 +22,11 @@ type ConnectionStatusMessageProps = { statusTone?: 'default' | 'success' | 'danger'; onLinkPress?: ComponentProps['onLinkPress']; shouldIncludeHorizontalPadding?: boolean; + + /** + * Accessible name for the action button + */ + actionAccessibilityLabel?: string; }; function ConnectionStatusMessage({ @@ -32,6 +37,7 @@ function ConnectionStatusMessage({ statusTone = 'default', onLinkPress, shouldIncludeHorizontalPadding = true, + actionAccessibilityLabel, }: ConnectionStatusMessageProps) { const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); const theme = useTheme(); @@ -76,6 +82,7 @@ function ConnectionStatusMessage({ style={styles.alignSelfStart} onPress={onActionPress} isDisabled={isActionDisabled} + accessibilityLabel={actionAccessibilityLabel} > {actionText} diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx index 8873706b9b6e..272e84a74c9c 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/BulletItemRenderer.tsx @@ -16,11 +16,16 @@ import {TNodeChildrenRenderer} from 'react-native-render-html'; function BulletItemRenderer({tnode}: {tnode: TNode}) { const styles = useThemeStyles(); const theme = useTheme(); + const ariaLabel = tnode.attributes?.['aria-label']; return ( {CONST.DOT_SEPARATOR} - + diff --git a/src/components/Icon/chunks/illustrations.chunk.ts b/src/components/Icon/chunks/illustrations.chunk.ts index 2bafe8d3dac4..7dd01688dbfa 100644 --- a/src/components/Icon/chunks/illustrations.chunk.ts +++ b/src/components/Icon/chunks/illustrations.chunk.ts @@ -42,6 +42,7 @@ import Hand from '@assets/images/hand.svg'; import LaptopWithMembers from '@assets/images/illustration-laptop-members.svg'; import LaptopOnDeskWithCoffeeAndKey from '@assets/images/laptop-on-desk-with-coffee-and-key.svg'; import LaptopWithSecondScreenAndHourglass from '@assets/images/laptop-with-second-screen-and-hourglass.svg'; +import LaptopWithSecondScreenBank from '@assets/images/laptop-with-second-screen-bank.svg'; import LaptopWithSecondScreenSync from '@assets/images/laptop-with-second-screen-sync.svg'; import LaptopWithSecondScreenX from '@assets/images/laptop-with-second-screen-x.svg'; // Multifactor Authentication Illustrations @@ -103,6 +104,7 @@ import ValidateCode from '@assets/images/product-illustrations/validate-code.svg import WorkspaceScene from '@assets/images/product-illustrations/workspace-scene.svg'; import ReceiptStack from '@assets/images/receipt-upload.svg'; import RunningTurtle from '@assets/images/running-turtle.svg'; +import ScissorsCuttingMoney from '@assets/images/scissors-cutting-money.svg'; import Shutter from '@assets/images/shutter.svg'; // Simple Illustrations - Bucket 3 (Common) import Puzzle from '@assets/images/simple-illustrations/emptystate__puzzlepieces.svg'; @@ -278,6 +280,7 @@ const Illustrations = { Computer, ExpensifyCardImage, LaptopWithSecondScreenAndHourglass, + LaptopWithSecondScreenBank, LaptopWithSecondScreenSync, LaptopWithSecondScreenX, LaptopOnDeskWithCoffeeAndKey, @@ -347,6 +350,7 @@ const Illustrations = { ReceiptStack, RunningTurtle, Shutter, + ScissorsCuttingMoney, TravelCVV, TurtleInShell, diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx index 641353d35287..1915ff05f136 100644 --- a/src/components/KYCWall/BaseKYCWall.tsx +++ b/src/components/KYCWall/BaseKYCWall.tsx @@ -16,6 +16,7 @@ import {moveIOUReportToPolicy, moveIOUReportToPolicyAndInviteSubmitter} from '@l import {doesPolicyHavePartiallySetupBankAccount} from '@libs/BankAccountUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import Log from '@libs/Log'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import setNavigationActionToMicrotaskQueue from '@libs/Navigation/helpers/setNavigationActionToMicrotaskQueue'; import Navigation from '@libs/Navigation/Navigation'; import {hasExpensifyPaymentMethod} from '@libs/PaymentUtils'; @@ -27,7 +28,7 @@ import {setKYCWallSource} from '@userActions/Wallet'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import {doesPersonalDetailExist, loginSelector} from '@src/selectors/PersonalDetails'; import {lastWorkspaceNumberSelector, ownerPoliciesSelector} from '@src/selectors/Policy'; @@ -252,6 +253,14 @@ function KYCWall({ return; } + const achBankAccountID = policy?.achAccount?.bankAccountID; + const achBankAccountState = policy?.achAccount?.bankAccountID ? bankAccountList?.[policy?.achAccount?.bankAccountID]?.accountData?.state : undefined; + if (achBankAccountID && achBankAccountState === CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED) { + Log.info('[KYC Wallet] Redirecting to fix bank account: policy bank account failed validation', false, {policyID: policy?.id, achBankAccountID}); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.getRoute(achBankAccountID.toString()))); + return; + } + if (policy?.id !== undefined && doesPolicyHavePartiallySetupBankAccount(bankAccountList, policy.id)) { Log.info('[KYC Wallet] Redirecting to bank account setup: policy has a partially set up bank account', false, {policyID: policy.id}); navigateToBankAccountRoute({policyID: policy.id}); diff --git a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx index 8da49d2a3f78..8a963f8190f2 100644 --- a/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx +++ b/src/components/MoneyReportHeaderPrimaryAction/PayPrimaryAction.tsx @@ -25,6 +25,7 @@ import {search} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; +import {selectPartiallySetupBankAccount} from '@libs/PaymentUtils'; import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {canIOUBePaid as canIOUBePaidAction, hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, hasUpdatedTotal, isInvoiceReport as isInvoiceReportUtil} from '@libs/ReportUtils'; @@ -131,7 +132,17 @@ function PayPrimaryAction({reportID, chatReportID, isDisabled}: PayPrimaryAction } if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); - } else if (isAnyTransactionOnHold) { + return; + } + + if ( + type === CONST.IOU.PAYMENT_TYPE.VBBA && + selectPartiallySetupBankAccount({state: methodID ? bankAccountList?.[methodID]?.accountData?.state : undefined, methodID, policyID: moneyRequestReport?.policyID}) + ) { + return; + } + + if (isAnyTransactionOnHold) { openHoldMenu({ requestType: CONST.IOU.REPORT_ACTION_TYPE.PAY, paymentType: type, diff --git a/src/components/Navigation/DebugTabView.tsx b/src/components/Navigation/DebugTabView.tsx index f031578e1cce..c0018918d0bd 100644 --- a/src/components/Navigation/DebugTabView.tsx +++ b/src/components/Navigation/DebugTabView.tsx @@ -103,6 +103,8 @@ function getSettingsMessage(status: IndicatorStatus | undefined): TranslationPat return 'debug.indicatorStatus.completeHrSetup'; case CONST.INDICATOR_STATUS.HAS_HR_CONNECTION_ERROR: return 'debug.indicatorStatus.theresAProblemWithAnHRConnection'; + case CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT: + return 'debug.indicatorStatus.aBankAccountFailedValidation'; default: return undefined; } @@ -131,10 +133,9 @@ function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAcco case CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS: return ROUTES.WORKSPACE_ACCOUNTING.getRoute(indicatorPolicyID); case CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS: - return ROUTES.SETTINGS_WALLET; case CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS: - return ROUTES.SETTINGS_WALLET; case CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT: + case CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT: return ROUTES.SETTINGS_WALLET; case CONST.INDICATOR_STATUS.HAS_MERGE_HR_SETUP_NEEDED: case CONST.INDICATOR_STATUS.HAS_HR_CONNECTION_ERROR: diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx index 07856fdb2e3b..126c2e07fcba 100644 --- a/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx +++ b/src/components/ReportActionItem/MoneyRequestReportPreview/PayActionButton.tsx @@ -17,6 +17,7 @@ import usePolicy from '@hooks/usePolicy'; import {generateDefaultWorkspaceName} from '@libs/actions/Policy/Policy'; import {getTotalAmountForIOUReportPreviewButton} from '@libs/MoneyRequestReportUtils'; import {isTrackOnboardingChoice} from '@libs/OnboardingUtils'; +import {selectPartiallySetupBankAccount} from '@libs/PaymentUtils'; import {hasDynamicExternalWorkflow} from '@libs/PolicyUtils'; import {getReportOrDraftReport, hasHeldExpensesFromTransactions as hasHeldExpensesReportUtils, hasUpdatedTotal, isInvoiceReport as isInvoiceReportUtils} from '@libs/ReportUtils'; @@ -26,6 +27,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import {bankAccountStatesSelector} from '@selectors/BankAccount'; import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; @@ -61,6 +63,7 @@ function PayActionButton() { const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const [rules] = useOnyx(ONYXKEYS.COLLECTION.RULE); + const [bankAccountStates] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: bankAccountStatesSelector}); const isTrackIntentUser = isTrackOnboardingChoice(introSelected?.choice); @@ -79,7 +82,14 @@ function PayActionButton() { } if (isDelegateAccessRestricted) { showDelegateNoAccessModal(); - } else if (hasHeldExpensesReportUtils(transactions)) { + return; + } + + if (type === CONST.IOU.PAYMENT_TYPE.VBBA && selectPartiallySetupBankAccount({state: methodID ? bankAccountStates?.[methodID] : undefined, methodID, policyID: policy?.id})) { + return; + } + + if (hasHeldExpensesReportUtils(transactions)) { onHoldMenuOpen(type, shouldShowPayButton, methodID); } else if (chatReport && iouReport) { const currentChatReport = getReportOrDraftReport(chatReportID) ?? chatReport; diff --git a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx index 32c772abe7f4..b86a9b04bb8c 100644 --- a/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx +++ b/src/components/Search/SearchList/ListItem/ActionCell/PayActionCell.tsx @@ -16,6 +16,7 @@ import {payInvoice, payMoneyRequest} from '@libs/actions/IOU/PayMoneyRequest'; import {getChatReportWithFallback, getSearchPayOnyxData} from '@libs/actions/Search'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import Log from '@libs/Log'; +import {selectPartiallySetupBankAccount} from '@libs/PaymentUtils'; import {canIOUBePaid, getReimbursableTotal, isArchivedReport, isIndividualInvoiceRoom, isInvoiceReport} from '@libs/ReportUtils'; import CONST from '@src/CONST'; @@ -111,6 +112,10 @@ function PayActionCell({isLoading, policyID, reportID, hash, amount, shouldDisab return; } + if (type === CONST.IOU.PAYMENT_TYPE.VBBA && selectPartiallySetupBankAccount({state: methodID ? bankAccountList?.[methodID]?.accountData?.state : undefined, methodID, policyID})) { + return; + } + const additionalOnyxData = getSearchPayOnyxData(hash, reportID); if (isInvoiceReport(iouReport)) { diff --git a/src/hooks/useAccountIndicatorChecks.ts b/src/hooks/useAccountIndicatorChecks.ts index 82a139d19fc8..ec8c4e85fc3e 100644 --- a/src/hooks/useAccountIndicatorChecks.ts +++ b/src/hooks/useAccountIndicatorChecks.ts @@ -1,5 +1,5 @@ import {hasPaymentMethodError} from '@libs/actions/PaymentMethods'; -import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo} from '@libs/BankAccountUtils'; +import {hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo, hasValidationFailedBankAccount} from '@libs/BankAccountUtils'; import {hasCardPendingDigitalWalletApproval, hasPendingExpensifyCardAction} from '@libs/CardUtils'; import {hasSubscriptionGreenDotInfo, hasSubscriptionRedDotError} from '@libs/SubscriptionUtils'; import {expensifyLoginsSelector, hasDeviceManagementError, hasLoginListError, hasLoginListInfo} from '@libs/UserUtils'; @@ -68,6 +68,7 @@ function useAccountIndicatorChecks(): AccountIndicatorChecksResult { [CONST.INDICATOR_STATUS.HAS_EMPLOYEE_CARD_FEED_ERRORS]: !isPolicyAdmin ? hasCompanyCardFeedErrors : false, [CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT]: Object.values(bankAccountList ?? {}).some((bankAccount) => bankAccount?.accountData?.state === CONST.BANK_ACCOUNT.STATE.LOCKED), [CONST.INDICATOR_STATUS.HAS_DEVICE_MANAGEMENT_ERROR]: hasDeviceManagementErrorValue, + [CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT]: hasValidationFailedBankAccount(bankAccountList), }; const infoChecks: Partial> = { diff --git a/src/languages/de.ts b/src/languages/de.ts index b89f089e357f..df634cb3d65b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -493,6 +493,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'In Zwischenablage kopieren', thisIsTakingLongerThanExpected: 'Das dauert länger als erwartet ...', domains: 'Domains', + actionRequired: 'Aktion erforderlich', duplicate: 'Duplizieren', duplicated: 'Dupliziert', duplicateExpense: 'Doppelte Ausgabe', @@ -2824,6 +2825,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Bitte bestätigen Sie Testtransaktionen', accountRequiresAttention: 'Dieses Konto erfordert Ihre Aufmerksamkeit', unlock: 'Entsperren', + debitBlocked: 'Ihre Bank hat Testtransaktionen auf dieses Konto blockiert', + insufficientFunds: 'Testtransaktionen sind aufgrund unzureichender Deckung fehlgeschlagen', + validationFailedFallback: 'Ihre Bank hat Testtransaktionen auf dieses Konto blockiert. Kontaktieren Sie Concierge', + }, + fixBankAccount: { + title: 'Bankkonto beheben', + debitBlockedBody: + 'Ihre Bank hat die Testtransaktionen zu diesem Konto blockiert.

Bitte lassen Sie die ACH-IDs von Expensify auf die Whitelist setzen:127023945022702394504270239450Senden Sie anschließend die Testtransaktionen unten erneut.', + insufficientFundsBody: 'Testtransaktionen sind aufgrund unzureichender Kontodeckung fehlgeschlagen. Sobald wieder Geld verfügbar ist, verwenden Sie die Schaltfläche unten.', + resendButton: 'Transaktionen erneut senden', + successTitle: 'Transaktionen gesendet!', + successBody: 'Prüfen Sie Ihr Bankkonto in den nächsten 1–2 Werktagen.', + successButton: 'Verstanden', }, }, addCardToDigitalWallet: { @@ -11667,6 +11681,7 @@ Fügen Sie weitere Ausgabelimits hinzu, um den Cashflow Ihres Unternehmens zu sc aBankAccountIsLocked: 'Ein Bankkonto ist gesperrt', completeHrSetup: 'HR-Einrichtung abschließen', theresAProblemWithAnHRConnection: 'Es gibt ein Problem mit einer HR-Verbindung', + aBankAccountFailedValidation: 'Ein Bankkonto hat die Validierung nicht bestanden', }, }, emptySearchView: { diff --git a/src/languages/el.ts b/src/languages/el.ts index 8ada63d5c650..bf90d6479af4 100644 --- a/src/languages/el.ts +++ b/src/languages/el.ts @@ -492,6 +492,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Αντιγραφή στο πρόχειρο', thisIsTakingLongerThanExpected: 'Αυτό παίρνει περισσότερο χρόνο από το αναμενόμενο...', domains: 'Τομείς', + actionRequired: 'Απαιτείται ενέργεια', duplicate: 'Διπλότυπο', duplicated: 'Διπλότυπο', duplicateExpense: 'Διπλή δαπάνη', @@ -2878,6 +2879,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Παρακαλούμε επιβεβαιώστε τις δοκιμαστικές συναλλαγές', accountRequiresAttention: 'Αυτός ο λογαριασμός χρειάζεται προσοχή', unlock: 'Ξεκλείδωμα', + debitBlocked: 'Η τράπεζά σας μπλόκαρε τις δοκιμαστικές συναλλαγές σε αυτόν τον λογαριασμό', + insufficientFunds: 'Οι δοκιμαστικές συναλλαγές απέτυχαν λόγω ανεπαρκών διαθέσιμων χρημάτων', + validationFailedFallback: 'Η τράπεζά σας μπλόκαρε τις δοκιμαστικές συναλλαγές σε αυτόν τον λογαριασμό. Επικοινωνήστε με το Concierge', + }, + fixBankAccount: { + title: 'Διορθώστε τον τραπεζικό λογαριασμό', + debitBlockedBody: + 'Η τράπεζά σας μπλόκαρε τις δοκιμαστικές συναλλαγές σε αυτόν τον λογαριασμό.

Παρακαλούμε ζητήστε τους να προσθέσουν στις λίστες επιτρεπόμενων τα ACH IDs της Expensify:127023945022702394504270239450Στη συνέχεια, στείλτε ξανά τις δοκιμαστικές συναλλαγές παρακάτω.', + insufficientFundsBody: 'Οι δοκιμαστικές συναλλαγές απέτυχαν λόγω ανεπαρκών διαθέσιμων χρημάτων. Μόλις υπάρχουν διαθέσιμα χρήματα, χρησιμοποιήστε το παρακάτω κουμπί.', + resendButton: 'Επαναποστολή συναλλαγών', + successTitle: 'Οι συναλλαγές εστάλησαν!', + successBody: 'Ελέγξτε τον τραπεζικό σας λογαριασμό μέσα στις επόμενες 1–2 εργάσιμες ημέρες.', + successButton: 'Το κατάλαβα', }, }, addCardToDigitalWallet: { @@ -11882,6 +11896,7 @@ ${reportName}`, aBankAccountIsLocked: 'Ένας τραπεζικός λογαριασμός είναι κλειδωμένος', completeHrSetup: 'Ολοκληρώστε τη ρύθμιση HR', theresAProblemWithAnHRConnection: 'Υπάρχει πρόβλημα με μια σύνδεση HR', + aBankAccountFailedValidation: 'Ένας τραπεζικός λογαριασμός απέτυχε στην επαλήθευση', }, }, emptySearchView: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 402bd42409a2..bbd449d03237 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -577,6 +577,7 @@ const translations = { copyToClipboard: 'Copy to clipboard', thisIsTakingLongerThanExpected: 'This is taking longer than expected...', domains: 'Domains', + actionRequired: 'Action required', duplicate: 'Duplicate', duplicated: 'Duplicated', duplicateExpense: 'Duplicate expense', @@ -2937,6 +2938,19 @@ const translations = { confirmTestTransactions: 'Please confirm test transactions', accountRequiresAttention: 'This account requires attention', unlock: 'Unlock', + debitBlocked: 'Your bank blocked test transactions to this account', + insufficientFunds: 'Test transactions failed due to insufficient funds', + validationFailedFallback: 'Your bank blocked test transactions to this account. Contact Concierge', + }, + fixBankAccount: { + title: 'Fix bank account', + debitBlockedBody: + "Your bank blocked the test transactions to this account.

Please have them whitelist Expensify's ACH IDs:127023945022702394504270239450Then, resend the test transactions below.", + insufficientFundsBody: 'Test transactions failed due to insufficient funds. Once funds are available, use the button below.', + resendButton: 'Resend transactions', + successTitle: 'Transactions sent!', + successBody: 'Check your bank account in the next 1-2 business days.', + successButton: 'Got it', }, assignedCards: 'Cards', assignedCardsDescription: 'Transactions from assigned cards sync automatically.', @@ -11925,6 +11939,7 @@ const translations = { aBankAccountIsLocked: 'A bank account is locked', completeHrSetup: 'Complete HR setup', theresAProblemWithAnHRConnection: "There's a problem with an HR connection", + aBankAccountFailedValidation: 'A bank account has failed validation', }, }, emptySearchView: { diff --git a/src/languages/es.ts b/src/languages/es.ts index 1bc77257a021..62240c6b231f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -482,6 +482,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Copiar al portapapeles', thisIsTakingLongerThanExpected: 'Está tardando más de lo esperado...', domains: 'Dominios', + actionRequired: 'Acción requerida', duplicate: 'Duplicar', duplicated: 'Duplicado', duplicateExpense: 'Duplicar gasto', @@ -2734,6 +2735,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Confirma las transacciones de prueba', accountRequiresAttention: 'Esta cuenta requiere atención', unlock: 'Desbloquear', + debitBlocked: 'Tu banco bloqueó las transacciones de prueba a esta cuenta', + insufficientFunds: 'Las transacciones de prueba fallaron por fondos insuficientes', + validationFailedFallback: 'Tu banco bloqueó las transacciones de prueba a esta cuenta. Contacta con Concierge', + }, + fixBankAccount: { + title: 'Corregir cuenta bancaria', + debitBlockedBody: + 'Tu banco bloqueó las transacciones de prueba a esta cuenta.

Pídeles que incluyan en la lista segura los ID de ACH de Expensify:127023945022702394504270239450Luego, vuelve a enviar las transacciones de prueba que aparecen abajo.', + insufficientFundsBody: 'Las transacciones de prueba fallaron por fondos insuficientes. Una vez que haya fondos disponibles, usa el botón de abajo.', + resendButton: 'Reenviar transacciones', + successTitle: '¡Transacciones enviadas!', + successBody: 'Revisa tu cuenta bancaria en los próximos 1-2 días hábiles.', + successButton: 'Entendido', }, }, addCardToDigitalWallet: { @@ -11852,6 +11866,7 @@ ${reportName}`, aBankAccountIsLocked: 'Una cuenta bancaria está bloqueada', completeHrSetup: 'Completa la configuración de RR. HH.', theresAProblemWithAnHRConnection: 'Hay un problema con una conexión de RR. HH.', + aBankAccountFailedValidation: 'Una cuenta bancaria no ha pasado la validación', }, }, emptySearchView: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 310fc61f12fe..525c2406377e 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -492,6 +492,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Copier dans le presse-papiers', thisIsTakingLongerThanExpected: 'Cela prend plus de temps que prévu...', domains: 'Domaines', + actionRequired: 'Action requise', duplicate: 'Dupliquer', duplicated: 'Dupliqué', duplicateExpense: 'Dépense en double', @@ -2829,6 +2830,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Veuillez confirmer les transactions de test', accountRequiresAttention: 'Ce compte nécessite une attention particulière', unlock: 'Déverrouiller', + debitBlocked: 'Votre banque a bloqué les transactions de test vers ce compte', + insufficientFunds: 'Les transactions de test ont échoué en raison de fonds insuffisants', + validationFailedFallback: 'Votre banque a bloqué les transactions de test vers ce compte. Contacter Concierge', + }, + fixBankAccount: { + title: 'Corriger le compte bancaire', + debitBlockedBody: + 'Votre banque a bloqué les transactions de test vers ce compte.

Veuillez leur demander d’ajouter les identifiants ACH d’Expensify à leur liste blanche :127023945022702394504270239450Puis, renvoyez les transactions de test ci-dessous.', + insufficientFundsBody: 'Les transactions de test ont échoué en raison de fonds insuffisants. Une fois que des fonds seront disponibles, utilisez le bouton ci-dessous.', + resendButton: 'Renvoyer les transactions', + successTitle: 'Transactions envoyées !', + successBody: 'Vérifiez votre compte bancaire dans un délai de 1 à 2 jours ouvrables.', + successButton: 'Compris', }, }, addCardToDigitalWallet: { @@ -11692,6 +11706,7 @@ Ajoutez davantage de règles de dépenses pour protéger la trésorerie de l’e aBankAccountIsLocked: 'Un compte bancaire est verrouillé', completeHrSetup: 'Terminer la configuration RH', theresAProblemWithAnHRConnection: 'Un problème est survenu avec une connexion RH', + aBankAccountFailedValidation: 'Un compte bancaire n’a pas réussi la validation', }, }, emptySearchView: { diff --git a/src/languages/it.ts b/src/languages/it.ts index b6e53cfc15c7..6501933e53ae 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -492,6 +492,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Copia negli appunti', thisIsTakingLongerThanExpected: 'Sta impiegando più tempo del previsto...', domains: 'Domini', + actionRequired: 'Azione richiesta', duplicate: 'Duplica', duplicated: 'Duplicato', duplicateExpense: 'Spesa duplicata', @@ -2813,6 +2814,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Conferma le transazioni di prova', accountRequiresAttention: 'Questo conto richiede attenzione', unlock: 'Sblocca', + debitBlocked: 'La tua banca ha bloccato le transazioni di prova verso questo conto', + insufficientFunds: 'Le transazioni di prova non sono riuscite per fondi insufficienti', + validationFailedFallback: 'La tua banca ha bloccato le transazioni di prova su questo conto. Contatta Concierge', + }, + fixBankAccount: { + title: 'Correggi conto bancario', + debitBlockedBody: + 'La tua banca ha bloccato le transazioni di prova su questo conto.

Chiedi loro di inserire negli elenchi consentiti gli ID ACH di Expensify:127023945022702394504270239450Poi reinvia le transazioni di prova qui sotto.', + insufficientFundsBody: 'Le transazioni di prova non sono riuscite per fondi insufficienti. Quando i fondi saranno disponibili, usa il pulsante qui sotto.', + resendButton: 'Reinvia transazioni', + successTitle: 'Transazioni inviate!', + successBody: 'Controlla il tuo conto bancario tra 1-2 giorni lavorativi.', + successButton: 'Capito', }, }, addCardToDigitalWallet: { @@ -11617,6 +11631,7 @@ Aggiungi altre regole di spesa per proteggere il flusso di cassa aziendale.`, aBankAccountIsLocked: 'Un conto bancario è bloccato', completeHrSetup: 'Completa la configurazione HR', theresAProblemWithAnHRConnection: 'C’è un problema con una connessione HR', + aBankAccountFailedValidation: 'La verifica di un conto bancario non è andata a buon fine', }, }, emptySearchView: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 599bb3daddb0..d85ccc376b4e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -491,6 +491,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'クリップボードにコピー', thisIsTakingLongerThanExpected: '想定より時間がかかっています…', domains: 'ドメイン', + actionRequired: '対応が必要です', duplicate: '複製', duplicated: '複製済み', duplicateExpense: '重複経費', @@ -2792,6 +2793,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'テスト取引を確認してください', accountRequiresAttention: 'このアカウントには対応が必要です', unlock: 'ロック解除', + debitBlocked: 'この口座へのテスト取引が銀行によってブロックされました', + insufficientFunds: '残高不足のためテスト取引に失敗しました', + validationFailedFallback: 'この口座へのテスト取引は銀行によりブロックされました。Concierge に連絡する', + }, + fixBankAccount: { + title: '銀行口座を修正', + debitBlockedBody: + 'お使いの銀行が、この口座へのテスト取引をブロックしました。

銀行に依頼して、Expensify の ACH ID をホワイトリストに登録してもらってください。127023945022702394504270239450その後、下記のテスト取引を再送してください。', + insufficientFundsBody: 'テスト取引は残高不足のため失敗しました。資金が利用可能になりましたら、下のボタンをご利用ください。', + resendButton: '取引を再送信', + successTitle: '取引を送信しました!', + successBody: '1〜2営業日以内に銀行口座をご確認ください。', + successButton: '了解しました', }, }, addCardToDigitalWallet: { @@ -11448,6 +11462,7 @@ ${reportName}`, aBankAccountIsLocked: '銀行口座がロックされています', completeHrSetup: '人事設定を完了する', theresAProblemWithAnHRConnection: '人事連携に問題が発生しています', + aBankAccountFailedValidation: '銀行口座の認証に失敗しました', }, }, emptySearchView: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5533c966ad1e..0a3bebf018af 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -492,6 +492,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Kopieer naar klembord', thisIsTakingLongerThanExpected: 'Dit duurt langer dan verwacht...', domains: 'Domeinen', + actionRequired: 'Actie vereist', duplicate: 'Dupliceren', duplicated: 'Gedupliceerd', opensInNewTab: 'Opent in een nieuw tabblad', @@ -2817,6 +2818,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Bevestig testtransacties alsjeblieft', accountRequiresAttention: 'Deze account vereist aandacht', unlock: 'Ontgrendelen', + debitBlocked: 'Je bank heeft testtransacties naar deze rekening geblokkeerd', + insufficientFunds: 'Testtransacties mislukt door onvoldoende saldo', + validationFailedFallback: 'Je bank heeft testtransacties naar deze rekening geblokkeerd. Neem contact op met Concierge', + }, + fixBankAccount: { + title: 'Bankrekening herstellen', + debitBlockedBody: + "Je bank heeft de testtransacties naar deze rekening geblokkeerd.

Vraag hen om de ACH-ID's van Expensify op de whitelist te zetten:127023945022702394504270239450Stuur daarna de testtransacties hieronder opnieuw.", + insufficientFundsBody: 'Testtransacties zijn mislukt vanwege onvoldoende saldo. Zodra er voldoende saldo is, gebruik je de knop hieronder.', + resendButton: 'Transacties opnieuw verzenden', + successTitle: 'Transacties verzonden!', + successBody: 'Controleer je bankrekening over 1–2 werkdagen.', + successButton: 'Begrepen', }, }, addCardToDigitalWallet: { @@ -11578,6 +11592,7 @@ er bestedingsregels toe om de kasstroom van het bedrijf te beschermen.`, aBankAccountIsLocked: 'Een bankrekening is geblokkeerd', completeHrSetup: 'HR-configuratie voltooien', theresAProblemWithAnHRConnection: 'Er is een probleem met een HR-verbinding', + aBankAccountFailedValidation: 'Een bankrekening is niet door de verificatie gekomen', }, }, emptySearchView: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 51d3b1a9a068..99e863a81899 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -495,6 +495,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Skopiuj do schowka', thisIsTakingLongerThanExpected: 'To trwa dłużej, niż się spodziewaliśmy...', domains: 'Domeny', + actionRequired: 'Wymagane działanie', duplicate: 'Duplikuj', duplicated: 'Zduplikowano', opensInNewTab: 'Otwiera się w nowej karcie', @@ -2851,6 +2852,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Potwierdź proszę transakcje testowe', accountRequiresAttention: 'To konto wymaga uwagi', unlock: 'Odblokuj', + debitBlocked: 'Twój bank zablokował transakcje testowe na to konto', + insufficientFunds: 'Testowe transakcje nie powiodły się z powodu niewystarczających środków', + validationFailedFallback: 'Twój bank zablokował transakcje testowe na to konto. Skontaktuj się z Concierge', + }, + fixBankAccount: { + title: 'Napraw konto bankowe', + debitBlockedBody: + 'Twój bank zablokował transakcje testowe na to konto.

Poproś ich o dodanie do listy zaufanych identyfikatorów ACH Expensify:127023945022702394504270239450Następnie wyślij ponownie poniższe transakcje testowe.', + insufficientFundsBody: 'Transakcje testowe nie powiodły się z powodu braku środków. Gdy środki będą dostępne, użyj przycisku poniżej.', + resendButton: 'Wyślij transakcje ponownie', + successTitle: 'Transakcje wysłane!', + successBody: 'Sprawdź swoje konto bankowe w ciągu najbliższych 1–2 dni roboczych.', + successButton: 'Rozumiem', }, }, addCardToDigitalWallet: { @@ -11598,6 +11612,7 @@ Dodaj więcej zasad wydatków, żeby chronić płynność finansową firmy.`, aBankAccountIsLocked: 'Konto bankowe jest zablokowane', completeHrSetup: 'Dokończ konfigurację HR', theresAProblemWithAnHRConnection: 'Wystąpił problem z połączeniem HR', + aBankAccountFailedValidation: 'Konto bankowe nie przeszło weryfikacji', }, }, emptySearchView: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 58a4f1fe6176..d259084d90ec 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -491,6 +491,7 @@ const translations: TranslationDeepObject = { copyToClipboard: 'Copiar para a área de transferência', thisIsTakingLongerThanExpected: 'Isso está demorando mais do que o esperado...', domains: 'Domínios', + actionRequired: 'Ação necessária', duplicate: 'Duplicar', duplicated: 'Duplicado', duplicateExpense: 'Duplicar despesa', @@ -2808,6 +2809,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: 'Confirme as transações de teste', accountRequiresAttention: 'Esta conta precisa de atenção', unlock: 'Desbloquear', + debitBlocked: 'Seu banco bloqueou as transações de teste para esta conta', + insufficientFunds: 'As transações de teste falharam por falta de fundos', + validationFailedFallback: 'Seu banco bloqueou as transações de teste para esta conta. Fale com a Concierge', + }, + fixBankAccount: { + title: 'Corrigir conta bancária', + debitBlockedBody: + 'Seu banco bloqueou as transações de teste para esta conta.

Peça para incluírem na lista de permissões os IDs ACH da Expensify:127023945022702394504270239450Depois, reenvie as transações de teste abaixo.', + insufficientFundsBody: 'As transações de teste falharam por falta de fundos. Quando houver saldo disponível, use o botão abaixo.', + resendButton: 'Reenviar transações', + successTitle: 'Transações enviadas!', + successBody: 'Verifique sua conta bancária nos próximos 1 a 2 dias úteis.', + successButton: 'Entendi', }, }, addCardToDigitalWallet: { @@ -11571,6 +11585,7 @@ Adicione mais regras de gasto para proteger o fluxo de caixa da empresa.`, aBankAccountIsLocked: 'Uma conta bancária está bloqueada', completeHrSetup: 'Concluir configuração de RH', theresAProblemWithAnHRConnection: 'Há um problema com uma conexão de RH', + aBankAccountFailedValidation: 'Uma conta bancária falhou na validação', }, }, emptySearchView: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index f7dc38dc2d74..78a230509dfd 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -487,6 +487,7 @@ const translations: TranslationDeepObject = { copyToClipboard: '复制到剪贴板', thisIsTakingLongerThanExpected: '这比预期花费的时间更长…', domains: '域名', + actionRequired: '需要操作', duplicate: '复制', duplicated: '已重复', duplicateExpense: '重复报销', @@ -2718,6 +2719,19 @@ const translations: TranslationDeepObject = { confirmTestTransactions: '请确认测试交易', accountRequiresAttention: '此账户需要处理', unlock: '解锁', + debitBlocked: '您的银行已拦截到此账户的测试交易', + insufficientFunds: '测试交易因资金不足而失败', + validationFailedFallback: '您的银行已拦截发往此账户的测试交易。联系 Concierge', + }, + fixBankAccount: { + title: '修复银行账户', + debitBlockedBody: + '您的银行拦截了发往此账户的测试交易。

请联系银行将 Expensify 的 ACH ID 加入白名单:127023945022702394504270239450然后,重新发送下方的测试交易。', + insufficientFundsBody: '测试交易因资金不足而失败。资金到位后,请使用下方按钮。', + resendButton: '重新发送交易', + successTitle: '交易已发送!', + successBody: '请在接下来的 1–2 个工作日内查看您的银行账户。', + successButton: '知道了', }, }, addCardToDigitalWallet: { @@ -11153,6 +11167,7 @@ ${reportName}`, aBankAccountIsLocked: '银行账户已锁定', completeHrSetup: '完成人力资源设置', theresAProblemWithAnHRConnection: 'HR 连接出现问题', + aBankAccountFailedValidation: '银行账户验证失败', }, }, emptySearchView: { diff --git a/src/libs/API/parameters/ResendFailedValidationAmountsParams.ts b/src/libs/API/parameters/ResendFailedValidationAmountsParams.ts new file mode 100644 index 000000000000..a278e7c4f27b --- /dev/null +++ b/src/libs/API/parameters/ResendFailedValidationAmountsParams.ts @@ -0,0 +1,5 @@ +type ResendFailedValidationAmountsParams = { + bankAccountID: number; +}; + +export default ResendFailedValidationAmountsParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 18a9c8a8ee5f..99e3c18889e3 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -229,6 +229,7 @@ export type {default as UpdateThemeParams} from './UpdateThemeParams'; export type {default as UpdateUserAvatarParams} from './UpdateUserAvatarParams'; export type {default as UpdateGroupChatAvatarParams} from './UpdateGroupChatAvatarParams'; export type {default as UpdateRoomAvatarParams} from './UpdateRoomAvatarParams'; +export type {default as ResendFailedValidationAmountsParams} from './ResendFailedValidationAmountsParams'; export type {default as ValidateBankAccountWithTransactionsParams} from './ValidateBankAccountWithTransactionsParams'; export type {default as ValidateSecondaryLoginParams} from './ValidateSecondaryLoginParams'; export type {default as ValidateTwoFactorAuthParams} from './ValidateTwoFactorAuthParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 4351dc7b7256..6195561cb503 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -46,6 +46,7 @@ const WRITE_COMMANDS = { DELETE_PAYMENT_BANK_ACCOUNT: 'DeletePaymentBankAccount', UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT: 'UpdatePersonalInformationForBankAccount', VALIDATE_BANK_ACCOUNT_WITH_TRANSACTIONS: 'ValidateBankAccountWithTransactions', + RESEND_FAILED_VALIDATION_AMOUNTS: 'ResendFailedValidationAmounts', UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT: 'UpdateCompanyInformationForBankAccount', UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT: 'UpdateBeneficialOwnersForBankAccount', CONNECT_BANK_ACCOUNT_MANUALLY: 'ConnectBankAccountManually', @@ -808,6 +809,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DELETE_PAYMENT_BANK_ACCOUNT]: Parameters.DeletePaymentBankAccountParams; [WRITE_COMMANDS.UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT]: Parameters.UpdatePersonalInformationForBankAccountParams; [WRITE_COMMANDS.VALIDATE_BANK_ACCOUNT_WITH_TRANSACTIONS]: Parameters.ValidateBankAccountWithTransactionsParams; + [WRITE_COMMANDS.RESEND_FAILED_VALIDATION_AMOUNTS]: Parameters.ResendFailedValidationAmountsParams; [WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT]: Parameters.UpdateCompanyInformationForBankAccountParams; [WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT]: UpdateBeneficialOwnersForBankAccountParams; [WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_MANUALLY]: Parameters.ConnectBankAccountParams; diff --git a/src/libs/BankAccountUtils.ts b/src/libs/BankAccountUtils.ts index f34ad0e810ce..440204591829 100644 --- a/src/libs/BankAccountUtils.ts +++ b/src/libs/BankAccountUtils.ts @@ -23,6 +23,7 @@ type BankAccountConnectionStatus = { messageKey?: TranslationPaths; actionKey?: TranslationPaths; requiresUnlockHandler?: boolean; + requiresFixHandler?: boolean; tooltipKey?: TranslationPaths; brickRoadIndicator?: ValueOf; }; @@ -50,7 +51,12 @@ function getBankAccountSearchLabel(bankAccount: OnyxEntry } function isBankAccountPartiallySetup(state: string | undefined) { - return state === CONST.BANK_ACCOUNT.STATE.SETUP || state === CONST.BANK_ACCOUNT.STATE.VERIFYING || state === CONST.BANK_ACCOUNT.STATE.PENDING; + return ( + state === CONST.BANK_ACCOUNT.STATE.SETUP || + state === CONST.BANK_ACCOUNT.STATE.VERIFYING || + state === CONST.BANK_ACCOUNT.STATE.PENDING || + state === CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED + ); } function getBankAccountState(accountData: AccountData | undefined): string | undefined { @@ -69,6 +75,20 @@ function hasBankAccountAllowDebit(accountData: AccountData | undefined): boolean return !!accountData.allowDebit; } +function hasInsufficientFundsError(accountData: AccountData | undefined): boolean { + if (!accountData?.additionalData?.lastNocCode) { + return false; + } + return CONST.BANK_ACCOUNT.NOC_CODE.INSUFFICIENT_FUNDS.includes(accountData.additionalData.lastNocCode); +} + +function hasDebitBlockedError(accountData: AccountData | undefined): boolean { + if (!accountData?.additionalData?.lastNocCode) { + return false; + } + return CONST.BANK_ACCOUNT.NOC_CODE.DEBIT_BLOCKED.includes(accountData.additionalData.lastNocCode); +} + function getIncompleteBankAccountStatus(): BankAccountConnectionStatus { return { labelKey: 'walletPage.bankAccountStatus.incomplete', @@ -88,7 +108,9 @@ function getIncompleteBankAccountStatus(): BankAccountConnectionStatus { * ReimbursementAccountPage, which routes a PENDING account to the validation (test transaction) step only when the * currency is USD. An absent currency is treated as USD, matching BankAccount.getCurrency(). */ -function getBankAccountConnectionStatus(state: string | undefined, currency?: string): BankAccountConnectionStatus | undefined { +function getBankAccountConnectionStatus(accountData: AccountData | undefined, currency?: string): BankAccountConnectionStatus | undefined { + const state = getBankAccountState(accountData); + if (state === CONST.BANK_ACCOUNT.STATE.PENDING && !!currency && currency !== CONST.CURRENCY.USD) { return getIncompleteBankAccountStatus(); } @@ -124,10 +146,39 @@ function getBankAccountConnectionStatus(state: string | undefined, currency?: st tone: 'danger', brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, }; + case CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED: { + if (hasInsufficientFundsError(accountData)) { + return { + labelKey: 'walletPage.bankAccountStatus.pending', + messageKey: 'walletPage.bankAccountStatus.insufficientFunds', + actionKey: 'common.actionBadge.fix', + requiresFixHandler: true, + tone: 'danger', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }; + } + if (hasDebitBlockedError(accountData)) { + return { + labelKey: 'walletPage.bankAccountStatus.pending', + messageKey: 'walletPage.bankAccountStatus.debitBlocked', + actionKey: 'common.actionBadge.fix', + requiresFixHandler: true, + tone: 'danger', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }; + } + return { + labelKey: 'walletPage.bankAccountStatus.pending', + messageKey: 'walletPage.bankAccountStatus.validationFailedFallback', + 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. @@ -151,6 +202,10 @@ function hasPartiallySetupBankAccount(bankAccountList: OnyxEntry isBankAccountPartiallySetup(bankAccount?.accountData?.state)); } +function hasValidationFailedBankAccount(bankAccountList: OnyxEntry): boolean { + return Object.values(bankAccountList ?? {}).some((bankAccount) => bankAccount?.accountData?.state === CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED); +} + const PERSONAL_INFO_STEP = { NAME: 1, ADDRESS: 2, @@ -303,10 +358,13 @@ export { getDefaultCompanyWebsite, getBankAccountState, hasBankAccountAllowDebit, + hasInsufficientFundsError, + hasDebitBlockedError, getBankAccountConnectionStatus, getRequiredKYBDocuments, getLastFourDigits, hasPartiallySetupBankAccount, + hasValidationFailedBankAccount, hasPersonalBankAccountMissingInfo, isBankAccountPartiallySetup, isUserAddressVerificationRequired, diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 488a2f3c4fbd..52584a4825b4 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -423,6 +423,7 @@ const NewTeachersUniteNavigator = createModalStackNavigator({ [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/DynamicVerifyAccountPage').default, [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/DynamicAddBankAccountVerifyAccountPage').default, + [SCREENS.SETTINGS.DYNAMIC_FIX_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/FixBankAccountPage').default, [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../../pages/ShareCodePage').default, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: withAgentAccessDenied(() => require('../../../../pages/settings/Profile/PronounsPage').default), [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../../pages/settings/Profile/DisplayNamePage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 1028f3033061..ee900ea6ed70 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -472,6 +472,7 @@ const config: LinkingOptions['config'] = { }, [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.VERIFY_ACCOUNT.path, [SCREENS.SETTINGS.DYNAMIC_ADD_BANK_ACCOUNT_VERIFY_ACCOUNT]: DYNAMIC_ROUTES.ADD_BANK_ACCOUNT_VERIFY_ACCOUNT.path, + [SCREENS.SETTINGS.DYNAMIC_FIX_BANK_ACCOUNT]: DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.path, [SCREENS.SETTINGS.SUBSCRIPTION.DYNAMIC_PAYMENT_CARD_CURRENCY_SELECTOR]: DYNAMIC_ROUTES.PAYMENT_CARD_CURRENCY_SELECTOR.path, [SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHODS]: DYNAMIC_ROUTES.CONTACT_METHODS.path, [SCREENS.SETTINGS.PROFILE.DYNAMIC_CONTACT_METHOD_DETAILS]: DYNAMIC_ROUTES.CONTACT_METHOD_DETAILS.path, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index ae375caa37e1..a02641e1a1d1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -143,6 +143,9 @@ type SettingsNavigatorParamList = { shouldSetUpUSBankAccount?: 'true'; } | undefined; + [SCREENS.SETTINGS.DYNAMIC_FIX_BANK_ACCOUNT]: { + bankAccountID: string; + }; [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_REASON]: undefined; [SCREENS.SETTINGS.DYNAMIC_EXIT_SURVEY_CONFIRM]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index 4d754732fa2f..7edd5de9f9ac 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -11,7 +11,7 @@ import type {CurrencyListActionsContextType} from '@hooks/useCurrencyList'; import type {ThemeStyles} from '@styles/index'; import CONST from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {AccountData, BillingGraceEndPeriod, Policy, Report, Rule} from '@src/types/onyx'; import type BankAccount from '@src/types/onyx/BankAccount'; import type Fund from '@src/types/onyx/Fund'; @@ -26,8 +26,10 @@ import type {Merge, ValueOf} from 'type-fest'; import isEmpty from 'lodash/isEmpty'; import {approveMoneyRequest} from './actions/IOU/ReportWorkflow'; +import {navigateToBankAccountRoute} from './actions/ReimbursementAccount'; import {isBankAccountPartiallySetup} from './BankAccountUtils'; import BankAccountModel from './models/BankAccount'; +import createDynamicRoute from './Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from './Navigation/Navigation'; import {shouldRestrictUserBillableActions} from './SubscriptionUtils'; @@ -180,12 +182,10 @@ function getBusinessBankAccountOptions(formattedPaymentMethods: PaymentMethod[], return false; } const accountData = method.accountData; - const isPartiallySetup = isBankAccountPartiallySetup(accountData.state); return ( accountData.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && - (accountData.state === CONST.BANK_ACCOUNT.STATE.OPEN || accountData.state === CONST.BANK_ACCOUNT.STATE.LOCKED) && + accountData.state !== CONST.BANK_ACCOUNT.STATE.DELETED && method?.methodID != null && - !isPartiallySetup && matchesCurrency(method, currency) ); }) @@ -199,6 +199,29 @@ function getBusinessBankAccountOptions(formattedPaymentMethods: PaymentMethod[], })); } +/** + * Select a partially-setup BBA toggles either BBA setup flow to continue setup or fixing instructions when validation failed. + * + * @returns true when it navigated, meaning the caller must abandon its payment flow. + */ +function selectPartiallySetupBankAccount({state, methodID, policyID}: {state: string | undefined; methodID: number | undefined; policyID: string | undefined}): boolean { + if (!methodID) { + return false; + } + + if (state === CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED) { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.getRoute(methodID.toString()))); + return true; + } + + if (isBankAccountPartiallySetup(state)) { + navigateToBankAccountRoute({policyID, bankAccountID: methodID}); + return true; + } + + return false; +} + function calculateWalletTransferBalanceFee(currentBalance: number, methodType: string): number { const transferMethodTypeFeeStructure = methodType === CONST.WALLET.TRANSFER_METHOD_TYPE.INSTANT ? CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.INSTANT : CONST.WALLET.TRANSFER_METHOD_TYPE_FEE.ACH; @@ -343,6 +366,7 @@ export { getPaymentMethodDescription, formatPaymentMethods, getBusinessBankAccountOptions, + selectPartiallySetupBankAccount, matchesCurrency, calculateWalletTransferBalanceFee, selectPaymentType, diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index eed2dbb908e2..b0466770f646 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -12,6 +12,7 @@ import type { EnableGlobalReimbursementsForUSDBankAccountParams, FinishCorpayBankAccountOnboardingParams, OpenReimbursementAccountPageParams, + ResendFailedValidationAmountsParams, SaveCorpayOnboardingBeneficialOwnerParams, SendReminderForCorpaySignerInformationParams, ShareBankAccountAndSetPayerParams, @@ -2005,6 +2006,59 @@ function pressLockedBankAccount( Onyx.merge(`${ONYXKEYS.COLLECTION.NVP_LOCKED_VBA_UNLOCK_REQUESTED}${bankAccountID}`, new Date().toISOString()); } +function resendFailedValidationAmounts(bankAccountID: number) { + const parameters: ResendFailedValidationAmountsParams = {bankAccountID}; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountID]: { + pendingFields: {accountData: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errors: null, + }, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountID]: { + pendingFields: {accountData: null}, + accountData: { + state: CONST.BANK_ACCOUNT.STATE.PENDING, + additionalData: {lastNocCode: null}, + }, + errors: null, + }, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountID]: { + pendingFields: {accountData: null}, + errors: getMicroSecondOnyxErrorWithTranslationKey('common.genericErrorMessage'), + }, + }, + }, + ], + }; + + API.write(WRITE_COMMANDS.RESEND_FAILED_VALIDATION_AMOUNTS, parameters, onyxData); +} + +function clearResendFailedValidationAmountsErrors(bankAccountID: number) { + Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, {[bankAccountID]: {errors: null}}); +} + export { acceptACHContractForBankAccount, addBusinessWebsiteForDraft, @@ -2067,6 +2121,8 @@ export { updatePersonalBankAccountInfo, initiateBankAccountUnlock, pressLockedBankAccount, + resendFailedValidationAmounts, + clearResendFailedValidationAmountsErrors, uploadUserKYBDocs, createCollectOnlyDepositAccount, openDepositAccountSetup, diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 58f578a30cfb..4b9276f99dae 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -40,7 +40,7 @@ import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; import enhanceParameters from '@libs/Network/enhanceParameters'; import {getIsOffline} from '@libs/NetworkState'; import {rand64} from '@libs/NumberUtils'; -import {getActivePaymentType} from '@libs/PaymentUtils'; +import {getActivePaymentType, selectPartiallySetupBankAccount} from '@libs/PaymentUtils'; import { getAccountIDForSubmitManagerEmail, getSubmitReportManagerAccountID, @@ -2533,6 +2533,14 @@ function handleBulkPayItemSelected(params: { // KYC wall, which only accepts the policy-linked account and would bounce the user to the bank account setup. const selectedBankAccountID = (item?.additionalData as BulkPaySelectionData | undefined)?.bankAccountID; const selectedBankAccountData = selectedBankAccountID ? bankAccountList?.[selectedBankAccountID]?.accountData : undefined; + + if ( + item.key === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT && + selectPartiallySetupBankAccount({state: selectedBankAccountData?.state, methodID: selectedBankAccountID, policyID: policyFromPaymentMethod?.id ?? policyFromContext?.id}) + ) { + return; + } + const isPayingWithSelectedOpenBusinessBankAccount = item.key === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT && selectedBankAccountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && diff --git a/src/pages/settings/Wallet/FixBankAccountPage.tsx b/src/pages/settings/Wallet/FixBankAccountPage.tsx new file mode 100644 index 000000000000..4ce570d7977e --- /dev/null +++ b/src/pages/settings/Wallet/FixBankAccountPage.tsx @@ -0,0 +1,160 @@ +import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import Button from '@components/Button'; +import ConfirmationPage from '@components/ConfirmationPage'; +import FixedFooter from '@components/FixedFooter'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButtonAndTitle from '@components/Header/composed/HeaderWithBackButtonAndTitle'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; + +import useAccessibilityAnnouncement from '@hooks/useAccessibilityAnnouncement'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; + +import {clearResendFailedValidationAmountsErrors, resendFailedValidationAmounts} from '@libs/actions/BankAccounts'; +import {hasDebitBlockedError, hasInsufficientFundsError} from '@libs/BankAccountUtils'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import {useRoute} from '@react-navigation/native'; +import React, {useEffect, useState} from 'react'; +import {View} from 'react-native'; + +type FixBankAccountPageRoute = PlatformStackScreenProps['route']; + +function FixBankAccountPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const route = useRoute(); + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.path); + const {isOffline} = useNetwork(); + const [hasSubmitted, setHasSubmitted] = useState(false); + const illustrations = useMemoizedLazyIllustrations(['LaptopWithSecondScreenBank', 'ScissorsCuttingMoney']); + + const bankAccountID = route.params?.bankAccountID ? Number(route.params?.bankAccountID) : undefined; + const [bankAccount] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, { + selector: (list) => (route.params?.bankAccountID ? list?.[route.params?.bankAccountID] : undefined), + }); + const accountData = bankAccount?.accountData; + + const isInsufficientFunds = hasInsufficientFundsError(accountData); + const isDebitBlocked = hasDebitBlockedError(accountData); + const isValidationFailed = accountData?.state === CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED && (isInsufficientFunds || isDebitBlocked); + + const isLoading = !!bankAccount?.pendingFields?.accountData; + const resendErrorMessage = getLatestErrorMessage(bankAccount); + const didSend = hasSubmitted && !isLoading && !isValidationFailed && !resendErrorMessage; + + useAccessibilityAnnouncement(translate('walletPage.fixBankAccount.successTitle'), didSend, {shouldAnnounceOnNative: true, shouldAnnounceOnWeb: true}); + + // Clear stale errors when the RHP unmounts so the next open starts clean. + useEffect( + () => () => { + if (!bankAccountID) { + return; + } + clearResendFailedValidationAmountsErrors(bankAccountID); + }, + [bankAccountID], + ); + + const onResend = () => { + if (!bankAccountID) { + return; + } + setHasSubmitted(true); + resendFailedValidationAmounts(bankAccountID); + }; + + const onDismiss = () => Navigation.dismissModal(); + const onBack = () => Navigation.goBack(backPath); + + const resendButton = ( + + {!!resendErrorMessage && ( + + + + )} + + + ); + + return ( + + + + {didSend && ( + + )} + {!didSend && isInsufficientFunds && ( + <> + + {resendButton} + + )} + {!didSend && !isInsufficientFunds && ( + <> + + + {translate('common.actionRequired')} + + + + {resendButton} + + )} + + + ); +} + +export default FixBankAccountPage; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 9bdb8fe5e9e1..bf0caf1ea68e 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -232,15 +232,22 @@ function PaymentMethodList({ onActionPress: (e: GestureResponderEvent | KeyboardEvent | undefined) => void, onUnlockPress?: (e: GestureResponderEvent | KeyboardEvent | undefined) => void, isPendingDelete = false, + onFixPress?: () => void, + accountName?: string, ): 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, + actionAccessibilityLabel: status.actionKey && accountName ? `${translate(status.actionKey)}, ${accountName}` : undefined, // An account queued for deletion is struck through, so its action is disabled rather than hidden. isActionDisabled: isPendingDelete, onActionPress: () => { + if (onFixPress) { + onFixPress(); + return; + } if (status.requiresUnlockHandler) { (onUnlockPress ?? onActionPress)(undefined); return; @@ -604,8 +611,7 @@ function PaymentMethodList({ // `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const bankAccountCurrency = ('bankCurrency' in paymentMethod ? paymentMethod.bankCurrency : undefined) || paymentMethod.accountData?.additionalData?.currency; - const bankConnectionStatus = - shouldShowConnectionStatus && !isMissingPersonalInfo ? getBankAccountConnectionStatus(getBankAccountState(paymentMethod.accountData), bankAccountCurrency) : undefined; + const bankConnectionStatus = shouldShowConnectionStatus && !isMissingPersonalInfo ? getBankAccountConnectionStatus(paymentMethod.accountData, bankAccountCurrency) : undefined; const paymentMethodPress = (e: GestureResponderEvent | KeyboardEvent | undefined) => pressHandler({ event: e, @@ -619,10 +625,18 @@ function PaymentMethodList({ ...paymentMethodData, })); + const methodID = paymentMethod.methodID; + const onFixPress = + bankConnectionStatus?.requiresFixHandler && methodID !== undefined + ? () => Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.getRoute(methodID.toString()))) + : undefined; + + const rowTitle = paymentMethod.title?.includes(CONST.MASKED_PAN_PREFIX) ? paymentMethod.accountData?.additionalData?.bankName : paymentMethod.title; + return { ...paymentMethod, - title: paymentMethod.title?.includes(CONST.MASKED_PAN_PREFIX) ? paymentMethod.accountData?.additionalData?.bankName : paymentMethod.title, - onPress: paymentMethodPress, + title: rowTitle, + onPress: onFixPress ?? paymentMethodPress, onThreeDotsMenuPress: paymentMethodThreeDotsPress, disabled: paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, isMethodActive, @@ -637,6 +651,8 @@ function PaymentMethodList({ paymentMethodPress, paymentMethodThreeDotsPress, paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + onFixPress, + rowTitle, ) : undefined, }; diff --git a/src/pages/settings/Wallet/PaymentMethodListItem.tsx b/src/pages/settings/Wallet/PaymentMethodListItem.tsx index 1e4e354d2acb..c124b872f649 100644 --- a/src/pages/settings/Wallet/PaymentMethodListItem.tsx +++ b/src/pages/settings/Wallet/PaymentMethodListItem.tsx @@ -52,6 +52,7 @@ type ConnectionStatusDetails = { tooltipText?: string; message?: string; actionText?: string; + actionAccessibilityLabel?: string; onActionPress?: () => void; isActionDisabled?: boolean; onLinkPress?: ComponentProps['onLinkPress']; @@ -308,6 +309,7 @@ function PaymentMethodListItem({item, shouldShowDefaultBadge, threeDotsMenuItems | undefined; if ( hasLockedBankAccount || + hasValidationFailedBankAccount(bankAccountList) || hasPaymentMethodError(bankAccountList, fundList, allCards, session, policies) || !isEmptyObject(userWallet?.errors) || !isEmptyObject(walletTerms?.errors) || diff --git a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx index 3369a6fbb054..ec5c0d5cc9b9 100644 --- a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx +++ b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx @@ -11,6 +11,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {isBankAccountPartiallySetup} from '@libs/BankAccountUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import {REIMBURSEMENT_ACCOUNT_ROUTE_NAMES} from '@libs/ReimbursementAccountUtils'; import {appendParam} from '@libs/Url'; @@ -27,7 +28,7 @@ import {navigateToBankAccountRoute, prepareNewBankAccountSetup} from '@userActio import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import React, {useState} from 'react'; @@ -76,6 +77,11 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness return; } + if (methodID && accountData?.state === CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED) { + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.getRoute(methodID.toString()))); + return; + } + const newReimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; if (bankAccountList && methodID && methodID !== connectedAccountBankAccountID && methodID !== policy?.achAccount?.bankAccountID) { diff --git a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx index 636cadbe0751..d378b8909969 100644 --- a/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx +++ b/src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx @@ -29,6 +29,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {clearPolicyErrorField, isCurrencySupportedForDirectReimbursement, isCurrencySupportedForGlobalReimbursement, setWorkspaceReimbursement} from '@libs/actions/Policy/Policy'; import {getBankAccountConnectionStatus, isBankAccountPartiallySetup, showUnlockAlreadyRequestedModal} from '@libs/BankAccountUtils'; import {getLatestErrorField} from '@libs/ErrorUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import {temporaryGetDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; @@ -43,7 +44,7 @@ import {callFunctionIfActionIsAllowed} from '@userActions/Session'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type {TupleToUnion} from 'type-fest'; @@ -127,7 +128,9 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { const workflowsBackTo = ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID); const isBankAccountFullySetup = policy?.achAccount && (policy?.achAccount.state === CONST.BANK_ACCOUNT.STATE.OPEN || policy?.achAccount.state === CONST.BANK_ACCOUNT.STATE.LOCKED); - const bankAccountConnectedToWorkspace = Object.values(bankAccountList ?? {}).find((bankAccount) => bankAccount?.accountData?.additionalData?.policyID === policy?.id); + const bankAccountConnectedToWorkspace = Object.values(bankAccountList ?? {}).find( + (bankAccount) => bankAccount?.accountData?.additionalData?.policyID === policy?.id || bankAccount?.methodID === policy?.achAccount?.bankAccountID, + ); const bankName = isBankAccountFullySetup ? (policy?.achAccount?.bankName ?? '') : (bankAccountConnectedToWorkspace?.accountData?.additionalData?.bankName ?? ''); const addressName = isBankAccountFullySetup ? (policy?.achAccount?.addressName ?? '') : (bankAccountConnectedToWorkspace?.accountData?.addressName ?? ''); const accountData = isBankAccountFullySetup ? policy?.achAccount : bankAccountConnectedToWorkspace?.accountData; @@ -157,7 +160,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { // `||` not `??`: bankCurrency can be an empty string, which should fall through to additionalData. // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const bankAccountCurrency = bankAccountConnectedToWorkspace?.bankCurrency || bankAccountConnectedToWorkspace?.accountData?.additionalData?.currency; - const bankConnectionStatus = getBankAccountConnectionStatus(state, bankAccountCurrency); + const bankConnectionStatus = getBankAccountConnectionStatus(accountData, bankAccountCurrency); const bankConnectionBrickRoadIndicator = bankConnectionStatus?.brickRoadIndicator ?? (hasReimburserError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined); const bankConnectionStatusAddon = bankConnectionStatus ? ( Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.FIX_BANK_ACCOUNT.getRoute(bankAccountID.toString()))) + : undefined; + const bankAccountMenuItemProps: React.ComponentProps = { title: bankTitle, description: getPaymentMethodDescription(CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT, accountData, translate), - onPress: canInteractWithBankAccountRow ? handleBankAccountPress : undefined, + onPress: canInteractWithBankAccountRow ? (handleFixValidationFailedPress ?? handleBankAccountPress) : undefined, displayInDefaultIconColor: true, icon: bankIcon.icon, iconHeight: bankIcon.iconHeight ?? bankIcon.iconSize, @@ -331,7 +339,7 @@ function WorkflowsPaymentsTab({policyID}: WorkflowsPaymentsTabProps) { ): Record => { + const states: Record = {}; + for (const [bankAccountID, bankAccount] of Object.entries(bankAccountList ?? {})) { + states[bankAccountID] = bankAccount?.accountData?.state; + } + return states; +}; + +// eslint-disable-next-line import/prefer-default-export +export {bankAccountStatesSelector}; diff --git a/src/styles/index.ts b/src/styles/index.ts index 447f31c0a549..4c237e5f0c0d 100644 --- a/src/styles/index.ts +++ b/src/styles/index.ts @@ -6919,7 +6919,7 @@ const staticStyles = (theme: ThemeColors) => height: 180, }, paymentMethodErrorRow: { - paddingHorizontal: variables.iconSizeMenuItem + variables.iconSizeNormal / 2, + paddingHorizontal: variables.iconSizeMenuItem, }, transactionReceiptButton: { minWidth: variables.transactionReceiptButtonWidth, diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts index ed901b726d23..ac0a950ce32d 100644 --- a/src/types/onyx/BankAccount.ts +++ b/src/types/onyx/BankAccount.ts @@ -60,6 +60,9 @@ type BankAccountAdditionalData = { /** Phone number of the bank account owner */ companyPhone?: string; + + /** ACH Return Codes, reason the payment was returned or flagged for correction */ + lastNocCode?: string; }; /** Model of bank account */ diff --git a/tests/unit/BankAccountUtilsTest.ts b/tests/unit/BankAccountUtilsTest.ts index 23036f167f60..3688c3e95b01 100644 --- a/tests/unit/BankAccountUtilsTest.ts +++ b/tests/unit/BankAccountUtilsTest.ts @@ -6,6 +6,8 @@ import { getLastFourDigits, getRequiredKYBDocuments, hasBankAccountAllowDebit, + hasDebitBlockedError, + hasInsufficientFundsError, hasPartiallySetupBankAccount, hasPersonalBankAccountMissingInfo, isBankAccountPartiallySetup, @@ -275,16 +277,60 @@ describe('BankAccountUtils', () => { }); }); + describe('hasInsufficientFundsError', () => { + it.each(CONST.BANK_ACCOUNT.NOC_CODE.INSUFFICIENT_FUNDS)('returns true when lastNocCode is %s', (code) => { + expect(hasInsufficientFundsError({additionalData: {lastNocCode: code}} as AccountData)).toBe(true); + }); + + it.each(CONST.BANK_ACCOUNT.NOC_CODE.DEBIT_BLOCKED)('returns false when lastNocCode is debit-blocked code %s', (code) => { + expect(hasInsufficientFundsError({additionalData: {lastNocCode: code}} as AccountData)).toBe(false); + }); + + it('returns false when lastNocCode is unrelated', () => { + expect(hasInsufficientFundsError({additionalData: {lastNocCode: 'R99'}} as AccountData)).toBe(false); + }); + + it('returns false when accountData is undefined', () => { + expect(hasInsufficientFundsError(undefined)).toBe(false); + }); + + it('returns false when additionalData is missing', () => { + expect(hasInsufficientFundsError({} as AccountData)).toBe(false); + }); + + it('returns false when lastNocCode is missing', () => { + expect(hasInsufficientFundsError({additionalData: {}} as AccountData)).toBe(false); + }); + }); + + describe('hasDebitBlockedError', () => { + it.each(CONST.BANK_ACCOUNT.NOC_CODE.DEBIT_BLOCKED)('returns true when lastNocCode is %s', (code) => { + expect(hasDebitBlockedError({additionalData: {lastNocCode: code}} as AccountData)).toBe(true); + }); + + it.each(CONST.BANK_ACCOUNT.NOC_CODE.INSUFFICIENT_FUNDS)('returns false when lastNocCode is insufficient-funds code %s', (code) => { + expect(hasDebitBlockedError({additionalData: {lastNocCode: code}} as AccountData)).toBe(false); + }); + + it('returns false when lastNocCode is unrelated', () => { + expect(hasDebitBlockedError({additionalData: {lastNocCode: 'R99'}} as AccountData)).toBe(false); + }); + + it('returns false when accountData is undefined', () => { + expect(hasDebitBlockedError(undefined)).toBe(false); + }); + }); + describe('getBankAccountConnectionStatus', () => { it('maps OPEN bank accounts to Active without an RBR', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.OPEN)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.OPEN})).toEqual({ labelKey: 'walletPage.bankAccountStatus.active', tone: 'success', }); }); it('maps SETUP bank accounts to Incomplete with the finish action', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.SETUP)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.SETUP})).toEqual({ labelKey: 'walletPage.bankAccountStatus.incomplete', messageKey: 'walletPage.bankAccountStatus.finishAddingBankAccount', actionKey: 'walletPage.bankAccountStatus.finish', @@ -294,7 +340,7 @@ describe('BankAccountUtils', () => { }); it('maps PENDING bank accounts to Pending with the confirm action', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.PENDING})).toEqual({ labelKey: 'walletPage.bankAccountStatus.pending', messageKey: 'walletPage.bankAccountStatus.confirmTestTransactions', actionKey: 'common.confirm', @@ -304,7 +350,7 @@ describe('BankAccountUtils', () => { }); it('maps VERIFYING bank accounts to Verifying with only a tooltip', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.VERIFYING)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.VERIFYING})).toEqual({ labelKey: 'walletPage.bankAccountStatus.verifying', tooltipKey: 'walletPage.bankAccountStatus.reviewingDocumentation', tone: 'default', @@ -312,7 +358,7 @@ describe('BankAccountUtils', () => { }); it('maps LOCKED bank accounts to Locked with the unlock action', () => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.LOCKED)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.LOCKED})).toEqual({ labelKey: 'common.locked', messageKey: 'walletPage.bankAccountStatus.accountRequiresAttention', actionKey: 'walletPage.bankAccountStatus.unlock', @@ -323,7 +369,7 @@ describe('BankAccountUtils', () => { }); it.each([CONST.CURRENCY.USD, undefined])('keeps the confirm action for a PENDING account in currency "%s"', (currency) => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING, currency)).toEqual( + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.PENDING}, currency)).toEqual( expect.objectContaining({ labelKey: 'walletPage.bankAccountStatus.pending', actionKey: 'common.confirm', @@ -332,7 +378,7 @@ describe('BankAccountUtils', () => { }); it.each(['GBP', 'EUR', 'AUD'])('maps a PENDING account in currency "%s" to Incomplete, since only USD accounts have test transactions', (currency) => { - expect(getBankAccountConnectionStatus(CONST.BANK_ACCOUNT.STATE.PENDING, currency)).toEqual({ + expect(getBankAccountConnectionStatus({state: CONST.BANK_ACCOUNT.STATE.PENDING}, currency)).toEqual({ labelKey: 'walletPage.bankAccountStatus.incomplete', messageKey: 'walletPage.bankAccountStatus.finishAddingBankAccount', actionKey: 'walletPage.bankAccountStatus.finish', @@ -344,13 +390,62 @@ describe('BankAccountUtils', () => { it.each([CONST.BANK_ACCOUNT.STATE.OPEN, CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.VERIFYING, CONST.BANK_ACCOUNT.STATE.LOCKED])( 'is unaffected by a non-USD currency in state "%s"', (state) => { - expect(getBankAccountConnectionStatus(state, 'GBP')).toEqual(getBankAccountConnectionStatus(state)); + expect(getBankAccountConnectionStatus({state}, 'GBP')).toEqual(getBankAccountConnectionStatus({state})); }, ); - it.each([undefined, '', 'UNKNOWN'])('returns undefined for unsupported state "%s"', (state) => { - expect(getBankAccountConnectionStatus(state)).toBeUndefined(); + it.each([{state: undefined}, {state: ''}, {state: 'UNKNOWN'}])('returns undefined for unsupported state "%s"', (accountData) => { + expect(getBankAccountConnectionStatus(accountData)).toBeUndefined(); + }); + + it.each(CONST.BANK_ACCOUNT.NOC_CODE.INSUFFICIENT_FUNDS)('maps VALIDATION_FAILED with insufficient-funds code %s to the insufficient-funds Fix status', (code) => { + expect( + getBankAccountConnectionStatus({ + state: CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED, + additionalData: {lastNocCode: code}, + } as AccountData), + ).toEqual({ + labelKey: 'walletPage.bankAccountStatus.pending', + messageKey: 'walletPage.bankAccountStatus.insufficientFunds', + actionKey: 'common.actionBadge.fix', + requiresFixHandler: true, + tone: 'danger', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }); + }); + + it.each(CONST.BANK_ACCOUNT.NOC_CODE.DEBIT_BLOCKED)('maps VALIDATION_FAILED with debit-blocked code %s to the debit-blocked Fix status', (code) => { + expect( + getBankAccountConnectionStatus({ + state: CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED, + additionalData: {lastNocCode: code}, + } as AccountData), + ).toEqual({ + labelKey: 'walletPage.bankAccountStatus.pending', + messageKey: 'walletPage.bankAccountStatus.debitBlocked', + actionKey: 'common.actionBadge.fix', + requiresFixHandler: true, + tone: 'danger', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }); }); + + it.each([{lastNocCode: undefined}, {lastNocCode: ''}, {lastNocCode: 'R99'}])( + 'maps VALIDATION_FAILED with unknown NOC bucket (lastNocCode: "$lastNocCode") to the Concierge fallback status', + ({lastNocCode}) => { + expect( + getBankAccountConnectionStatus({ + state: CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED, + additionalData: {lastNocCode}, + } as AccountData), + ).toEqual({ + labelKey: 'walletPage.bankAccountStatus.pending', + messageKey: 'walletPage.bankAccountStatus.validationFailedFallback', + tone: 'danger', + brickRoadIndicator: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR, + }); + }, + ); }); describe('hasPartiallySetupBankAccount', () => { diff --git a/tests/unit/BankAccountsTest.ts b/tests/unit/BankAccountsTest.ts index c16d4367bdde..0c899eff83b4 100644 --- a/tests/unit/BankAccountsTest.ts +++ b/tests/unit/BankAccountsTest.ts @@ -3,7 +3,7 @@ import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import DateUtils from '@libs/DateUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; -import {addPersonalBankAccount, connectBankAccountManually, connectBankAccountWithPlaid, getCorpayOnboardingFields} from '@userActions/BankAccounts'; +import {addPersonalBankAccount, connectBankAccountManually, connectBankAccountWithPlaid, getCorpayOnboardingFields, resendFailedValidationAmounts} from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -119,4 +119,77 @@ describe('BankAccounts', () => { expect(mockRead).toHaveBeenCalledWith(READ_COMMANDS.GET_CORPAY_ONBOARDING_FIELDS, {countryISO: CONST.COUNTRY.GB}); }); }); + + describe('resendFailedValidationAmounts', () => { + const bankAccountID = 5555; + + test('sends the ResendFailedValidationAmounts write command with the bankAccountID', () => { + resendFailedValidationAmounts(bankAccountID); + + expect(mockWrite).toHaveBeenCalledWith(WRITE_COMMANDS.RESEND_FAILED_VALIDATION_AMOUNTS, {bankAccountID}, expect.anything()); + }); + + test('optimistically marks accountData as pending and clears errors', () => { + resendFailedValidationAmounts(bankAccountID); + + const [, , onyxData] = mockWrite.mock.calls.at(-1) ?? []; + expect(onyxData?.optimisticData).toEqual([ + expect.objectContaining({ + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountID]: { + pendingFields: {accountData: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + errors: null, + }, + }, + }), + ]); + }); + + test('successData clears the pending marker, sets state to PENDING, and clears lastNocCode + errors', () => { + resendFailedValidationAmounts(bankAccountID); + + const [, , onyxData] = mockWrite.mock.calls.at(-1) ?? []; + expect(onyxData?.successData).toEqual([ + expect.objectContaining({ + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: { + [bankAccountID]: { + pendingFields: {accountData: null}, + accountData: { + state: CONST.BANK_ACCOUNT.STATE.PENDING, + additionalData: {lastNocCode: null}, + }, + errors: null, + }, + }, + }), + ]); + }); + + test('failureData clears the pending marker and surfaces a generic error message', () => { + resendFailedValidationAmounts(bankAccountID); + + expect(mockWrite).toHaveBeenLastCalledWith( + WRITE_COMMANDS.RESEND_FAILED_VALIDATION_AMOUNTS, + {bankAccountID}, + expect.objectContaining({ + failureData: [ + expect.objectContaining({ + key: ONYXKEYS.BANK_ACCOUNT_LIST, + value: expect.objectContaining({ + [bankAccountID]: expect.objectContaining({ + pendingFields: {accountData: null}, + }), + }), + }), + ], + }), + ); + + // Runtime check that the failure patch also carries an errors object (typed matcher would trigger no-unsafe-assignment). + const failureValue = mockWrite.mock.calls.at(-1)?.[2]?.failureData?.at(0)?.value; + expect(JSON.stringify(failureValue)).toContain('"errors":'); + }); + }); }); diff --git a/tests/unit/BulletListRendererTest.tsx b/tests/unit/BulletListRendererTest.tsx index 0534f43ecc4a..64c4bbba3848 100644 --- a/tests/unit/BulletListRendererTest.tsx +++ b/tests/unit/BulletListRendererTest.tsx @@ -49,6 +49,8 @@ jest.mock('@libs/Parser', () => ({ const buildTextTNode = (text: string) => createMock({type: 'text', data: text}); const buildTNode = (text = '') => createMock['tnode']>({type: 'block', children: [buildTextTNode(text)]}); +const buildTNodeWithAttributes = (text: string, attributes: Record) => + createMock['tnode']>({type: 'block', attributes, children: [buildTextTNode(text)]}); const buildULTNode = (children: Array<{tagName: string; text: string}>) => createMock['tnode']>({ type: 'block', @@ -96,6 +98,29 @@ describe('Bullet list rendering', () => { expect(screen.getByText(CONST.DOT_SEPARATOR)).toBeTruthy(); expect(screen.getByText('First item')).toBeTruthy(); }); + + // Given a bullet item whose content is a long run of digits, which screen readers would otherwise speak as one + // large number ("one billion two hundred seventy million...") instead of an identifier the user can read out + it('exposes an aria-label from the markup as the item accessibility label', () => { + // When the item carries an aria-label spelling out the digits + // HTML attribute names are hyphenated, so they can't satisfy the camelCase property naming rule + // eslint-disable-next-line @typescript-eslint/naming-convention + render(); + + // Then the item is a single accessibility element named by that label, while the digits stay visible as-is + expect(screen.getByLabelText('1 2 7 0 2 3 9 4 5 0')).toBeTruthy(); + expect(screen.getByText('1270239450')).toBeTruthy(); + }); + + // Given the many existing bullet items that carry no aria-label + it('does not become an accessibility element when no aria-label is present', () => { + // When the item is rendered without the attribute + render(); + + // Then nothing is grouped under a label, so the content is read normally + expect(screen.queryByLabelText('Plain item')).toBeNull(); + expect(screen.getByText('Plain item')).toBeTruthy(); + }); }); describe('OLRenderer', () => { diff --git a/tests/unit/PaymentUtilsTest.ts b/tests/unit/PaymentUtilsTest.ts index 729c31eeebb2..28793727b7c6 100644 --- a/tests/unit/PaymentUtilsTest.ts +++ b/tests/unit/PaymentUtilsTest.ts @@ -360,40 +360,36 @@ describe('PaymentUtils', () => { expect(getBusinessBankAccountOptions(methods)).toHaveLength(0); }); - it('drops partially setup accounts', () => { + it.each([CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.VERIFYING, CONST.BANK_ACCOUNT.STATE.PENDING, CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED])( + 'keeps partially setup accounts in %s state', + (state) => { + expect( + getBusinessBankAccountOptions([ + createMockPaymentMethod({ + accountData: { + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + state, + }, + }), + ]), + ).toHaveLength(1); + }, + ); + + it('drops DELETED accounts', () => { expect( getBusinessBankAccountOptions([ createMockPaymentMethod({ accountData: { type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, - state: CONST.BANK_ACCOUNT.STATE.SETUP, - }, - }), - ]), - ).toHaveLength(0); - expect( - getBusinessBankAccountOptions([ - createMockPaymentMethod({ - accountData: { - type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, - state: CONST.BANK_ACCOUNT.STATE.VERIFYING, - }, - }), - ]), - ).toHaveLength(0); - expect( - getBusinessBankAccountOptions([ - createMockPaymentMethod({ - accountData: { - type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, - state: CONST.BANK_ACCOUNT.STATE.PENDING, + state: CONST.BANK_ACCOUNT.STATE.DELETED, }, }), ]), ).toHaveLength(0); }); - it('drops non-OPEN and non-LOCKED state', () => { + it('keeps every non-deleted state', () => { const methods: PaymentMethod[] = [ createMockPaymentMethod({ accountData: { @@ -414,7 +410,7 @@ describe('PaymentUtils', () => { }, }), ]; - expect(getBusinessBankAccountOptions(methods)).toHaveLength(2); + expect(getBusinessBankAccountOptions(methods)).toHaveLength(3); }); it('drops methods with null or undefined methodID', () => { @@ -498,7 +494,7 @@ describe('PaymentUtils', () => { expect(result).toHaveLength(0); }); - it('filters to only valid BUSINESS OPEN or LOCKED with methodID and maps rest correctly', () => { + it('filters to only BUSINESS accounts with a methodID and maps rest correctly', () => { const methods: PaymentMethod[] = [ createMockPaymentMethod({ accountData: { @@ -525,8 +521,8 @@ describe('PaymentUtils', () => { ]; const result = getBusinessBankAccountOptions(methods); - expect(result).toHaveLength(2); - expect(result.at(0)?.text).toBe('Valid Business'); + // The personal account is dropped; the business ones are kept whatever their state. + expect(result.map((option) => option.text)).toEqual(['Valid Business', 'Setup', 'Locked']); }); }); }); diff --git a/tests/unit/useAccountTabIndicatorStatusTest.ts b/tests/unit/useAccountTabIndicatorStatusTest.ts index c06c5a354adf..fd4351fc0123 100644 --- a/tests/unit/useAccountTabIndicatorStatusTest.ts +++ b/tests/unit/useAccountTabIndicatorStatusTest.ts @@ -11,6 +11,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {OnyxMultiSetInput} from 'react-native-onyx'; import type {IndicatorTestCase} from 'tests/utils/IndicatorTestUtils'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -85,10 +86,26 @@ const TEST_CASES = { indicatorColor: defaultTheme.danger, status: CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT, }, + hasValidationFailedBankAccount: { + name: 'has validation failed bank account', + indicatorColor: defaultTheme.danger, + status: CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT, + }, } as const satisfies Record; const policyKey = `${ONYXKEYS.COLLECTION.POLICY}1` as const; +function getMockAccountDataForStatus(status: IndicatorTestCase['status']): {state: ValueOf} | undefined { + switch (status) { + case CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT: + return {state: CONST.BANK_ACCOUNT.STATE.LOCKED}; + case CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT: + return {state: CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED}; + default: + return undefined; + } +} + const getMockForTestCase = ({name, status}: IndicatorTestCase) => ({ [ONYXKEYS.BANK_ACCOUNT_LIST]: { @@ -101,12 +118,7 @@ const getMockForTestCase = ({name, status}: IndicatorTestCase) => error: 'Something went wrong', } : undefined, - accountData: - status === CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT - ? { - state: CONST.BANK_ACCOUNT.STATE.LOCKED, - } - : undefined, + accountData: getMockAccountDataForStatus(status), }, }, [ONYXKEYS.USER_WALLET]: { diff --git a/tests/unit/useIndicatorStatusTest.tsx b/tests/unit/useIndicatorStatusTest.tsx index 731ab2f7ce3d..2cbe1f764d1f 100644 --- a/tests/unit/useIndicatorStatusTest.tsx +++ b/tests/unit/useIndicatorStatusTest.tsx @@ -11,6 +11,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {ErrorFields, Errors} from '@src/types/onyx/OnyxCommon'; import type {OnyxMultiSetInput} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; import Onyx from 'react-native-onyx'; @@ -127,6 +128,12 @@ const TEST_CASES = { status: CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT, indicatorPolicyID: undefined, }, + hasValidationFailedBankAccount: { + name: 'has validation failed bank account', + indicatorColor: defaultTheme.danger, + status: CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT, + indicatorPolicyID: undefined, + }, } as const satisfies Record; const TEST_CASES_NON_ADMIN = { @@ -153,6 +160,17 @@ const TEST_CASES_NON_ADMIN = { hasEmployeeCardFeedErrors: cardFeedErrorTestCases.employee, } as const satisfies Record; +function getMockAccountDataForStatus(status: IndicatorTestCase['status']): {state: ValueOf} | undefined { + switch (status) { + case CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT: + return {state: CONST.BANK_ACCOUNT.STATE.LOCKED}; + case CONST.INDICATOR_STATUS.HAS_VALIDATION_FAILED_BANK_ACCOUNT: + return {state: CONST.BANK_ACCOUNT.STATE.VALIDATION_FAILED}; + default: + return undefined; + } +} + const getMockForTestCase = ({name, status}: IndicatorTestCase, isAdmin: boolean) => createMock({ [`${ONYXKEYS.COLLECTION.POLICY}1` as const]: { @@ -224,12 +242,7 @@ const getMockForTestCase = ({name, status}: IndicatorTestCase, isAdmin: boolean) error: 'Something went wrong', } : undefined, - accountData: - status === CONST.INDICATOR_STATUS.HAS_LOCKED_BANK_ACCOUNT - ? { - state: CONST.BANK_ACCOUNT.STATE.LOCKED, - } - : undefined, + accountData: getMockAccountDataForStatus(status), }, }, [ONYXKEYS.USER_WALLET]: {