diff --git a/cspell.json b/cspell.json index 1e1382d4174f..d55ae14f4fed 100644 --- a/cspell.json +++ b/cspell.json @@ -667,6 +667,8 @@ "setuptools", "shareeEmail", "Sharees", + "sharee", + "sharees", "Sharons", "shellcheck", "shellenv", diff --git a/src/languages/de.ts b/src/languages/de.ts index 56eef0b683ad..8dcb4844bd53 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -2459,6 +2459,16 @@ ${amount} für ${merchant} – ${date}`, admins: 'Admins', payer: 'Zahler', paymentAccount: 'Zahlungskonto', + shareBankAccount: { + shareTitle: 'Bankkontozugriff teilen?', + shareDescription: ({admin}: {admin: string}) => `Sie müssen ${admin} den Bankkontozugriff gewähren, damit dieser als Zahler eingetragen werden kann.`, + validationTitle: 'Bankkonto wartet auf Validierung', + validationDescription: ({admin}: {admin: string}) => + `Sie müssen dieses Bankkonto validieren. Anschließend können Sie den Zugriff auf das Bankkonto mit ${admin} teilen, um ihn/sie als Zahler festzulegen.`, + errorTitle: 'Zahler kann nicht geändert werden', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} hat keinen Zugriff auf dieses Bankkonto, daher kann er nicht als Zahler festgelegt werden. Kontaktieren Sie ${owner}, falls das Bankkonto freigegeben werden soll.`, + }, }, reportFraudPage: { title: 'Virtuelle Kartenbetrugsfälle melden', diff --git a/src/languages/en.ts b/src/languages/en.ts index 9e1b3c985c71..4310a3367f91 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -2500,6 +2500,16 @@ const translations = { admins: 'Admins', payer: 'Payer', paymentAccount: 'Payment account', + shareBankAccount: { + shareTitle: 'Share bank account access?', + shareDescription: ({admin}: {admin: string}) => `You'll need to share bank account access with ${admin} to make them the payer.`, + validationTitle: 'Bank account awaiting validation', + validationDescription: ({admin}: {admin: string}) => + `You need to validate this bank account. Once that's done, you can share bank account access with ${admin} to make them the payer.`, + errorTitle: "Can't change payer", + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} doesn't have access to this bank account, so you can't make them the payer. Chat with ${owner} if the bank account should be shared.`, + }, }, reportFraudPage: { title: 'Report virtual card fraud', diff --git a/src/languages/es.ts b/src/languages/es.ts index 28c768351a01..3c11cd118fd9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2361,6 +2361,16 @@ ${amount} para ${merchant} - ${date}`, admins: 'Administradores', payer: 'Pagador', paymentAccount: 'Cuenta de pago', + shareBankAccount: { + shareTitle: '¿Compartir acceso a la cuenta bancaria?', + shareDescription: ({admin}: {admin: string}) => `Necesitarás compartir el acceso a la cuenta bancaria con ${admin} para que sea el pagador.`, + validationTitle: 'Cuenta bancaria en espera de validación', + validationDescription: ({admin}: {admin: string}) => + `Necesitas validar esta cuenta bancaria. Una vez hecho esto, puedes compartir el acceso a la cuenta bancaria con ${admin} para que sea el pagador.`, + errorTitle: 'No se puede cambiar el pagador', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} no tiene acceso a esta cuenta bancaria, por lo que no puede asignarle el pago. Chatea con ${owner} si la cuenta bancaria debe compartirse.`, + }, }, reportFraudPage: { title: 'Reportar fraude con la tarjeta virtual', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index e3141cb9aa26..01975c47ac03 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -2465,6 +2465,16 @@ ${amount} pour ${merchant} - ${date}`, admins: 'Administrateurs', payer: 'Payer', paymentAccount: 'Compte de paiement', + shareBankAccount: { + shareTitle: "Partager l'accès au compte bancaire?", + shareDescription: ({admin}: {admin: string}) => `Vous devrez partager l'accès au compte bancaire avec ${admin} pour qu'il/elle soit le payeur.`, + validationTitle: 'Compte bancaire en attente de validation', + validationDescription: ({admin}: {admin: string}) => + `Vous devez valider ce compte bancaire. Une fois cette opération effectuée, vous pourrez partager l'accès au compte bancaire avec ${admin} pour en faire le payeur.`, + errorTitle: 'Impossible de modifier le payeur', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} n'a pas accès à ce compte bancaire, vous ne pouvez donc pas le désigner comme payeur. Discutez avec ${owner} si le compte bancaire doit être partagé.`, + }, }, reportFraudPage: { title: 'Signaler une fraude à la carte virtuelle', diff --git a/src/languages/it.ts b/src/languages/it.ts index 2814a3f55957..9ce4dfc1db90 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -2454,6 +2454,16 @@ ${amount} per ${merchant} - ${date}`, admins: 'Amministratori', payer: 'Pagatore', paymentAccount: 'Conto di pagamento', + shareBankAccount: { + shareTitle: "Condividere l'accesso al conto bancario?", + shareDescription: ({admin}: {admin: string}) => `Dovrai condividere l'accesso al conto bancario con ${admin} per renderlo il pagatore.`, + validationTitle: 'Conto bancario in attesa di convalida', + validationDescription: ({admin}: {admin: string}) => + `Devi convalidare questo conto bancario. Una volta fatto, puoi condividere l'accesso al conto bancario con ${admin} per renderlo il pagatore.`, + errorTitle: 'Impossibile cambiare il pagatore', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} non ha accesso a questo conto bancario, quindi non puoi impostarlo come pagatore. Chatta con ${owner} se il conto bancario deve essere condiviso.`, + }, }, reportFraudPage: { title: 'Segnala frode con carta virtuale', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1fb0fb5313e1..008ea153d912 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -2437,6 +2437,16 @@ ${date} の ${merchant} への ${amount}`, admins: '管理者', payer: '支払者', paymentAccount: '支払口座', + shareBankAccount: { + shareTitle: '銀行口座へのアクセスを共有しますか?', + shareDescription: ({admin}: {admin: string}) => `${admin}を支払人にするには、銀行口座へのアクセスを${admin}と共有する必要があります。`, + validationTitle: '銀行口座の検証待ち', + validationDescription: ({admin}: {admin: string}) => + `この銀行口座を検証する必要があります。検証が完了すると、銀行口座へのアクセス権を${admin}と共有して、支払人にすることができます。`, + errorTitle: '支払人を変更できません', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} はこの銀行口座へのアクセス権がないため、支払人に指定できません。銀行口座を共有する必要がある場合は、${owner} とチャットしてください。`, + }, }, reportFraudPage: { title: 'バーチャルカードの不正利用を報告', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 5bee400ffe6f..18a54e15818c 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -2452,6 +2452,16 @@ ${amount} voor ${merchant} - ${date}`, admins: 'Beheerders', payer: 'Betaler', paymentAccount: 'Betaalrekening', + shareBankAccount: { + shareTitle: 'Bankrekeningtoegang delen?', + shareDescription: ({admin}: {admin: string}) => `U moet bankrekeningtoegang delen met ${admin} om hem/haar de betaler te maken.`, + validationTitle: 'Bankrekening wacht op validatie', + validationDescription: ({admin}: {admin: string}) => + `U moet deze bankrekening valideren. Zodra dat is gedaan, kunt u de toegang tot de bankrekening delen met ${admin} om hem/haar de betaler te maken.`, + errorTitle: 'Betaler kan niet worden gewijzigd', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} heeft geen toegang tot deze bankrekening, dus je kunt hem/haar niet als betaler instellen. Chat met ${owner} als de bankrekening gedeeld moet worden.`, + }, }, reportFraudPage: { title: 'Fraude met virtuele kaart melden', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 3bea0ab6b5ff..0d63f249fe15 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -2447,6 +2447,16 @@ ${amount} dla ${merchant} - ${date}`, admins: 'Administratorzy', payer: 'Płatnik', paymentAccount: 'Konto płatnicze', + shareBankAccount: { + shareTitle: 'Udostępnić dostęp do konta bankowego?', + shareDescription: ({admin}: {admin: string}) => `Musisz udostępnić dostęp do konta bankowego użytkownikowi ${admin}, aby uczynić go płatnikiem.`, + validationTitle: 'Konto bankowe oczekuje na walidację', + validationDescription: ({admin}: {admin: string}) => + `Musisz zweryfikować to konto bankowe. Po wykonaniu tej czynności możesz udostępnić dostęp do konta bankowego użytkownikowi ${admin}, aby uczynić go płatnikiem.`, + errorTitle: 'Nie można zmienić płatnika', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} nie ma dostępu do tego konta bankowego, więc nie możesz uczynić go płatnikiem. Porozmawiaj z ${owner}, jeśli konto bankowe powinno być współdzielone.`, + }, }, reportFraudPage: { title: 'Zgłoś oszustwo kartą wirtualną', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e2c3fb53a3f5..449352d8052a 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -2445,6 +2445,16 @@ ${amount} para ${merchant} - ${date}`, admins: 'Admins', payer: 'Pagador', paymentAccount: 'Conta de pagamento', + shareBankAccount: { + shareTitle: 'Compartilhar acesso à conta bancária?', + shareDescription: ({admin}: {admin: string}) => `Você precisará compartilhar o acesso à conta bancária com ${admin} para torná-lo o pagador.`, + validationTitle: 'Conta bancária aguardando validação', + validationDescription: ({admin}: {admin: string}) => + `Você precisa validar esta conta bancária. Depois disso, você poderá compartilhar o acesso à conta bancária com ${admin} para torná-lo o pagador.`, + errorTitle: 'Não é possível alterar o pagador', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} não tem acesso a esta conta bancária, portanto, você não pode torná-lo o pagador. Converse com ${owner} se a conta bancária deve ser compartilhada.`, + }, }, reportFraudPage: { title: 'Denunciar fraude no cartão virtual', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 96415f4fa273..8a533c1c4f1a 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -2397,6 +2397,15 @@ ${amount},商户:${merchant} - 日期:${date}`, admins: '管理员', payer: '付款人', paymentAccount: '支付账户', + shareBankAccount: { + shareTitle: '共享银行账户访问权限?', + shareDescription: ({admin}: {admin: string}) => `您需要与 ${admin} 共享银行账户访问权限,才能将其设置为付款人。`, + validationTitle: '银行账户等待验证', + validationDescription: ({admin}: {admin: string}) => `您需要验证此银行账户。验证完成后,您可以将银行账户访问权限分享给 ${admin},使其成为付款人。`, + errorTitle: '无法更改付款人', + errorDescription: ({admin, owner}: {admin: string; owner: string}) => + `${admin} 没有此银行账户的访问权限,因此您无法将其设置为付款人。请联系 ${owner} 了解是否需要共享此银行账户。`, + }, }, reportFraudPage: { title: '报告虚拟卡欺诈', diff --git a/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts b/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts index eea0788b3927..20349d0336b8 100644 --- a/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts +++ b/src/libs/API/parameters/OpenPolicyWorkflowsPageParams.ts @@ -1,5 +1,6 @@ type OpenPolicyWorkflowsPageParams = { policyID: string; + includeAllBankAccounts?: boolean; }; export default OpenPolicyWorkflowsPageParams; diff --git a/src/libs/API/parameters/ShareBankAccountAndSetPayerParams.ts b/src/libs/API/parameters/ShareBankAccountAndSetPayerParams.ts new file mode 100644 index 000000000000..15f5e1689473 --- /dev/null +++ b/src/libs/API/parameters/ShareBankAccountAndSetPayerParams.ts @@ -0,0 +1,7 @@ +type ShareBankAccountAndSetPayerParams = { + bankAccountID: number; + shareeAccountID: number; + policyID: string; +}; + +export default ShareBankAccountAndSetPayerParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index f18688d3cee3..3943f534c25e 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -152,6 +152,7 @@ export type {default as AcceptWalletTermsParams} from './AcceptWalletTermsParams export type {default as ChronosRemoveOOOEventParams} from './ChronosRemoveOOOEventParams'; export type {default as TransferWalletBalanceParams} from './TransferWalletBalanceParams'; export type {default as DeleteWorkspaceParams} from './DeleteWorkspaceParams'; +export type {default as ShareBankAccountAndSetPayerParams} from './ShareBankAccountAndSetPayerParams'; export type {default as CreateWorkspaceParams} from './CreateWorkspaceParams'; export type {default as UpdateWorkspaceGeneralSettingsParams} from './UpdateWorkspaceGeneralSettingsParams'; export type {default as DeleteWorkspaceAvatarParams} from './DeleteWorkspaceAvatarParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 536a0c074a27..ec28e077af9c 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -520,6 +520,7 @@ const WRITE_COMMANDS = { GET_CORPAY_BANK_ACCOUNT_FIELDS: 'GetCorpayBankAccountFields', BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', SHARE_BANK_ACCOUNT: 'ShareBankAccount', + SHARE_BANK_ACCOUNT_AND_UPDATE_POLICY_REIMBURSER: 'ShareBankAccountAndUpdatePolicyReimburser', UPDATE_WORKSPACE_CUSTOM_UNIT: 'UpdateWorkspaceCustomUnit', GET_ACCESSIBLE_POLICIES: 'GetAccessibleDomainPoliciesForOnyx', VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES: 'ValidateUserAndGetAccessiblePolicies', @@ -746,6 +747,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RENAME_POLICY_TAG]: Parameters.RenamePolicyTagsParams; [WRITE_COMMANDS.UPDATE_POLICY_TAG_GL_CODE]: Parameters.UpdatePolicyTagGLCodeParams; [WRITE_COMMANDS.SHARE_BANK_ACCOUNT]: Parameters.ShareBankAccountParams; + [WRITE_COMMANDS.SHARE_BANK_ACCOUNT_AND_UPDATE_POLICY_REIMBURSER]: Parameters.ShareBankAccountAndSetPayerParams; [WRITE_COMMANDS.SET_POLICY_TAGS_ENABLED]: Parameters.SetPolicyTagsEnabled; [WRITE_COMMANDS.DELETE_POLICY_TAGS]: Parameters.DeletePolicyTagsParams; [WRITE_COMMANDS.CREATE_TASK]: Parameters.CreateTaskParams; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 6ac536e761e0..af19c37c4924 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -14,6 +14,7 @@ import type { OpenReimbursementAccountPageParams, SaveCorpayOnboardingBeneficialOwnerParams, SendReminderForCorpaySignerInformationParams, + ShareBankAccountAndSetPayerParams, ShareBankAccountParams, UnshareBankAccountParams, ValidateBankAccountWithTransactionsParams, @@ -1453,6 +1454,51 @@ function shareBankAccount(bankAccountID: number, emailList: string[]) { API.write(WRITE_COMMANDS.SHARE_BANK_ACCOUNT, parameters, onyxData); } +function shareBankAccountAndSetPayer(bankAccountID: number, shareeAccountID: number, policyID: string) { + const parameters: ShareBankAccountAndSetPayerParams = { + bankAccountID, + shareeAccountID, + policyID, + }; + + const onyxData: OnyxData = { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SHARE_BANK_ACCOUNT, + value: { + isLoading: true, + errors: null, + }, + }, + ], + successData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SHARE_BANK_ACCOUNT, + value: { + isLoading: false, + errors: null, + admins: null, + shouldShowSuccess: true, + }, + }, + ], + failureData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SHARE_BANK_ACCOUNT, + value: { + isLoading: false, + errors: getMicroSecondOnyxErrorWithTranslationKey('walletPage.shareBankAccountFailure'), + }, + }, + ], + }; + + API.write(WRITE_COMMANDS.SHARE_BANK_ACCOUNT_AND_UPDATE_POLICY_REIMBURSER, parameters, onyxData); +} + /** * Get bank account from bankAccountID */ @@ -1538,6 +1584,7 @@ export { clearReimbursementAccountSaveCorpayOnboardingDirectorInformation, clearCorpayBankAccountFields, finishCorpayBankAccountOnboarding, + shareBankAccountAndSetPayer, clearReimbursementAccountFinishCorpayBankAccountOnboarding, enableGlobalReimbursementsForUSDBankAccount, clearEnableGlobalReimbursementsForUSDBankAccount, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 734f9ce93be7..1d62ebb2849d 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -3373,7 +3373,7 @@ function duplicateWorkspace(policy: Policy, options: DuplicatePolicyDataOptions) return params; } -function openPolicyWorkflowsPage(policyID: string) { +function openPolicyWorkflowsPage(policyID: string, includeAllBankAccounts?: boolean) { if (!policyID) { Log.warn('openPolicyWorkflowsPage invalid params', {policyID}); return; @@ -3409,7 +3409,7 @@ function openPolicyWorkflowsPage(policyID: string) { ], }; - const params: OpenPolicyWorkflowsPageParams = {policyID}; + const params: OpenPolicyWorkflowsPageParams = {policyID, includeAllBankAccounts}; API.read(READ_COMMANDS.OPEN_POLICY_WORKFLOWS_PAGE, params, onyxData); } diff --git a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx index 0718f015a42c..c28583798342 100644 --- a/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx +++ b/src/pages/settings/Wallet/UnshareBankAccount/UnshareBankAccount.tsx @@ -15,6 +15,7 @@ import useDebouncedState from '@hooks/useDebouncedState'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; import {formatMemberForList, getHeaderMessage, getSearchValueForPhoneOrEmail} from '@libs/OptionsListUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import tokenizedSearch from '@libs/tokenizedSearch'; @@ -42,6 +43,8 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { const {translate} = useLocalize(); const admins = bankAccountList?.[bankAccountID]?.accountData?.sharees; const totalAdmins = bankAccountList?.[bankAccountID]?.accountData?.sharees?.length; + const error = getLatestErrorMessage(bankAccountList?.[bankAccountID] ?? {}); + const isExpensifyCardError = error?.includes(CONST.EXPENSIFY_CARD.BANK); const isExpensifyCardSettlementAccount = bankAccountList?.[bankAccountID]?.isExpensifyCardSettlementAccount ?? false; const shouldShowTextInput = Number(totalAdmins) >= CONST.STANDARD_LIST_ITEM_LIMIT; const textInputLabel = shouldShowTextInput ? translate('common.search') : undefined; @@ -57,6 +60,14 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { } }, [totalAdmins, shouldShowSuccess]); + useEffect(() => { + if (!isExpensifyCardError) { + return; + } + setUnshareUser(undefined); + setShowExpensifyCardErrorModal(true); + }, [isExpensifyCardError]); + const handleUnshare = () => { if (!bankAccountID || !unshareUser?.login) { return; @@ -101,7 +112,10 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { return adminsToDisplay; }; - const hideUnshareErrorModal = () => setShowExpensifyCardErrorModal(false); + const hideUnshareErrorModal = () => { + clearUnshareBankAccountErrors(Number(bankAccountID)); + setShowExpensifyCardErrorModal(false); + }; const itemRightSideComponent = (item: ListItem) => { return ( @@ -148,7 +162,7 @@ function UnshareBankAccount({route}: ShareBankAccountProps) { rightHandSideComponent={itemRightSideComponent} footerContent={ clearUnshareBankAccountErrors(Number(bankAccountID))} /> diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index 8fb4d87a4497..84b23b069512 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -121,7 +121,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { const onPressAutoReportingFrequency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(route.params.policyID)), [route.params.policyID]); const fetchData = useCallback(() => { - openPolicyWorkflowsPage(route.params.policyID); + openPolicyWorkflowsPage(route.params.policyID, true); getPaymentMethods(true); }, [route.params.policyID]); @@ -460,7 +460,7 @@ function WorkspaceWorkflowsPage({policy, route}: WorkspaceWorkflowsPageProps) { brickRoadIndicator={hasReimburserError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} /> )} - {shouldShowBankAccount && !isAccountInSetupState && ( + {shouldShowBankAccount && ( ; }; @@ -44,47 +59,60 @@ type MembersSection = Section; function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingReportData = true}: WorkspaceWorkflowsPayerPageProps) { const {translate, formatPhoneNumber} = useLocalize(); const policyName = policy?.name ?? ''; + const policyID = policy?.id; + const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const bankAccountConnectedToWorkspace = Object.values(bankAccountList ?? {}).find((account) => account?.accountData?.additionalData?.policyID === policyID); + const policyBankAccountID = policy?.achAccount?.bankAccountID; + const bankAccountFromList = policyBankAccountID ? bankAccountList?.[policyBankAccountID] : undefined; + const bankAccountInfo = bankAccountFromList ?? bankAccountConnectedToWorkspace; + const bankAccountID = policyBankAccountID ?? bankAccountInfo?.accountData?.bankAccountID; + const [introSelected] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED); const {isOffline} = useNetwork(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [countryCode = CONST.DEFAULT_COUNTRY_CODE] = useOnyx(ONYXKEYS.COUNTRY_CODE); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); const [searchTerm, setSearchTerm] = useState(''); + const [sharedBankAccountData] = useOnyx(ONYXKEYS.SHARE_BANK_ACCOUNT); + const [selectedPayer, setSelectedPayer] = useState(policy?.achAccount?.reimburser); + const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false; + const styles = useThemeStyles(); + const {showConfirmModal} = useConfirmModal(); + const isLoading = sharedBankAccountData?.isLoading ?? false; + const [isAlertVisible, setIsAlertVisible] = useState(false); + const [showValidationModal, setShowValidationModal] = useState(false); + const [showErrorModal, setShowErrorModal] = useState(false); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); + const selectedPayerDetails = selectedPayer ? getPersonalDetailByEmail(selectedPayer) : undefined; + const ownerDetails = policy?.owner ? getPersonalDetailByEmail(policy?.owner) : undefined; + const accountID = selectedPayer ? policyMemberEmailsToAccountIDs?.[selectedPayer] : ''; + const authorizedPayerEmail = personalDetails?.[accountID]?.login ?? ''; - const isDeletedPolicyEmployee = useCallback( - (policyEmployee: PolicyEmployee) => !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors), - [isOffline], - ); + const isDeletedPolicyEmployee = (policyEmployee: PolicyEmployee) => + !isOffline && policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyEmployee.errors); - const [formattedPolicyAdmins, formattedAuthorizedPayer] = useMemo(() => { + const getPayersAndAdmins = () => { const policyAdminDetails: MemberOption[] = []; const authorizedPayerDetails: MemberOption[] = []; - - const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); - for (const [email, policyEmployee] of Object.entries(policy?.employeeList ?? {})) { - const accountID = policyMemberEmailsToAccountIDs?.[email] ?? ''; - const details = personalDetails?.[accountID]; + const adminAccountID = policyMemberEmailsToAccountIDs?.[email] ?? ''; + const details = personalDetails?.[adminAccountID]; if (!details) { - Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`); + Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${adminAccountID}`); continue; } - const isOwner = policy?.owner === details?.login; const isAdmin = policyEmployee.role === CONST.POLICY.ROLE.ADMIN; const shouldSkipMember = isDeletedPolicyEmployee(policyEmployee) || isExpensifyTeam(details?.login) || (!isOwner && !isAdmin); - if (shouldSkipMember) { continue; } - const roleBadge = ; - - const isAuthorizedPayer = policy?.achAccount?.reimburser === details?.login; - + const isAuthorizedPayer = selectedPayer === details?.login; const formattedMember = { - keyForList: String(accountID), - accountID, + keyForList: String(adminAccountID), + accountID: adminAccountID, isSelected: isAuthorizedPayer, - isDisabled: policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyEmployee.errors), + isDisabled: policyEmployee.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyEmployee.errors) || isLoading, text: formatPhoneNumber(getDisplayNameOrDefault(details)), alternateText: formatPhoneNumber(details?.login ?? ''), rightElement: roleBadge, @@ -93,13 +121,12 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR source: details.avatar ?? icons.FallbackAvatar, name: formatPhoneNumber(details?.login ?? ''), type: CONST.ICON_TYPE_AVATAR, - id: accountID, + id: adminAccountID, }, ], errors: policyEmployee.errors, pendingAction: (policyEmployee.pendingAction ?? isAuthorizedPayer) ? policy?.pendingFields?.reimburser : null, }; - if (isAuthorizedPayer) { authorizedPayerDetails.push(formattedMember); } else { @@ -107,25 +134,15 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR } } return [policyAdminDetails, authorizedPayerDetails]; - }, [ - policy?.employeeList, - policy?.owner, - policy?.achAccount?.reimburser, - policy?.pendingFields?.reimburser, - personalDetails, - isDeletedPolicyEmployee, - translate, - formatPhoneNumber, - icons.FallbackAvatar, - ]); - - const sections: MembersSection[] = useMemo(() => { - const sectionsArray: MembersSection[] = []; + }; + + const [formattedPolicyAdmins, formattedAuthorizedPayer] = getPayersAndAdmins(); + const getSections = () => { + const sectionsArray: MembersSection[] = []; if (searchTerm !== '') { const searchValue = getSearchValueForPhoneOrEmail(searchTerm, countryCode); const filteredOptions = tokenizedSearch([...formattedPolicyAdmins, ...formattedAuthorizedPayer], searchValue, (option) => [option.text ?? '', option.login ?? '']); - return [ { title: undefined, @@ -134,52 +151,107 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR }, ]; } - sectionsArray.push({ data: formattedAuthorizedPayer, sectionIndex: 1, }); - sectionsArray.push({ title: translate('workflowsPayerPage.admins'), data: formattedPolicyAdmins, sectionIndex: 2, }); return sectionsArray; - }, [searchTerm, formattedAuthorizedPayer, translate, formattedPolicyAdmins, countryCode]); - - const headerMessage = useMemo( - () => (searchTerm && !sections.at(0)?.data.length ? translate('common.noResultsFound') : ''), - - // eslint-disable-next-line react-hooks/exhaustive-deps - [translate, sections], - ); + }; - const setPolicyAuthorizedPayer = (member: MemberOption) => { - const authorizedPayerEmail = personalDetails?.[member.accountID]?.login ?? ''; + const sections: MembersSection[] = getSections(); + const headerMessage = searchTerm && !sections.at(0)?.data.length ? translate('common.noResultsFound') : ''; + const handleConfirm = () => { + if (!bankAccountID || !authorizedPayerEmail || !accountID || !policyID) { + return; + } + if (!selectedPayer) { + setIsAlertVisible(true); + return; + } if (policy?.achAccount?.reimburser === authorizedPayerEmail || policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES) { Navigation.goBack(); return; } + shareBankAccountAndSetPayer(Number(bankAccountID), accountID, policyID); + }; + const onButtonPress = () => { + if (!selectedPayer || !policy || !authorizedPayerEmail) { + Navigation.closeRHPFlow(); + return; + } setWorkspacePayer(policy?.id, authorizedPayerEmail); - Navigation.goBack(); + Navigation.closeRHPFlow(); }; - // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = useMemo( - () => (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES, - [policy, isLoadingReportData], - ); + const handleShareBankAccount = () => { + // No payer selected — nothing to share with + if (!selectedPayer) { + return; + } + const isSelectedPayerOwner = policy?.owner === selectedPayer; + const isSelectedAlreadyAPayer = policy?.achAccount?.reimburser === selectedPayer; + const isAccountAlreadyShared = bankAccountInfo?.accountData?.sharees ? bankAccountInfo?.accountData.sharees.includes(selectedPayer) : false; + const isAccountAlreadySharedOnMainBankAccount = policy?.achAccount?.sharees ? policy?.achAccount.sharees.includes(selectedPayer) : false; - const totalNumberOfEmployeesEitherOwnerOrAdmin = useMemo(() => { - return Object.entries(policy?.employeeList ?? {}).filter(([email, policyEmployee]) => { - const isOwner = policy?.owner === email; - const isAdmin = policyEmployee.role === CONST.POLICY.ROLE.ADMIN; - return !isDeletedPolicyEmployee(policyEmployee) && (isOwner || isAdmin); + // Selected payer already has access (owner, reimburser, or sharee) — proceed without sharing + if (isAccountAlreadyShared || isSelectedPayerOwner || isSelectedAlreadyAPayer || isAccountAlreadySharedOnMainBankAccount) { + onButtonPress(); + return; + } + + // Bank account setup incomplete — block and show validation + if (isBankAccountPartiallySetup(bankAccountInfo?.accountData?.state)) { + setShowValidationModal(true); + return; + } + const isAccountAlreadySharedWithCurrentUser = + bankAccountInfo?.accountData?.sharees && currentUserPersonalDetails?.login ? bankAccountInfo?.accountData?.sharees.includes(currentUserPersonalDetails?.login) : false; + const isOwner = policy?.owner === currentUserPersonalDetails?.login; + + // Current user has no right to share (not owner and not a sharee) — show error + if (!isOwner && !isAccountAlreadyShared && !isAccountAlreadySharedWithCurrentUser) { + setShowErrorModal(true); + return; + } + showConfirmModal({ + title: translate('workflowsPayerPage.shareBankAccount.shareTitle'), + success: true, + confirmText: translate('common.share'), + prompt: ( + + + + ), + }).then((result) => { + // User dismissed or cancelled the confirm modal — do not share + if (result.action !== ModalActions.CONFIRM) { + return; + } + handleConfirm(); }); - }, [isDeletedPolicyEmployee, policy?.employeeList, policy?.owner]); + }; + + const setPolicyAuthorizedPayer = (member: MemberOption) => setSelectedPayer(personalDetails?.[member.accountID]?.login); + + const shouldShowBlockingPage = + (isEmptyObject(policy) && !isLoadingReportData) || isPendingDeletePolicy(policy) || policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES; + + const totalNumberOfEmployeesEitherOwnerOrAdmin = Object.entries(policy?.employeeList ?? {}).filter(([email, policyEmployee]) => { + const isOwner = policy?.owner === email; + const isAdmin = policyEmployee.role === CONST.POLICY.ROLE.ADMIN; + return !isDeletedPolicyEmployee(policyEmployee) && (isOwner || isAdmin); + }); const shouldShowSearchInput = totalNumberOfEmployeesEitherOwnerOrAdmin.length >= CONST.STANDARD_LIST_ITEM_LIMIT; @@ -190,7 +262,8 @@ function WorkspaceWorkflowsPayerPage({route, policy, personalDetails, isLoadingR featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED} > - + {shouldShowSuccess && selectedPayer ? ( + + ) : ( + + } + containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]} + /> + } + /> + )} + { + setShowValidationModal(false); + }} + success + onCancel={() => setShowValidationModal(false)} + prompt={ + + { + setShowValidationModal(false); + navigateToBankAccountRoute({policyID, backTo: ROUTES.WORKSPACE_WORKFLOWS.getRoute(policyID)}); + }} + html={translate('workflowsPayerPage.shareBankAccount.validationDescription', { + admin: selectedPayerDetails?.displayName ?? '', + })} + /> + + } + shouldShowCancelButton={false} + confirmText={translate('common.buttonConfirm')} + /> + setShowErrorModal(false)} + onConfirm={() => { + setShowErrorModal(false); + }} + success + prompt={ + + { + if (!currentUserPersonalDetails?.accountID || !policy?.ownerAccountID) { + return; + } + setShowErrorModal(false); + navigateToAndOpenReportWithAccountIDs([policy.ownerAccountID], currentUserPersonalDetails.accountID, introSelected); + }} + html={translate('workflowsPayerPage.shareBankAccount.errorDescription', { + admin: selectedPayerDetails?.displayName ?? '', + owner: ownerDetails?.displayName ?? '', + })} + /> + + } + shouldShowCancelButton={false} + confirmText={translate('common.buttonConfirm')} + /> ); } diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx new file mode 100644 index 000000000000..341b33ad9db6 --- /dev/null +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPayerSuccessPage.tsx @@ -0,0 +1,50 @@ +import React, {useEffect} from 'react'; +import ConfirmationPage from '@components/ConfirmationPage'; +import ScrollView from '@components/ScrollView'; +import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import {clearShareBankAccount} from '@userActions/BankAccounts'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function WorkspaceWorkflowsPayerSuccessPage() { + const {translate} = useLocalize(); + const [sharedBankAccountData] = useOnyx(ONYXKEYS.SHARE_BANK_ACCOUNT); + const shouldShowSuccess = sharedBankAccountData?.shouldShowSuccess ?? false; + const styles = useThemeStyles(); + const illustrations = useMemoizedLazyIllustrations(['ShareBank', 'Telescope']); + + useEffect(() => { + return () => { + if (!shouldShowSuccess) { + return; + } + clearShareBankAccount(); + }; + }, [shouldShowSuccess]); + + const onButtonPress = () => Navigation.closeRHPFlow(); + + return ( + + + + ); +} + +export default WorkspaceWorkflowsPayerSuccessPage; diff --git a/tests/actions/ShareBankAccountAndWorkspacePayerTest.ts b/tests/actions/ShareBankAccountAndWorkspacePayerTest.ts new file mode 100644 index 000000000000..b1e72cd8c44c --- /dev/null +++ b/tests/actions/ShareBankAccountAndWorkspacePayerTest.ts @@ -0,0 +1,133 @@ +import Onyx from 'react-native-onyx'; +import {shareBankAccountAndSetPayer} from '@libs/actions/BankAccounts'; +import {setWorkspacePayer} from '@libs/actions/Policy/Policy'; +import {write} from '@libs/API'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +jest.mock('@libs/API', () => ({ + write: jest.fn(), +})); + +describe('actions/ShareBankAccountAndWorkspacePayer', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + jest.clearAllMocks(); + return Onyx.clear().then(waitForBatchedUpdates); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('shareBankAccountAndSetPayer', () => { + it('should call API.write with ShareBankAccountAndUpdatePolicyReimburser command and correct parameters', async () => { + const bankAccountID = 123; + const shareeAccountID = 456; + const policyID = 'policy_789'; + + shareBankAccountAndSetPayer(bankAccountID, shareeAccountID, policyID); + await waitForBatchedUpdates(); + + expect(write).toHaveBeenCalledWith( + WRITE_COMMANDS.SHARE_BANK_ACCOUNT_AND_UPDATE_POLICY_REIMBURSER, + { + bankAccountID, + shareeAccountID, + policyID, + }, + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.SHARE_BANK_ACCOUNT, + value: expect.objectContaining({ + isLoading: true, + errors: null, + }), + }), + ]), + successData: expect.arrayContaining([ + expect.objectContaining({ + key: ONYXKEYS.SHARE_BANK_ACCOUNT, + value: expect.objectContaining({ + isLoading: false, + errors: null, + admins: null, + shouldShowSuccess: true, + }), + }), + ]), + failureData: expect.arrayContaining([ + expect.objectContaining({ + key: ONYXKEYS.SHARE_BANK_ACCOUNT, + value: expect.objectContaining({ + isLoading: false, + }), + }), + ]), + }), + ); + }); + }); + + describe('setWorkspacePayer', () => { + it('should call API.write with SetWorkspacePayer command and correct parameters', async () => { + const policyID = 'policy_abc'; + const reimburserEmail = 'payer@example.com'; + + setWorkspacePayer(policyID, reimburserEmail); + await waitForBatchedUpdates(); + + expect(write).toHaveBeenCalledWith( + WRITE_COMMANDS.SET_WORKSPACE_PAYER, + { + policyID, + reimburserEmail, + }, + expect.objectContaining({ + optimisticData: expect.arrayContaining([ + expect.objectContaining({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: expect.objectContaining({ + reimburser: reimburserEmail, + achAccount: {reimburser: reimburserEmail}, + errorFields: {reimburser: null}, + pendingFields: {reimburser: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }), + }), + ]), + successData: expect.arrayContaining([ + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: expect.objectContaining({ + errorFields: {reimburser: null}, + pendingFields: {reimburser: null}, + }), + }), + ]), + failureData: expect.arrayContaining([ + expect.objectContaining({ + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: expect.objectContaining({ + // Error object shape from ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey + errorFields: expect.objectContaining({ + reimburser: expect.anything(), // eslint-disable-line @typescript-eslint/no-unsafe-assignment -- Jest matcher + }), + pendingFields: {reimburser: null}, + }), + }), + ]), + }), + ); + }); + }); +});