diff --git a/src/CONST.ts b/src/CONST.ts index 72486e37abbe..94752b1f81c7 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -670,6 +670,7 @@ const CONST = { PER_DIEM: 'newDotPerDiem', NEWDOT_MERGE_ACCOUNTS: 'newDotMergeAccounts', NEWDOT_MANAGER_MCTEST: 'newDotManagerMcTest', + NEWDOT_INTERNATIONAL_DEPOSIT_BANK_ACCOUNT: 'newDotInternationalDepositBankAccount', NSQS: 'nsqs', }, BUTTON_STATES: { @@ -930,6 +931,7 @@ const CONST = { CONFIGURE_REIMBURSEMENT_SETTINGS_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/workspaces/Configure-Reimbursement-Settings', COPILOT_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/copilots-and-delegates/Assign-or-remove-a-Copilot', DELAYED_SUBMISSION_HELP_URL: 'https://help.expensify.com/articles/expensify-classic/reports/Automatically-submit-employee-reports', + ENCRYPTION_AND_SECURITY_HELP_URL: 'https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security', PLAN_TYPES_AND_PRICING_HELP_URL: 'https://help.expensify.com/articles/new-expensify/billing-and-subscriptions/Plan-types-and-pricing', TEST_RECEIPT_URL: `${CLOUDFRONT_URL}/images/fake-receipt__tacotodds.png`, // Use Environment.getEnvironmentURL to get the complete URL with port number @@ -6452,6 +6454,53 @@ const CONST = { }, }, + CORPAY_FIELDS: { + EXCLUDED_COUNTRIES: ['IR', 'CU', 'SY', 'UA', 'KP', 'RU'] as string[], + EXCLUDED_CURRENCIES: ['IRR', 'CUP', 'SYP', 'UAH', 'KPW', 'RUB'] as string[], + BANK_ACCOUNT_DETAILS_FIELDS: ['accountNumber', 'localAccountNumber', 'routingCode', 'localRoutingCode', 'swiftBicCode'] as string[], + ACCOUNT_TYPE_KEY: 'BeneficiaryAccountType', + ACCOUNT_HOLDER_COUNTRY_KEY: 'accountHolderCountry', + BANK_INFORMATION_FIELDS: ['bankName', 'bankAddressLine1', 'bankAddressLine2', 'bankCity', 'bankRegion', 'bankPostal', 'BeneficiaryBankBranchName'] as string[], + ACCOUNT_HOLDER_FIELDS: [ + 'accountHolderName', + 'accountHolderAddress1', + 'accountHolderAddress2', + 'accountHolderCity', + 'accountHolderRegion', + 'accountHolderCountry', + 'accountHolderPostal', + 'accountHolderPhoneNumber', + 'accountHolderEmail', + 'ContactName', + 'BeneficiaryCPF', + 'BeneficiaryRUT', + 'BeneficiaryCedulaID', + 'BeneficiaryTaxID', + ] as string[], + SPECIAL_LIST_REGION_KEYS: ['bankRegion', 'accountHolderRegion'] as string[], + SPECIAL_LIST_ADDRESS_KEYS: ['bankAddressLine1', 'accountHolderAddress1'] as string[], + STEPS_NAME: { + COUNTRY_SELECTOR: 'CountrySelector', + BANK_ACCOUNT_DETAILS: 'BankAccountDetails', + ACCOUNT_TYPE: 'AccountType', + BANK_INFORMATION: 'BankInformation', + ACCOUNT_HOLDER_INFORMATION: 'AccountHolderInformation', + CONFIRMATION: 'Confirmation', + SUCCESS: 'Success', + }, + INDEXES: { + MAPPING: { + COUNTRY_SELECTOR: 0, + BANK_ACCOUNT_DETAILS: 1, + ACCOUNT_TYPE: 2, + BANK_INFORMATION: 3, + ACCOUNT_HOLDER_INFORMATION: 4, + CONFIRMATION: 5, + SUCCESS: 6, + }, + }, + }, + HYBRID_APP: { REORDERING_REACT_NATIVE_ACTIVITY_TO_FRONT: 'reorderingReactNativeActivityToFront', }, diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 8fa6ab602960..f668a9de1438 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -609,6 +609,8 @@ const ONYXKEYS = { HOME_ADDRESS_FORM_DRAFT: 'homeAddressFormDraft', PERSONAL_DETAILS_FORM: 'personalDetailsForm', PERSONAL_DETAILS_FORM_DRAFT: 'personalDetailsFormDraft', + INTERNATIONAL_BANK_ACCOUNT_FORM: 'internationalBankAccountForm', + INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT: 'internationalBankAccountFormDraft', NEW_ROOM_FORM: 'newRoomForm', NEW_ROOM_FORM_DRAFT: 'newRoomFormDraft', ROOM_SETTINGS_FORM: 'roomSettingsForm', @@ -835,6 +837,7 @@ type OnyxFormValuesMapping = { [ONYXKEYS.FORMS.RULES_MAX_EXPENSE_AGE_FORM]: FormTypes.RulesMaxExpenseAgeForm; [ONYXKEYS.FORMS.SEARCH_SAVED_SEARCH_RENAME_FORM]: FormTypes.SearchSavedSearchRenameForm; [ONYXKEYS.FORMS.DEBUG_DETAILS_FORM]: FormTypes.DebugReportForm | FormTypes.DebugReportActionForm | FormTypes.DebugTransactionForm | FormTypes.DebugTransactionViolationForm; + [ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM]: FormTypes.InternationalBankAccountForm; [ONYXKEYS.FORMS.WORKSPACE_PER_DIEM_FORM]: FormTypes.WorkspacePerDiemForm; }; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 9025ebcec844..33c94e343568 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -202,6 +202,7 @@ const ROUTES = { }, SETTINGS_ADD_DEBIT_CARD: 'settings/wallet/add-debit-card', SETTINGS_ADD_BANK_ACCOUNT: 'settings/wallet/add-bank-account', + SETTINGS_ADD_US_BANK_ACCOUNT: 'settings/wallet/add-us-bank-account', SETTINGS_ENABLE_PAYMENTS: 'settings/wallet/enable-payments', SETTINGS_WALLET_CARD_DIGITAL_DETAILS_UPDATE_ADDRESS: { route: 'settings/wallet/card/:domain/digital-details/update-address', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 5a5635037d02..3d85cd907f2a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -70,6 +70,7 @@ const SCREENS = { ADD_DEBIT_CARD: 'Settings_Add_Debit_Card', ADD_PAYMENT_CARD_CHANGE_CURRENCY: 'Settings_Add_Payment_Card_Change_Currency', ADD_BANK_ACCOUNT: 'Settings_Add_Bank_Account', + ADD_US_BANK_ACCOUNT: 'Settings_Add_US_Bank_Account', CLOSE: 'Settings_Close', TWO_FACTOR_AUTH: 'Settings_TwoFactorAuth', REPORT_CARD_LOST_OR_DAMAGED: 'Settings_ReportCardLostOrDamaged', diff --git a/src/components/CurrencyPicker.tsx b/src/components/CurrencyPicker.tsx index bf4b12be292f..6d670b0d63d8 100644 --- a/src/components/CurrencyPicker.tsx +++ b/src/components/CurrencyPicker.tsx @@ -1,59 +1,71 @@ -import React, {forwardRef, useState} from 'react'; -import type {ForwardedRef} from 'react'; -import {View} from 'react-native'; +import type {ReactNode} from 'react'; +import React, {Fragment, useState} from 'react'; import useLocalize from '@hooks/useLocalize'; -import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; -import variables from '@styles/variables'; +import {getCurrencySymbol} from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; import CONST from '@src/CONST'; -import CurrencySelectionListWithOnyx from './CurrencySelectionList'; +import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; +import CurrencySelectionList from './CurrencySelectionList'; +import type {CurrencyListItem} from './CurrencySelectionList/types'; import HeaderWithBackButton from './HeaderWithBackButton'; import MenuItemWithTopDescription from './MenuItemWithTopDescription'; import Modal from './Modal'; import ScreenWrapper from './ScreenWrapper'; -import type {ValuePickerItem, ValuePickerProps} from './ValuePicker/types'; type CurrencyPickerProps = { - selectedCurrency?: string; + /** Label for the input */ + label: string; + + /** Current value of the selected item */ + value?: string; + + /** Custom content to display in the header */ + headerContent?: ReactNode; + + /** Callback when the list item is selected */ + onInputChange?: (value: string, key?: string) => void; + + /** Form Error description */ + errorText?: string; + + /** List of currencies to exclude from the list */ + excludeCurrencies?: string[]; + + /** Is the MenuItem interactive */ + interactive?: boolean; + + /** Should show the full page offline view (whenever the user is offline) */ + shouldShowFullPageOfflineView?: boolean; }; -function CurrencyPicker({selectedCurrency, label = '', errorText = '', value, onInputChange, furtherDetails}: ValuePickerProps & CurrencyPickerProps, forwardedRef: ForwardedRef) { - const StyleUtils = useStyleUtils(); - const styles = useThemeStyles(); - const [isPickerVisible, setIsPickerVisible] = useState(false); - const {translate} = useLocalize(); - const showPickerModal = () => { - setIsPickerVisible(true); - }; +function CurrencyPicker({label, value, errorText, headerContent, excludeCurrencies, interactive, shouldShowFullPageOfflineView = false, onInputChange = () => {}}: CurrencyPickerProps) { + const {translate} = useLocalize(); + const [isPickerVisible, setIsPickerVisible] = useState(false); + const styles = useThemeStyles(); const hidePickerModal = () => { setIsPickerVisible(false); }; - const updateInput = (item: ValuePickerItem) => { - if (item.value !== selectedCurrency) { - onInputChange?.(item.value); - } + const updateInput = (item: CurrencyListItem) => { + onInputChange?.(item.currencyCode); hidePickerModal(); }; - const descStyle = !selectedCurrency || selectedCurrency.length === 0 ? StyleUtils.getFontSizeStyle(variables.fontSizeLabel) : null; + const BlockingComponent = shouldShowFullPageOfflineView ? FullPageOfflineBlockingView : Fragment; return ( - + <> setIsPickerVisible(true)} brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} errorText={errorText} + interactive={interactive} /> - - updateInput({value: item.currencyCode})} - searchInputLabel={translate('common.currency')} - initiallySelectedCurrencyCode={selectedCurrency} - /> + + {!!headerContent && headerContent} + + - + ); } CurrencyPicker.displayName = 'CurrencyPicker'; - -export default forwardRef(CurrencyPicker); +export default CurrencyPicker; diff --git a/src/components/CurrencySelectionList/index.tsx b/src/components/CurrencySelectionList/index.tsx index 1e8b5294286f..fd0d4306f7c6 100644 --- a/src/components/CurrencySelectionList/index.tsx +++ b/src/components/CurrencySelectionList/index.tsx @@ -5,7 +5,7 @@ import SelectionList from '@components/SelectionList'; import RadioListItem from '@components/SelectionList/RadioListItem'; import SelectableListItem from '@components/SelectionList/SelectableListItem'; import useLocalize from '@hooks/useLocalize'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; +import {getCurrencySymbol} from '@libs/CurrencyUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {CurrencyListItem, CurrencySelectionListProps} from './types'; @@ -17,6 +17,7 @@ function CurrencySelectionList({ selectedCurrencies = [], canSelectMultiple = false, recentlyUsedCurrencies, + excludedCurrencies = [], }: CurrencySelectionListProps) { const [currencyList] = useOnyx(ONYXKEYS.CURRENCY_LIST); const [searchValue, setSearchValue] = useState(''); @@ -25,10 +26,10 @@ function CurrencySelectionList({ const {sections, headerMessage} = useMemo(() => { const currencyOptions: CurrencyListItem[] = Object.entries(currencyList ?? {}).reduce((acc, [currencyCode, currencyInfo]) => { const isSelectedCurrency = currencyCode === initiallySelectedCurrencyCode || selectedCurrencies.includes(currencyCode); - if (isSelectedCurrency || !currencyInfo?.retired) { + if (!excludedCurrencies.includes(currencyCode) && (isSelectedCurrency || !currencyInfo?.retired)) { acc.push({ currencyName: currencyInfo?.name ?? '', - text: `${currencyCode} - ${CurrencyUtils.getCurrencySymbol(currencyCode)}`, + text: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, currencyCode, keyForList: currencyCode, isSelected: isSelectedCurrency, @@ -43,7 +44,7 @@ function CurrencySelectionList({ const isSelectedCurrency = currencyCode === initiallySelectedCurrencyCode; return { currencyName: currencyInfo?.name ?? '', - text: `${currencyCode} - ${CurrencyUtils.getCurrencySymbol(currencyCode)}`, + text: `${currencyCode} - ${getCurrencySymbol(currencyCode)}`, currencyCode, keyForList: currencyCode, isSelected: isSelectedCurrency, @@ -86,7 +87,7 @@ function CurrencySelectionList({ } return {sections: result, headerMessage: isEmpty ? translate('common.noResultsFound') : ''}; - }, [currencyList, searchValue, translate, initiallySelectedCurrencyCode, selectedCurrencies, getUnselectedOptions, recentlyUsedCurrencies]); + }, [currencyList, recentlyUsedCurrencies, searchValue, getUnselectedOptions, translate, initiallySelectedCurrencyCode, selectedCurrencies, excludedCurrencies]); return ( = FormProps, @@ -193,7 +197,7 @@ function FormProvider( const submit = useDebounceNonReactive( useCallback(() => { // Return early if the form is already submitting to avoid duplicate submission - if (formState?.isLoading) { + if (!!formState?.isLoading || isLoading) { return; } @@ -214,7 +218,7 @@ function FormProvider( } KeyboardUtils.dismiss().then(() => onSubmit(trimmedStringValues)); - }, [enabledWhenOffline, formState?.isLoading, inputValues, network?.isOffline, onSubmit, onValidate, shouldTrimValues]), + }, [enabledWhenOffline, formState?.isLoading, inputValues, isLoading, network?.isOffline, onSubmit, onValidate, shouldTrimValues]), 1000, {leading: true, trailing: false}, ); @@ -415,6 +419,7 @@ function FormProvider( onSubmit={submit} inputRefs={inputRefs} errors={errors} + isLoading={isLoading} enabledWhenOffline={enabledWhenOffline} > {typeof children === 'function' ? children({inputValues}) : children} diff --git a/src/components/Form/FormWrapper.tsx b/src/components/Form/FormWrapper.tsx index 64bb2173f5b0..43fc9cd7af78 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -10,7 +10,7 @@ import ScrollView from '@components/ScrollView'; import ScrollViewWithContext from '@components/ScrollViewWithContext'; import useStyledSafeAreaInsets from '@hooks/useStyledSafeAreaInsets'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; import type {OnyxFormKey} from '@src/ONYXKEYS'; import type {Form} from '@src/types/form'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; @@ -36,6 +36,9 @@ type FormWrapperProps = ChildrenProps & /** Callback to submit the form */ onSubmit: () => void; + + /** Whether the form is loading */ + isLoading?: boolean; }; function FormWrapper({ @@ -57,6 +60,7 @@ function FormWrapper({ shouldHideFixErrorsAlert = false, disablePressOnEnter = false, isSubmitDisabled = false, + isLoading = false, }: FormWrapperProps) { const styles = useThemeStyles(); const {paddingBottom: safeAreaInsetPaddingBottom} = useStyledSafeAreaInsets(); @@ -65,7 +69,7 @@ function FormWrapper({ const [formState] = useOnyx(`${formID}`); - const errorMessage = useMemo(() => (formState ? ErrorUtils.getLatestErrorMessage(formState) : undefined), [formState]); + const errorMessage = useMemo(() => (formState ? getLatestErrorMessage(formState) : undefined), [formState]); const onFixTheErrorsLinkPressed = useCallback(() => { const errorFields = !isEmptyObject(errors) ? errors : formState?.errorFields ?? {}; @@ -112,7 +116,7 @@ function FormWrapper({ buttonText={submitButtonText} isDisabled={isSubmitDisabled} isAlertVisible={((!isEmptyObject(errors) || !isEmptyObject(formState?.errorFields)) && !shouldHideFixErrorsAlert) || !!errorMessage} - isLoading={!!formState?.isLoading} + isLoading={!!formState?.isLoading || isLoading} message={isEmptyObject(formState?.errorFields) ? errorMessage : undefined} onSubmit={onSubmit} footerContent={footerContent} @@ -143,6 +147,7 @@ function FormWrapper({ formState?.isLoading, shouldHideFixErrorsAlert, errorMessage, + isLoading, onSubmit, footerContent, onFixTheErrorsLinkPressed, diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx index 2d3b6af3ad98..86a9d8adbd76 100644 --- a/src/components/SelectionList/BaseSelectionList.tsx +++ b/src/components/SelectionList/BaseSelectionList.tsx @@ -67,6 +67,7 @@ function BaseSelectionList( showScrollIndicator = true, showLoadingPlaceholder = false, showConfirmButton = false, + isConfirmButtonDisabled = false, shouldUseDefaultTheme = false, shouldPreventDefaultFocusOnSelectRow = false, containerStyle, @@ -679,7 +680,7 @@ function BaseSelectionList( if ( (prevTextInputValue === textInputValue && flattenedSections.selectedOptions.length === prevSelectedOptionsLength) || flattenedSections.allOptions.length === 0 || - shouldUpdateFocusedIndex + (flattenedSections.selectedOptions.length !== prevSelectedOptionsLength && shouldUpdateFocusedIndex) ) { return; } @@ -789,7 +790,7 @@ function BaseSelectionList( { captureOnInputs: true, shouldBubble: !flattenedSections.allOptions.at(focusedIndex) || focusedIndex === -1, - isActive: !disableKeyboardShortcuts && isFocused, + isActive: !disableKeyboardShortcuts && isFocused && !isConfirmButtonDisabled, }, ); @@ -872,6 +873,7 @@ function BaseSelectionList( onPress={onConfirm} pressOnEnter enterKeyEventListenerPriority={1} + isDisabled={isConfirmButtonDisabled} /> )} diff --git a/src/components/SelectionList/types.ts b/src/components/SelectionList/types.ts index 83ace42fb45f..caf941911ec5 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -488,6 +488,9 @@ type BaseSelectionListProps = Partial & { /** Whether to show the default confirm button */ showConfirmButton?: boolean; + /** Whether to show the default confirm button disabled */ + isConfirmButtonDisabled?: boolean; + /** Whether to use the default theme for the confirm button */ shouldUseDefaultTheme?: boolean; diff --git a/src/components/TextPicker/index.tsx b/src/components/TextPicker/index.tsx index 968338391aaa..38125f5129ed 100644 --- a/src/components/TextPicker/index.tsx +++ b/src/components/TextPicker/index.tsx @@ -7,11 +7,17 @@ import CONST from '@src/CONST'; import TextSelectorModal from './TextSelectorModal'; import type {TextPickerProps} from './types'; -function TextPicker({value, description, placeholder = '', errorText = '', onInputChange, furtherDetails, rightLabel, ...rest}: TextPickerProps, forwardedRef: ForwardedRef) { +function TextPicker( + {value, description, placeholder = '', errorText = '', onInputChange, furtherDetails, rightLabel, disabled = false, interactive = true, ...rest}: TextPickerProps, + forwardedRef: ForwardedRef, +) { const styles = useThemeStyles(); const [isPickerVisible, setIsPickerVisible] = useState(false); const showPickerModal = () => { + if (disabled) { + return; + } setIsPickerVisible(true); }; @@ -30,7 +36,7 @@ function TextPicker({value, description, placeholder = '', errorText = '', onInp diff --git a/src/components/TextPicker/types.ts b/src/components/TextPicker/types.ts index e260a478e4c2..dded73952f1f 100644 --- a/src/components/TextPicker/types.ts +++ b/src/components/TextPicker/types.ts @@ -42,7 +42,7 @@ type TextPickerProps = { /** Whether to show the tooltip text */ shouldShowTooltips?: boolean; -} & Pick & +} & Pick & TextProps; export type {TextSelectorModalProps, TextPickerProps}; diff --git a/src/hooks/useInternationalBankAccountFormSubmit.ts b/src/hooks/useInternationalBankAccountFormSubmit.ts new file mode 100644 index 000000000000..6042bd165070 --- /dev/null +++ b/src/hooks/useInternationalBankAccountFormSubmit.ts @@ -0,0 +1,27 @@ +import type {FormOnyxKeys} from '@components/Form/types'; +import type {OnyxFormKey} from '@src/ONYXKEYS'; +import ONYXKEYS from '@src/ONYXKEYS'; +import useStepFormSubmit from './useStepFormSubmit'; +import type {SubStepProps} from './useSubStep/types'; + +type UseInternationalBankAccountFormSubmitParams = Pick & { + formId?: OnyxFormKey; + fieldIds: Array>; + shouldSaveDraft: boolean; +}; + +/** + * Hook for handling submit method in Missing Personal Details substeps. + * When user is in editing mode, we should save values only when user confirms the change + * @param onNext - callback + * @param fieldIds - field IDs for particular step + * @param shouldSaveDraft - if we should save draft values + */ +export default function useInternationalBankAccountFormSubmit({onNext, fieldIds, shouldSaveDraft}: UseInternationalBankAccountFormSubmitParams) { + return useStepFormSubmit({ + formId: ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM, + onNext, + fieldIds, + shouldSaveDraft, + }); +} diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts index e59e18cf85b5..cc1c79d593d9 100644 --- a/src/hooks/useSubStep/index.ts +++ b/src/hooks/useSubStep/index.ts @@ -1,48 +1,72 @@ import type {ComponentType} from 'react'; -import {useCallback, useRef, useState} from 'react'; +import {useCallback, useMemo, useRef, useState} from 'react'; import type {SubStepProps, UseSubStep} from './types'; +function calculateLastIndex(bodyContentLength: number, skipSteps: number[] = []) { + let lastIndex = bodyContentLength - 1; + while (skipSteps.includes(lastIndex)) { + lastIndex -= 1; + } + + return lastIndex; +} + /** * This hook ensures uniform handling of components across different screens, enabling seamless integration and navigation through sub steps of the VBBA flow. * @param bodyContent - array of components to display in particular step * @param onFinished - callback triggered after finish last step * @param startFrom - initial index for bodyContent array * @param onNextSubStep - callback triggered after finish each step + * @param skipSteps - array of indexes to skip */ -export default function useSubStep({bodyContent, onFinished, startFrom = 0, onNextSubStep = () => {}}: UseSubStep) { +export default function useSubStep({bodyContent, onFinished, startFrom = 0, skipSteps = [], onNextSubStep = () => {}}: UseSubStep) { const [screenIndex, setScreenIndex] = useState(startFrom); const isEditing = useRef(false); + if (bodyContent.length === skipSteps.length) { + throw new Error('All steps are skipped'); + } + + const lastScreenIndex = useMemo(() => calculateLastIndex(bodyContent.length, skipSteps), [bodyContent.length, skipSteps]); + const prevScreen = useCallback(() => { - const prevScreenIndex = screenIndex - 1; + let decrementNumber = 1; + while (screenIndex - decrementNumber >= 0 && skipSteps.includes(screenIndex - decrementNumber)) { + decrementNumber += 1; + } + const prevScreenIndex = screenIndex - decrementNumber; if (prevScreenIndex < 0) { return; } setScreenIndex(prevScreenIndex); - }, [screenIndex]); + }, [screenIndex, skipSteps]); const nextScreen = useCallback( (finishData?: unknown) => { if (isEditing.current) { isEditing.current = false; - setScreenIndex(bodyContent.length - 1); + setScreenIndex(lastScreenIndex); return; } - const nextScreenIndex = screenIndex + 1; + let incrementNumber = 1; + while (screenIndex + incrementNumber < lastScreenIndex && skipSteps.includes(screenIndex + incrementNumber)) { + incrementNumber += 1; + } + const nextScreenIndex = screenIndex + incrementNumber; - if (nextScreenIndex === bodyContent.length) { + if (nextScreenIndex === lastScreenIndex + 1) { onFinished(finishData); } else { onNextSubStep(); setScreenIndex(nextScreenIndex); } }, - [screenIndex, bodyContent.length, onFinished, onNextSubStep], + [screenIndex, lastScreenIndex, skipSteps, onFinished, onNextSubStep], ); const moveTo = useCallback((step: number) => { @@ -50,14 +74,15 @@ export default function useSubStep({bodyContent, on setScreenIndex(step); }, []); - const resetScreenIndex = useCallback(() => { - setScreenIndex(0); + const resetScreenIndex = useCallback((newScreenIndex = 0) => { + isEditing.current = false; + setScreenIndex(newScreenIndex); }, []); const goToTheLastStep = useCallback(() => { isEditing.current = false; - setScreenIndex(bodyContent.length - 1); - }, [bodyContent]); + setScreenIndex(lastScreenIndex); + }, [lastScreenIndex]); // eslint-disable-next-line react-compiler/react-compiler return { diff --git a/src/hooks/useSubStep/types.ts b/src/hooks/useSubStep/types.ts index 603534e68c15..a4d28265b7f3 100644 --- a/src/hooks/useSubStep/types.ts +++ b/src/hooks/useSubStep/types.ts @@ -15,6 +15,9 @@ type SubStepProps = { /** moves user to previous sub step */ prevScreen?: () => void; + + /** resets screen index to passed value */ + resetScreenIndex?: (index?: number) => void; }; type UseSubStep = { @@ -29,6 +32,9 @@ type UseSubStep = { /** index of initial sub step to display */ startFrom?: number; + + /** array of indexes to skip */ + skipSteps?: number[]; }; export type {SubStepProps, UseSubStep}; diff --git a/src/languages/en.ts b/src/languages/en.ts index e5bb80c6cd9b..e7b562f70085 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1463,8 +1463,8 @@ const translations = { getPaidBackFaster: 'Get paid back faster', secureAccessToYourMoney: 'Secure access to your money', receiveMoney: 'Receive money in your local currency', - expensifyWallet: 'Expensify Wallet', - sendAndReceiveMoney: 'Send and receive money with friends.', + expensifyWallet: 'Expensify Wallet (Beta)', + sendAndReceiveMoney: 'Send and receive money with friends. US bank accounts only.', enableWallet: 'Enable wallet', addBankAccountToSendAndReceive: 'Get paid back for expenses you submit to a workspace.', addBankAccount: 'Add bank account', @@ -2023,6 +2023,17 @@ const translations = { ownershipPercentage: 'Please enter a valid percentage number.', }, }, + addPersonalBankAccount: { + countrySelectionStepHeader: "Where's your bank account located?", + accountDetailsStepHeader: 'What are your account details?', + accountTypeStepHeader: 'What type of account is this?', + bankInformationStepHeader: 'What are your bank details?', + accountHolderInformationStepHeader: 'What are the account holder details?', + howDoWeProtectYourData: 'How do we protect your data?', + currencyHeader: "What's your bank account's currency?", + confirmationStepHeader: 'Check your info.', + confirmationStepSubHeader: 'Double check the details below, and check the terms box to confirm.', + }, addPersonalBankAccountPage: { enterPassword: 'Enter Expensify password', alreadyAdded: 'This account has already been added.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 5350eb20f46d..37e349e092d7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1464,8 +1464,8 @@ const translations = { getPaidBackFaster: 'Recibe tus pagos más rápido', secureAccessToYourMoney: 'Acceso seguro a tu dinero', receiveMoney: 'Recibe dinero en tu moneda local', - expensifyWallet: 'Billetera Expensify', - sendAndReceiveMoney: 'Envía y recibe dinero desde tu Billetera Expensify.', + expensifyWallet: 'Billetera Expensify (Beta)', + sendAndReceiveMoney: 'Envía y recibe dinero desde tu Billetera Expensify. Solo cuentas bancarias de EE. UU.', enableWallet: 'Habilitar billetera', addBankAccountToSendAndReceive: 'Recibe el reembolso de los gastos que envíes a un espacio de trabajo.', addBankAccount: 'Añadir cuenta bancaria', @@ -2045,6 +2045,17 @@ const translations = { ownershipPercentage: 'Por favor, ingrese un número de porcentaje válido.', }, }, + addPersonalBankAccount: { + countrySelectionStepHeader: '¿Dónde está ubicada tu cuenta bancaria?', + accountDetailsStepHeader: '¿Cuáles son los detalles de tu cuenta?', + accountTypeStepHeader: '¿Qué tipo de cuenta es esta?', + bankInformationStepHeader: '¿Cuáles son los detalles de tu banco?', + accountHolderInformationStepHeader: '¿Cuáles son los detalles del titular de la cuenta?', + howDoWeProtectYourData: '¿Cómo protegemos tus datos?', + currencyHeader: '¿Cuál es la moneda de tu cuenta bancaria?', + confirmationStepHeader: 'Verifica tu información.', + confirmationStepSubHeader: 'Verifica dos veces los detalles a continuación y marca la casilla de términos para confirmar.', + }, addPersonalBankAccountPage: { enterPassword: 'Escribe tu contraseña de Expensify', alreadyAdded: 'Esta cuenta ya ha sido añadida.', diff --git a/src/libs/API/parameters/BankAccountCreateCorpayParams.ts b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts index 3c617d326009..c753d4c4ffb2 100644 --- a/src/libs/API/parameters/BankAccountCreateCorpayParams.ts +++ b/src/libs/API/parameters/BankAccountCreateCorpayParams.ts @@ -1,5 +1,5 @@ type BankAccountCreateCorpayParams = { - type: number; + type?: number; isSavings: boolean; isWithdrawal: boolean; inputs: string; diff --git a/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts index 3e02b57f9e12..a1228a023abe 100644 --- a/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts +++ b/src/libs/API/parameters/GetCorpayBankAccountFieldsParams.ts @@ -1,8 +1,8 @@ type GetCorpayBankAccountFieldsParams = { countryISO: string; - currency: string; - isWithdrawal: boolean; - isBusinessBankAccount: boolean; + currency?: string; + isWithdrawal?: boolean; + isBusinessBankAccount?: boolean; }; export default GetCorpayBankAccountFieldsParams; diff --git a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts index 5b7a221a8702..6ef6b3712439 100644 --- a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts +++ b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts @@ -1,6 +1,6 @@ type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; - policyID?: string; + policyID: string; }; export default VerifyIdentityForBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index af86552e4feb..54362c4558f0 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -8,7 +8,6 @@ export type {default as RestartBankAccountSetupParams} from './RestartBankAccoun export type {default as AddSchoolPrincipalParams} from './AddSchoolPrincipalParams'; export type {default as AuthenticatePusherParams} from './AuthenticatePusherParams'; export type {default as BankAccountHandlePlaidErrorParams} from './BankAccountHandlePlaidErrorParams'; -export type {default as BankAccountCreateCorpayParams} from './BankAccountCreateCorpayParams'; export type {default as BeginAppleSignInParams} from './BeginAppleSignInParams'; export type {default as BeginGoogleSignInParams} from './BeginGoogleSignInParams'; export type {default as BeginSignInParams} from './BeginSignInParams'; @@ -30,7 +29,6 @@ export type {default as ExpandURLPreviewParams} from './ExpandURLPreviewParams'; export type {default as GetMissingOnyxMessagesParams} from './GetMissingOnyxMessagesParams'; export type {default as GetNewerActionsParams} from './GetNewerActionsParams'; export type {default as GetOlderActionsParams} from './GetOlderActionsParams'; -export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; export type {default as GetPolicyCategoriesParams} from './GetPolicyCategories'; export type {default as GetReportPrivateNoteParams} from './GetReportPrivateNoteParams'; export type {default as GetRouteParams} from './GetRouteParams'; @@ -355,6 +353,8 @@ export type {default as UpdateQuickbooksDesktopCompanyCardExpenseAccountTypePara export type {default as TogglePolicyPerDiemParams} from './TogglePolicyPerDiemParams'; export type {default as OpenPolicyPerDiemRatesPageParams} from './OpenPolicyPerDiemRatesPageParams'; export type {default as TogglePlatformMuteParams} from './TogglePlatformMuteParams'; +export type {default as GetCorpayBankAccountFieldsParams} from './GetCorpayBankAccountFieldsParams'; +export type {default as BankAccountCreateCorpayParams} from './BankAccountCreateCorpayParams'; export type {default as JoinAccessiblePolicyParams} from './JoinAccessiblePolicyParams'; export type {default as ImportPerDiemRatesParams} from './ImportPerDiemRatesParams'; export type {default as ExportPerDiemCSVParams} from './ExportPerDiemCSVParams'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index f826cf78ab4c..b24a687e930a 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -443,6 +443,7 @@ const WRITE_COMMANDS = { SELF_TOUR_VIEWED: 'SelfTourViewed', UPDATE_INVOICE_COMPANY_NAME: 'UpdateInvoiceCompanyName', UPDATE_INVOICE_COMPANY_WEBSITE: 'UpdateInvoiceCompanyWebsite', + GET_CORPAY_BANK_ACCOUNT_FIELDS: 'GetCorpayBankAccountFields', BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', UPDATE_WORKSPACE_CUSTOM_UNIT: 'UpdateWorkspaceCustomUnit', VALIDATE_USER_AND_GET_ACCESSIBLE_POLICIES: 'ValidateUserAndGetAccessiblePolicies', @@ -775,6 +776,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_ADD_NEW_USERS_AUTOMATICALLY]: Parameters.UpdateSubscriptionAddNewUsersAutomaticallyParams; [WRITE_COMMANDS.UPDATE_SUBSCRIPTION_SIZE]: Parameters.UpdateSubscriptionSizeParams; [WRITE_COMMANDS.REQUEST_TAX_EXEMPTION]: null; + [WRITE_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS]: Parameters.GetCorpayBankAccountFieldsParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_CUSTOM_UNIT]: Parameters.UpdateWorkspaceCustomUnitParams; [WRITE_COMMANDS.RESET_SMS_DELIVERY_FAILURE_STATUS]: Parameters.ResetSMSDeliveryFailureStatusParams; [WRITE_COMMANDS.SAVE_CORPAY_ONBOARDING_COMPANY_DETAILS]: Parameters.SaveCorpayOnboardingCompanyDetailsParams; @@ -1059,6 +1061,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { DISCONNECT_AS_DELEGATE: 'DisconnectAsDelegate', COMPLETE_HYBRID_APP_ONBOARDING: 'CompleteHybridAppOnboarding', CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP: 'ConnectPolicyToQuickbooksDesktop', + BANK_ACCOUNT_CREATE_CORPAY: 'BankAccount_CreateCorpay', // PayMoneyRequestOnSearch only works online (pattern C) and we need to play the success sound only when the request is successful PAY_MONEY_REQUEST_ON_SEARCH: 'PayMoneyRequestOnSearch', @@ -1082,6 +1085,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.DISCONNECT_AS_DELEGATE]: EmptyObject; [SIDE_EFFECT_REQUEST_COMMANDS.COMPLETE_HYBRID_APP_ONBOARDING]: EmptyObject; [SIDE_EFFECT_REQUEST_COMMANDS.CONNECT_POLICY_TO_QUICKBOOKS_DESKTOP]: Parameters.ConnectPolicyToQuickBooksDesktopParams; + [SIDE_EFFECT_REQUEST_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY]: Parameters.BankAccountCreateCorpayParams; [SIDE_EFFECT_REQUEST_COMMANDS.PAY_MONEY_REQUEST_ON_SEARCH]: Parameters.PayMoneyRequestOnSearchParams; }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index cf6bb5eae810..0b31401d7e25 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -258,7 +258,8 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/EnablePayments/EnablePayments').default, [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/VerifyAccountPage').default, [SCREENS.SETTINGS.ADD_DEBIT_CARD]: () => require('../../../../pages/settings/Wallet/AddDebitCardPage').default, - [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default, + [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: () => require('../../../../pages/settings/Wallet/InternationalDepositAccount').default, + [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: () => require('../../../../pages/AddPersonalBankAccountPage').default, [SCREENS.SETTINGS.PROFILE.STATUS]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: () => require('../../../../pages/settings/Profile/CustomStatus/StatusClearAfterPage').default, [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: () => require('../../../../pages/settings/Profile/CustomStatus/SetDatePage').default, diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index cbd4eef1e8de..10544aef9e8e 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -244,6 +244,10 @@ const config: LinkingOptions['config'] = { path: ROUTES.SETTINGS_ADD_BANK_ACCOUNT, exact: true, }, + [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: { + path: ROUTES.SETTINGS_ADD_US_BANK_ACCOUNT, + exact: true, + }, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: { path: ROUTES.SETTINGS_PRONOUNS, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 41e8c8cc7824..67752a152941 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -175,6 +175,7 @@ type SettingsNavigatorParamList = { }; [SCREENS.SETTINGS.ADD_DEBIT_CARD]: undefined; [SCREENS.SETTINGS.ADD_BANK_ACCOUNT]: undefined; + [SCREENS.SETTINGS.ADD_US_BANK_ACCOUNT]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER]: undefined; [SCREENS.SETTINGS.PROFILE.STATUS_CLEAR_AFTER_DATE]: undefined; diff --git a/src/libs/PaymentUtils.ts b/src/libs/PaymentUtils.ts index c18ebd217406..b9b6c384b4f2 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -6,7 +6,7 @@ import type BankAccount from '@src/types/onyx/BankAccount'; import type Fund from '@src/types/onyx/Fund'; import type PaymentMethod from '@src/types/onyx/PaymentMethod'; import type {ACHAccount} from '@src/types/onyx/Policy'; -import * as Localize from './Localize'; +import {translateLocal} from './Localize'; import BankAccountModel from './models/BankAccount'; type AccountType = ValueOf | undefined; @@ -27,16 +27,16 @@ function hasExpensifyPaymentMethod(fundList: Record, bankAccountLi return validBankAccount || (shouldIncludeDebitCard && validDebitCard); } -function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData'] | ACHAccount): string { +function getPaymentMethodDescription(accountType: AccountType, account: BankAccount['accountData'] | Fund['accountData'] | ACHAccount, bankCurrency?: string): string { if (account) { if (accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && 'accountNumber' in account) { - return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; + return `${bankCurrency} ${CONST.DOT_SEPARATOR} ${translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; } if (accountType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT && 'accountNumber' in account) { - return `${Localize.translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; + return `${translateLocal('paymentMethodList.accountLastFour')} ${account.accountNumber?.slice(-4)}`; } if (accountType === CONST.PAYMENT_METHODS.DEBIT_CARD && 'cardNumber' in account) { - return `${Localize.translateLocal('paymentMethodList.cardLastFour')} ${account.cardNumber?.slice(-4)}`; + return `${translateLocal('paymentMethodList.cardLastFour')} ${account.cardNumber?.slice(-4)}`; } } return ''; @@ -61,7 +61,7 @@ function formatPaymentMethods(bankAccountList: Record, fund }); combinedPaymentMethods.push({ ...bankAccount, - description: getPaymentMethodDescription(bankAccount?.accountType, bankAccount.accountData), + description: getPaymentMethodDescription(bankAccount?.accountType, bankAccount.accountData, bankAccount.bankCurrency), icon, iconSize, iconHeight, diff --git a/src/libs/Permissions.ts b/src/libs/Permissions.ts index 423eafb3f122..5850fee6e1aa 100644 --- a/src/libs/Permissions.ts +++ b/src/libs/Permissions.ts @@ -41,6 +41,10 @@ function canUseManagerMcTest(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NEWDOT_MANAGER_MCTEST) || canUseAllBetas(betas); } +function canUseInternationalBankAccount(betas: OnyxEntry): boolean { + return !!betas?.includes(CONST.BETAS.NEWDOT_INTERNATIONAL_DEPOSIT_BANK_ACCOUNT) || canUseAllBetas(betas); +} + function canUseNSQS(betas: OnyxEntry): boolean { return !!betas?.includes(CONST.BETAS.NSQS) || canUseAllBetas(betas); } @@ -54,5 +58,6 @@ export default { canUsePerDiem, canUseMergeAccounts, canUseManagerMcTest, + canUseInternationalBankAccount, canUseNSQS, }; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 16397e117807..a6e01969d506 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -12,7 +12,7 @@ import type { VerifyIdentityForBankAccountParams, } from '@libs/API/parameters'; import type {SaveCorpayOnboardingCompanyDetails} from '@libs/API/parameters/SaveCorpayOnboardingCompanyDetailsParams'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils'; import {translateLocal} from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; @@ -21,7 +21,7 @@ import type {Country} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; -import type {PersonalBankAccountForm} from '@src/types/form'; +import type {InternationalBankAccountForm, PersonalBankAccountForm} from '@src/types/form'; import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, ReimbursementAccountForm, RequestorStepProps} from '@src/types/form/ReimbursementAccountForm'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, ReimbursementAccountStep, ReimbursementAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; @@ -64,6 +64,12 @@ function clearPlaid(): Promise { return Onyx.set(ONYXKEYS.PLAID_DATA, CONST.PLAID.DEFAULT_DATA); } +function clearInternationalBankAccount() { + return clearPlaid() + .then(() => Onyx.set(ONYXKEYS.CORPAY_FIELDS, null)) + .then(() => Onyx.set(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT, null)); +} + function openPlaidView() { clearPlaid().then(() => setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID)); } @@ -76,7 +82,7 @@ function setPlaidEvent(eventName: string | null) { * Open the personal bank account setup flow, with an optional exitReportID to redirect to once the flow is finished. */ function openPersonalBankAccountSetupView(exitReportID?: string, policyID?: string, source?: string, isUserValidated = true) { - clearPlaid().then(() => { + clearInternationalBankAccount().then(() => { if (exitReportID) { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID}); } @@ -628,7 +634,7 @@ function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBan /** * Verify the user's identity via Onfido */ -function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoDataWithApplicantID, policyID?: string) { +function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoDataWithApplicantID, policyID: string) { const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), @@ -704,6 +710,56 @@ function validatePlaidSelection(values: FormOnyxValues): Form return errorFields; } +function fetchCorpayFields(bankCountry: string, bankCurrency?: string, isWithdrawal?: boolean, isBusinessBankAccount?: boolean) { + API.write( + WRITE_COMMANDS.GET_CORPAY_BANK_ACCOUNT_FIELDS, + {countryISO: bankCountry, currency: bankCurrency, isWithdrawal, isBusinessBankAccount}, + { + optimisticData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, + value: { + isLoading: true, + }, + }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT, + value: { + bankCountry, + bankCurrency: bankCurrency ?? null, + }, + }, + ], + finallyData: [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.PERSONAL_BANK_ACCOUNT, + value: { + isLoading: false, + }, + }, + ], + }, + ); +} + +function createCorpayBankAccountForWalletFlow(data: InternationalBankAccountForm, classification: string, destinationCountry: string, preferredMethod: string) { + const inputData = { + ...data, + classification, + destinationCountry, + preferredMethod, + setupType: 'manual', + fieldsType: 'international', + country: data.bankCountry, + currency: data.bankCurrency, + }; + // eslint-disable-next-line rulesdir/no-api-side-effects-method + return API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.BANK_ACCOUNT_CREATE_CORPAY, {isWithdrawal: false, isSavings: true, inputs: JSON.stringify(inputData)}); +} + export { acceptACHContractForBankAccount, addBusinessWebsiteForDraft, @@ -733,8 +789,10 @@ export { updateAddPersonalBankAccountDraft, clearPersonalBankAccountSetupType, validatePlaidSelection, - getCorpayBankAccountFields, + fetchCorpayFields, clearReimbursementAccountBankCreation, + getCorpayBankAccountFields, + createCorpayBankAccountForWalletFlow, getCorpayOnboardingFields, saveCorpayOnboardingCompanyDetails, clearReimbursementAccountSaveCorpayOnboardingCompanyDetails, diff --git a/src/pages/AddPersonalBankAccountPage.tsx b/src/pages/AddPersonalBankAccountPage.tsx index b4660f21a3c9..7e99a9427216 100644 --- a/src/pages/AddPersonalBankAccountPage.tsx +++ b/src/pages/AddPersonalBankAccountPage.tsx @@ -9,11 +9,12 @@ import InputWrapper from '@components/Form/InputWrapper'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; +import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; import Navigation from '@libs/Navigation/Navigation'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as PaymentMethods from '@userActions/PaymentMethods'; +import {addPersonalBankAccount, clearPersonalBankAccount, validatePlaidSelection} from '@userActions/BankAccounts'; +import {continueSetup} from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -27,7 +28,9 @@ function AddPersonalBankAccountPage() { const [isUserValidated] = useOnyx(ONYXKEYS.USER, {selector: (user) => !!user?.validated}); const [personalBankAccount] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT); const [plaidData] = useOnyx(ONYXKEYS.PLAID_DATA); + const {canUseInternationalBankAccount} = usePermissions(); const shouldShowSuccess = personalBankAccount?.shouldShowSuccess ?? false; + const topMostCentralPane = Navigation.getTopMostCentralPaneRouteFromRootState(); const goBack = useCallback(() => { @@ -52,7 +55,7 @@ function AddPersonalBankAccountPage() { const selectedPlaidBankAccount = bankAccounts.find((bankAccount) => bankAccount.plaidAccountID === selectedPlaidAccountId); if (selectedPlaidBankAccount) { - BankAccounts.addPersonalBankAccount(selectedPlaidBankAccount, policyID, source); + addPersonalBankAccount(selectedPlaidBankAccount, policyID, source); } }, [plaidData, selectedPlaidAccountId, personalBankAccount]); @@ -64,7 +67,7 @@ function AddPersonalBankAccountPage() { if (exitReportID) { Navigation.dismissModal(exitReportID); } else if (shouldContinue && onSuccessFallbackRoute) { - PaymentMethods.continueSetup(onSuccessFallbackRoute); + continueSetup(onSuccessFallbackRoute); } else { goBack(); } @@ -72,7 +75,7 @@ function AddPersonalBankAccountPage() { [personalBankAccount, goBack], ); - useEffect(() => BankAccounts.clearPersonalBankAccount, []); + useEffect(() => clearPersonalBankAccount, []); return ( {shouldShowSuccess ? ( diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx index 95d8f2c39663..1314bad9dd6f 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.tsx +++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx @@ -17,12 +17,12 @@ import ValidateCodeActionModal from '@components/ValidateCodeActionModal'; import useLocalize from '@hooks/useLocalize'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import * as ErrorUtils from '@libs/ErrorUtils'; +import {getEarliestErrorField, getLatestErrorField} from '@libs/ErrorUtils'; import getPlaidDesktopMessage from '@libs/getPlaidDesktopMessage'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as Link from '@userActions/Link'; -import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; -import * as User from '@userActions/User'; +import {openPlaidView, setBankAccountSubStep} from '@userActions/BankAccounts'; +import {openExternalLink, openExternalLinkWithToken} from '@userActions/Link'; +import {updateReimbursementAccountDraft} from '@userActions/ReimbursementAccount'; +import {clearContactMethodErrors, requestValidateCodeAction, validateSecondaryLogin} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -81,7 +81,7 @@ function BankAccountStep({ const selectedSubStep = useRef(''); const loginData = useMemo(() => loginList?.[contactMethod], [loginList, contactMethod]); - const validateLoginError = ErrorUtils.getEarliestErrorField(loginData, 'validateLogin'); + const validateLoginError = getEarliestErrorField(loginData, 'validateLogin'); const hasMagicCodeBeenSent = !!loginData?.validateCodeSent; let subStep = reimbursementAccount?.achData?.subStep ?? ''; @@ -99,9 +99,9 @@ function BankAccountStep({ } if (selectedSubStep.current === CONST.BANK_ACCOUNT.SUBSTEP.MANUAL) { - BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); + setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); } else if (selectedSubStep.current === CONST.BANK_ACCOUNT.SUBSTEP.PLAID) { - BankAccounts.openPlaidView(); + openPlaidView(); } }, [account?.validated]); @@ -115,7 +115,7 @@ function BankAccountStep({ [bankInfoStepKeys.PLAID_ACCOUNT_ID]: '', [bankInfoStepKeys.PLAID_ACCESS_TOKEN]: '', }; - ReimbursementAccount.updateReimbursementAccountDraft(bankAccountData); + updateReimbursementAccountDraft(bankAccountData); }; if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID || subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { @@ -152,7 +152,7 @@ function BankAccountStep({ > {!!plaidDesktopMessage && ( - Link.openExternalLinkWithToken(bankAccountRoute)}>{translate(plaidDesktopMessage)} + openExternalLinkWithToken(bankAccountRoute)}>{translate(plaidDesktopMessage)} )} {!!personalBankAccounts.length && ( @@ -186,7 +186,7 @@ function BankAccountStep({ return; } removeExistingBankAccountDetails(); - BankAccounts.openPlaidView(); + openPlaidView(); }} shouldShowRightIcon wrapperStyle={[styles.sectionMenuItemTopDescription]} @@ -203,7 +203,7 @@ function BankAccountStep({ return; } removeExistingBankAccountDetails(); - BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); + setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); }} shouldShowRightIcon wrapperStyle={[styles.sectionMenuItemTopDescription]} @@ -213,11 +213,11 @@ function BankAccountStep({ {translate('common.privacy')} Link.openExternalLink('https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security/')} + onPress={() => openExternalLink(CONST.ENCRYPTION_AND_SECURITY_HELP_URL)} style={[styles.flexRow, styles.alignItemsCenter]} accessibilityLabel={translate('bankAccount.yourDataIsSecure')} > - {translate('bankAccount.yourDataIsSecure')} + {translate('bankAccount.yourDataIsSecure')} User.requestValidateCodeAction()} - handleSubmitForm={(validateCode) => User.validateSecondaryLogin(loginList, contactMethod, validateCode)} - validateError={!isEmptyObject(validateLoginError) ? validateLoginError : ErrorUtils.getLatestErrorField(loginData, 'validateCodeSent')} - clearError={() => User.clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')} + sendValidateCode={() => requestValidateCodeAction()} + handleSubmitForm={(validateCode) => validateSecondaryLogin(loginList, contactMethod, validateCode)} + validateError={!isEmptyObject(validateLoginError) ? validateLoginError : getLatestErrorField(loginData, 'validateCodeSent')} + clearError={() => clearContactMethodErrors(contactMethod, !isEmptyObject(validateLoginError) ? 'validateLogin' : 'validateCodeSent')} onClose={() => toggleValidateCodeActionModal?.(false)} /> diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts b/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts index 8c84f5680e19..76432490d8d9 100644 --- a/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts +++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/types.ts @@ -1,6 +1,6 @@ import type {SubStepProps} from '@hooks/useSubStep/types'; -import type CorpayFormFields from '@src/types/onyx/CorpayFields'; +import type {CorpayFields} from '@src/types/onyx/CorpayFields'; -type BankInfoSubStepProps = SubStepProps & {corpayFields?: CorpayFormFields; preferredMethod?: string}; +type BankInfoSubStepProps = SubStepProps & {corpayFields?: CorpayFields; preferredMethod?: string}; -export type {BankInfoSubStepProps, CorpayFormFields}; +export type {BankInfoSubStepProps, CorpayFields}; diff --git a/src/pages/ReimbursementAccount/RequestorOnfidoStep.tsx b/src/pages/ReimbursementAccount/RequestorOnfidoStep.tsx deleted file mode 100644 index 26197abd5f6c..000000000000 --- a/src/pages/ReimbursementAccount/RequestorOnfidoStep.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import React from 'react'; -import type {OnyxEntry} from 'react-native-onyx'; -import {withOnyx} from 'react-native-onyx'; -import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import Onfido from '@components/Onfido'; -import type {OnfidoData} from '@components/Onfido/types'; -import ScreenWrapper from '@components/ScreenWrapper'; -import ScrollView from '@components/ScrollView'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import Growl from '@libs/Growl'; -import * as BankAccounts from '@userActions/BankAccounts'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {ReimbursementAccount} from '@src/types/onyx'; - -type RequestorOnfidoStepOnyxProps = { - /** The token required to initialize the Onfido SDK */ - onfidoToken: OnyxEntry; - - /** The application ID for our Onfido instance */ - onfidoApplicantID: OnyxEntry; -}; - -type RequestorOnfidoStepProps = RequestorOnfidoStepOnyxProps & { - /** The bank account currently in setup */ - reimbursementAccount: ReimbursementAccount; - - /** Goes to the previous step */ - onBackButtonPress: () => void; -}; - -const HEADER_STEP_COUNTER = {step: 3, total: 5}; -const ONFIDO_ERROR_DISPLAY_DURATION = 10000; - -function RequestorOnfidoStep({onBackButtonPress, reimbursementAccount, onfidoToken, onfidoApplicantID}: RequestorOnfidoStepProps) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - - const submitOnfidoData = (onfidoData: OnfidoData) => { - BankAccounts.verifyIdentityForBankAccount(reimbursementAccount.achData?.bankAccountID ?? -1, { - ...onfidoData, - applicantID: onfidoApplicantID ?? '-1', - }); - BankAccounts.updateReimbursementAccountDraft({isOnfidoSetupComplete: true}); - }; - - const handleOnfidoError = () => { - // In case of any unexpected error we log it to the server, show a growl, and return the user back to the requestor step so they can try again. - Growl.error(translate('onfidoStep.genericError'), ONFIDO_ERROR_DISPLAY_DURATION); - BankAccounts.clearOnfidoToken(); - BankAccounts.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR); - }; - - const handleOnfidoUserExit = () => { - BankAccounts.clearOnfidoToken(); - BankAccounts.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR); - }; - - return ( - - - - - - - - - ); -} - -RequestorOnfidoStep.displayName = 'RequestorOnfidoStep'; - -export default withOnyx({ - onfidoToken: { - key: ONYXKEYS.ONFIDO_TOKEN, - }, - onfidoApplicantID: { - key: ONYXKEYS.ONFIDO_APPLICANT_ID, - }, -})(RequestorOnfidoStep); diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx new file mode 100644 index 000000000000..03397cdc8665 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -0,0 +1,158 @@ +import React, {useCallback, useMemo} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useSubStep from '@hooks/useSubStep'; +import {clearDraftValues} from '@libs/actions/FormActions'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {InternationalBankAccountForm} from '@src/types/form'; +import type {BankAccountList, CorpayFields, PrivatePersonalDetails} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import AccountHolderInformation from './substeps/AccountHolderInformation'; +import AccountType from './substeps/AccountType'; +import BankAccountDetails from './substeps/BankAccountDetails'; +import BankInformation from './substeps/BankInformation'; +import Confirmation from './substeps/Confirmation'; +import CountrySelection from './substeps/CountrySelection'; +import Success from './substeps/Success'; +import type {CustomSubStepProps} from './types'; +import {getFieldsMap, getInitialPersonalDetailsValues, getInitialSubstep, getSubstepValues, testValidation} from './utils'; + +type InternationalDepositAccountContentProps = { + privatePersonalDetails: OnyxEntry; + corpayFields: OnyxEntry; + bankAccountList: OnyxEntry; + draftValues: OnyxEntry; + country: OnyxEntry; + isAccountLoading: boolean; +}; + +const formSteps = [CountrySelection, BankAccountDetails, AccountType, BankInformation, AccountHolderInformation, Confirmation, Success]; + +function getSkippedSteps(skipAccountTypeStep: boolean, skipAccountHolderInformationStep: boolean) { + const skippedSteps = []; + if (skipAccountTypeStep) { + skippedSteps.push(CONST.CORPAY_FIELDS.INDEXES.MAPPING.ACCOUNT_TYPE); + } + if (skipAccountHolderInformationStep) { + skippedSteps.push(CONST.CORPAY_FIELDS.INDEXES.MAPPING.ACCOUNT_HOLDER_INFORMATION); + } + return skippedSteps; +} + +function InternationalDepositAccountContent({privatePersonalDetails, corpayFields, bankAccountList, draftValues, country, isAccountLoading}: InternationalDepositAccountContentProps) { + const {translate} = useLocalize(); + + const fieldsMap = useMemo(() => getFieldsMap(corpayFields), [corpayFields]); + + const values = useMemo( + () => getSubstepValues(privatePersonalDetails, corpayFields, bankAccountList, draftValues, country, fieldsMap), + [privatePersonalDetails, corpayFields, bankAccountList, draftValues, country, fieldsMap], + ); + + const initialAccountHolderDetailsValues = useMemo(() => getInitialPersonalDetailsValues(privatePersonalDetails), [privatePersonalDetails]); + + const startFrom = useMemo(() => getInitialSubstep(values, fieldsMap), [fieldsMap, values]); + + const skipAccountTypeStep = isEmptyObject(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE]); + + const skipAccountHolderInformationStep = testValidation(initialAccountHolderDetailsValues, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION]); + + const skippedSteps = getSkippedSteps(skipAccountTypeStep, skipAccountHolderInformationStep); + + const topMostCentralPane = Navigation.getTopMostCentralPaneRouteFromRootState(); + + const goBack = useCallback(() => { + switch (topMostCentralPane?.name) { + case SCREENS.SETTINGS.WALLET.ROOT: + Navigation.goBack(ROUTES.SETTINGS_WALLET, true); + break; + case SCREENS.REPORT: + Navigation.closeRHPFlow(); + break; + default: + Navigation.goBack(); + break; + } + }, [topMostCentralPane]); + + const handleFinishStep = useCallback(() => { + clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); + goBack(); + }, [goBack]); + + const { + componentToRender: SubStep, + isEditing, + nextScreen, + prevScreen, + screenIndex, + moveTo, + resetScreenIndex, + } = useSubStep({bodyContent: formSteps, startFrom, onFinished: handleFinishStep, skipSteps: skippedSteps}); + + const handleBackButtonPress = () => { + if (isEditing) { + resetScreenIndex(CONST.CORPAY_FIELDS.INDEXES.MAPPING.CONFIRMATION); + return; + } + + // Clicking back on the first screen should dismiss the modal + if (screenIndex === CONST.CORPAY_FIELDS.INDEXES.MAPPING.COUNTRY_SELECTOR) { + clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); + goBack(); + return; + } + + // Clicking back on the success screen should dismiss the modal + if (screenIndex === CONST.CORPAY_FIELDS.INDEXES.MAPPING.SUCCESS) { + clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); + goBack(); + return; + } + prevScreen(); + }; + + const handleNextScreen = useCallback(() => { + if (isEditing) { + resetScreenIndex(CONST.CORPAY_FIELDS.INDEXES.MAPPING.CONFIRMATION); + return; + } + nextScreen(); + }, [resetScreenIndex, isEditing, nextScreen]); + + if (isAccountLoading) { + return ; + } + + return ( + + + + + ); +} + +InternationalDepositAccountContent.displayName = 'InternationalDepositAccountContent'; + +export default InternationalDepositAccountContent; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx new file mode 100644 index 000000000000..6eeec3ff1ebb --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +import usePermissions from '@hooks/usePermissions'; +import AddPersonalBankAccountPage from '@pages/AddPersonalBankAccountPage'; +import ONYXKEYS from '@src/ONYXKEYS'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; +import InternationalDepositAccountContent from './InternationalDepositAccountContent'; + +function InternationalDepositAccount() { + const [privatePersonalDetails, privatePersonalDetailsMetadata] = useOnyx(ONYXKEYS.PRIVATE_PERSONAL_DETAILS); + const [corpayFields, corpayFieldsMetadata] = useOnyx(ONYXKEYS.CORPAY_FIELDS); + const [bankAccountList, bankAccountListMetadata] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST); + const [draftValues, draftValuesMetadata] = useOnyx(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM_DRAFT); + const [country, countryMetadata] = useOnyx(ONYXKEYS.COUNTRY); + const [isAccountLoading, isLoadingMetadata] = useOnyx(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {selector: (personalBankAccount) => personalBankAccount?.isLoading}); + const {canUseInternationalBankAccount} = usePermissions(); + + const isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); + + if (!canUseInternationalBankAccount) { + return ; + } + + if (isLoading) { + return ; + } + + return ( + + ); +} + +InternationalDepositAccount.displayName = 'InternationalDepositAccount'; + +export default InternationalDepositAccount; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountHolderInformation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountHolderInformation.tsx new file mode 100644 index 000000000000..6acd39fc328c --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountHolderInformation.tsx @@ -0,0 +1,129 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import AddressSearch from '@components/AddressSearch'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import TextInput from '@components/TextInput'; +import TextPicker from '@components/TextPicker'; +import ValuePicker from '@components/ValuePicker'; +import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import Text from '@src/components/Text'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CorpayFormField} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +function getInputComponent(field: CorpayFormField) { + if ((field.valueSet ?? []).length > 0) { + return ValuePicker; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { + return ValuePicker; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { + return AddressSearch; + } + if (field.id === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY) { + return TextPicker; + } + return TextInput; +} + +function getItems(field: CorpayFormField) { + if ((field.valueSet ?? []).length > 0) { + return (field.valueSet ?? []).map(({id, text}) => ({value: id, label: text})); + } + return (field.links?.[0]?.content.regions ?? []).map(({name, code}) => ({value: code, label: name})); +} + +function AccountHolderInformation({isEditing, onNext, formValues, fieldsMap}: CustomSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const handleSubmit = useInternationalBankAccountFormSubmit({ + fieldIds: Object.keys(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION]), + onNext, + shouldSaveDraft: isEditing, + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION], translate); + }, + [fieldsMap, translate], + ); + + const getStyle = useCallback( + (field: CorpayFormField, index: number) => { + if ((field.valueSet ?? []).length > 0) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { + return [index === 0 ? styles.pb2 : styles.pv2]; + } + if (field.id === CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + return [index === 0 ? styles.pb2 : styles.pv2]; + }, + [styles.mhn5, styles.pb1, styles.pb2, styles.pv1, styles.pv2], + ); + + return ( + + + {translate('addPersonalBankAccount.accountHolderInformationStepHeader')} + {Object.values(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION]) + .sort((a, b) => CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.indexOf(a.id) - CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.indexOf(b.id)) + .map((field, index) => ( + + + + ))} + + + ); +} + +AccountHolderInformation.displayName = 'AccountHolderInformation'; + +export default AccountHolderInformation; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountType.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountType.tsx new file mode 100644 index 000000000000..564c41e4d0f5 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountType.tsx @@ -0,0 +1,98 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {Option} from '@libs/searchOptions'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {setDraftValues} from '@userActions/FormActions'; +import Text from '@src/components/Text'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function AccountType({isEditing, onNext, formValues, fieldsMap}: CustomSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [currentAccountType, setCurrentAccountType] = useState(formValues[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]); + const [error, setError] = useState(undefined); + + const fieldData = fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE]?.[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY] ?? {}; + + const onAccountTypeSelected = useCallback(() => { + setError(undefined); + if (isEditing && formValues[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY] === currentAccountType) { + onNext(); + return; + } + if (fieldData.isRequired && !currentAccountType) { + setError('common.error.pleaseSelectOne'); + return; + } + setDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM, {[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]: currentAccountType}); + onNext(); + }, [currentAccountType, fieldData.isRequired, formValues, isEditing, onNext]); + + const onSelectionChange = useCallback( + (country: Option) => { + if (!isEditing) { + setDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM, {[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY]: country.value}); + } + setCurrentAccountType(country.value); + }, + [isEditing], + ); + + const options = useMemo( + () => + (fieldData.valueSet ?? []).map((item) => { + return { + value: item.id, + keyForList: item.id, + text: item.text, + isSelected: currentAccountType === item.id, + searchValue: item.text, + }; + }), + [fieldData.valueSet, currentAccountType], + ); + + const button = useMemo(() => { + const buttonText = isEditing ? translate('common.confirm') : translate('common.next'); + return ( + + ); + }, [error, isEditing, onAccountTypeSelected, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate]); + + return ( + <> + + {translate('addPersonalBankAccount.accountTypeStepHeader')} + + + + ); +} + +AccountType.displayName = 'AccountType'; + +export default AccountType; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankAccountDetails.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankAccountDetails.tsx new file mode 100644 index 000000000000..aca3582e0cce --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankAccountDetails.tsx @@ -0,0 +1,121 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import CurrencyPicker from '@components/CurrencyPicker'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import TextInput from '@components/TextInput'; +import TextLink from '@components/TextLink'; +import ValuePicker from '@components/ValuePicker'; +import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import {fetchCorpayFields} from '@userActions/BankAccounts'; +import Text from '@src/components/Text'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function BankAccountDetails({isEditing, onNext, resetScreenIndex, formValues, fieldsMap}: CustomSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + const {isOffline} = useNetwork(); + + const handleSubmit = useInternationalBankAccountFormSubmit({ + fieldIds: Object.keys(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS] ?? {}), + onNext, + shouldSaveDraft: isEditing, + }); + + const onCurrencySelected = useCallback( + (value: string) => { + if (formValues.bankCurrency === value) { + return; + } + fetchCorpayFields(formValues.bankCountry, value); + resetScreenIndex?.(CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS); + }, + [formValues.bankCountry, formValues.bankCurrency, resetScreenIndex], + ); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS], translate); + }, + [fieldsMap, translate], + ); + + const currencyHeaderContent = ( + + {translate('addPersonalBankAccount.currencyHeader')} + + ); + + return ( + + + {translate('addPersonalBankAccount.accountDetailsStepHeader')} + + + + {Object.values(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS] ?? {}).map((field) => ( + 0 ? [styles.mhn5, styles.pv1] : [styles.pv2]} + key={field.id} + > + 0 ? ValuePicker : TextInput} + inputID={field.id} + defaultValue={formValues[field.id]} + label={field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`)} + items={(field.valueSet ?? []).map(({id, text}) => ({value: id, label: text}))} + shouldSaveDraft={!isEditing} + /> + + ))} + + + + + {translate('addPersonalBankAccount.howDoWeProtectYourData')} + + + + + + ); +} + +BankAccountDetails.displayName = 'BankAccountDetails'; + +export default BankAccountDetails; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankInformation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankInformation.tsx new file mode 100644 index 000000000000..871808792a09 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankInformation.tsx @@ -0,0 +1,119 @@ +import React, {useCallback} from 'react'; +import {View} from 'react-native'; +import AddressSearch from '@components/AddressSearch'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import TextInput from '@components/TextInput'; +import ValuePicker from '@components/ValuePicker'; +import useInternationalBankAccountFormSubmit from '@hooks/useInternationalBankAccountFormSubmit'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {getValidationErrors} from '@pages/settings/Wallet/InternationalDepositAccount/utils'; +import Text from '@src/components/Text'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {CorpayFormField} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +function getInputComponent(field: CorpayFormField) { + if ((field.valueSet ?? []).length > 0) { + return ValuePicker; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { + return ValuePicker; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { + return AddressSearch; + } + return TextInput; +} + +function getItems(field: CorpayFormField) { + if ((field.valueSet ?? []).length > 0) { + return (field.valueSet ?? []).map(({id, text}) => ({value: id, label: text})); + } + return (field.links?.[0]?.content.regions ?? []).map(({name, code}) => ({value: code, label: name})); +} + +function BankInformation({isEditing, onNext, formValues, fieldsMap}: CustomSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const handleSubmit = useInternationalBankAccountFormSubmit({ + fieldIds: Object.keys(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION]), + onNext, + shouldSaveDraft: isEditing, + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + return getValidationErrors(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION], translate); + }, + [fieldsMap, translate], + ); + + const getStyle = useCallback( + (field: CorpayFormField, index: number) => { + if ((field.valueSet ?? []).length > 0) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_REGION_KEYS.includes(field.id)) { + return [styles.mhn5, index === 0 ? styles.pb1 : styles.pv1]; + } + if (CONST.CORPAY_FIELDS.SPECIAL_LIST_ADDRESS_KEYS.includes(field.id)) { + return [index === 0 ? styles.pb2 : styles.pv2]; + } + return [index === 0 ? styles.pb2 : styles.pv2]; + }, + [styles.mhn5, styles.pb1, styles.pb2, styles.pv1, styles.pv2], + ); + + return ( + + + {translate('addPersonalBankAccount.bankInformationStepHeader')} + {Object.values(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION]) + .sort((a, b) => CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.indexOf(a.id) - CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.indexOf(b.id)) + .map((field, index) => ( + + + + ))} + + + ); +} + +BankInformation.displayName = 'BankInformation'; + +export default BankInformation; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Confirmation.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Confirmation.tsx new file mode 100644 index 000000000000..38e3de4cf1ec --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Confirmation.tsx @@ -0,0 +1,194 @@ +import React, {useCallback, useState} from 'react'; +import {useOnyx} from 'react-native-onyx'; +import CheckboxWithLabel from '@components/CheckboxWithLabel'; +import FormProvider from '@components/Form/FormProvider'; +import InputWrapper from '@components/Form/InputWrapper'; +import type {FormInputErrors, FormOnyxValues} from '@components/Form/types'; +import FormHelpMessage from '@components/FormHelpMessage'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import TextLink from '@components/TextLink'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {createCorpayBankAccountForWalletFlow} from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const STEP_INDEXES = CONST.CORPAY_FIELDS.INDEXES.MAPPING; + +type MenuItemProps = { + description: string; + title: string; + shouldShowRightIcon: boolean; + onPress: () => void; + interactive?: boolean; +}; + +function TermsAndConditionsLabel() { + const {translate} = useLocalize(); + return ( + + {translate('common.iAcceptThe')} + {`${translate('common.addCardTermsOfService')}`} + + ); +} + +function Confirmation({onNext, onMove, formValues, fieldsMap}: CustomSubStepProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(''); + const [corpayFields] = useOnyx(ONYXKEYS.CORPAY_FIELDS); + const {isOffline} = useNetwork(); + + const getDataAndGoToNextStep = (values: FormOnyxValues) => { + setError(''); + setIsSubmitting(true); + createCorpayBankAccountForWalletFlow( + {...formValues, ...values}, + corpayFields?.classification ?? '', + corpayFields?.destinationCountry ?? '', + corpayFields?.preferredMethod ?? '', + ).then((response) => { + setIsSubmitting(false); + if (response?.jsonCode) { + if (response.jsonCode === CONST.JSON_CODE.SUCCESS) { + onNext(); + } else { + setError(response.message ?? ''); + } + } + }); + }; + + const summaryItems: MenuItemProps[] = [ + { + description: translate('common.country'), + title: formValues.bankCountry, + shouldShowRightIcon: !isOffline, + onPress: () => { + onMove(STEP_INDEXES.COUNTRY_SELECTOR); + }, + interactive: !isOffline, + }, + { + description: translate('common.currency'), + title: formValues.bankCurrency, + shouldShowRightIcon: !isOffline, + onPress: () => { + onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); + }, + interactive: !isOffline, + }, + ]; + + Object.entries(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS] ?? {}).forEach(([fieldName, field]) => { + summaryItems.push({ + description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); + }, + }); + }); + + Object.entries(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE] ?? {}).forEach(([fieldName, field]) => { + summaryItems.push({ + description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.ACCOUNT_TYPE); + }, + }); + }); + + Object.entries(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION] ?? {}) + .sort(([field1], [field2]) => CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.indexOf(field1) - CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.indexOf(field2)) + .forEach(([fieldName, field]) => { + summaryItems.push({ + description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.BANK_INFORMATION); + }, + }); + }); + + Object.entries(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION] ?? {}) + .sort(([field1], [field2]) => CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.indexOf(field1) - CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.indexOf(field2)) + .forEach(([fieldName, field]) => { + summaryItems.push({ + description: field.label + (field.isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: fieldName !== CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY, + onPress: () => { + onMove(STEP_INDEXES.ACCOUNT_HOLDER_INFORMATION); + }, + interactive: fieldName !== CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_COUNTRY_KEY, + }); + }); + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values.acceptTerms) { + errors.acceptTerms = translate('common.error.acceptTerms'); + } + return errors; + }, + [translate], + ); + + return ( + + {translate('addPersonalBankAccount.confirmationStepHeader')} + {translate('addPersonalBankAccount.confirmationStepSubHeader')} + {summaryItems.map(({description, title, shouldShowRightIcon, interactive, onPress}) => ( + + ))} + + + + + + ); +} + +Confirmation.displayName = 'Confirmation'; + +export default Confirmation; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/CountrySelection.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/CountrySelection.tsx new file mode 100644 index 000000000000..23f34d4d9369 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/CountrySelection.tsx @@ -0,0 +1,94 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import SelectionList from '@components/SelectionList'; +import RadioListItem from '@components/SelectionList/RadioListItem'; +import useDebouncedState from '@hooks/useDebouncedState'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import searchOptions from '@libs/searchOptions'; +import type {Option} from '@libs/searchOptions'; +import StringUtils from '@libs/StringUtils'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import {fetchCorpayFields} from '@userActions/BankAccounts'; +import Text from '@src/components/Text'; +import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; +import ROUTES from '@src/ROUTES'; + +function CountrySelection({isEditing, onNext, formValues, resetScreenIndex}: CustomSubStepProps) { + const {translate} = useLocalize(); + const {isOffline} = useNetwork(); + const styles = useThemeStyles(); + const [searchValue, debouncedSearchValue, setSearchValue] = useDebouncedState(''); + const [currentCountry, setCurrentCountry] = useState(formValues.bankCountry); + + const onCountrySelected = useCallback(() => { + if (currentCountry === CONST.COUNTRY.US) { + Navigation.navigate(ROUTES.SETTINGS_ADD_US_BANK_ACCOUNT); + return; + } + if (isEditing && formValues.bankCountry === currentCountry) { + onNext(); + return; + } + fetchCorpayFields(currentCountry); + resetScreenIndex?.(CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS); + }, [currentCountry, formValues.bankCountry, isEditing, onNext, resetScreenIndex]); + + const onSelectionChange = useCallback((country: Option) => { + setCurrentCountry(country.value); + }, []); + + const countries = useMemo( + () => + Object.keys(CONST.ALL_COUNTRIES) + .filter((countryISO) => !CONST.CORPAY_FIELDS.EXCLUDED_COUNTRIES.includes(countryISO)) + .map((countryISO) => { + const countryName = translate(`allCountries.${countryISO}` as TranslationPaths); + return { + value: countryISO, + keyForList: countryISO, + text: countryName, + isSelected: currentCountry === countryISO, + searchValue: StringUtils.sanitizeString(`${countryISO}${countryName}`), + }; + }), + [translate, currentCountry], + ); + + const searchResults = searchOptions(debouncedSearchValue, countries); + const headerMessage = debouncedSearchValue.trim() && !searchResults.length ? translate('common.noResultsFound') : ''; + + return ( + + + {translate('addPersonalBankAccount.countrySelectionStepHeader')} + + + + ); +} + +CountrySelection.displayName = 'CountrySelection'; + +export default CountrySelection; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Success.tsx b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Success.tsx new file mode 100644 index 000000000000..3a0731010c82 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Success.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ConfirmationPage from '@components/ConfirmationPage'; +import useLocalize from '@hooks/useLocalize'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; + +function Confirmation({onNext}: CustomSubStepProps) { + const {translate} = useLocalize(); + + return ( + + ); +} + +Confirmation.displayName = 'Confirmation'; + +export default Confirmation; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/types.ts b/src/pages/settings/Wallet/InternationalDepositAccount/types.ts new file mode 100644 index 000000000000..2adfc7774c37 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/types.ts @@ -0,0 +1,20 @@ +import type {ValueOf} from 'type-fest'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import type CONST from '@src/CONST'; +import type {InternationalBankAccountForm} from '@src/types/form'; +import type {CorpayFieldsMap} from '@src/types/onyx/CorpayFields'; + +type CustomSubStepProps = SubStepProps & { + /** User's form values */ + formValues: InternationalBankAccountForm; + + /** Fields map for the step rendering */ + fieldsMap: Record, CorpayFieldsMap>; +}; + +type CountryZipRegex = { + regex?: RegExp; + samples?: string; +}; + +export type {CustomSubStepProps, CountryZipRegex}; diff --git a/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts new file mode 100644 index 000000000000..8ddc1150f4c5 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -0,0 +1,147 @@ +import lodashSortBy from 'lodash/sortBy'; +import type {OnyxEntry} from 'react-native-onyx'; +import type {ValueOf} from 'type-fest'; +import type {FormOnyxValues} from '@components/Form/types'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; +import {addErrorMessage} from '@libs/ErrorUtils'; +import {getCurrentAddress} from '@libs/PersonalDetailsUtils'; +import CONST from '@src/CONST'; +import type ONYXKEYS from '@src/ONYXKEYS'; +import type {InternationalBankAccountForm} from '@src/types/form'; +import type {BankAccount, BankAccountList, CorpayFields, PrivatePersonalDetails} from '@src/types/onyx'; +import type {CorpayFieldsMap} from '@src/types/onyx/CorpayFields'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; + +function getFieldsMap(corpayFields: OnyxEntry): Record, CorpayFieldsMap> { + return (corpayFields?.formFields ?? []).reduce((acc, field) => { + if (!field.id) { + return acc; + } + if (field.id === CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY) { + acc[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE] = {[field.id]: field}; + } else if (CONST.CORPAY_FIELDS.ACCOUNT_HOLDER_FIELDS.includes(field.id)) { + acc[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION] = acc[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION] ?? {}; + acc[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION][field.id] = field; + } else if (CONST.CORPAY_FIELDS.BANK_INFORMATION_FIELDS.includes(field.id)) { + acc[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION] = acc[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION] ?? {}; + acc[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION][field.id] = field; + } else { + acc[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS] = acc[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS] ?? {}; + acc[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS][field.id] = field; + } + return acc; + }, {} as Record, CorpayFieldsMap>); +} + +function getLatestCreatedBankAccount(bankAccountList: OnyxEntry): BankAccount | undefined { + return lodashSortBy(Object.values(bankAccountList ?? {}), 'accountData.created').pop(); +} + +function getSubstepValues( + privatePersonalDetails: OnyxEntry, + corpayFields: OnyxEntry, + bankAccountList: OnyxEntry, + internationalBankAccountDraft: OnyxEntry, + country: OnyxEntry, + fieldsMap: Record, CorpayFieldsMap>, +): InternationalBankAccountForm { + const address = getCurrentAddress(privatePersonalDetails); + const personalDetailsFieldMap = fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION]; + const {street} = address ?? {}; + const [street1, street2] = street ? street.split('\n') : [undefined, undefined]; + const firstName = privatePersonalDetails?.legalFirstName ?? ''; + const lastName = privatePersonalDetails?.legalLastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim() ? `${firstName} ${lastName}`.trim() : undefined; + const latestBankAccount = getLatestCreatedBankAccount(bankAccountList); + return { + ...internationalBankAccountDraft, + bankCountry: internationalBankAccountDraft?.bankCountry ?? corpayFields?.bankCountry ?? address?.country ?? latestBankAccount?.bankCountry ?? country ?? '', + bankCurrency: internationalBankAccountDraft?.bankCurrency ?? corpayFields?.bankCurrency, + accountHolderName: !isEmptyObject(personalDetailsFieldMap?.accountHolderName) ? internationalBankAccountDraft?.accountHolderName ?? fullName : undefined, + accountHolderAddress1: !isEmptyObject(personalDetailsFieldMap?.accountHolderAddress1) ? internationalBankAccountDraft?.accountHolderAddress1 ?? street1 : undefined, + accountHolderAddress2: !isEmptyObject(personalDetailsFieldMap?.accountHolderAddress2) ? internationalBankAccountDraft?.accountHolderAddress2 ?? street2 : undefined, + accountHolderCity: !isEmptyObject(personalDetailsFieldMap?.accountHolderCity) ? internationalBankAccountDraft?.accountHolderCity ?? address?.city : undefined, + accountHolderCountry: !isEmptyObject(personalDetailsFieldMap?.accountHolderCountry) + ? internationalBankAccountDraft?.accountHolderCountry ?? corpayFields?.bankCountry ?? address?.country ?? latestBankAccount?.bankCountry ?? country ?? '' + : undefined, + accountHolderPostal: !isEmptyObject(personalDetailsFieldMap?.accountHolderPostal) ? internationalBankAccountDraft?.accountHolderPostal ?? address?.zip : undefined, + accountHolderPhoneNumber: !isEmptyObject(personalDetailsFieldMap?.accountHolderPhoneNumber) + ? internationalBankAccountDraft?.accountHolderPhoneNumber ?? privatePersonalDetails?.phoneNumber + : undefined, + } as unknown as InternationalBankAccountForm; +} + +function getInitialPersonalDetailsValues(privatePersonalDetails: OnyxEntry): InternationalBankAccountForm { + const address = getCurrentAddress(privatePersonalDetails); + const {street} = address ?? {}; + const [street1, street2] = street ? street.split('\n') : [undefined, undefined]; + const firstName = privatePersonalDetails?.legalFirstName ?? ''; + const lastName = privatePersonalDetails?.legalLastName ?? ''; + const fullName = `${firstName} ${lastName}`.trim(); + return { + accountHolderName: fullName, + accountHolderAddress1: street1 ?? '', + accountHolderAddress2: street2 ?? '', + accountHolderCity: address?.city ?? '', + accountHolderCountry: address?.country ?? '', + accountHolderPostal: address?.zip ?? '', + accountHolderPhoneNumber: privatePersonalDetails?.phoneNumber ?? '', + } as InternationalBankAccountForm; +} + +function testValidation(values: InternationalBankAccountForm, fieldsMap: CorpayFieldsMap = {}) { + for (const fieldName in fieldsMap) { + if (!fieldName) { + // eslint-disable-next-line no-continue + continue; + } + if (fieldsMap[fieldName].isRequired && (values[fieldName] ?? '') === '') { + return false; + } + for (const rule of fieldsMap[fieldName].validationRules) { + const regExpCheck = new RegExp(rule.regEx); + if (!regExpCheck.test(values[fieldName] ?? '')) { + return false; + } + } + } + return true; +} + +function getInitialSubstep(values: InternationalBankAccountForm, fieldsMap: Record, CorpayFieldsMap>) { + if (values.bankCountry === '' || isEmptyObject(fieldsMap)) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.COUNTRY_SELECTOR; + } + if (values.bankCurrency === '' || !testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS])) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS; + } + if (!testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE])) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.ACCOUNT_TYPE; + } + if (!testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION])) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_INFORMATION; + } + if (!testValidation(values, fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION])) { + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.ACCOUNT_HOLDER_INFORMATION; + } + return CONST.CORPAY_FIELDS.INDEXES.MAPPING.CONFIRMATION; +} + +function getValidationErrors(values: FormOnyxValues, fieldsMap: CorpayFieldsMap, translate: LocaleContextProps['translate']) { + const errors = {}; + Object.entries(fieldsMap).forEach(([fieldName, field]) => { + if (field.isRequired && values[fieldName] === '') { + addErrorMessage(errors, fieldName, translate('common.error.fieldRequired')); + return; + } + field.validationRules.forEach((rule) => { + const regExpCheck = new RegExp(rule.regEx); + if (!regExpCheck.test(values[fieldName])) { + addErrorMessage(errors, fieldName, rule.errorMessage); + } + }); + }); + return errors; +} + +export {getFieldsMap, getSubstepValues, getInitialPersonalDetailsValues, getInitialSubstep, testValidation, getValidationErrors}; diff --git a/src/pages/settings/Wallet/PaymentMethodList.tsx b/src/pages/settings/Wallet/PaymentMethodList.tsx index 17960257c189..df1b998daefa 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -95,6 +95,7 @@ type PaymentMethodListProps = { icon?: FormattedSelectedPaymentMethodIcon, isDefault?: boolean, methodID?: number, + description?: string, ) => void; /** The policy invoice's transfer bank accountID */ @@ -126,7 +127,7 @@ function dismissError(item: PaymentMethodItem) { const isBankAccount = item.accountType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT; const paymentList = isBankAccount ? ONYXKEYS.BANK_ACCOUNT_LIST : ONYXKEYS.FUND_LIST; - const paymentID = isBankAccount ? item.accountData?.bankAccountID ?? CONST.DEFAULT_NUMBER_ID : item.accountData?.fundID ?? CONST.DEFAULT_NUMBER_ID; + const paymentID = isBankAccount ? item.accountData?.bankAccountID : item.accountData?.fundID; if (!paymentID) { Log.info('Unable to clear payment method error: ', undefined, item); @@ -310,6 +311,7 @@ function PaymentMethodList({ }, paymentMethod.isDefault, paymentMethod.methodID, + paymentMethod.description, ), wrapperStyle: isMethodActive ? [StyleUtils.getButtonBackgroundColorStyle(CONST.BUTTON_STATES.PRESSED)] : null, disabled: paymentMethod.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index 39db6739bf1c..8e78e86994ec 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -31,16 +31,16 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import useWindowDimensions from '@hooks/useWindowDimensions'; -import * as CurrencyUtils from '@libs/CurrencyUtils'; +import {convertToDisplayString} from '@libs/CurrencyUtils'; import getClickedTargetLocation from '@libs/getClickedTargetLocation'; import Navigation from '@libs/Navigation/Navigation'; -import * as PaymentUtils from '@libs/PaymentUtils'; +import {formatPaymentMethods, getPaymentMethodDescription} from '@libs/PaymentUtils'; import PaymentMethodList from '@pages/settings/Wallet/PaymentMethodList'; import WalletEmptyState from '@pages/settings/Wallet/WalletEmptyState'; import variables from '@styles/variables'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as Modal from '@userActions/Modal'; -import * as PaymentMethods from '@userActions/PaymentMethods'; +import {deletePaymentBankAccount, openPersonalBankAccountSetupView, setPersonalBankAccountContinueKYCOnSuccess} from '@userActions/BankAccounts'; +import {close as closeModal} from '@userActions/Modal'; +import {clearWalletError, clearWalletTermsError, deletePaymentCard, makeDefaultPaymentMethod as makeDefaultPaymentMethodPaymentMethods, openWalletPage} from '@userActions/PaymentMethods'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -140,6 +140,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { icon?: FormattedSelectedPaymentMethodIcon, isDefault?: boolean, methodID?: string | number, + description?: string, ) => { if (shouldShowAddPaymentMenu) { setShouldShowAddPaymentMenu(false); @@ -161,14 +162,14 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { formattedSelectedPaymentMethod = { title: account?.addressName ?? '', icon, - description: PaymentUtils.getPaymentMethodDescription(accountType, account), + description: description ?? getPaymentMethodDescription(accountType, account), type: CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT, }; } else if (accountType === CONST.PAYMENT_METHODS.DEBIT_CARD) { formattedSelectedPaymentMethod = { title: account?.addressName ?? '', icon, - description: PaymentUtils.getPaymentMethodDescription(accountType, account), + description: description ?? getPaymentMethodDescription(accountType, account), type: CONST.PAYMENT_METHODS.DEBIT_CARD, }; } @@ -177,7 +178,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { selectedPaymentMethod: account ?? {}, selectedPaymentMethodType: accountType, formattedSelectedPaymentMethod, - methodID: methodID ?? '-1', + methodID: methodID ?? CONST.DEFAULT_NUMBER_ID, }); setShouldShowDefaultDeleteMenu(true); setMenuPosition(); @@ -209,7 +210,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { return; } if (paymentType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT || paymentType === CONST.PAYMENT_METHODS.BUSINESS_BANK_ACCOUNT) { - BankAccounts.openPersonalBankAccountSetupView(); + openPersonalBankAccountSetupView(); return; } @@ -227,14 +228,14 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { const makeDefaultPaymentMethod = useCallback(() => { const paymentCardList = fundList ?? {}; // Find the previous default payment method so we can revert if the MakeDefaultPaymentMethod command errors - const paymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles); + const paymentMethods = formatPaymentMethods(bankAccountList ?? {}, paymentCardList, styles); const previousPaymentMethod = paymentMethods.find((method) => !!method.isDefault); const currentPaymentMethod = paymentMethods.find((method) => method.methodID === paymentMethod.methodID); if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { - PaymentMethods.makeDefaultPaymentMethod(paymentMethod.selectedPaymentMethod.bankAccountID ?? -1, 0, previousPaymentMethod, currentPaymentMethod); + makeDefaultPaymentMethodPaymentMethods(paymentMethod.selectedPaymentMethod.bankAccountID ?? CONST.DEFAULT_NUMBER_ID, 0, previousPaymentMethod, currentPaymentMethod); } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD) { - PaymentMethods.makeDefaultPaymentMethod(0, paymentMethod.selectedPaymentMethod.fundID ?? -1, previousPaymentMethod, currentPaymentMethod); + makeDefaultPaymentMethodPaymentMethods(0, paymentMethod.selectedPaymentMethod.fundID ?? CONST.DEFAULT_NUMBER_ID, previousPaymentMethod, currentPaymentMethod); } }, [ paymentMethod.methodID, @@ -250,9 +251,9 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { const bankAccountID = paymentMethod.selectedPaymentMethod.bankAccountID; const fundID = paymentMethod.selectedPaymentMethod.fundID; if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT && bankAccountID) { - BankAccounts.deletePaymentBankAccount(bankAccountID); + deletePaymentBankAccount(bankAccountID); } else if (paymentMethod.selectedPaymentMethodType === CONST.PAYMENT_METHODS.DEBIT_CARD && fundID) { - PaymentMethods.deletePaymentCard(fundID); + deletePaymentCard(fundID); } }, [paymentMethod.selectedPaymentMethod.bankAccountID, paymentMethod.selectedPaymentMethod.fundID, paymentMethod.selectedPaymentMethodType]); @@ -276,7 +277,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { if (network.isOffline) { return; } - PaymentMethods.openWalletPage(); + openWalletPage(); }, [network.isOffline]); useLayoutEffect(() => { @@ -323,7 +324,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { }, [hideDefaultDeleteMenu, paymentMethod.methodID, paymentMethod.selectedPaymentMethodType, bankAccountList, fundList, shouldShowDefaultDeleteMenu]); // Don't show "Make default payment method" button if it's the only payment method or if it's already the default const isCurrentPaymentMethodDefault = () => { - const hasMultiplePaymentMethods = PaymentUtils.formatPaymentMethods(bankAccountList ?? {}, fundList ?? {}, styles).length > 1; + const hasMultiplePaymentMethods = formatPaymentMethods(bankAccountList ?? {}, fundList ?? {}, styles).length > 1; if (hasMultiplePaymentMethods) { if (paymentMethod.formattedSelectedPaymentMethod.type === CONST.PAYMENT_METHODS.PERSONAL_BANK_ACCOUNT) { return paymentMethod.selectedPaymentMethod.bankAccountID === userWallet?.walletLinkedAccountID; @@ -366,7 +367,7 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { @@ -436,13 +437,13 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { { if (isActingAsDelegate) { - Modal.close(() => { + closeModal(() => { setIsNoDelegateAccessMenuVisible(true); }); return; @@ -598,12 +599,12 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { icon={Expensicons.Trashcan} onPress={() => { if (isActingAsDelegate) { - Modal.close(() => { + closeModal(() => { setIsNoDelegateAccessMenuVisible(true); }); return; } - Modal.close(() => setShowConfirmDeleteModal(true)); + closeModal(() => setShowConfirmDeleteModal(true)); }} wrapperStyle={[styles.pv3, styles.ph5, !shouldUseNarrowLayout ? styles.sidebarPopover : {}]} /> diff --git a/src/pages/workspace/WorkspaceConfirmationPage.tsx b/src/pages/workspace/WorkspaceConfirmationPage.tsx index 9b76a7d30e95..d5acbe8630a1 100644 --- a/src/pages/workspace/WorkspaceConfirmationPage.tsx +++ b/src/pages/workspace/WorkspaceConfirmationPage.tsx @@ -19,7 +19,6 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {createWorkspaceWithPolicyDraftAndNavigateToIt} from '@libs/actions/App'; import {generateDefaultWorkspaceName, generatePolicyID} from '@libs/actions/Policy/Policy'; import type {CustomRNImageManipulatorResult} from '@libs/cropOrRotateImage/types'; -import {getCurrency} from '@libs/CurrencyUtils'; import {addErrorMessage} from '@libs/ErrorUtils'; import getCurrentUrl from '@libs/Navigation/currentUrl'; import Navigation from '@libs/Navigation/Navigation'; @@ -55,6 +54,10 @@ function WorkspaceConfirmationPage() { addErrorMessage(errors, 'name', translate('common.error.characterLimitExceedCounter', {length: [...name].length, limit: CONST.TITLE_CHARACTER_LIMIT})); } + if (!isRequiredFulfilled(values[INPUT_IDS.CURRENCY])) { + errors[INPUT_IDS.CURRENCY] = translate('common.error.fieldRequired'); + } + return errors; }, [translate], @@ -73,9 +76,7 @@ function WorkspaceConfirmationPage() { const [workspaceNameFirstCharacter, setWorkspaceNameFirstCharacter] = useState(defaultWorkspaceName ?? ''); const userCurrency = allPersonalDetails?.[session?.accountID ?? CONST.DEFAULT_NUMBER_ID]?.localCurrencyCode ?? CONST.CURRENCY.USD; - const [currencyCode, setCurrencyCode] = useState(userCurrency); - const currency = getCurrency(currencyCode); const [workspaceAvatar, setWorkspaceAvatar] = useState<{avatarUri: string | null; avatarFileName?: string | null; avatarFileType?: string | null}>({ avatarUri: null, avatarFileName: null, @@ -149,7 +150,7 @@ function WorkspaceConfirmationPage() { scrollContextEnabled validate={validate} onSubmit={(val) => { - createWorkspaceWithPolicyDraftAndNavigateToIt('', val[INPUT_IDS.NAME], false, false, '', policyID, currencyCode, avatarFile as File); + createWorkspaceWithPolicyDraftAndNavigateToIt('', val[INPUT_IDS.NAME], false, false, '', policyID, val[INPUT_IDS.CURRENCY], avatarFile as File); }} enabledWhenOffline > @@ -174,13 +175,9 @@ function WorkspaceConfirmationPage() { { - setCurrencyCode(val as string); - }} + defaultValue={userCurrency} /> diff --git a/src/types/form/InternationalBankAccountForm.ts b/src/types/form/InternationalBankAccountForm.ts new file mode 100644 index 000000000000..f7c981b6a5c4 --- /dev/null +++ b/src/types/form/InternationalBankAccountForm.ts @@ -0,0 +1,6 @@ +import type {BaseForm} from './Form'; + +type InternationalBankAccountForm = BaseForm & Record; + +// eslint-disable-next-line import/prefer-default-export +export type {InternationalBankAccountForm}; diff --git a/src/types/form/index.ts b/src/types/form/index.ts index 51c1d545aacf..5e3fed6b615b 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -90,4 +90,5 @@ export type {PersonalDetailsForm} from './PersonalDetailsForm'; export type {WorkspaceConfirmationForm} from './WorkspaceConfirmationForm'; export type {MoneyRequestTimeForm} from './MoneyRequestTimeForm'; export type {MoneyRequestSubrateForm} from './MoneyRequestSubrateForm'; +export type {InternationalBankAccountForm} from './InternationalBankAccountForm'; export type {WorkspacePerDiemForm} from './WorkspacePerDiemForm'; diff --git a/src/types/onyx/BankAccount.ts b/src/types/onyx/BankAccount.ts index 6da03440fb2e..cd44e6256db6 100644 --- a/src/types/onyx/BankAccount.ts +++ b/src/types/onyx/BankAccount.ts @@ -59,6 +59,12 @@ type BankAccount = OnyxCommon.OnyxValueWithOfflineFeedback<{ /** All data related to the bank account */ accountData?: AccountData; + /** Currency code related to the bank account */ + bankCurrency: string; + + /** Country code related to the bank account */ + bankCountry: string; + /** Any additional error message to show */ errors?: OnyxCommon.Errors; }>; diff --git a/src/types/onyx/CorpayFields.ts b/src/types/onyx/CorpayFields.ts index 9a1d8bf4d2ab..2fa2961c41d2 100644 --- a/src/types/onyx/CorpayFields.ts +++ b/src/types/onyx/CorpayFields.ts @@ -21,10 +21,38 @@ type CorpayFormField = { /** Regular expression for the validation rule */ regEx: string; }>; + /** Contains possible list of values for dropdown field */ + valueSet?: Array<{ + /** Unique identifier for the form field value */ + id: string; + /** Label for the form field value */ + text: string; + }>; + /** Contains possible list of values for dropdown field (only for Canada region fields) */ + links?: Array<{ + /** Contains possible list of values for dropdown field (only for Canada region fields) */ + content: { + /** Whether the list of values complete */ + isCompleteList: boolean; + /** The list of regions */ + regions: Array<{ + /** Region code */ + code: string; + /** Region country code */ + country: string; + /** Region country name */ + countryName: string; + /** Unique Region identifier */ + id: string; + /** Region name */ + name: string; + }>; + }; + }>; }; -/** CorpayFormFields */ -type CorpayFormFields = { +/** CorpayFields */ +type CorpayFields = { /** Country of the bank */ bankCountry: string; /** Currency of the bank */ @@ -33,16 +61,19 @@ type CorpayFormFields = { classification: string; /** Destination country of the bank */ destinationCountry: string; - /** Form fields for the Corpay form */ - formFields: CorpayFormField[]; + /** Possible payment methods */ + paymentMethods: string[]; /** Preferred method for the bank */ preferredMethod: string; + /** Form fields for the Corpay form */ + formFields: CorpayFormField[]; /** Indicates if the fields are loading */ isLoading: boolean; /** Indicates if the fields loaded successfully */ isSuccess: boolean; }; -export default CorpayFormFields; +/** CorpayFieldsMap */ +type CorpayFieldsMap = Record; -export type {CorpayFormField}; +export type {CorpayFields, CorpayFormField, CorpayFieldsMap}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 670f1986a382..f323b6a94fef 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -16,7 +16,7 @@ import type CardFeeds from './CardFeeds'; import type {AddNewCompanyCardFeed, CompanyCardFeed} from './CardFeeds'; import type CardOnWaitlist from './CardOnWaitlist'; import type {CapturedLogs, Log} from './Console'; -import type CorpayFields from './CorpayFields'; +import type {CorpayFields, CorpayFormField} from './CorpayFields'; import type CorpayOnboardingFields from './CorpayOnboardingFields'; import type Credentials from './Credentials'; import type Currency from './Currency'; @@ -127,7 +127,6 @@ export type { CardList, CardOnWaitlist, Credentials, - CorpayFields, CorpayOnboardingFields, Currency, CurrencyList, @@ -245,6 +244,8 @@ export type { Onboarding, OnboardingPurpose, ValidateMagicCodeAction, + CorpayFields, + CorpayFormField, JoinablePolicies, DismissedProductTraining, }; diff --git a/tests/unit/useSubStepTest.tsx b/tests/unit/useSubStepTest.tsx index 7a5577005d4a..c4765cca315f 100644 --- a/tests/unit/useSubStepTest.tsx +++ b/tests/unit/useSubStepTest.tsx @@ -9,87 +9,290 @@ function MockSubStepComponent({screenIndex}: SubStepProps) { function MockSubStepComponent2({screenIndex}: SubStepProps) { return {screenIndex}; } +function MockSubStepComponent3({screenIndex}: SubStepProps) { + return {screenIndex}; +} +function MockSubStepComponent4({screenIndex}: SubStepProps) { + return {screenIndex}; +} const mockOnFinished = jest.fn(); +const mockOnFinished2 = jest.fn(); describe('useSubStep hook', () => { - it('returns componentToRender, isEditing, currentIndex, prevScreen, nextScreen, moveTo', () => { - const {result} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent], onFinished: mockOnFinished, startFrom: 0})); + describe('given skipSteps as empty array', () => { + it('returns componentToRender, isEditing, currentIndex, prevScreen, nextScreen, moveTo', () => { + const {result} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent], onFinished: mockOnFinished, startFrom: 0})); - const {componentToRender, isEditing, moveTo, nextScreen, prevScreen, screenIndex} = result.current; + const {componentToRender, isEditing, moveTo, nextScreen, prevScreen, screenIndex} = result.current; - expect(componentToRender).toBe(MockSubStepComponent); - expect(isEditing).toBe(false); - expect(screenIndex).toBe(0); - expect(typeof prevScreen).toBe('function'); - expect(typeof nextScreen).toBe('function'); - expect(typeof moveTo).toBe('function'); - }); + expect(componentToRender).toBe(MockSubStepComponent); + expect(isEditing).toBe(false); + expect(screenIndex).toBe(0); + expect(typeof prevScreen).toBe('function'); + expect(typeof nextScreen).toBe('function'); + expect(typeof moveTo).toBe('function'); + }); + + it('calls onFinished when it is the last step', () => { + const {result} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent], onFinished: mockOnFinished, startFrom: 0})); - it('calls onFinished when it is the last step', () => { - const {result} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent], onFinished: mockOnFinished, startFrom: 0})); + const {nextScreen} = result.current; - const {nextScreen} = result.current; + act(() => { + nextScreen(); + }); - act(() => { - nextScreen(); + expect(mockOnFinished).toHaveBeenCalledTimes(1); }); - expect(mockOnFinished).toHaveBeenCalledTimes(1); - }); + it('returns component at requested substep when calling moveTo', () => { + const {result, rerender} = renderHook(() => + useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 2}), + ); + + const {moveTo} = result.current; - it('returns component at requested substep when calling moveTo', () => { - const {result, rerender} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 2})); + act(() => { + moveTo(0); + }); - const {moveTo} = result.current; + rerender({}); - act(() => { - moveTo(0); + const {componentToRender} = result.current; + + expect(componentToRender).toBe(MockSubStepComponent2); }); - rerender({}); + it('returns substep component at the previous index when calling prevScreen (if possible)', () => { + const {result, rerender} = renderHook(() => + useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 1}), + ); - const {componentToRender} = result.current; + const {prevScreen, screenIndex} = result.current; - expect(componentToRender).toBe(MockSubStepComponent2); - }); + expect(screenIndex).toBe(1); - it('returns substep component at the previous index when calling prevScreen (if possible)', () => { - const {result, rerender} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 1})); + act(() => { + prevScreen(); + }); - const {prevScreen, screenIndex} = result.current; + rerender({}); - expect(screenIndex).toBe(1); + const {componentToRender, screenIndex: newScreenIndex} = result.current; + expect(newScreenIndex).toBe(0); - act(() => { - prevScreen(); + expect(componentToRender).toBe(MockSubStepComponent2); }); - rerender({}); + it('stays on the first substep component when calling prevScreen on the first screen', () => { + const {result, rerender} = renderHook(() => + useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 0}), + ); + + const {componentToRender, prevScreen, screenIndex} = result.current; + + expect(screenIndex).toBe(0); + expect(componentToRender).toBe(MockSubStepComponent2); - const {componentToRender, screenIndex: newScreenIndex} = result.current; - expect(newScreenIndex).toBe(0); + act(() => { + prevScreen(); + }); - expect(componentToRender).toBe(MockSubStepComponent2); + rerender({}); + + const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + + expect(newScreenIndex).toBe(0); + expect(newComponentToRender).toBe(MockSubStepComponent2); + }); }); - it('stays on the first substep component when calling prevScreen on the first screen', () => { - const {result, rerender} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 0})); + describe('given skipSteps as non-empty array', () => { + it('calls onFinished when it is the second last step (last step is skipped)', () => { + const {result} = renderHook(() => useSubStep({bodyContent: [MockSubStepComponent, MockSubStepComponent2], onFinished: mockOnFinished2, startFrom: 0, skipSteps: [1]})); + + const {nextScreen} = result.current; + + act(() => { + nextScreen(); + }); + + expect(mockOnFinished2).toHaveBeenCalledTimes(1); + }); + + it('returns component at requested substep when calling moveTo even though the step is marked as skipped', () => { + const {result, rerender} = renderHook(() => + useSubStep({bodyContent: [MockSubStepComponent2, MockSubStepComponent3, MockSubStepComponent], onFinished: mockOnFinished, startFrom: 2, skipSteps: [1]}), + ); + + const {moveTo} = result.current; + + act(() => { + moveTo(1); + }); + + rerender({}); + + const {componentToRender} = result.current; + + expect(componentToRender).toBe(MockSubStepComponent3); + }); + + it('returns substep component at the previous index when calling prevScreen (if possible)', () => { + const {result, rerender} = renderHook(() => + useSubStep({ + bodyContent: [MockSubStepComponent, MockSubStepComponent2, MockSubStepComponent3, MockSubStepComponent4], + onFinished: mockOnFinished, + startFrom: 3, + skipSteps: [0, 2], + }), + ); + + const {prevScreen, screenIndex} = result.current; + + expect(screenIndex).toBe(3); + + act(() => { + prevScreen(); + }); + + rerender({}); + + const {componentToRender, screenIndex: newScreenIndex} = result.current; + expect(newScreenIndex).toBe(1); + + expect(componentToRender).toBe(MockSubStepComponent2); + }); + + it('stays on the first substep component when calling prevScreen on the second screen if the first screen is skipped', () => { + const {result, rerender} = renderHook(() => + useSubStep({bodyContent: [MockSubStepComponent, MockSubStepComponent2, MockSubStepComponent3], onFinished: mockOnFinished, startFrom: 1, skipSteps: [0]}), + ); + + const {componentToRender, prevScreen, screenIndex} = result.current; + + expect(screenIndex).toBe(1); + expect(componentToRender).toBe(MockSubStepComponent2); + + act(() => { + prevScreen(); + }); + + rerender({}); + + const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + + expect(newScreenIndex).toBe(1); + expect(newComponentToRender).toBe(MockSubStepComponent2); + }); + + it('skips step which are marked as skipped when using nextScreen', () => { + const {result, rerender} = renderHook(() => + useSubStep({ + bodyContent: [MockSubStepComponent, MockSubStepComponent2, MockSubStepComponent3, MockSubStepComponent4], + onFinished: mockOnFinished, + startFrom: 0, + skipSteps: [1, 2], + }), + ); + + const {componentToRender, nextScreen, screenIndex} = result.current; + + expect(screenIndex).toBe(0); + expect(componentToRender).toBe(MockSubStepComponent); + + act(() => { + nextScreen(); + }); + + rerender({}); + + const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + + expect(newScreenIndex).toBe(3); + expect(newComponentToRender).toBe(MockSubStepComponent4); + }); - const {componentToRender, prevScreen, screenIndex} = result.current; + it('nextScreen works correctly when called from skipped screen', () => { + const {result, rerender} = renderHook(() => + useSubStep({ + bodyContent: [MockSubStepComponent, MockSubStepComponent2, MockSubStepComponent3, MockSubStepComponent4], + onFinished: mockOnFinished, + startFrom: 1, + skipSteps: [1, 2], + }), + ); - expect(screenIndex).toBe(0); - expect(componentToRender).toBe(MockSubStepComponent2); + const {componentToRender, nextScreen, screenIndex} = result.current; - act(() => { - prevScreen(); + expect(screenIndex).toBe(1); + expect(componentToRender).toBe(MockSubStepComponent2); + + act(() => { + nextScreen(); + }); + + rerender({}); + + const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + + expect(newScreenIndex).toBe(3); + expect(newComponentToRender).toBe(MockSubStepComponent4); }); - rerender({}); + it('skips step which are marked as skipped when using prevScreen', () => { + const {result, rerender} = renderHook(() => + useSubStep({ + bodyContent: [MockSubStepComponent, MockSubStepComponent2, MockSubStepComponent3, MockSubStepComponent4], + onFinished: mockOnFinished, + startFrom: 3, + skipSteps: [1, 2], + }), + ); + + const {componentToRender, prevScreen, screenIndex} = result.current; - const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + expect(screenIndex).toBe(3); + expect(componentToRender).toBe(MockSubStepComponent4); - expect(newScreenIndex).toBe(0); - expect(newComponentToRender).toBe(MockSubStepComponent2); + act(() => { + prevScreen(); + }); + + rerender({}); + + const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + + expect(newScreenIndex).toBe(0); + expect(newComponentToRender).toBe(MockSubStepComponent); + }); + + it('prevScreen works correctly when called from skipped screen', () => { + const {result, rerender} = renderHook(() => + useSubStep({ + bodyContent: [MockSubStepComponent, MockSubStepComponent2, MockSubStepComponent3, MockSubStepComponent4], + onFinished: mockOnFinished, + startFrom: 2, + skipSteps: [1, 2], + }), + ); + + const {componentToRender, prevScreen, screenIndex} = result.current; + + expect(screenIndex).toBe(2); + expect(componentToRender).toBe(MockSubStepComponent3); + + act(() => { + prevScreen(); + }); + + rerender({}); + + const {componentToRender: newComponentToRender, screenIndex: newScreenIndex} = result.current; + + expect(newScreenIndex).toBe(0); + expect(newComponentToRender).toBe(MockSubStepComponent); + }); }); });