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]: {