diff --git a/src/CONST.ts b/src/CONST.ts index f295a375e1a63..9aa0841092ad8 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -939,6 +939,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', // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', @@ -6440,6 +6441,50 @@ const CONST = { }, }, + CORPAY_FIELDS: { + BANK_ACCOUNT_DETAILS_FIELDS: ['accountNumber', 'localAccountNumber', 'routingCode', 'localRoutingCode', 'swiftBicCode'] as string[], + ACCOUNT_TYPE_KEY: 'BeneficiaryAccountType', + 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 b45a328210659..cfb0afd8b33f3 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -600,6 +600,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', @@ -819,6 +821,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 909f847fd75da..57b4f65a5bc6b 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -200,6 +200,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 6274be1044b4e..d0dc80d3e9d92 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -69,6 +69,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 new file mode 100644 index 0000000000000..6d2f4826fbc56 --- /dev/null +++ b/src/components/CurrencyPicker.tsx @@ -0,0 +1,86 @@ +import type {ReactNode} from 'react'; +import React, {useState} from 'react'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as CurrencyUtils from '@libs/CurrencyUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +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'; + +type CurrencyPickerProps = { + /** 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; +}; + +function CurrencyPicker({value, errorText, headerContent, onInputChange = () => {}}: CurrencyPickerProps) { + const {translate} = useLocalize(); + const [isPickerVisible, setIsPickerVisible] = useState(false); + const styles = useThemeStyles(); + + const hidePickerModal = () => { + setIsPickerVisible(false); + }; + + const updateInput = (item: CurrencyListItem) => { + onInputChange?.(item.currencyCode); + hidePickerModal(); + }; + + return ( + <> + setIsPickerVisible(true)} + brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={errorText} + /> + + + + {!!headerContent && headerContent} + + + + + ); +} + +CurrencyPicker.displayName = 'CurrencyPicker'; +export default CurrencyPicker; diff --git a/src/components/Form/FormProvider.tsx b/src/components/Form/FormProvider.tsx index 2731d6bd1f983..d9dfbca277fcc 100644 --- a/src/components/Form/FormProvider.tsx +++ b/src/components/Form/FormProvider.tsx @@ -69,6 +69,9 @@ type FormProviderProps = FormProps, @@ -189,7 +193,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; } @@ -210,7 +214,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}, ); @@ -406,6 +410,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 64bb2173f5b0b..7e3662e0d8d5e 100644 --- a/src/components/Form/FormWrapper.tsx +++ b/src/components/Form/FormWrapper.tsx @@ -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(); @@ -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 dabcaf90e4b25..78d4afe0e805c 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, @@ -765,7 +766,7 @@ function BaseSelectionList( { captureOnInputs: true, shouldBubble: !flattenedSections.allOptions.at(focusedIndex) || focusedIndex === -1, - isActive: !disableKeyboardShortcuts && isFocused, + isActive: !disableKeyboardShortcuts && isFocused && !isConfirmButtonDisabled, }, ); @@ -848,6 +849,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 3774821ce35fb..5c16543e25ef2 100644 --- a/src/components/SelectionList/types.ts +++ b/src/components/SelectionList/types.ts @@ -485,6 +485,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 968338391aaac..38125f5129edb 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 e260a478e4c2c..dded73952f1f2 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 0000000000000..6042bd1650701 --- /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 e59e18cf85b51..cc1c79d593d97 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 603534e68c15a..a4d28265b7f3c 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 f689b1a2ff188..a7723c8b9d39e 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1987,6 +1987,15 @@ 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?", + }, 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 fcaa8e861d59c..01c50ff8b24c7 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -2009,6 +2009,15 @@ 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?', + }, 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 3c617d3260097..c753d4c4ffb2d 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 3e02b57f9e123..a1228a023abef 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 5b7a221a87027..6ef6b3712439b 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 ea2d9893cf249..7b1f8a203ffc0 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 7b8c6df92a228..2980e0d822ab4 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -440,6 +440,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', @@ -769,6 +770,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.DELETE_MONEY_REQUEST_ON_SEARCH]: Parameters.DeleteMoneyRequestOnSearchParams; @@ -1047,6 +1049,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', @@ -1070,6 +1073,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 7e5ac879cf60b..058a0844a5bae 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -247,7 +247,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 04ed0261a2251..9d4d917311e41 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -242,6 +242,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 9869f4e39f94c..7c6c568a53594 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 c18ebd2174064..95fc334b906ce 100644 --- a/src/libs/PaymentUtils.ts +++ b/src/libs/PaymentUtils.ts @@ -27,10 +27,10 @@ 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} ${Localize.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)}`; @@ -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/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 09a1f0b4f8fda..40e2b188d33ff 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -11,7 +11,7 @@ import type { ValidateBankAccountWithTransactionsParams, VerifyIdentityForBankAccountParams, } from '@libs/API/parameters'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import * as Localize from '@libs/Localize'; import Navigation from '@libs/Navigation/Navigation'; @@ -19,7 +19,7 @@ import CONST 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'; @@ -62,6 +62,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(() => ReimbursementAccount.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID)); } @@ -74,7 +80,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, isUserValidated = true) { - clearPlaid().then(() => { + clearInternationalBankAccount().then(() => { if (exitReportID) { Onyx.merge(ONYXKEYS.PERSONAL_BANK_ACCOUNT, {exitReportID}); } @@ -563,7 +569,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), @@ -639,6 +645,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, @@ -668,8 +724,10 @@ export { updateAddPersonalBankAccountDraft, clearPersonalBankAccountSetupType, validatePlaidSelection, - getCorpayBankAccountFields, + fetchCorpayFields, clearReimbursementAccountBankCreation, + getCorpayBankAccountFields, + createCorpayBankAccountForWalletFlow, }; export type {BusinessAddress, PersonalAddress}; diff --git a/src/pages/ReimbursementAccount/BankAccountStep.tsx b/src/pages/ReimbursementAccount/BankAccountStep.tsx index 95d8f2c396636..120cc14b9afba 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.tsx +++ b/src/pages/ReimbursementAccount/BankAccountStep.tsx @@ -213,11 +213,11 @@ function BankAccountStep({ {translate('common.privacy')} Link.openExternalLink('https://help.expensify.com/articles/new-expensify/settings/Encryption-and-Data-Security/')} + onPress={() => Link.openExternalLink(CONST.ENCRYPTION_AND_SECURITY_HELP_URL)} style={[styles.flexRow, styles.alignItemsCenter]} accessibilityLabel={translate('bankAccount.yourDataIsSecure')} > - {translate('bankAccount.yourDataIsSecure')} + {translate('bankAccount.yourDataIsSecure')} ; - - /** 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 0000000000000..f859bb5838cb0 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/InternationalDepositAccountContent.tsx @@ -0,0 +1,140 @@ +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 * as FormActions from '@libs/actions/FormActions'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +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 handleFinishStep = useCallback(() => { + FormActions.clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); + Navigation.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) { + FormActions.clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); + Navigation.goBack(); + return; + } + + // Clicking back on the success screen should dismiss the modal + if (screenIndex === CONST.CORPAY_FIELDS.INDEXES.MAPPING.SUCCESS) { + FormActions.clearDraftValues(ONYXKEYS.FORMS.INTERNATIONAL_BANK_ACCOUNT_FORM); + Navigation.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 0000000000000..2f992147613a1 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/index.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import {useOnyx} from 'react-native-onyx'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; +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 isLoading = isLoadingOnyxValue(privatePersonalDetailsMetadata, corpayFieldsMetadata, bankAccountListMetadata, draftValuesMetadata, countryMetadata, isLoadingMetadata); + + 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 0000000000000..aacf04e2ebc4f --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountHolderInformation.tsx @@ -0,0 +1,130 @@ +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'; + +const ACCOUNT_HOLDER_COUNTRY = 'accountHolderCountry'; + +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 === ACCOUNT_HOLDER_COUNTRY) { + 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: true, + }); + + 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 === ACCOUNT_HOLDER_COUNTRY) { + 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 0000000000000..013683a381762 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/AccountType.tsx @@ -0,0 +1,75 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +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 * as FormActions from '@userActions/FormActions'; +import Text from '@src/components/Text'; +import CONST from '@src/CONST'; +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 fieldData = fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE]?.[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY] ?? {}; + + const onAccountTypeSelected = useCallback(() => { + if (isEditing && formValues[CONST.CORPAY_FIELDS.ACCOUNT_TYPE_KEY] === currentAccountType) { + onNext(); + return; + } + if (fieldData.isRequired && !currentAccountType) { + return; + } + FormActions.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) => { + setCurrentAccountType(country.value); + }, []); + + 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], + ); + + 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 0000000000000..782a4eafb091b --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankAccountDetails.tsx @@ -0,0 +1,114 @@ +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 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 * as BankAccounts 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 handleSubmit = useInternationalBankAccountFormSubmit({ + fieldIds: Object.keys(fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS] ?? {}), + onNext, + shouldSaveDraft: true, + }); + + const onCurrencySelected = useCallback( + (value: string) => { + if (formValues.bankCurrency === value) { + return; + } + BankAccounts.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}))} + /> + + ))} + + + + + {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 0000000000000..f4998062ed329 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/BankInformation.tsx @@ -0,0 +1,118 @@ +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: true, + }); + + 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 0000000000000..c049a7545b742 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/Confirmation.tsx @@ -0,0 +1,186 @@ +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 useThemeStyles from '@hooks/useThemeStyles'; +import type {CustomSubStepProps} from '@pages/settings/Wallet/InternationalDepositAccount/types'; +import * as BankAccounts from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const STEP_INDEXES = CONST.CORPAY_FIELDS.INDEXES.MAPPING; + +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 getDataAndGoToNextStep = (values: FormOnyxValues) => { + setError(''); + setIsSubmitting(true); + BankAccounts.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 = [ + { + description: translate('common.country'), + title: formValues.bankCountry, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.COUNTRY_SELECTOR); + }, + }, + { + description: translate('common.currency'), + title: formValues.bankCurrency, + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); + }, + }, + ]; + + // eslint-disable-next-line guard-for-in + for (const fieldName in fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS]) { + summaryItems.push({ + description: + fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS][fieldName].label + + (fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_ACCOUNT_DETAILS][fieldName].isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.BANK_ACCOUNT_DETAILS); + }, + }); + } + + // eslint-disable-next-line guard-for-in + for (const fieldName in fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE]) { + summaryItems.push({ + description: + fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE][fieldName].label + + (fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_TYPE][fieldName].isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.ACCOUNT_TYPE); + }, + }); + } + + // eslint-disable-next-line guard-for-in + for (const fieldName in fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION]) { + summaryItems.push({ + description: + fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION][fieldName].label + + (fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.BANK_INFORMATION][fieldName].isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.BANK_INFORMATION); + }, + }); + } + + // eslint-disable-next-line guard-for-in + for (const fieldName in fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION]) { + summaryItems.push({ + description: + fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION][fieldName].label + + (fieldsMap[CONST.CORPAY_FIELDS.STEPS_NAME.ACCOUNT_HOLDER_INFORMATION][fieldName].isRequired ? '' : ` (${translate('common.optional')})`), + title: formValues[fieldName], + shouldShowRightIcon: true, + onPress: () => { + onMove(STEP_INDEXES.ACCOUNT_HOLDER_INFORMATION); + }, + }); + } + + const validate = useCallback( + (values: FormOnyxValues): FormInputErrors => { + const errors: FormInputErrors = {}; + if (!values.acceptTerms) { + errors.acceptTerms = translate('common.error.acceptTerms'); + } + return errors; + }, + [translate], + ); + + return ( + + {translate('personalInfoStep.letsDoubleCheck')} + {summaryItems.map(({description, title, shouldShowRightIcon, 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 0000000000000..dc16737a5dbc2 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/substeps/CountrySelection.tsx @@ -0,0 +1,91 @@ +import React, {useCallback, useMemo, useState} from 'react'; +import {View} from 'react-native'; +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 * as BankAccounts 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; + } + BankAccounts.fetchCorpayFields(currentCountry, formValues.bankCurrency); + resetScreenIndex?.(CONST.CORPAY_FIELDS.INDEXES.MAPPING.BANK_ACCOUNT_DETAILS); + }, [currentCountry, formValues.bankCountry, formValues.bankCurrency, isEditing, onNext, resetScreenIndex]); + + const onSelectionChange = useCallback((country: Option) => { + setCurrentCountry(country.value); + }, []); + + const countries = useMemo( + () => + Object.keys(CONST.ALL_COUNTRIES).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 0000000000000..3a0731010c82e --- /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 0000000000000..2adfc7774c376 --- /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 0000000000000..5f33706934037 --- /dev/null +++ b/src/pages/settings/Wallet/InternationalDepositAccount/utils.ts @@ -0,0 +1,150 @@ +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 * as ErrorUtils from '@libs/ErrorUtils'; +import * as PersonalDetailsUtils 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 = PersonalDetailsUtils.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 = PersonalDetailsUtils.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; + } + fieldsMap[fieldName].validationRules.forEach((rule) => { + 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 = {}; + for (const fieldName in fieldsMap) { + if (!fieldName) { + // eslint-disable-next-line no-continue + continue; + } + if (fieldsMap[fieldName].isRequired && values[fieldName] === '') { + ErrorUtils.addErrorMessage(errors, fieldName, translate('common.error.fieldRequired')); + } + fieldsMap[fieldName].validationRules.forEach((rule) => { + const regExpCheck = new RegExp(rule.regEx); + if (!regExpCheck.test(values[fieldName])) { + ErrorUtils.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 a16bad99d2ccc..be0942e4716d2 100644 --- a/src/pages/settings/Wallet/PaymentMethodList.tsx +++ b/src/pages/settings/Wallet/PaymentMethodList.tsx @@ -96,6 +96,7 @@ type PaymentMethodListProps = { icon?: FormattedSelectedPaymentMethodIcon, isDefault?: boolean, methodID?: number, + description?: string, ) => void; /** The policy invoice's transfer bank accountID */ @@ -127,7 +128,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 ?? '' : item.accountData?.fundID ?? ''; + const paymentID = isBankAccount ? item.accountData?.bankAccountID : item.accountData?.fundID; if (!paymentID) { Log.info('Unable to clear payment method error: ', undefined, item); @@ -333,6 +334,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, @@ -421,7 +423,7 @@ function PaymentMethodList({ shouldShowDefaultBadge( filteredPaymentMethods, item, - userWallet?.walletLinkedAccountID ?? 0, + userWallet?.walletLinkedAccountID ?? CONST.DEFAULT_NUMBER_ID, invoiceTransferBankAccountID ? invoiceTransferBankAccountID === item.methodID : item.isDefault, ) ? translate('paymentMethodList.defaultPaymentMethod') diff --git a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx index 39db6739bf1c0..be3aa2b5327ad 100644 --- a/src/pages/settings/Wallet/WalletPage/WalletPage.tsx +++ b/src/pages/settings/Wallet/WalletPage/WalletPage.tsx @@ -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 ?? PaymentUtils.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 ?? PaymentUtils.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(); @@ -232,9 +233,9 @@ function WalletPage({shouldListenForResize = false}: WalletPageProps) { 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); + PaymentMethods.makeDefaultPaymentMethod(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); + PaymentMethods.makeDefaultPaymentMethod(0, paymentMethod.selectedPaymentMethod.fundID ?? CONST.DEFAULT_NUMBER_ID, previousPaymentMethod, currentPaymentMethod); } }, [ paymentMethod.methodID, diff --git a/src/types/form/InternationalBankAccountForm.ts b/src/types/form/InternationalBankAccountForm.ts new file mode 100644 index 0000000000000..f7c981b6a5c4c --- /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 3c9d90ba3c2d2..aced149ddc243 100644 --- a/src/types/form/index.ts +++ b/src/types/form/index.ts @@ -87,4 +87,5 @@ export type {WorkspaceCompanyCardFeedName} from './WorkspaceCompanyCardFeedName' export type {SearchSavedSearchRenameForm} from './SearchSavedSearchRenameForm'; export type {WorkspaceCompanyCardEditName} from './WorkspaceCompanyCardEditName'; export type {PersonalDetailsForm} from './PersonalDetailsForm'; +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 6da03440fb2eb..cd44e6256db62 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 9a1d8bf4d2ab0..2fa2961c41d2e 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 06d0e2cd89466..be43bd8a42ae8 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 Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; @@ -126,7 +126,6 @@ export type { CardList, CardOnWaitlist, Credentials, - CorpayFields, Currency, CurrencyList, CustomStatusDraft, @@ -243,6 +242,8 @@ export type { Onboarding, OnboardingPurpose, ValidateMagicCodeAction, + CorpayFields, + CorpayFormField, JoinablePolicies, DismissedProductTraining, }; diff --git a/tests/unit/useSubStepTest.tsx b/tests/unit/useSubStepTest.tsx index 7a5577005d4aa..c4765cca315f5 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); + }); }); });