diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 7d37ca62ac30..1e1d7329c1d3 100644 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -732,6 +732,9 @@ const CONST = { PLAID: 'plaid', NONE: '', }, + CONNECT_EXISTING_SOURCE: { + CHANGE_BANK_ACCOUNT: 'changeBankAccount', + }, REGEX: { US_ACCOUNT_NUMBER: /^[0-9]{4,17}$/, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index d673456d2bfc..fd5bda7e1170 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -406,6 +406,9 @@ const ONYXKEYS = { /** Stores information about the active reimbursement account being set up */ REIMBURSEMENT_ACCOUNT: 'reimbursementAccount', + /** Indicates whether the user started changing their business bank account to a fresh account */ + IS_CHANGING_TO_NEW_BANK_ACCOUNT: 'isChangingToNewBankAccount', + /** Stores Workspace ID that will be tied to reimbursement account during setup */ REIMBURSEMENT_ACCOUNT_WORKSPACE_ID: 'reimbursementAccountWorkspaceID', @@ -1580,6 +1583,7 @@ type OnyxValuesMapping = { [ONYXKEYS.SHARE_BANK_ACCOUNT]: OnyxTypes.ShareBankAccount; [ONYXKEYS.UNSHARE_BANK_ACCOUNT]: OnyxTypes.UnshareBankAccount; [ONYXKEYS.REIMBURSEMENT_ACCOUNT]: OnyxTypes.ReimbursementAccount; + [ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT]: boolean; [ONYXKEYS.REIMBURSEMENT_ACCOUNT_OPTION_PRESSED]: ValueOf; [ONYXKEYS.PREFERRED_EMOJI_SKIN_TONE]: number; [ONYXKEYS.FREQUENTLY_USED_EMOJIS]: OnyxTypes.FrequentlyUsedEmoji[]; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index f7b9df0bae9d..e5791d60e8c7 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -1271,13 +1271,16 @@ const ROUTES = { // TODO: rename the route as no longer accepts step BANK_ACCOUNT_WITH_STEP_TO_OPEN: { route: 'bank-account/new', - getRoute: ({policyID, bankAccountID, backTo}: {policyID: string | undefined; bankAccountID?: number; backTo?: string}) => { + getRoute: ({policyID, bankAccountID, backTo, isChangingBankAccount}: {policyID: string | undefined; bankAccountID?: number; backTo?: string; isChangingBankAccount?: boolean}) => { let queryString = ''; if (bankAccountID) { queryString = `?bankAccountID=${bankAccountID}`; } else if (policyID) { queryString = `?policyID=${policyID}`; } + if (isChangingBankAccount) { + queryString += queryString ? '&isChangingBankAccount=true' : '?isChangingBankAccount=true'; + } // TODO this backTo comes from drilling it through bank account form screens // should be removed once https://github.com/Expensify/App/pull/72219 is resolved return getUrlWithBackToParam(`bank-account/new${queryString}`, backTo); @@ -1295,7 +1298,11 @@ const ROUTES = { BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT: { route: 'bank-account/connect-existing-business-bank-account', - getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`bank-account/connect-existing-business-bank-account?policyID=${policyID}`, backTo), + getRoute: (policyID: string, backTo?: string, source?: ValueOf) => { + const baseRoute = `bank-account/connect-existing-business-bank-account?policyID=${policyID}` as const; + const routeWithSource = source ? (`${baseRoute}&source=${source}` as const) : baseRoute; + return getUrlWithBackToParam(routeWithSource, backTo); + }, }, BANK_ACCOUNT_NON_USD_SETUP: { route: 'bank-account/new/global/:page?/:subPage?/:action?', diff --git a/src/hooks/useChangeBankAccount.ts b/src/hooks/useChangeBankAccount.ts new file mode 100644 index 000000000000..efb8ca8feac0 --- /dev/null +++ b/src/hooks/useChangeBankAccount.ts @@ -0,0 +1,35 @@ +import Navigation from '@navigation/Navigation'; + +import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; + +import {hasOtherEligibleBusinessBankAccountsSelector} from '@selectors/ReimbursementAccount'; + +import useOnyx from './useOnyx'; + +/** + * Returns a handler that changes the workspace's verified business bank account. When there are other eligible + * business bank accounts to connect it opens the existing-account picker, otherwise it starts a fresh setup. + * Shared by ConnectedVerifiedBankAccount and VerifiedBankAccountFlowEntryPoint. + */ +function useChangeBankAccount(policyID: string | undefined, currency: string | undefined, bankAccountID: number | undefined) { + const [hasOtherEligibleAccountsToConnect = false] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST, {selector: hasOtherEligibleBusinessBankAccountsSelector(currency, bankAccountID)}); + + return () => { + if (!policyID || !currency) { + return; + } + + if (hasOtherEligibleAccountsToConnect) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(policyID, undefined, CONST.BANK_ACCOUNT.CONNECT_EXISTING_SOURCE.CHANGE_BANK_ACCOUNT)); + return; + } + + navigateToBankAccountRoute({policyID, isChangingBankAccount: true}); + }; +} + +export default useChangeBankAccount; diff --git a/src/languages/de.ts b/src/languages/de.ts index ab7159789b5f..6924e3f63670 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7039,6 +7039,7 @@ Der Control-Tarif beginnt bei 9 $ pro aktivem Mitglied und Monat.`, workspaceCurrencyNotSupported: 'Workspace-Währung wird nicht unterstützt', yourWorkspace: `Dein Arbeitsbereich ist auf eine nicht unterstützte Währung eingestellt. Sieh dir die Liste der unterstützten Währungen an.`, chooseAnExisting: 'Wähle ein bestehendes Bankkonto zum Bezahlen von Ausgaben oder füge ein neues hinzu.', + changeBankAccount: 'Bankkonto ändern', }, changeOwner: { changeOwnerPageTitle: 'Besitz übertragen', diff --git a/src/languages/en.ts b/src/languages/en.ts index b38453141ed1..f2b3c79a7bcc 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7223,6 +7223,7 @@ const translations = { workspaceCurrencyNotSupported: 'Workspace currency not supported', yourWorkspace: `Your workspace is set to an unsupported currency. View the list of supported currencies.`, chooseAnExisting: 'Choose an existing bank account to pay expenses or add a new one.', + changeBankAccount: 'Change bank account', }, changeOwner: { changeOwnerPageTitle: 'Transfer owner', diff --git a/src/languages/es.ts b/src/languages/es.ts index 80f5e64f2d4c..7a100143606e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -6951,6 +6951,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, workspaceCurrencyNotSupported: 'Moneda del espacio de trabajo no soportada', yourWorkspace: `Tu espacio de trabajo está configurado en una moneda no soportada. Consulta la lista de monedas soportadas.`, chooseAnExisting: 'Elige una cuenta bancaria existente para pagar gastos o añade una nueva.', + changeBankAccount: 'Cambiar cuenta bancaria', }, changeOwner: { changeOwnerPageTitle: 'Transferir la propiedad', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 2c80ad56187f..0d66a83cc6e9 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7067,6 +7067,7 @@ Le forfait Control commence à 9 $ par Membre actif et par mois.`, workspaceCurrencyNotSupported: "Devise de l'espace de travail non prise en charge", yourWorkspace: `Votre espace de travail est défini sur une devise non prise en charge. Consultez la liste des devises prises en charge.`, chooseAnExisting: 'Choisissez un compte bancaire existant pour payer les dépenses ou ajoutez-en un nouveau.', + changeBankAccount: 'Changer de compte bancaire', }, changeOwner: { changeOwnerPageTitle: 'Transférer le responsable', diff --git a/src/languages/it.ts b/src/languages/it.ts index c6cce232c27a..b747d965ddf2 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7021,6 +7021,7 @@ Il piano Control parte da 9 $ al mese per ogni membro attivo.`, workspaceCurrencyNotSupported: 'Valuta dello spazio di lavoro non supportata', yourWorkspace: `La tua area di lavoro è impostata su una valuta non supportata. Visualizza l’elenco delle valute supportate.`, chooseAnExisting: 'Scegli un conto bancario esistente per pagare le spese oppure aggiungine uno nuovo.', + changeBankAccount: 'Cambia conto bancario', }, changeOwner: { changeOwnerPageTitle: 'Trasferisci proprietario', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index cd0d1dd46a70..d16fbaf1575e 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -6937,6 +6937,7 @@ Control プランは、アクティブメンバー1人あたり月額 $9 から workspaceCurrencyNotSupported: 'ワークスペースの通貨はサポートされていません', yourWorkspace: `ご利用のワークスペースはサポートされていない通貨に設定されています。サポートされている通貨の一覧を表示します。`, chooseAnExisting: '既存の銀行口座を選択して経費を支払うか、新しい口座を追加してください。', + changeBankAccount: '銀行口座を変更', }, changeOwner: { changeOwnerPageTitle: '所有者を変更', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 983cfb709e44..4d0d276a1e8e 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7004,6 +7004,7 @@ Het Control-abonnement begint bij $9 per actieve deelnemer per maand.`, workspaceCurrencyNotSupported: 'Werkruimtevaluta wordt niet ondersteund', yourWorkspace: `Je werkruimte is ingesteld op een niet-ondersteunde valuta. Bekijk de lijst met ondersteunde valuta's.`, chooseAnExisting: 'Kies een bestaande bankrekening om onkosten te betalen of voeg een nieuwe toe.', + changeBankAccount: 'Bankrekening wijzigen', }, changeOwner: { changeOwnerPageTitle: 'Eigenaar overdragen', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 8ba7155c1df9..c07317c6889e 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -6984,6 +6984,7 @@ Plan Control zaczyna się od 9 USD za aktywnego członka miesięcznie.`, workspaceCurrencyNotSupported: 'Waluta przestrzeni roboczej nie jest obsługiwana', yourWorkspace: `Twoje miejsce pracy jest ustawione na nieobsługiwaną walutę. Zobacz listę obsługiwanych walut.`, chooseAnExisting: 'Wybierz istniejące konto bankowe do opłacania wydatków lub dodaj nowe.', + changeBankAccount: 'Zmień konto bankowe', }, changeOwner: { changeOwnerPageTitle: 'Przenieś właściciela', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 6e1b8d8a3984..62b9a4a3ce39 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -6996,6 +6996,7 @@ O plano Control começa em US$ 9 por membro ativo por mês.`, workspaceCurrencyNotSupported: 'Moeda do workspace não suportada', yourWorkspace: `Seu workspace está configurado para uma moeda não compatível. Veja a lista de moedas compatíveis.`, chooseAnExisting: 'Escolha uma conta bancária existente para pagar despesas ou adicione uma nova.', + changeBankAccount: 'Alterar conta bancária', }, changeOwner: { changeOwnerPageTitle: 'Transferir proprietário', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 7ad0e99a0c82..261a7104e7ca 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6801,6 +6801,7 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM workspaceCurrencyNotSupported: '不支持工作区货币', yourWorkspace: `您的工作区当前使用不受支持的货币。请查看支持的货币列表。`, chooseAnExisting: '选择现有银行账户来支付报销,或添加新账户。', + changeBankAccount: '更改银行账户', }, changeOwner: { changeOwnerPageTitle: '转移所有者', diff --git a/src/libs/ExportOnyxState/common.ts b/src/libs/ExportOnyxState/common.ts index 36dd807d3927..6059ebec527b 100644 --- a/src/libs/ExportOnyxState/common.ts +++ b/src/libs/ExportOnyxState/common.ts @@ -178,6 +178,7 @@ const safeOnyxKeys = new Set([ ONYXKEYS.IMPORTED_SPREADSHEET_MEMBER_ROLE, ONYXKEYS.INPUT_FOCUSED, ONYXKEYS.IS_BETA, + ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT, ONYXKEYS.IS_COMING_FROM_GLOBAL_REIMBURSEMENTS_FLOW, ONYXKEYS.IS_DEBUG_MODE_ENABLED, ONYXKEYS.IS_GPS_IN_PROGRESS_MODAL_OPEN, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6303a525a220..f3fd334b0988 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2433,6 +2433,7 @@ type ReimbursementAccountNavigatorParamList = { policyID?: string; bankAccountID?: string; subStep?: typeof CONST.BANK_ACCOUNT.STEP.COUNTRY; + isChangingBankAccount?: boolean; }; [SCREENS.REIMBURSEMENT_ACCOUNT_USD]: { page?: string; @@ -2472,6 +2473,8 @@ type ConnectExistingBankAccountNavigatorParamList = { policyID: string; // eslint-disable-next-line no-restricted-syntax -- backTo is a temporary param will be removed after https://github.com/Expensify/App/issues/73825 is done backTo?: Routes; + /** Identifies the entry point that opened this screen, so shared logic can branch on intent */ + source?: ValueOf; }; }; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 74d009dce659..4a3905921de8 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -649,7 +649,12 @@ function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover /** * Get eligible business bank accounts for the workspace reimbursement workflow */ -function getEligibleExistingBusinessBankAccounts(bankAccountList: BankAccountList | undefined, policyCurrency: string | undefined, shouldIncludePartiallySetup?: boolean) { +function getEligibleExistingBusinessBankAccounts( + bankAccountList: BankAccountList | undefined, + policyCurrency: string | undefined, + shouldIncludePartiallySetup?: boolean, + excludeBankAccountID?: number, +) { if (!bankAccountList || policyCurrency === undefined) { return []; } @@ -658,7 +663,8 @@ function getEligibleExistingBusinessBankAccounts(bankAccountList: BankAccountLis return ( account.bankCurrency === policyCurrency && (account.accountData?.state === CONST.BANK_ACCOUNT.STATE.OPEN || (shouldIncludePartiallySetup && isBankAccountPartiallySetup(account.accountData?.state))) && - account.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS + account.accountData?.type === CONST.BANK_ACCOUNT.TYPE.BUSINESS && + account.methodID !== excludeBankAccountID ); }); } diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 8f26550baaf0..4a17d0864adb 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -152,7 +152,7 @@ import type ReportNextStepDeprecated from '@src/types/onyx/ReportNextStepDepreca import type {OnyxData} from '@src/types/onyx/Request'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -import type {OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; +import type {NullishDeep, OnyxCollection, OnyxCollectionInputValue, OnyxEntry, OnyxUpdate} from 'react-native-onyx'; import type {TupleToUnion, ValueOf} from 'type-fest'; /* eslint-disable max-lines */ @@ -1202,12 +1202,13 @@ function setWorkspaceReimbursement({ ]; if (bankAccountID !== undefined && bankAccountList !== undefined) { - const optimisticBankAccountList: BankAccountList = {}; + const optimisticBankAccountList: NullishDeep = {}; if (oldBankAccountID) { optimisticBankAccountList[oldBankAccountID] = { ...optimisticBankAccountList[oldBankAccountID], accountData: { policyIDs: bankAccountList?.[oldBankAccountID]?.accountData?.policyIDs?.filter((id) => id !== policyID) ?? [], + additionalData: {policyID: null}, }, }; } @@ -1216,6 +1217,7 @@ function setWorkspaceReimbursement({ ...optimisticBankAccountList[bankAccountID], accountData: { policyIDs: [...new Set([...currentPolicyIDs, policyID])], + additionalData: {policyID}, }, }; @@ -1280,12 +1282,13 @@ function setWorkspaceReimbursement({ ]; if (bankAccountID !== undefined && bankAccountList !== undefined) { - const failureBankAccountList: BankAccountList = {}; + const failureBankAccountList: NullishDeep = {}; if (oldBankAccountID) { failureBankAccountList[oldBankAccountID] = { ...failureBankAccountList[oldBankAccountID], accountData: { policyIDs: bankAccountList?.[oldBankAccountID]?.accountData?.policyIDs ?? [], + additionalData: {policyID: bankAccountList?.[oldBankAccountID]?.accountData?.additionalData?.policyID ?? null}, }, }; } @@ -1293,6 +1296,7 @@ function setWorkspaceReimbursement({ ...failureBankAccountList[bankAccountID], accountData: { policyIDs: bankAccountList?.[bankAccountID]?.accountData?.policyIDs ?? [], + additionalData: {policyID: bankAccountList?.[bankAccountID]?.accountData?.additionalData?.policyID ?? null}, }, }; failureData.push({ diff --git a/src/libs/actions/ReimbursementAccount/index.ts b/src/libs/actions/ReimbursementAccount/index.ts index 842ede668503..610435867fb6 100644 --- a/src/libs/actions/ReimbursementAccount/index.ts +++ b/src/libs/actions/ReimbursementAccount/index.ts @@ -1,3 +1,5 @@ +import mapCurrencyToCountry from '@libs/mapCurrencyToCountry'; + import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReimbursementAccountForm} from '@src/types/form'; @@ -43,6 +45,28 @@ function clearReimbursementAccount() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, CONST.REIMBURSEMENT_ACCOUNT.DEFAULT_DATA); } +/** + * Prepares the app to set up a new bank account by marking the account change, clearing existing data, + * and initializing draft with country and currency. + * We need to temporarily clear this data to set up new account without disconnecting existing one + */ +function prepareNewBankAccountSetup(currency: string) { + Onyx.set(ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT, true); + clearReimbursementAccount(); + clearReimbursementAccountDraft(); + updateReimbursementAccountDraft({ + country: mapCurrencyToCountry(currency), + currency, + }); +} + +/** + * Cancels the change to new bank account + */ +function cancelChangingToNewBankAccount() { + Onyx.set(ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT, false); +} + /** * Triggers a modal to open allowing the user to reset their bank account */ @@ -88,4 +112,6 @@ export { setReimbursementAccountOptionPressed, updateReimbursementAccount, resetReimbursementAccount, + cancelChangingToNewBankAccount, + prepareNewBankAccountSetup, }; diff --git a/src/libs/actions/ReimbursementAccount/navigation.ts b/src/libs/actions/ReimbursementAccount/navigation.ts index eb7632ca333f..8c765c867b58 100644 --- a/src/libs/actions/ReimbursementAccount/navigation.ts +++ b/src/libs/actions/ReimbursementAccount/navigation.ts @@ -21,19 +21,22 @@ function goToWithdrawalAccountSetupStep(stepID: BankAccountStep) { * @param [backTo] - An optional return path. If provided, it will be URL-encoded and appended to the resulting URL. * @param [bankAccountID] - An optional bank account ID. There can be bank accounts that are not linked to any workspace. * @param [navigationOptions] - Optional navigation options to customize the navigation behavior. + * @param [isChangingBankAccount] - Whether the user is in change bank account flow. */ function navigateToBankAccountRoute({ policyID = '', bankAccountID, backTo, navigationOptions, + isChangingBankAccount, }: { policyID?: string; bankAccountID?: number; backTo?: string; navigationOptions?: LinkToOptions; + isChangingBankAccount?: boolean; }) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, bankAccountID, backTo}), navigationOptions); + Navigation.navigate(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, bankAccountID, backTo, isChangingBankAccount}), navigationOptions); } export {goToWithdrawalAccountSetupStep, navigateToBankAccountRoute}; diff --git a/src/libs/mapCurrencyToCountry.ts b/src/libs/mapCurrencyToCountry.ts index f4062674c183..4794dc280ee2 100644 --- a/src/libs/mapCurrencyToCountry.ts +++ b/src/libs/mapCurrencyToCountry.ts @@ -1,6 +1,7 @@ +import type {Country} from '@src/CONST'; import CONST from '@src/CONST'; -function mapCurrencyToCountry(currency: string): string { +function mapCurrencyToCountry(currency: string): Country | '' { switch (currency) { case CONST.CURRENCY.USD: return CONST.COUNTRY.US; diff --git a/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx b/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx index 8d66c45343f3..129e56885214 100644 --- a/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx +++ b/src/pages/ReimbursementAccount/ConnectedVerifiedBankAccount.tsx @@ -9,6 +9,7 @@ import ScrollView from '@components/ScrollView'; import Section from '@components/Section'; import Text from '@components/Text'; +import useChangeBankAccount from '@hooks/useChangeBankAccount'; import {useMemoizedLazyAsset, useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useResetBankAccountModal from '@hooks/useResetBankAccountModal'; @@ -61,7 +62,11 @@ function ConnectedVerifiedBankAccount({ const errors = reimbursementAccount?.errors ?? {}; const pendingAction = reimbursementAccount?.pendingAction; const {asset: ThumbsUpStars} = useMemoizedLazyAsset(() => loadIllustration('ThumbsUpStars' as IllustrationName)); - const icons = useMemoizedLazyExpensifyIcons(['Close']); + const icons = useMemoizedLazyExpensifyIcons(['Bank', 'Close']); + const policyID = reimbursementAccount?.achData?.policyID; + const currency = reimbursementAccount?.achData?.currency; + const shouldShowChangeBankAccount = !!policyID && !!currency; + const handleChangeBankAccount = useChangeBankAccount(policyID, currency, reimbursementAccount?.achData?.bankAccountID); useResetBankAccountModal({ reimbursementAccount, @@ -105,6 +110,16 @@ function ConnectedVerifiedBankAccount({ wrapperStyle={[styles.ph0, styles.mv3, styles.h13]} /> {translate('workspace.bankAccount.accountDescriptionWithCards')} + {shouldShowChangeBankAccount && ( + + )} state?.routes.findLast((lastRoute) => isFullScreenName(lastRoute.name))); + const [isChangingToNewBankAccount] = useOnyx(ONYXKEYS.IS_CHANGING_TO_NEW_BANK_ACCOUNT); const {isBetaEnabled} = usePermissions(); const policyName = policy?.name ?? ''; const policyIDParam = route.params?.policyID; const bankAccountIDParam = route.params?.bankAccountID; const subStepParam = route.params?.subStep; - const backTo = route.params.backTo; + const backTo = route.params?.backTo; + const isChangingBankAccount = !!route.params?.isChangingBankAccount; const isComingFromExpensifyCard = (backTo as string)?.includes(CONST.EXPENSIFY_CARD.ROUTE as string); const styles = useThemeStyles(); const {translate} = useLocalize(); @@ -110,6 +112,8 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const requestorStepRef = useRef(null); const hasRequestedNewBankAccountRef = useRef(false); const hasClearedStalePlaidErrorsRef = useRef(false); + const isChangingBankAccountRef = useRef(isChangingBankAccount); + const hasShownConnectedBankAccountRef = useRef(false); const prevReimbursementAccount = usePrevious(reimbursementAccount); const prevIsOffline = usePrevious(isOffline); const achData = reimbursementAccount?.achData; @@ -121,6 +125,16 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen acceptTermsAndConditions?: boolean; }>({}); const isLoadingWorkspaceReimbursement = policy?.isLoadingWorkspaceReimbursement; + const prevIsLoadingWorkspaceReimbursement = usePrevious(isLoadingWorkspaceReimbursement); + + const [isSettingBA, setIsSettingBA] = useState(false); + // Keeps the loader up while switching the workspace's bank account, bridging the gap between the switch request + // finishing and the follow-up refetch's own loading flag landing. + if (isLoadingWorkspaceReimbursement && !prevIsLoadingWorkspaceReimbursement && !isSettingBA) { + setIsSettingBA(true); + } else if (isSettingBA && !isLoadingWorkspaceReimbursement && reimbursementAccount?.isLoading) { + setIsSettingBA(false); + } const isNonUSDWorkspace = !!policyCurrency && policyCurrency !== CONST.CURRENCY.USD; const hasUnsupportedCurrency = isComingFromExpensifyCard && isBetaEnabled(CONST.BETAS.EXPENSIFY_CARD_EU_UK) && isNonUSDWorkspace @@ -158,11 +172,16 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen const currentStep = getInitialCurrentStep(); const [USDBankAccountStep, setUSDBankAccountStep] = useState(subStepParam ?? null); const [isNonUSDSetup, setIsNonUSDSetup] = useState(policy ? isNonUSDWorkspace : achData?.currency !== CONST.CURRENCY.USD || reimbursementAccountDraft?.currency !== CONST.CURRENCY.USD); + const isConnectedVerifiedBankAccountData = isNonUSDSetup ? achData?.state === CONST.BANK_ACCOUNT.STATE.OPEN : achData?.currentStep === CONST.BANK_ACCOUNT.STEP.ENABLE; useEffect(() => { + const isChangingBankAccountInstance = isChangingBankAccountRef.current; return () => { - clearReimbursementAccountDraft(); - clearReimbursementAccount(); + if (!isChangingBankAccountInstance) { + clearReimbursementAccountDraft(); + clearReimbursementAccount(); + } + cancelChangingToNewBankAccount(); getPaymentMethods(); }; }, []); @@ -217,7 +236,11 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen * Retrieve verified business bank account currently being set up. */ function fetchData(preserveCurrentStep = false) { - if ((!policyIDParam && !bankAccountIDParam) || isLoadingOnyxValue(reimbursementAccountMetadata)) { + if ( + (!policyIDParam && !bankAccountIDParam) || + isLoadingOnyxValue(reimbursementAccountMetadata) || + (policyIDParam !== undefined && backTo === ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(policyIDParam)) + ) { return; } if (bankAccountIDParam) { @@ -242,13 +265,46 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen openReimbursementAccountPage({stepToOpen, subStep, localCurrentStep, policyID: policyIDParam, shouldPreserveDraft: preserveCurrentStep}); } + // When the workspace's bank account is switched, the switch request and the refetch that reloads + // the new account run one after another. We must not refetch until the switch has committed to escape race condition. + useEffect(() => { + const isSettingFinished = !isLoadingWorkspaceReimbursement && prevIsLoadingWorkspaceReimbursement; + if (!isSettingFinished) { + return; + } + fetchData(); + // Run only on the loading transition, not when fetchData's identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoadingWorkspaceReimbursement, prevIsLoadingWorkspaceReimbursement]); + + // A pushed "change bank account" instance and its setup steps mutate the shared reimbursement account. When focus + // returns to this connected (non-changing) screen and the shared data has been clobbered, reload it so the + // "you're all set" page is shown again instead of the setup entry. + useEffect(() => { + if (isConnectedVerifiedBankAccountData && !isChangingBankAccount) { + hasShownConnectedBankAccountRef.current = true; + return; + } + if (isFocused && !isChangingBankAccount && hasShownConnectedBankAccountRef.current && !isConnectedVerifiedBankAccountData) { + fetchData(); + } + // fetchData is intentionally omitted; this must react to the connected data being clobbered, not to fetchData's identity. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isFocused, isChangingBankAccount, isConnectedVerifiedBankAccountData]); + useEffect(() => { // Consume this route intent only once so the response changing isPreviousPolicy does not trigger another request. - const shouldOpenNewBankAccount = route.params.stepToOpen === REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW && !hasRequestedNewBankAccountRef.current; + const shouldOpenNewBankAccount = route.params?.stepToOpen === REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW && !hasRequestedNewBankAccountRef.current; if ((!shouldOpenNewBankAccount && isPreviousPolicy && !!reimbursementAccount) || isLoadingOnyxValue(reimbursementAccountMetadata)) { return; } + // Skip while switching the workspace's bank account: the dedicated effect above fetches once the switch + // finishes, so fetching here would race it and could load the old account. + if (isChangingToNewBankAccount || isLoadingWorkspaceReimbursement) { + return; + } + if (policyIDParam) { setReimbursementAccountLoading(true); clearReimbursementAccountDraft(); @@ -486,7 +542,8 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen // On Android, when we open the app from the background, Onfido activity gets destroyed, so we need to reopen it. if ( (!!policyIDParam || !!bankAccountIDParam) && - (!hasACHDataBeenLoaded || isLoading || isLoadingWorkspaceReimbursement) && + !isChangingToNewBankAccount && + (!hasACHDataBeenLoaded || isLoading || isLoadingWorkspaceReimbursement || isSettingBA) && shouldShowOfflineLoader && (shouldReopenOnfido || !requestorStepRef?.current) ) { @@ -533,7 +590,9 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen ); } - if (shouldShowConnectedVerifiedBankAccount) { + // While this instance is starting a fresh setup (change bank account), show the setup entry instead of the connected + // account, even though the shared data still describes the currently connected account. + if (shouldShowConnectedVerifiedBankAccount && isConnectedVerifiedBankAccountData && !isChangingBankAccount) { if (topmostFullScreenRoute?.name === NAVIGATORS.SETTINGS_SPLIT_NAVIGATOR) { return ( @@ -563,6 +622,10 @@ function ReimbursementAccountPage({route, policy, isLoadingPolicy}: Reimbursemen ); } + // Once fresh data has loaded, trust the live value to avoid a one-frame flash from the effect-synced state lagging achData. + // On a "change bank account" instance never show "continue setup", since the shared data still describes the account being replaced. + const shouldShowContinueSetupButtonToDisplay = !isChangingBankAccount && (hasLoadedData ? shouldShowContinueSetupButtonValue : shouldShowContinueSetupButton); + return ( ); } diff --git a/src/pages/ReimbursementAccount/USD/utils/getStepToOpenFromRouteParams.ts b/src/pages/ReimbursementAccount/USD/utils/getStepToOpenFromRouteParams.ts index 15ec4698777c..40e50bc2f766 100644 --- a/src/pages/ReimbursementAccount/USD/utils/getStepToOpenFromRouteParams.ts +++ b/src/pages/ReimbursementAccount/USD/utils/getStepToOpenFromRouteParams.ts @@ -14,7 +14,7 @@ function getStepToOpenFromRouteParams( route: PlatformStackRouteProp, hasConfirmedUSDCurrency: boolean, ): TBankAccountStep | '' { - switch (route.params.stepToOpen) { + switch (route.params?.stepToOpen) { case REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW: return hasConfirmedUSDCurrency ? CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT : CONST.BANK_ACCOUNT.STEP.COUNTRY; case REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.COMPANY: diff --git a/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx b/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx index ee3fc9dad088..fc8f05ed7138 100644 --- a/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx +++ b/src/pages/ReimbursementAccount/VerifiedBankAccountFlowEntryPoint.tsx @@ -10,6 +10,7 @@ import Section from '@components/Section'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; +import useChangeBankAccount from '@hooks/useChangeBankAccount'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -26,7 +27,14 @@ import {hasActiveAdminWorkspaces} from '@libs/PolicyUtils'; import {goToWithdrawalAccountSetupStep, openPlaidView, updateReimbursementAccountDraft} from '@userActions/BankAccounts'; import {setDraftValues} from '@userActions/FormActions'; import {openExternalLink} from '@userActions/Link'; -import {requestResetBankAccount, resetReimbursementAccount, setBankAccountSubStep, setReimbursementAccountOptionPressed, updateReimbursementAccount} from '@userActions/ReimbursementAccount'; +import { + prepareNewBankAccountSetup, + requestResetBankAccount, + resetReimbursementAccount, + setBankAccountSubStep, + setReimbursementAccountOptionPressed, + updateReimbursementAccount, +} from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -73,6 +81,9 @@ type VerifiedBankAccountFlowEntryPointProps = { /** Whether the user is coming from the expensify card */ isComingFromExpensifyCard?: boolean; + + /** Whether this instance is starting a fresh setup from a "change bank account" flow */ + isChangingBankAccount?: boolean; }; const bankInfoStepKeys = INPUT_IDS.BANK_INFO_STEP; @@ -88,6 +99,7 @@ function VerifiedBankAccountFlowEntryPoint({ setUSDBankAccountStep, setShouldShowContinueSetupButton, isComingFromExpensifyCard, + isChangingBankAccount, }: VerifiedBankAccountFlowEntryPointProps) { const theme = useTheme(); const styles = useThemeStyles(); @@ -109,7 +121,18 @@ function VerifiedBankAccountFlowEntryPoint({ const personalBankAccounts = bankAccountList ? Object.keys(bankAccountList).filter((key) => bankAccountList[key].accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) : []; + const currency = reimbursementAccount?.achData?.currency; + // The "Change bank account" option is only offered when opening a partially setup account from the Workflows > Payments section + const isComingFromWorkflowsPayments = !!policyID && backTo === ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID); + const shouldShowChangeBankAccount = shouldShowContinueSetupButton === true && isComingFromWorkflowsPayments; + const handleChangeBankAccount = useChangeBankAccount(policyID, currency, reimbursementAccount?.achData?.bankAccountID); + const removeExistingBankAccountDetails = useCallback(() => { + // In a "change bank account" flow, start a completely fresh setup so the new account's steps aren't prefilled. + if (isChangingBankAccount && currency) { + prepareNewBankAccountSetup(currency); + return; + } const bankAccountData: Partial = { [bankInfoStepKeys.ROUTING_NUMBER]: '', [bankInfoStepKeys.ACCOUNT_NUMBER]: '', @@ -121,7 +144,7 @@ function VerifiedBankAccountFlowEntryPoint({ }; updateReimbursementAccountDraft(bankAccountData); updateReimbursementAccount({bankAccountID: 0}); - }, []); + }, [isChangingBankAccount, currency]); /** * Prepares and redirects user to next step in the USD flow @@ -146,6 +169,7 @@ function VerifiedBankAccountFlowEntryPoint({ } if (reimbursementAccountOptionPressed === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + removeExistingBankAccountDetails(); if (isNonUSDWorkspace) { if (isComingFromExpensifyCard) { setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {isComingFromExpensifyCard}); @@ -155,7 +179,6 @@ function VerifiedBankAccountFlowEntryPoint({ return; } - removeExistingBankAccountDetails(); prepareNextStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); setReimbursementAccountOptionPressed(CONST.BANK_ACCOUNT.SETUP_TYPE.NONE); } else if (reimbursementAccountOptionPressed === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { @@ -173,6 +196,8 @@ function VerifiedBankAccountFlowEntryPoint({ return; } + removeExistingBankAccountDetails(); + if (isNonUSDWorkspace) { if (isComingFromExpensifyCard) { setDraftValues(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, {isComingFromExpensifyCard}); @@ -181,7 +206,6 @@ function VerifiedBankAccountFlowEntryPoint({ return; } - removeExistingBankAccountDetails(); prepareNextStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); }; @@ -274,6 +298,16 @@ function VerifiedBankAccountFlowEntryPoint({ outerWrapperStyle={shouldUseNarrowLayout ? styles.mhn5 : styles.mhn8} disabled={!!pendingAction || (!isEmptyObject(errors) && !reimbursementAccount?.maxAttemptsReached)} /> + {shouldShowChangeBankAccount && ( + + )} >; + /** Bank account ID of an account that we do not want to show (i.e. it's already connected) */ + excludeBankAccountID?: number; + /** Whether to show the default badge for the payment method */ shouldHideDefaultBadge?: boolean; @@ -185,6 +188,7 @@ function PaymentMethodList({ filterType, filterCurrency, excludeStates, + excludeBankAccountID, shouldHideDefaultBadge = false, threeDotsMenuItems, onThreeDotsMenuPress, @@ -434,13 +438,14 @@ function PaymentMethodList({ ); } - if (filterType ?? filterCurrency) { + if (filterType ?? filterCurrency ?? excludeBankAccountID) { combinedPaymentMethods = combinedPaymentMethods.filter((paymentMethod) => { const account = paymentMethod as BankAccount; const typeMatches = !filterType || account.accountData?.type === filterType; const currencyMatches = !filterCurrency || account.bankCurrency === filterCurrency; + const shouldInclude = !excludeBankAccountID || account.methodID !== excludeBankAccountID; - return typeMatches && currencyMatches; + return typeMatches && currencyMatches && shouldInclude; }); } diff --git a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx index 674871cde30a..9abdff58dc81 100644 --- a/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx +++ b/src/pages/workspace/ConnectExistingBusinessBankAccountPage.tsx @@ -1,3 +1,4 @@ +import ActivityIndicator from '@components/ActivityIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import ScrollView from '@components/ScrollView'; @@ -22,14 +23,15 @@ import type {PaymentMethodPressHandlerParams} from '@pages/settings/Wallet/Walle import {setReimbursementAccountLoading} from '@userActions/BankAccounts'; import {setWorkspaceReimbursement} from '@userActions/Policy/Policy'; -import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; +import {navigateToBankAccountRoute, prepareNewBankAccountSetup} from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import React from 'react'; +import React, {useState} from 'react'; +import {View} from 'react-native'; type ConnectExistingBusinessBankAccountPageProps = PlatformStackScreenProps; @@ -43,14 +45,28 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness const policyName = policy?.name ?? ''; const policyCurrency = policy?.outputCurrency ?? ''; const {shouldUseNarrowLayout} = useResponsiveLayout(); + const isChangingBankAccount = route.params?.source === CONST.BANK_ACCOUNT.CONNECT_EXISTING_SOURCE.CHANGE_BANK_ACCOUNT; + const isBankAccountFullySetup = !!policy?.achAccount && (policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.OPEN || policy.achAccount.state === CONST.BANK_ACCOUNT.STATE.LOCKED); + const connectedBankAccount = Object.values(bankAccountList ?? {}).find((bankAccount) => bankAccount?.accountData?.additionalData?.policyID === policyID); + const connectedAccountBankAccountID = (isBankAccountFullySetup ? policy?.achAccount?.bankAccountID : connectedBankAccount?.methodID) ?? CONST.DEFAULT_NUMBER_ID; const styles = useThemeStyles(); const {translate} = useLocalize(); + const [isSelectingBankAccount, setIsSelectingBankAccount] = useState(false); + const handleAddBankAccountPress = () => { - setReimbursementAccountLoading(true); + // When changing the bank account we prepare a fresh setup (clearing the current account without disconnecting it). + // We keep the policyID in the route so the newly connected account still links to the workspace's Workflows > Payments. + // We must not set the loading flag here: during the change flow the page skips fetchData, so nothing would reset it. + if (isChangingBankAccount) { + prepareNewBankAccountSetup(policyCurrency); + } else { + setReimbursementAccountLoading(true); + } + Navigation.setNavigationActionToMicrotaskQueue(() => { - Navigation.navigate(appendParam(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, backTo}), 'stepToOpen', REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW)); + Navigation.navigate(appendParam(ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute({policyID, backTo, isChangingBankAccount}), 'stepToOpen', REIMBURSEMENT_ACCOUNT_ROUTE_NAMES.NEW)); }); }; @@ -61,7 +77,8 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness const newReimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner ?? ''; - if (bankAccountList && methodID && !bankAccountList[methodID]?.accountData?.policyIDs?.includes(policyID)) { + if (bankAccountList && methodID && methodID !== connectedAccountBankAccountID && methodID !== policy?.achAccount?.bankAccountID) { + setIsSelectingBankAccount(true); setWorkspaceReimbursement({ policyID, currentAchAccount: policy?.achAccount, @@ -81,12 +98,18 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness } Navigation.setNavigationActionToMicrotaskQueue(() => { - if (isBankAccountPartiallySetup(accountData?.state)) { - navigateToBankAccountRoute({policyID, backTo, navigationOptions: {forceReplace: true}}); - } else { + if (!isBankAccountPartiallySetup(accountData?.state)) { setReimbursementAccountLoading(false); Navigation.closeRHPFlow(); + return; } + + if (isChangingBankAccount) { + Navigation.goBack(backTo); + return; + } + + navigateToBankAccountRoute({policyID, backTo, navigationOptions: {forceReplace: true}}); }); }; @@ -100,20 +123,30 @@ function ConnectExistingBusinessBankAccountPage({route}: ConnectExistingBusiness subtitle={policyName} onBackButtonPress={Navigation.goBack} /> - - {translate('workspace.bankAccount.chooseAnExisting')} - - + {isSelectingBankAccount ? ( + + + + ) : ( + + {translate('workspace.bankAccount.chooseAnExisting')} + + + )} ); } diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 3937413adf20..c21c7375ae11 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -401,6 +401,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const isAccountInSetupState = isBankAccountPartiallySetup(state); const isBusinessBankAccountLocked = state === CONST.BANK_ACCOUNT.STATE.LOCKED; const canChangePayer = canWritePayments && !isAccountInSetupState; + const hasOtherEligibleExistingAccounts = getEligibleExistingBusinessBankAccounts(bankAccountList, policy?.outputCurrency, true, bankAccountID).length > 0; const shouldShowBankAccount = (!!isBankAccountFullySetup || !!bankAccountConnectedToWorkspace) && policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; const shouldShowPayer = shouldShowBankAccount || policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_MANUAL; @@ -702,13 +703,11 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { return; } - // User who is not reimburser can't initiate unlocking process but can connect new account - if (state === CONST.BANK_ACCOUNT.STATE.LOCKED && bankAccountID && !isUserReimburser) { - // If user has existing accounts and no bank account setup in progress we should show screen to choose an existing account - if (hasValidExistingAccounts && !shouldShowContinueModal) { - Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(route.params.policyID)); - return; - } + // A non-reimburser can't edit or unlock the workspace's connected account, so if they have another + // eligible existing account and no setup in progress, let them link it (change the workspace's account). + if (!isUserReimburser && hasOtherEligibleExistingAccounts && !shouldShowContinueModal) { + Navigation.navigate(ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(route.params.policyID)); + return; } navigateToBankAccountRoute({ diff --git a/src/selectors/ReimbursementAccount.ts b/src/selectors/ReimbursementAccount.ts index c1e7508a820b..62180756f2fa 100644 --- a/src/selectors/ReimbursementAccount.ts +++ b/src/selectors/ReimbursementAccount.ts @@ -1,4 +1,6 @@ -import type {ReimbursementAccount} from '@src/types/onyx'; +import {getEligibleExistingBusinessBankAccounts} from '@libs/WorkflowUtils'; + +import type {BankAccountList, ReimbursementAccount} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnyxEntry} from 'react-native-onyx'; @@ -7,4 +9,12 @@ const reimbursementAccountErrorSelector = (reimbursementAccount: OnyxEntry) => !isEmptyObject(reimbursementAccount?.errors); -export {reimbursementAccountErrorSelector, hasReimbursementAccountErrorsSelector}; +/** + * Returns a selector that reports whether there are other eligible existing business bank accounts that could be connected. + */ +const hasOtherEligibleBusinessBankAccountsSelector = + (currency: string | undefined, excludeBankAccountID: number | undefined) => + (bankAccountList: OnyxEntry): boolean => + getEligibleExistingBusinessBankAccounts(bankAccountList, currency, true, excludeBankAccountID).length > 0; + +export {reimbursementAccountErrorSelector, hasReimbursementAccountErrorsSelector, hasOtherEligibleBusinessBankAccountsSelector}; diff --git a/tests/unit/hooks/useChangeBankAccount.test.ts b/tests/unit/hooks/useChangeBankAccount.test.ts new file mode 100644 index 000000000000..4033d0f4b797 --- /dev/null +++ b/tests/unit/hooks/useChangeBankAccount.test.ts @@ -0,0 +1,115 @@ +import {act, renderHook} from '@testing-library/react-native'; + +import useChangeBankAccount from '@hooks/useChangeBankAccount'; + +import Navigation from '@navigation/Navigation'; + +import {navigateToBankAccountRoute, prepareNewBankAccountSetup} from '@userActions/ReimbursementAccount'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {BankAccountList} from '@src/types/onyx'; + +import Onyx from 'react-native-onyx'; + +import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates'; + +jest.mock('@navigation/Navigation', () => ({ + __esModule: true, + default: {navigate: jest.fn()}, +})); + +jest.mock('@userActions/ReimbursementAccount', () => ({ + __esModule: true, + prepareNewBankAccountSetup: jest.fn(), + navigateToBankAccountRoute: jest.fn(), +})); + +const POLICY_ID = '1'; +const CURRENCY = CONST.CURRENCY.USD; +const CURRENT_BANK_ACCOUNT_ID = 999; + +const mockNavigate = jest.mocked(Navigation.navigate); +const mockPrepareNewBankAccountSetup = jest.mocked(prepareNewBankAccountSetup); +const mockNavigateToBankAccountRoute = jest.mocked(navigateToBankAccountRoute); + +function buildBusinessBankAccount(methodID: number) { + return { + methodID, + bankCurrency: CURRENCY, + bankCountry: 'US', + accountData: { + state: CONST.BANK_ACCOUNT.STATE.OPEN, + type: CONST.BANK_ACCOUNT.TYPE.BUSINESS, + }, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/naming-convention +const OTHER_ELIGIBLE_BANK_ACCOUNT_LIST = {'111': buildBusinessBankAccount(111)} as unknown as BankAccountList; +// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion +const CURRENT_ONLY_BANK_ACCOUNT_LIST = {[`${CURRENT_BANK_ACCOUNT_ID}`]: buildBusinessBankAccount(CURRENT_BANK_ACCOUNT_ID)} as unknown as BankAccountList; + +async function setBankAccountList(bankAccountList: BankAccountList) { + await Onyx.merge(ONYXKEYS.BANK_ACCOUNT_LIST, bankAccountList); + await waitForBatchedUpdates(); +} + +describe('useChangeBankAccount', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + await Onyx.clear(); + await waitForBatchedUpdates(); + jest.clearAllMocks(); + }); + + afterEach(async () => { + await Onyx.clear(); + }); + + it('opens the existing-account picker when there are other eligible business bank accounts', async () => { + // The current account (999) plus another eligible open business account with a different methodID. + await setBankAccountList(OTHER_ELIGIBLE_BANK_ACCOUNT_LIST); + + const {result} = renderHook(() => useChangeBankAccount(POLICY_ID, CURRENCY, CURRENT_BANK_ACCOUNT_ID)); + await waitForBatchedUpdates(); + + act(() => result.current()); + + expect(mockNavigate).toHaveBeenCalledWith( + ROUTES.BANK_ACCOUNT_CONNECT_EXISTING_BUSINESS_BANK_ACCOUNT.getRoute(POLICY_ID, undefined, CONST.BANK_ACCOUNT.CONNECT_EXISTING_SOURCE.CHANGE_BANK_ACCOUNT), + ); + expect(mockPrepareNewBankAccountSetup).not.toHaveBeenCalled(); + expect(mockNavigateToBankAccountRoute).not.toHaveBeenCalled(); + }); + + it('starts a fresh setup when there are no other eligible business bank accounts', async () => { + // The only account is the current one, so it is excluded and nothing else is eligible. + await setBankAccountList(CURRENT_ONLY_BANK_ACCOUNT_LIST); + + const {result} = renderHook(() => useChangeBankAccount(POLICY_ID, CURRENCY, CURRENT_BANK_ACCOUNT_ID)); + await waitForBatchedUpdates(); + + act(() => result.current()); + + expect(mockNavigateToBankAccountRoute).toHaveBeenCalledWith({policyID: POLICY_ID, isChangingBankAccount: true}); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('does nothing when policyID or currency is missing', async () => { + await setBankAccountList(OTHER_ELIGIBLE_BANK_ACCOUNT_LIST); + + const {result} = renderHook(() => useChangeBankAccount(undefined, undefined, CURRENT_BANK_ACCOUNT_ID)); + await waitForBatchedUpdates(); + + act(() => result.current()); + + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockPrepareNewBankAccountSetup).not.toHaveBeenCalled(); + expect(mockNavigateToBankAccountRoute).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/selectors/ReimbursementAccountTest.ts b/tests/unit/selectors/ReimbursementAccountTest.ts new file mode 100644 index 000000000000..f681d6dda5e4 --- /dev/null +++ b/tests/unit/selectors/ReimbursementAccountTest.ts @@ -0,0 +1,70 @@ +import CONST from '@src/CONST'; +import type {BankAccount, BankAccountList} from '@src/types/onyx'; + +import {hasOtherEligibleBusinessBankAccountsSelector} from '@selectors/ReimbursementAccount'; + +const CURRENCY = 'USD'; + +function createBankAccount(methodID: number, overrides?: {currency?: string; state?: string; type?: string}): BankAccount { + return { + methodID, + bankCurrency: overrides?.currency ?? CURRENCY, + bankCountry: CONST.COUNTRY.US, + accountData: { + state: overrides?.state ?? CONST.BANK_ACCOUNT.STATE.OPEN, + type: overrides?.type ?? CONST.BANK_ACCOUNT.TYPE.BUSINESS, + }, + }; +} + +describe('hasOtherEligibleBusinessBankAccountsSelector', () => { + it('returns false when the bank account list is undefined or empty', () => { + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)(undefined)).toBe(false); + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)({})).toBe(false); + }); + + it('returns false when the currency is undefined', () => { + const bankAccountList: BankAccountList = {a1: createBankAccount(1)}; + expect(hasOtherEligibleBusinessBankAccountsSelector(undefined, undefined)(bankAccountList)).toBe(false); + }); + + it('returns true when there is an open business bank account in the matching currency', () => { + const bankAccountList: BankAccountList = {a1: createBankAccount(1)}; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)(bankAccountList)).toBe(true); + }); + + it('returns true for partially set up business bank accounts (SETUP, VERIFYING, PENDING)', () => { + for (const state of [CONST.BANK_ACCOUNT.STATE.SETUP, CONST.BANK_ACCOUNT.STATE.VERIFYING, CONST.BANK_ACCOUNT.STATE.PENDING]) { + const bankAccountList: BankAccountList = {a1: createBankAccount(1, {state})}; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)(bankAccountList)).toBe(true); + } + }); + + it('ignores accounts in a non-eligible state (e.g. DELETED)', () => { + const bankAccountList: BankAccountList = {a1: createBankAccount(1, {state: CONST.BANK_ACCOUNT.STATE.DELETED})}; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)(bankAccountList)).toBe(false); + }); + + it('ignores accounts with a mismatched currency', () => { + const bankAccountList: BankAccountList = {a1: createBankAccount(1, {currency: 'EUR'})}; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)(bankAccountList)).toBe(false); + }); + + it('ignores personal (non-business) bank accounts', () => { + const bankAccountList: BankAccountList = {a1: createBankAccount(1, {type: CONST.BANK_ACCOUNT.TYPE.PERSONAL})}; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, undefined)(bankAccountList)).toBe(false); + }); + + it('excludes the current bank account by methodID', () => { + const bankAccountList: BankAccountList = {a1: createBankAccount(1)}; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, 1)(bankAccountList)).toBe(false); + }); + + it('returns true when another eligible account exists besides the excluded one', () => { + const bankAccountList: BankAccountList = { + a1: createBankAccount(1), + a2: createBankAccount(2), + }; + expect(hasOtherEligibleBusinessBankAccountsSelector(CURRENCY, 1)(bankAccountList)).toBe(true); + }); +});