diff --git a/src/CONST.ts b/src/CONST.ts index eae4b8ec7a2b..f1b7e74bbde4 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -195,6 +195,71 @@ const CONST = { DOMAIN: '@expensify.sms', }, BANK_ACCOUNT: { + BANK_INFO_STEP: { + INPUT_KEY: { + ROUTING_NUMBER: 'routingNumber', + ACCOUNT_NUMBER: 'accountNumber', + PLAID_MASK: 'plaidMask', + IS_SAVINGS: 'isSavings', + BANK_NAME: 'bankName', + PLAID_ACCOUNT_ID: 'plaidAccountID', + PLAID_ACCESS_TOKEN: 'plaidAccessToken', + }, + }, + PERSONAL_INFO_STEP: { + INPUT_KEY: { + FIRST_NAME: 'firstName', + LAST_NAME: 'lastName', + DOB: 'dob', + SSN_LAST_4: 'ssnLast4', + STREET: 'requestorAddressStreet', + CITY: 'requestorAddressCity', + STATE: 'requestorAddressState', + ZIP_CODE: 'requestorAddressZipCode', + }, + }, + BUSINESS_INFO_STEP: { + INPUT_KEY: { + COMPANY_NAME: 'companyName', + COMPANY_TAX_ID: 'companyTaxID', + COMPANY_WEBSITE: 'website', + COMPANY_PHONE: 'companyPhone', + STREET: 'addressStreet', + CITY: 'addressCity', + STATE: 'addressState', + ZIP_CODE: 'addressZipCode', + INCORPORATION_TYPE: 'incorporationType', + INCORPORATION_DATE: 'incorporationDate', + INCORPORATION_STATE: 'incorporationState', + HAS_NO_CONNECTION_TO_CANNABIS: 'hasNoConnectionToCannabis', + }, + }, + BENEFICIAL_OWNER_INFO_STEP: { + SUBSTEP: { + IS_USER_UBO: 1, + IS_ANYONE_ELSE_UBO: 2, + UBO_DETAILS_FORM: 3, + ARE_THERE_MORE_UBOS: 4, + UBOS_LIST: 5, + }, + INPUT_KEY: { + OWNS_MORE_THAN_25_PERCENT: 'ownsMoreThan25Percent', + HAS_OTHER_BENEFICIAL_OWNERS: 'hasOtherBeneficialOwners', + BENEFICIAL_OWNERS: 'beneficialOwners', + }, + BENEFICIAL_OWNER_DATA: { + BENEFICIAL_OWNER_KEYS: 'beneficialOwnerKeys', + PREFIX: 'beneficialOwner', + FIRST_NAME: 'firstName', + LAST_NAME: 'lastName', + DOB: 'dob', + SSN_LAST_4: 'ssnLast4', + STREET: 'street', + CITY: 'city', + STATE: 'state', + ZIP_CODE: 'zipCode', + }, + }, PLAID: { ALLOWED_THROTTLED_COUNT: 2, ERROR: { @@ -205,6 +270,13 @@ const CONST = { EXIT: 'EXIT', }, }, + COMPLETE_VERIFICATION: { + INPUT_KEY: { + IS_AUTHORIZED_TO_USE_BANK_ACCOUNT: 'isAuthorizedToUseBankAccount', + CERTIFY_TRUE_INFORMATION: 'certifyTrueInformation', + ACCEPT_TERMS_AND_CONDITIONS: 'acceptTermsAndConditions', + }, + }, ERROR: { MISSING_ROUTING_NUMBER: '402 Missing routingNumber', MAX_ROUTING_NUMBER: '402 Maximum Size Exceeded routingNumber', @@ -214,14 +286,18 @@ const CONST = { STEP: { // In the order they appear in the VBA flow BANK_ACCOUNT: 'BankAccountStep', - COMPANY: 'CompanyStep', REQUESTOR: 'RequestorStep', + COMPANY: 'CompanyStep', + BENEFICIAL_OWNERS: 'BeneficialOwnersStep', ACH_CONTRACT: 'ACHContractStep', VALIDATION: 'ValidationStep', ENABLE: 'EnableStep', }, + STEP_NAMES: ['1', '2', '3', '4', '5'], + STEPS_HEADER_HEIGHT: 40, SUBSTEP: { MANUAL: 'manual', + PLAID: 'plaid', }, VERIFICATIONS: { ERROR_MESSAGE: 'verifications.errorMessage', @@ -493,6 +569,8 @@ const CONST = { ONFIDO_FACIAL_SCAN_POLICY_URL: 'https://onfido.com/facial-scan-policy-and-release/', ONFIDO_PRIVACY_POLICY_URL: 'https://onfido.com/privacy/', ONFIDO_TERMS_OF_SERVICE_URL: 'https://onfido.com/terms-of-service/', + LIST_OF_RESTRICTED_BUSINESSES: 'https://community.expensify.com/discussion/6191/list-of-restricted-businesses', + // Use Environment.getEnvironmentURL to get the complete URL with port number DEV_NEW_EXPENSIFY_URL: 'https://dev.new.expensify.com:', OLDDOT_URLS: { @@ -3194,6 +3272,34 @@ const CONST = { }, REPORT_FIELD_TITLE_FIELD_ID: 'text_title', + + REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX: { + BANK_ACCOUNT: { + ACCOUNT_NUMBERS: 0, + }, + PERSONAL_INFO: { + LEGAL_NAME: 0, + DATE_OF_BIRTH: 1, + SSN: 2, + ADDRESS: 3, + }, + BUSINESS_INFO: { + BUSINESS_NAME: 0, + TAX_ID_NUMBER: 1, + COMPANY_WEBSITE: 2, + PHONE_NUMBER: 3, + COMPANY_ADDRESS: 4, + COMPANY_TYPE: 5, + INCORPORATION_DATE: 6, + INCORPORATION_STATE: 7, + }, + UBO: { + LEGAL_NAME: 0, + DATE_OF_BIRTH: 1, + SSN: 2, + ADDRESS: 3, + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 4a9c809485e5..751ee105ceb7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -141,6 +141,7 @@ const ONYXKEYS = { /** Token needed to initialize Onfido */ ONFIDO_TOKEN: 'onfidoToken', + ONFIDO_APPLICANT_ID: 'onfidoApplicantID', /** Indicates which locale should be used */ NVP_PREFERRED_LOCALE: 'preferredLocale', @@ -426,6 +427,7 @@ type OnyxValues = { [ONYXKEYS.IS_PLAID_DISABLED]: boolean; [ONYXKEYS.PLAID_LINK_TOKEN]: string; [ONYXKEYS.ONFIDO_TOKEN]: string; + [ONYXKEYS.ONFIDO_APPLICANT_ID]: string; [ONYXKEYS.NVP_PREFERRED_LOCALE]: OnyxTypes.Locale; [ONYXKEYS.USER_WALLET]: OnyxTypes.UserWallet; [ONYXKEYS.WALLET_ONFIDO]: OnyxTypes.WalletOnfido; @@ -563,8 +565,8 @@ type OnyxValues = { [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM]: OnyxTypes.ReportFieldEditForm; [ONYXKEYS.FORMS.REPORT_FIELD_EDIT_FORM_DRAFT]: OnyxTypes.Form; // @ts-expect-error Different values are defined under the same key: ReimbursementAccount and ReimbursementAccountForm - [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.Form; - [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.Form; + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM]: OnyxTypes.ReimbursementAccountForm; + [ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT]: OnyxTypes.ReimbursementAccountForm; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT]: OnyxTypes.PersonalBankAccount; [ONYXKEYS.FORMS.PERSONAL_BANK_ACCOUNT_DRAFT]: OnyxTypes.PersonalBankAccount; }; diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index a5160a13f8e9..b6fc639546a8 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -1,6 +1,6 @@ import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useCallback, useEffect, useRef} from 'react'; +import React, {useCallback, useEffect, useRef, useState} from 'react'; import {ActivityIndicator, View} from 'react-native'; import {withOnyx} from 'react-native-onyx'; import _ from 'underscore'; @@ -16,10 +16,12 @@ import * as BankAccounts from '@userActions/BankAccounts'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; +import FormHelpMessage from './FormHelpMessage'; import Icon from './Icon'; import getBankIcon from './Icon/BankIcons'; import Picker from './Picker'; import PlaidLink from './PlaidLink'; +import RadioButtons from './RadioButtons'; import Text from './Text'; const propTypes = { @@ -55,6 +57,15 @@ const propTypes = { /** Are we adding a withdrawal account? */ allowDebit: PropTypes.bool, + + /** Is displayed in new VBBA */ + isDisplayedInNewVBBA: PropTypes.bool, + + /** Text to display on error message */ + errorText: PropTypes.string, + + /** Function called whenever radio button value changes */ + onInputChange: PropTypes.func, }; const defaultProps = { @@ -68,6 +79,9 @@ const defaultProps = { allowDebit: false, bankAccountID: 0, isPlaidDisabled: false, + isDisplayedInNewVBBA: false, + errorText: '', + onInputChange: () => {}, }; function AddPlaidBankAccount({ @@ -82,11 +96,23 @@ function AddPlaidBankAccount({ bankAccountID, allowDebit, isPlaidDisabled, + isDisplayedInNewVBBA, + errorText, + onInputChange, }) { const theme = useTheme(); const styles = useThemeStyles(); + const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts', []); + const defaultSelectedPlaidAccount = _.find(plaidBankAccounts, (account) => account.plaidAccountID === selectedPlaidAccountID); + const defaultSelectedPlaidAccountID = lodashGet(defaultSelectedPlaidAccount, 'plaidAccountID', ''); + const defaultSelectedPlaidAccountMask = lodashGet( + _.find(plaidBankAccounts, (account) => account.plaidAccountID === selectedPlaidAccountID), + 'mask', + '', + ); const subscribedKeyboardShortcuts = useRef([]); const previousNetworkState = useRef(); + const [selectedPlaidAccountMask, setSelectedPlaidAccountMask] = useState(defaultSelectedPlaidAccountMask); const {translate} = useLocalize(); const {isOffline} = useNetwork(); @@ -162,17 +188,28 @@ function AddPlaidBankAccount({ previousNetworkState.current = isOffline; }, [allowDebit, bankAccountID, isAuthenticatedWithPlaid, isOffline]); - const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts') || []; const token = getPlaidLinkToken(); const options = _.map(plaidBankAccounts, (account) => ({ value: account.plaidAccountID, - label: `${account.addressName} ${account.mask}`, + label: account.addressName, })); const {icon, iconSize, iconStyles} = getBankIcon({styles}); const plaidErrors = lodashGet(plaidData, 'errors'); const plaidDataErrorMessage = !_.isEmpty(plaidErrors) ? _.chain(plaidErrors).values().first().value() : ''; const bankName = lodashGet(plaidData, 'bankName'); + /** + * @param {String} plaidAccountID + * + * When user selects one of plaid accounts we need to set the mask in order to display it on UI + */ + const handleSelectingPlaidAccount = (plaidAccountID) => { + const mask = _.find(plaidBankAccounts, (account) => account.plaidAccountID === plaidAccountID).mask; + setSelectedPlaidAccountMask(mask); + onSelect(plaidAccountID); + onInputChange(plaidAccountID); + }; + if (isPlaidDisabled) { return ( @@ -239,6 +276,37 @@ function AddPlaidBankAccount({ return {renderPlaidLink()}; } + if (isDisplayedInNewVBBA) { + return ( + + {translate('bankAccount.chooseAnAccount')} + {!_.isEmpty(text) && {text}} + + + + {bankName} + {selectedPlaidAccountMask.length > 0 && ( + {`${translate('bankAccount.accountEnding')} ${selectedPlaidAccountMask}`} + )} + + + {`${translate('bankAccount.chooseAnAccountBelow')}:`} + + + + ); + } + // Plaid bank accounts view return ( diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a2ca930690ac..759908a4647f 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -1,7 +1,7 @@ import {setYear} from 'date-fns'; import _ from 'lodash'; import PropTypes from 'prop-types'; -import React, {forwardRef, useState} from 'react'; +import React, {forwardRef, useEffect, useState} from 'react'; import {View} from 'react-native'; import * as Expensicons from '@components/Icon/Expensicons'; import refPropTypes from '@components/refPropTypes'; @@ -9,6 +9,7 @@ import TextInput from '@components/TextInput'; import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; import CalendarPicker from './CalendarPicker'; @@ -42,6 +43,12 @@ const propTypes = { /** A function that is passed by FormWrapper */ onTouched: PropTypes.func.isRequired, + /** Saves a draft of the input value when used in a form */ + shouldSaveDraft: PropTypes.bool, + + /** ID of the wrapping form */ + formID: PropTypes.string, + ...baseTextInputPropTypes, }; @@ -50,9 +57,28 @@ const datePickerDefaultProps = { minDate: setYear(new Date(), CONST.CALENDAR_PICKER.MIN_YEAR), maxDate: setYear(new Date(), CONST.CALENDAR_PICKER.MAX_YEAR), value: undefined, + shouldSaveDraft: false, + formID: '', }; -function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, value}) { +function DatePicker({ + forwardedRef, + containerStyles, + defaultValue, + disabled, + errorText, + inputID, + isSmallScreenWidth, + label, + maxDate, + minDate, + onInputChange, + onTouched, + placeholder, + value, + shouldSaveDraft, + formID, +}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined); @@ -67,6 +93,19 @@ function DatePicker({forwardedRef, containerStyles, defaultValue, disabled, erro setSelectedDate(newValue); }; + useEffect(() => { + // Value is provided to input via props and onChange never fires. We have to save draft manually. + if (shouldSaveDraft && formID !== '') { + FormActions.setDraftValues(formID, {[inputID]: selectedDate}); + } + + if (selectedDate === value || _.isUndefined(value)) { + return; + } + + setSelectedDate(value); + }, [formID, inputID, selectedDate, shouldSaveDraft, value]); + return ( diff --git a/src/components/Form/types.ts b/src/components/Form/types.ts index 5ade522e6d7f..2705b2c8fd4e 100644 --- a/src/components/Form/types.ts +++ b/src/components/Form/types.ts @@ -6,7 +6,9 @@ import type AmountTextInput from '@components/AmountTextInput'; import type CheckboxWithLabel from '@components/CheckboxWithLabel'; import type Picker from '@components/Picker'; import type SingleChoiceQuestion from '@components/SingleChoiceQuestion'; +import type StatePicker from '@components/StatePicker'; import type TextInput from '@components/TextInput'; +import type BusinessTypePicker from '@pages/ReimbursementAccount/BusinessInfo/substeps/TypeBusiness/BusinessTypePicker'; import type {OnyxFormKey, OnyxValues} from '@src/ONYXKEYS'; import type Form from '@src/types/onyx/Form'; import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; @@ -18,7 +20,16 @@ import type {BaseForm, FormValueType} from '@src/types/onyx/Form'; * TODO: Add remaining inputs here once these components are migrated to Typescript: * CountrySelector | StatePicker | DatePicker | EmojiPickerButtonDropdown | RoomNameInput | ValuePicker */ -type ValidInputs = typeof TextInput | typeof AmountTextInput | typeof SingleChoiceQuestion | typeof CheckboxWithLabel | typeof Picker | typeof AddressSearch | typeof AmountForm; +type ValidInputs = + | typeof TextInput + | typeof AmountTextInput + | typeof SingleChoiceQuestion + | typeof CheckboxWithLabel + | typeof Picker + | typeof AddressSearch + | typeof AmountForm + | typeof BusinessTypePicker + | typeof StatePicker; type ValueTypeKey = 'string' | 'boolean' | 'date'; @@ -72,6 +83,9 @@ type FormProps = { /** Text to be displayed in the submit button */ submitButtonText: string; + /** Submit button styles */ + submitButtonStyles?: StyleProp; + /** Controls the submit button's visibility */ isSubmitButtonVisible?: boolean; diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx new file mode 100644 index 000000000000..074e10d521cf --- /dev/null +++ b/src/components/InteractiveStepSubHeader.tsx @@ -0,0 +1,110 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useImperativeHandle, useState} from 'react'; +import type {ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import useThemeStyles from '@hooks/useThemeStyles'; +import colors from '@styles/theme/colors'; +import variables from '@styles/variables'; +import CONST from '@src/CONST'; +import Icon from './Icon'; +import * as Expensicons from './Icon/Expensicons'; +import PressableWithFeedback from './Pressable/PressableWithFeedback'; +import Text from './Text'; + +type InteractiveStepSubHeaderProps = { + /** List of the Route Name to navigate when the step is selected */ + stepNames: readonly string[]; + + /** Function to call when a step is selected */ + onStepSelected?: (stepName: string) => void; + + /** The index of the step to start with */ + startStepIndex?: number; +}; + +type InteractiveStepSubHeaderHandle = { + /** Move to the next step */ + moveNext: () => void; +}; + +const MIN_AMOUNT_FOR_EXPANDING = 3; +const MIN_AMOUNT_OF_STEPS = 2; + +function InteractiveStepSubHeader({stepNames, startStepIndex = 0, onStepSelected}: InteractiveStepSubHeaderProps, ref: ForwardedRef) { + const styles = useThemeStyles(); + const containerWidthStyle: ViewStyle = stepNames.length < MIN_AMOUNT_FOR_EXPANDING ? styles.mnw60 : styles.mnw100; + + if (stepNames.length < MIN_AMOUNT_OF_STEPS) { + throw new Error(`stepNames list must have at least ${MIN_AMOUNT_OF_STEPS} elements.`); + } + + const [currentStep, setCurrentStep] = useState(startStepIndex); + useImperativeHandle( + ref, + () => ({ + moveNext: () => { + setCurrentStep((actualStep) => actualStep + 1); + }, + }), + [], + ); + + const amountOfUnions = stepNames.length - 1; + + return ( + + {stepNames.map((stepName, index) => { + const isCompletedStep = currentStep > index; + const isLockedStep = currentStep < index; + const isLockedLine = currentStep < index + 1; + const hasUnion = index < amountOfUnions; + + const moveToStep = () => { + if (isLockedStep || !onStepSelected) { + return; + } + setCurrentStep(index); + onStepSelected(stepNames[index]); + }; + + return ( + + + {isCompletedStep ? ( + + ) : ( + {index + 1} + )} + + {hasUnion ? : null} + + ); + })} + + ); +} + +InteractiveStepSubHeader.displayName = 'InteractiveStepSubHeader'; + +export type {InteractiveStepSubHeaderProps, InteractiveStepSubHeaderHandle}; + +export default forwardRef(InteractiveStepSubHeader); diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index cb6843af65c0..3407c5ad9afa 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -1,4 +1,5 @@ import React, {useState} from 'react'; +import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useThemeStyles from '@hooks/useThemeStyles'; import RadioButtonWithLabel from './RadioButtonWithLabel'; @@ -12,21 +13,27 @@ type RadioButtonsProps = { /** List of choices to display via radio buttons */ items: Choice[]; + /** Default checked value */ + defaultCheckedValue?: string; + /** Callback to fire when selecting a radio button */ onPress: (value: string) => void; + + /** Style for radio button */ + radioButtonStyle?: StyleProp; }; -function RadioButtons({items, onPress}: RadioButtonsProps) { +function RadioButtons({items, onPress, defaultCheckedValue = '', radioButtonStyle}: RadioButtonsProps) { const styles = useThemeStyles(); - const [checkedValue, setCheckedValue] = useState(''); + const [checkedValue, setCheckedValue] = useState(defaultCheckedValue); return ( - + {items.map((item) => ( { setCheckedValue(item.value); return onPress(item.value); diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index bc0e70e64419..141e056afd93 100644 --- a/src/components/ReimbursementAccountLoadingIndicator.js +++ b/src/components/ReimbursementAccountLoadingIndicator.js @@ -4,7 +4,6 @@ import {StyleSheet, View} from 'react-native'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; import FullPageOfflineBlockingView from './BlockingViews/FullPageOfflineBlockingView'; -import FullScreenLoadingIndicator from './FullscreenLoadingIndicator'; import HeaderWithBackButton from './HeaderWithBackButton'; import Lottie from './Lottie'; import LottieAnimations from './LottieAnimations'; @@ -12,9 +11,6 @@ import ScreenWrapper from './ScreenWrapper'; import Text from './Text'; const propTypes = { - /** Whether the user is submitting verifications data */ - isSubmittingVerificationsData: PropTypes.bool.isRequired, - /** Method to trigger when pressing back button of the header */ onBackButtonPress: PropTypes.func.isRequired, }; @@ -33,22 +29,18 @@ function ReimbursementAccountLoadingIndicator(props) { onBackButtonPress={props.onBackButtonPress} /> - {props.isSubmittingVerificationsData ? ( - - - - {translate('reimbursementAccountLoadingAnimation.explanationLine')} - + + + + {translate('reimbursementAccountLoadingAnimation.explanationLine')} - ) : ( - - )} + ); diff --git a/src/components/StatePicker/index.tsx b/src/components/StatePicker/index.tsx index b00111319b4a..72262346b0d7 100644 --- a/src/components/StatePicker/index.tsx +++ b/src/components/StatePicker/index.tsx @@ -3,6 +3,7 @@ import React, {useState} from 'react'; import type {ForwardedRef} from 'react'; import {View} from 'react-native'; import FormHelpMessage from '@components/FormHelpMessage'; +import type {MenuItemProps} from '@components/MenuItem'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -24,11 +25,14 @@ type StatePickerProps = { /** Label to display on field */ label?: string; + /** Any additional styles to apply */ + wrapperStyle?: MenuItemProps['wrapperStyle']; + /** Callback to call when the picker modal is dismissed */ onBlur?: () => void; }; -function StatePicker({value, onInputChange, label, onBlur, errorText = ''}: StatePickerProps, ref: ForwardedRef) { +function StatePicker({value, onInputChange, label, onBlur, errorText = '', wrapperStyle}: StatePickerProps, ref: ForwardedRef) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -63,11 +67,10 @@ function StatePicker({value, onInputChange, label, onBlur, errorText = ''}: Stat ref={ref} shouldShowRightIcon title={title} - // Label can be an empty string - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - description={label || translate('common.state')} + description={label ?? translate('common.state')} descriptionTextStyle={descStyle} onPress={showPickerModal} + wrapperStyle={wrapperStyle} /> diff --git a/src/hooks/useReimbursementAccountStepFormSubmit.ts b/src/hooks/useReimbursementAccountStepFormSubmit.ts new file mode 100644 index 000000000000..1f4de6ed2677 --- /dev/null +++ b/src/hooks/useReimbursementAccountStepFormSubmit.ts @@ -0,0 +1,45 @@ +import {useCallback} from 'react'; +import * as FormActions from '@userActions/FormActions'; +import type {OnyxFormKeyWithoutDraft} from '@userActions/FormActions'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccountDraftValues} from '@src/types/onyx/ReimbursementAccountDraft'; +import type {SubStepProps} from './useSubStep/types'; + +type UseReimbursementAccountStepFormSubmitParams = Pick & { + formId?: OnyxFormKeyWithoutDraft; + fieldIds: Array; +}; + +/** + * Hook for handling submit method in ReimbursementAccount substeps. + * When user is in editing mode we should save values only when user confirm that + * @param formId - ID for particular form + * @param isEditing - if form is in editing mode + * @param onNext - callback + * @param fieldIds - field IDs for particular step + */ +export default function useReimbursementAccountStepFormSubmit({ + formId = ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM, + isEditing, + onNext, + fieldIds, +}: UseReimbursementAccountStepFormSubmitParams) { + return useCallback( + (values: ReimbursementAccountDraftValues) => { + if (isEditing) { + const stepValues = fieldIds.reduce( + (acc, key) => ({ + ...acc, + [key]: values[key], + }), + {}, + ); + + FormActions.setDraftValues(formId, stepValues); + } + + onNext(); + }, + [isEditing, onNext, formId, fieldIds], + ); +} diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts new file mode 100644 index 000000000000..ad4cf032858d --- /dev/null +++ b/src/hooks/useSubStep/index.ts @@ -0,0 +1,57 @@ +import {useCallback, useRef, useState} from 'react'; +import type {SubStepProps, UseSubStep} from './types'; + +/** + * 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 + */ +export default function useSubStep({bodyContent, onFinished, startFrom = 0}: UseSubStep) { + const [screenIndex, setScreenIndex] = useState(startFrom); + const isEditing = useRef(false); + + const prevScreen = useCallback(() => { + const prevScreenIndex = screenIndex - 1; + + if (prevScreenIndex < 0) { + return; + } + + setScreenIndex(prevScreenIndex); + }, [screenIndex]); + + const nextScreen = useCallback(() => { + if (isEditing.current) { + isEditing.current = false; + + setScreenIndex(bodyContent.length - 1); + + return; + } + + const nextScreenIndex = screenIndex + 1; + + if (nextScreenIndex === bodyContent.length) { + onFinished(); + } else { + setScreenIndex(nextScreenIndex); + } + }, [screenIndex, bodyContent.length, onFinished]); + + const moveTo = useCallback((step: number) => { + isEditing.current = true; + setScreenIndex(step); + }, []); + + const resetScreenIndex = useCallback(() => { + setScreenIndex(0); + }, []); + + const goToTheLastStep = useCallback(() => { + isEditing.current = false; + setScreenIndex(bodyContent.length - 1); + }, [bodyContent]); + + return {componentToRender: bodyContent[screenIndex], isEditing: isEditing.current, screenIndex, prevScreen, nextScreen, moveTo, resetScreenIndex, goToTheLastStep}; +} diff --git a/src/hooks/useSubStep/types.ts b/src/hooks/useSubStep/types.ts new file mode 100644 index 000000000000..ffdee5825197 --- /dev/null +++ b/src/hooks/useSubStep/types.ts @@ -0,0 +1,31 @@ +import type {ComponentType} from 'react'; + +type SubStepProps = { + /** value indicating whether user is editing one of the sub steps */ + isEditing: boolean; + + /** continues to next sub step */ + onNext: () => void; + + /** moves user to passed sub step */ + onMove: (step: number) => void; + + /** index of currently displayed sub step */ + screenIndex?: number; + + /** moves user to previous sub step */ + prevScreen?: () => void; +}; + +type UseSubStep = { + /** array of components that will become sub steps */ + bodyContent: Array>; + + /** called on last sub step */ + onFinished: () => void; + + /** index of initial sub step to display */ + startFrom?: number; +}; + +export type {SubStepProps, UseSubStep}; diff --git a/src/languages/en.ts b/src/languages/en.ts index d46fb31eaf50..5d825e44bc64 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -155,7 +155,7 @@ export default { lastName: 'Last name', phone: 'Phone', phoneNumber: 'Phone number', - phoneNumberPlaceholder: '(xxx)xxx-xxxx', + phoneNumberPlaceholder: '(xxx) xxx-xxxx', email: 'Email', and: 'and', details: 'Details', @@ -191,6 +191,7 @@ export default { noPO: 'PO boxes and mail drop addresses are not allowed', city: 'City', state: 'State', + streetAddress: 'Street address', stateOrProvince: 'State / Province', country: 'Country', zip: 'Zip code', @@ -296,6 +297,7 @@ export default { tbd: 'TBD', selectCurrency: 'Select a currency', card: 'Card', + whyDoWeAskForThis: 'Why do we ask for this?', required: 'Required', showing: 'Showing', of: 'of', @@ -1275,8 +1277,15 @@ export default { return result; }, bankAccount: { + bankInfo: 'Bank info', + confirmBankInfo: 'Confirm bank info', + manuallyAdd: 'Manually add your bank account', + letsDoubleCheck: "Let's double check that everything looks right.", + accountEnding: 'Account ending in', + thisBankAccount: 'This bank account will be used for business payments on your workspace', accountNumber: 'Account number', routingNumber: 'Routing number', + chooseAnAccountBelow: 'Choose an account below', addBankAccount: 'Add bank account', chooseAnAccount: 'Choose an account', connectOnlineWithPlaid: 'Connect online with Plaid', @@ -1294,6 +1303,7 @@ export default { hasBeenThrottledError: 'There was an error adding your bank account. Please wait a few minutes and try again.', hasCurrencyError: 'Oops! It appears that your workspace currency is set to a different currency than USD. To proceed, please set it to USD and try again', error: { + youNeedToSelectAnOption: 'You need to select an option to proceed.', noBankAccountAvailable: 'Sorry, no bank account is available', noBankAccountSelected: 'Please choose an account', taxID: 'Please enter a valid tax ID number', @@ -1485,12 +1495,74 @@ export default { }, requestorStep: { headerTitle: 'Personal information', - subtitle: 'Please provide your personal information.', learnMore: 'Learn more', isMyDataSafe: 'Is my data safe?', - onFidoConditions: 'By continuing with the request to add this bank account, you confirm that you have read, understand and accept ', - isControllingOfficer: 'I am authorized to use my company bank account for business spend', - isControllingOfficerError: 'You must be a controlling officer with authorization to operate the business bank account.', + }, + personalInfoStep: { + personalInfo: 'Personal info', + enterYourLegalFirstAndLast: 'Enter your legal first and last name.', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + legalName: 'Legal name', + enterYourDateOfBirth: 'Enter your date of birth.', + enterTheLast4: 'Enter the last 4 of your SSN.', + dontWorry: "Don't worry, we don't do any personal credit checks!", + last4SSN: 'Last 4 Social Security Number', + enterYourAddress: 'Enter your address.', + address: 'Address', + letsDoubleCheck: "Let's double check that everything looks right.", + byAddingThisBankAccount: 'By adding this bank account, you confirm that you have read, understand and accept', + }, + businessInfoStep: { + businessInfo: 'Business info', + enterTheNameOfYourBusiness: 'Enter the name of your business.', + businessName: 'Legal business name', + enterYourCompanysTaxIdNumber: 'Enter your company’s Tax ID number.', + taxIDNumber: 'Tax ID number', + taxIDNumberPlaceholder: '9 digits', + enterYourCompanysWebsite: 'Enter your company’s website.', + companyWebsite: 'Company website', + enterYourCompanysPhoneNumber: 'Enter your company’s phone number.', + enterYourCompanysAddress: 'Enter your company’s address.', + selectYourCompanysType: 'Select your company’s type.', + companyType: 'Company type', + incorporationType: { + LLC: 'LLC', + CORPORATION: 'Corp', + PARTNERSHIP: 'Partnership', + COOPERATIVE: 'Cooperative', + SOLE_PROPRIETORSHIP: 'Sole proprietorship', + OTHER: 'Other', + }, + selectYourCompanysIncorporationDate: 'Select your company’s incorporation date.', + incorporationDate: 'Incorporation date', + incorporationDatePlaceholder: 'Start date (yyyy-mm-dd)', + incorporationState: 'Incorporation state', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: 'Please select the state your company was incorporated in.', + letsDoubleCheck: "Let's double check that everything looks right.", + companyAddress: 'Company address', + listOfRestrictedBusinesses: 'list of restricted businesses', + confirmCompanyIsNot: 'I confirm that this company is not on the', + }, + beneficialOwnerInfoStep: { + doYouOwn25percent: 'Do you own 25% or more of', + doAnyIndividualOwn25percent: 'Do any individuals own 25% or more of', + areThereMoreIndividualsWhoOwn25percent: 'Are there more individuals who own 25% or more of', + regulationRequiresUsToVerifyTheIdentity: 'Regulation requires us to verify the identity of any individual that owns more than 25% of the company.', + companyOwner: 'Company owner', + enterLegalFirstAndLastName: 'Enter the legal first and last name of the owner.', + legalFirstName: 'Legal first name', + legalLastName: 'Legal last name', + enterTheDateOfBirthOfTheOwner: 'Enter the date of birth of the owner.', + enterTheLast4: 'Enter the last 4 of the owner’s SSN.', + last4SSN: 'Last 4 Social Security Number', + dontWorry: "Don't worry, we don't do any personal credit checks!", + enterTheOwnersAddress: 'Enter the owner’s address.', + letsDoubleCheck: 'Let’s double check that everything looks right.', + legalName: 'Legal name', + address: 'Address', + byAddingThisBankAccount: 'By adding this bank account, you confirm that you have read, understand and accept', + owners: 'Owners', }, validationStep: { headerTitle: 'Validate Bank Account', @@ -1523,6 +1595,34 @@ export default { certify: 'Must certify information is true and accurate', }, }, + completeVerificationStep: { + completeVerification: 'Complete verification', + confirmAgreements: 'Please confirm the agreements below.', + certifyTrueAndAccurate: 'I certify that the information provided is true and accurate', + certifyTrueAndAccurateError: 'Must certify information is true and accurate', + isAuthorizedToUseBankAccount: 'I am authorized to use my company bank account for business spend', + isAuthorizedToUseBankAccountError: 'You must be a controlling officer with authorization to operate the business bank account.', + termsAndConditions: 'terms and conditions', + }, + connectBankAccountStep: { + connectBankAccount: 'Connect bank account', + finishButtonText: 'Finish setup', + validateYourBankAccount: 'Validate your bank account', + validateButtonText: 'Validate', + validationInputLabel: 'Transaction', + maxAttemptsReached: 'Validation for this bank account has been disabled due to too many incorrect attempts.', + description: 'A day or two after you add your account to Expensify we send three (3) transactions to your account. They have a merchant line like "Expensify, Inc. Validation".', + descriptionCTA: 'Please enter each transaction amount in the fields below. Example: 1.51.', + reviewingInfo: "Thanks! We're reviewing your information, and will be in touch shortly. Please check your chat with Concierge ", + forNextSteps: ' for next steps to finish setting up your bank account.', + letsChatCTA: "Yes, let's chat", + letsChatText: 'Thanks for doing that. We need your help verifying a few pieces of information, but we can work this out quickly over chat. Ready?', + letsChatTitle: "Let's chat!", + enable2FATitle: 'Prevent fraud, enable two-factor authentication!', + enable2FAText: + 'We take your security seriously, so please set up two-factor authentication for your account now. That will allow us to dispute Expensify Card digital transactions, and will reduce your risk for fraud.', + secureYourAccount: 'Secure your account', + }, reimbursementAccountLoadingAnimation: { oneMoment: 'One moment', explanationLine: 'We’re taking a look at your information. You will be able to continue with next steps shortly.', diff --git a/src/languages/es.ts b/src/languages/es.ts index b02aef189545..8f7f4a678535 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -143,9 +143,9 @@ export default { continue: 'Continuar', firstName: 'Nombre', lastName: 'Apellidos', - phone: 'teléfono', + phone: 'Teléfono', phoneNumber: 'Número de teléfono', - phoneNumberPlaceholder: '(xxx)xxx-xxxx', + phoneNumberPlaceholder: '(xxx) xxx-xxxx', email: 'Email', and: 'y', details: 'Detalles', @@ -181,6 +181,7 @@ export default { noPO: 'No se aceptan apartados ni direcciones postales', city: 'Ciudad', state: 'Estado', + streetAddress: 'Dirección', stateOrProvince: 'Estado / Provincia', country: 'País', zip: 'Código postal', @@ -286,6 +287,7 @@ export default { tbd: 'Por determinar', selectCurrency: 'Selecciona una moneda', card: 'Tarjeta', + whyDoWeAskForThis: '¿Por qué pedimos esto?', required: 'Obligatorio', showing: 'Mostrando', of: 'de', @@ -1291,8 +1293,15 @@ export default { return result; }, bankAccount: { + bankInfo: 'Información bancaria', + confirmBankInfo: 'Confirmar información bancaria', + manuallyAdd: 'Añadir manualmente tu cuenta bancaria', + letsDoubleCheck: 'Verifiquemos que todo esté correcto.', + accountEnding: 'Cuenta terminada en', + thisBankAccount: 'Esta cuenta bancaria se utilizará para pagos comerciales en tu espacio de trabajo', accountNumber: 'Número de cuenta', routingNumber: 'Número de ruta', + chooseAnAccountBelow: 'Elige una cuenta a continuación', addBankAccount: 'Añadir cuenta bancaria', chooseAnAccount: 'Elige una cuenta', connectOnlineWithPlaid: 'Conéctate a Plaid online', @@ -1312,6 +1321,7 @@ export default { hasCurrencyError: '¡Ups! Parece que la moneda de tu espacio de trabajo está configurada en una moneda diferente a USD. Para continuar, por favor configúrala en USD e inténtalo nuevamente.', error: { + youNeedToSelectAnOption: 'Debes seleccionar una opción para continuar.', noBankAccountAvailable: 'Lo sentimos, no hay ninguna cuenta bancaria disponible', noBankAccountSelected: 'Por favor, elige una cuenta bancaria', taxID: 'Por favor, introduce un número de identificación fiscal válido', @@ -1507,12 +1517,74 @@ export default { }, requestorStep: { headerTitle: 'Información personal', - subtitle: 'Dé más información sobre tí.', learnMore: 'Más información', isMyDataSafe: '¿Están seguros mis datos?', - onFidoConditions: 'Al continuar con la solicitud de añadir esta cuenta bancaria, confirma que ha leído, entiende y acepta ', - isControllingOfficer: 'Estoy autorizado a utilizar la cuenta bancaria de mi compañía para gastos de empresa', - isControllingOfficerError: 'Debe ser un oficial controlador con autorización para operar la cuenta bancaria de la compañía', + }, + personalInfoStep: { + personalInfo: 'Información Personal', + enterYourLegalFirstAndLast: 'Introduce tu nombre y apellidos', + legalFirstName: 'Nombre', + legalLastName: 'Apellidos', + legalName: 'Nombre legal', + enterYourDateOfBirth: 'Introduce tu fecha de nacimiento', + enterTheLast4: 'Introduce los últimos 4 dígitos de tu número de la seguridad social', + dontWorry: 'No te preocupes, no hacemos ninguna verificación de crédito', + last4SSN: 'Últimos 4 dígitos de tu número de la seguridad social', + enterYourAddress: 'Introduce tu dirección', + address: 'Dirección', + letsDoubleCheck: 'Revisemos que todo esté bien', + byAddingThisBankAccount: 'Añadiendo esta cuenta bancaria, confirmas que has leído, entendido y aceptado', + }, + businessInfoStep: { + businessInfo: 'Información de la empresa', + enterTheNameOfYourBusiness: 'Introduce el nombre de tu empresa.', + businessName: 'Nombre de la empresa', + enterYourCompanysTaxIdNumber: 'Introduce el número de identificación fiscal.', + taxIDNumber: 'Número de identificación fiscal', + taxIDNumberPlaceholder: '9 dígitos', + enterYourCompanysWebsite: 'Introduce la página web de tu empresa.', + companyWebsite: 'Página web de la empresa', + enterYourCompanysPhoneNumber: 'Introduce el número de teléfono de tu empresa.', + enterYourCompanysAddress: 'Introduce la dirección de tu empresa.', + selectYourCompanysType: 'Selecciona el tipo de empresa.', + companyType: 'Tipo de empresa', + incorporationType: { + LLC: 'SRL', + CORPORATION: 'Corporación', + PARTNERSHIP: 'Sociedad', + COOPERATIVE: 'Cooperativa', + SOLE_PROPRIETORSHIP: 'Empresa individual', + OTHER: 'Otros', + }, + selectYourCompanysIncorporationDate: 'Selecciona la fecha de constitución de la empresa.', + incorporationDate: 'Fecha de constitución', + incorporationDatePlaceholder: 'Fecha de inicio (yyyy-mm-dd)', + incorporationState: 'Estado en el que se constituyó', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: 'Selecciona el estado en el que se constituyó la empresa.', + letsDoubleCheck: 'Verifiquemos que todo esté correcto', + companyAddress: 'Dirección de la empresa', + listOfRestrictedBusinesses: 'lista de negocios restringidos', + confirmCompanyIsNot: 'Confirmo que esta empresa no está en la', + }, + beneficialOwnerInfoStep: { + doYouOwn25percent: '¿Posees el 25% o más de', + doAnyIndividualOwn25percent: '¿Alguna persona posee el 25% o más de', + areThereMoreIndividualsWhoOwn25percent: '¿Hay más personas que posean el 25% o más de', + regulationRequiresUsToVerifyTheIdentity: 'La ley nos exige verificar la identidad de cualquier persona que posea más del 25% de la empresa.', + companyOwner: 'Dueño de la empresa', + enterLegalFirstAndLastName: 'Introduce el nombre y apellidos legales del dueño.', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellidos legales', + enterTheDateOfBirthOfTheOwner: 'Introduce la fecha de nacimiento del dueño.', + enterTheLast4: 'Introduce los últimos 4 dígitos del número de la seguridad social del dueño.', + last4SSN: 'Últimos 4 dígitos del número de la seguridad social', + dontWorry: 'No te preocupes, ¡no realizamos verificaciones de crédito personales!', + enterTheOwnersAddress: 'Introduce la dirección del dueño.', + letsDoubleCheck: 'Vamos a verificar que todo esté correcto.', + legalName: 'Nombre legal', + address: 'Dirección', + byAddingThisBankAccount: 'Al añadir esta cuenta bancaria, confirmas que has leído, comprendido y aceptado', + owners: 'Dueños', }, validationStep: { headerTitle: 'Validar cuenta bancaria', @@ -1546,6 +1618,35 @@ export default { certify: 'Debe certificar que la información es verdadera y precisa', }, }, + completeVerificationStep: { + completeVerification: 'Completar la verificación', + confirmAgreements: 'Por favor, confirma los acuerdos siguientes.', + certifyTrueAndAccurate: 'Certifico que la información dada es verdadera y precisa', + certifyTrueAndAccurateError: 'Debe certificar que la información es verdadera y precisa', + isAuthorizedToUseBankAccount: 'Estoy autorizado para usar la cuenta bancaria de mi empresa para gastos de empresa', + isAuthorizedToUseBankAccountError: 'Debes ser el responsable oficial con autorización para operar la cuenta bancaria de la empresa.', + termsAndConditions: 'Términos y Condiciones', + }, + connectBankAccountStep: { + connectBankAccount: 'Conectar cuenta bancaria', + finishButtonText: 'Finalizar configuración', + validateYourBankAccount: 'Valida tu cuenta bancaria', + validateButtonText: 'Validar', + validationInputLabel: 'Transacción', + maxAttemptsReached: 'La validación de esta cuenta bancaria se ha desactivado debido a demasiados intentos incorrectos.', + description: + 'Un día o dos después de añadir tu cuenta a Expensify, te enviaremos tres (3) transacciones a tu cuenta. Tienen un nombre de comerciante similar a "Expensify, Inc. Validation".', + descriptionCTA: 'Introduce el importe de cada transacción en los campos siguientes. Ejemplo: 1.51.', + reviewingInfo: '¡Gracias! Estamos revisando tu información y nos comunicaremos contigo en breve. Consulta el chat con Concierge ', + forNextSteps: ' para conocer los próximos pasos para terminar de configurar tu cuenta bancaria.', + letsChatCTA: 'Sí, vamos a chatear', + letsChatText: 'Gracias. Necesitamos tu ayuda para verificar la información, pero podemos resolverlo rápidamente a través del chat. ¿Estás Listo?', + letsChatTitle: '¡Vamos a chatear!', + enable2FATitle: '¡Evita fraudes, activa la autenticación de dos factores!', + enable2FAText: + 'Tu seguridad es importante para nosotros. Por favor, configura ahora la autenticación de dos factores. Eso nos permitirá disputar las transacciones de la Tarjeta Expensify y reducirá tu riesgo de fraude.', + secureYourAccount: 'Asegura tu cuenta', + }, reimbursementAccountLoadingAnimation: { oneMoment: 'Un momento', explanationLine: 'Estamos verificando tu información y podrás continuar con los siguientes pasos en unos momentos.', diff --git a/src/libs/API/parameters/AcceptACHContractForBankAccount.ts b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts new file mode 100644 index 000000000000..cd9473038fb0 --- /dev/null +++ b/src/libs/API/parameters/AcceptACHContractForBankAccount.ts @@ -0,0 +1,5 @@ +import type {ACHContractStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; + +type AcceptACHContractForBankAccount = ACHContractStepProps & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; + +export default AcceptACHContractForBankAccount; diff --git a/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts b/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts index 4f166cfd3aa9..17a72588a1e2 100644 --- a/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts +++ b/src/libs/API/parameters/ConnectBankAccountManuallyParams.ts @@ -3,5 +3,7 @@ type ConnectBankAccountManuallyParams = { accountNumber?: string; routingNumber?: string; plaidMask?: string; + canUseNewVbbaFlow?: boolean; + policyID?: string; }; export default ConnectBankAccountManuallyParams; diff --git a/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts b/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts index 63df9d280412..e41a3192420e 100644 --- a/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts +++ b/src/libs/API/parameters/ConnectBankAccountWithPlaidParams.ts @@ -5,6 +5,8 @@ type ConnectBankAccountWithPlaidParams = { bank?: string; plaidAccountID: string; plaidAccessToken: string; + canUseNewVbbaFlow?: boolean; + policyID?: string; }; export default ConnectBankAccountWithPlaidParams; diff --git a/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts b/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts index d831609b2e0a..5cd4bff2b94b 100644 --- a/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts +++ b/src/libs/API/parameters/OpenReimbursementAccountPageParams.ts @@ -7,6 +7,8 @@ type OpenReimbursementAccountPageParams = { stepToOpen: ReimbursementAccountStep; subStep: ReimbursementAccountSubStep; localCurrentStep: ReimbursementAccountStep; + policyID?: string; + canUseNewVbbaFlow?: boolean; }; export default OpenReimbursementAccountPageParams; diff --git a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts index 414c87ee8989..aad6b1d34685 100644 --- a/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams.ts @@ -1,5 +1,5 @@ import type {ACHContractStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; -type UpdateBeneficialOwnersForBankAccountParams = ACHContractStepProps; +type UpdateBeneficialOwnersForBankAccountParams = ACHContractStepProps & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; export default UpdateBeneficialOwnersForBankAccountParams; diff --git a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts index 7588039a9abf..a2dc4ab2e1d3 100644 --- a/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdateCompanyInformationForBankAccountParams.ts @@ -2,7 +2,7 @@ import type {BankAccountStepProps, CompanyStepProps, ReimbursementAccountProps} type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps; -type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {policyID: string}; +type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {bankAccountID: number; canUseNewVbbaFlow?: boolean}; export default UpdateCompanyInformationForBankAccountParams; export type {BankAccountCompanyInformation}; diff --git a/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts b/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts index 4de2e462fc7a..b4ec55877e71 100644 --- a/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts +++ b/src/libs/API/parameters/UpdatePersonalInformationForBankAccountParams.ts @@ -1,5 +1,5 @@ import type {RequestorStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; -type UpdatePersonalInformationForBankAccountParams = RequestorStepProps; +type UpdatePersonalInformationForBankAccountParams = RequestorStepProps & {bankAccountID: number; canUseNewVbbaFlow: boolean}; export default UpdatePersonalInformationForBankAccountParams; diff --git a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts index 424cef92c08f..2104977e04d5 100644 --- a/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts +++ b/src/libs/API/parameters/VerifyIdentityForBankAccountParams.ts @@ -1,5 +1,6 @@ type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; + canUseNewVbbaFlow?: boolean; }; export default VerifyIdentityForBankAccountParams; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 4d784463c2f8..482c5e0336c4 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -139,3 +139,4 @@ export type {default as ReplaceReceiptParams} from './ReplaceReceiptParams'; export type {default as SubmitReportParams} from './SubmitReportParams'; export type {default as DetachReceiptParams} from './DetachReceiptParams'; export type {default as PayMoneyRequestParams} from './PayMoneyRequestParams'; +export type {default as AcceptACHContractForBankAccount} from './AcceptACHContractForBankAccount'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 4b383bacddaa..f5d99d8cf40e 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -141,6 +141,7 @@ const WRITE_COMMANDS = { DETACH_RECEIPT: 'DetachReceipt', PAY_MONEY_REQUEST_WITH_WALLET: 'PayMoneyRequestWithWallet', PAY_MONEY_REQUEST: 'PayMoneyRequest', + ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT: 'AcceptACHContractForBankAccount', } as const; type WriteCommand = ValueOf; @@ -279,6 +280,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.DETACH_RECEIPT]: Parameters.DetachReceiptParams; [WRITE_COMMANDS.PAY_MONEY_REQUEST_WITH_WALLET]: Parameters.PayMoneyRequestParams; [WRITE_COMMANDS.PAY_MONEY_REQUEST]: Parameters.PayMoneyRequestParams; + [WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT]: Parameters.AcceptACHContractForBankAccount; }; const READ_COMMANDS = { diff --git a/src/libs/ErrorUtils.ts b/src/libs/ErrorUtils.ts index 58946983bb35..7db0cd4c3eb0 100644 --- a/src/libs/ErrorUtils.ts +++ b/src/libs/ErrorUtils.ts @@ -60,8 +60,8 @@ type OnyxDataWithErrors = { errors?: Errors | null; }; -function getLatestErrorMessage(onyxData: TOnyxData): Localize.MaybePhraseKey { - const errors = onyxData.errors ?? {}; +function getLatestErrorMessage(onyxData: TOnyxData | null): Localize.MaybePhraseKey { + const errors = onyxData?.errors ?? {}; if (Object.keys(errors).length === 0) { return ''; diff --git a/src/libs/ValidationUtils.ts b/src/libs/ValidationUtils.ts index 7ee7d6c4f048..e52075ccba06 100644 --- a/src/libs/ValidationUtils.ts +++ b/src/libs/ValidationUtils.ts @@ -5,9 +5,9 @@ import isDate from 'lodash/isDate'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; import type {OnyxCollection} from 'react-native-onyx'; +import type {OnyxFormValuesFields} from '@components/Form/types'; import CONST from '@src/CONST'; import type {Report} from '@src/types/onyx'; -import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import * as CardUtils from './CardUtils'; import DateUtils from './DateUtils'; import * as LoginUtils from './LoginUtils'; @@ -74,8 +74,12 @@ function isValidPastDate(date: string | Date): boolean { /** * Used to validate a value that is "required". + * @param value - field value */ -function isRequiredFulfilled(value: string | Date | unknown[] | Record | null): boolean { +function isRequiredFulfilled(value?: string | boolean | Date | unknown[] | Record | null): boolean { + if (!value) { + return false; + } if (typeof value === 'string') { return !StringUtils.isEmptyString(value); } @@ -88,18 +92,27 @@ function isRequiredFulfilled(value: string | Date | unknown[] | Record = {[P in K[number]]: string}; /** * Used to add requiredField error to the fields passed. + * @param values - all form values + * @param requiredFields - required fields for particular form */ -function getFieldRequiredErrors(values: OnyxCommon.Errors, requiredFields: string[]) { - const errors: OnyxCommon.Errors = {}; - requiredFields.forEach((fieldKey) => { - if (isRequiredFulfilled(values[fieldKey])) { +function getFieldRequiredErrors(values: T, requiredFields: K): GetFieldRequiredErrorsReturn { + const errors: GetFieldRequiredErrorsReturn = {} as GetFieldRequiredErrorsReturn; + + requiredFields.forEach((fieldKey: K[number]) => { + if (isRequiredFulfilled(values[fieldKey as keyof OnyxFormValuesFields])) { return; } + errors[fieldKey] = 'common.error.fieldRequired'; }); + return errors; } diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index 2c65804cb428..1342a869bf7b 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -7,13 +7,9 @@ import type { ConnectBankAccountWithPlaidParams, DeletePaymentBankAccountParams, OpenReimbursementAccountPageParams, - UpdateCompanyInformationForBankAccountParams, - UpdatePersonalInformationForBankAccountParams, ValidateBankAccountWithTransactionsParams, VerifyIdentityForBankAccountParams, } from '@libs/API/parameters'; -import type UpdateBeneficialOwnersForBankAccountParams from '@libs/API/parameters/UpdateBeneficialOwnersForBankAccountParams'; -import type {BankAccountCompanyInformation} from '@libs/API/parameters/UpdateCompanyInformationForBankAccountParams'; import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as ErrorUtils from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -24,7 +20,7 @@ import ROUTES from '@src/ROUTES'; import type {Route} from '@src/ROUTES'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, BankAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; -import type {OnfidoData} from '@src/types/onyx/ReimbursementAccountDraft'; +import type {ACHContractStepProps, BeneficialOwnersStepProps, CompanyStepProps, OnfidoData, RequestorStepProps} from '@src/types/onyx/ReimbursementAccountDraft'; import type {OnyxData} from '@src/types/onyx/Request'; import * as ReimbursementAccount from './ReimbursementAccount'; @@ -47,6 +43,20 @@ type ReimbursementAccountStep = BankAccountStep | ''; type ReimbursementAccountSubStep = BankAccountSubStep | ''; +type BusinessAddress = { + addressStreet?: string; + addressCity?: string; + addressState?: string; + addressZipCode?: string; +}; + +type PersonalAddress = { + requestorAddressStreet?: string; + requestorAddressCity?: string; + requestorAddressState?: string; + requestorAddressZipCode?: string; +}; + function clearPlaid(): Promise { Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, ''); Onyx.set(ONYXKEYS.PLAID_CURRENT_EVENT, null); @@ -87,6 +97,7 @@ function clearPersonalBankAccount() { function clearOnfidoToken() { Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, ''); + Onyx.merge(ONYXKEYS.ONFIDO_APPLICANT_ID, ''); } /** @@ -133,10 +144,14 @@ function getVBBADataForOnyx(currentStep?: BankAccountStep): OnyxData { }; } +function addBusinessWebsiteForDraft(websiteUrl: string) { + Onyx.merge(ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, {website: websiteUrl}); +} + /** * Submit Bank Account step with Plaid data so php can perform some checks. */ -function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount) { +function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string) { const parameters: ConnectBankAccountWithPlaidParams = { bankAccountID, routingNumber: selectedPlaidBankAccount.routingNumber, @@ -144,6 +159,8 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc bank: selectedPlaidBankAccount.bankName, plaidAccountID: selectedPlaidBankAccount.plaidAccountID, plaidAccessToken: selectedPlaidBankAccount.plaidAccessToken, + canUseNewVbbaFlow: true, + policyID, }; API.write(WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_WITH_PLAID, parameters, getVBBADataForOnyx()); @@ -156,10 +173,10 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc */ function addPersonalBankAccount(account: PlaidBankAccount) { const parameters: AddPersonalBankAccountParams = { - addressName: account.addressName, + addressName: account.addressName ?? '', routingNumber: account.routingNumber, accountNumber: account.accountNumber, - isSavings: account.isSavings, + isSavings: account.isSavings ?? false, setupType: 'plaid', bank: account.bankName, plaidAccountID: account.plaidAccountID, @@ -234,9 +251,19 @@ function deletePaymentBankAccount(bankAccountID: number) { * Update the user's personal information on the bank account in database. * * This action is called by the requestor step in the Verified Bank Account flow + * @param bankAccountID - ID for bank account + * @param params - User personal data */ -function updatePersonalInformationForBankAccount(params: UpdatePersonalInformationForBankAccountParams) { - API.write(WRITE_COMMANDS.UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT, params, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR)); +function updatePersonalInformationForBankAccount(bankAccountID: number, params: RequestorStepProps) { + API.write( + WRITE_COMMANDS.UPDATE_PERSONAL_INFORMATION_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR), + ); } function validateBankAccount(bankAccountID: number, validateCode: string) { @@ -283,7 +310,14 @@ function clearReimbursementAccount() { Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, null); } -function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep) { +/** + * Function to display and fetch data for Reimbursement Account step + * @param stepToOpen - current step to open + * @param subStep - particular step + * @param localCurrentStep - last step on device + * @param policyID - policy ID + */ +function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subStep: ReimbursementAccountSubStep, localCurrentStep: ReimbursementAccountStep, policyID: string) { const onyxData: OnyxData = { optimisticData: [ { @@ -318,6 +352,8 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS stepToOpen, subStep, localCurrentStep, + policyID, + canUseNewVbbaFlow: true, }; return API.read(READ_COMMANDS.OPEN_REIMBURSEMENT_ACCOUNT_PAGE, parameters, onyxData); @@ -325,30 +361,64 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS /** * Updates the bank account in the database with the company step data + * @param params - Business step form data */ -function updateCompanyInformationForBankAccount(bankAccount: BankAccountCompanyInformation, policyID: string) { - const parameters: UpdateCompanyInformationForBankAccountParams = {...bankAccount, policyID}; +function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps) { + API.write( + WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY), + ); +} - API.write(WRITE_COMMANDS.UPDATE_COMPANY_INFORMATION_FOR_BANK_ACCOUNT, parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY)); +/** + * Add beneficial owners for the bank account and verify the accuracy of the information provided + * @param params - Beneficial Owners step form params + */ +function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps) { + API.write( + WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(), + ); } /** - * Add beneficial owners for the bank account, accept the ACH terms and conditions and verify the accuracy of the information provided + * Accept the ACH terms and conditions and verify the accuracy of the information provided + * @param params - Verification step form params */ -function updateBeneficialOwnersForBankAccount(params: UpdateBeneficialOwnersForBankAccountParams) { - API.write(WRITE_COMMANDS.UPDATE_BENEFICIAL_OWNERS_FOR_BANK_ACCOUNT, params, getVBBADataForOnyx()); +function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContractStepProps) { + API.write( + WRITE_COMMANDS.ACCEPT_ACH_CONTRACT_FOR_BANK_ACCOUNT, + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(), + ); } /** * Create the bank account with manually entered data. - * + * @param plaidMask - scheme for Plaid account number */ -function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string) { +function connectBankAccountManually(bankAccountID: number, accountNumber?: string, routingNumber?: string, plaidMask?: string, policyID?: string) { const parameters: ConnectBankAccountManuallyParams = { bankAccountID, accountNumber, routingNumber, plaidMask, + canUseNewVbbaFlow: true, + policyID, }; API.write(WRITE_COMMANDS.CONNECT_BANK_ACCOUNT_MANUALLY, parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT)); @@ -361,6 +431,7 @@ function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoD const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), + canUseNewVbbaFlow: true, }; API.write(WRITE_COMMANDS.VERIFY_IDENTITY_FOR_BANK_ACCOUNT, parameters, getVBBADataForOnyx()); @@ -421,6 +492,8 @@ function setReimbursementAccountLoading(isLoading: boolean) { } export { + acceptACHContractForBankAccount, + addBusinessWebsiteForDraft, addPersonalBankAccount, clearOnfidoToken, clearPersonalBankAccount, @@ -443,3 +516,5 @@ export { verifyIdentityForBankAccount, setReimbursementAccountLoading, }; + +export type {BusinessAddress, PersonalAddress}; diff --git a/src/libs/actions/FormActions.ts b/src/libs/actions/FormActions.ts index ed612a757f4b..0d2d3e867dbf 100644 --- a/src/libs/actions/FormActions.ts +++ b/src/libs/actions/FormActions.ts @@ -33,4 +33,5 @@ function clearDraftValues(formID: OnyxFormKeyWithoutDraft) { Onyx.set(FormUtils.getDraftKey(formID), null); } +export type {OnyxFormKeyWithoutDraft}; export {setDraftValues, setErrorFields, setErrors, clearErrors, clearErrorFields, setIsLoading, clearDraftValues}; diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index 217cacf921a6..12b5b940a0f2 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -12,7 +12,7 @@ export {setBankAccountFormValidationErrors, setPersonalBankAccountFormValidation * - CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL to ask them to enter their accountNumber and routingNumber * - CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID to ask them to login to their bank via Plaid * - * @param {String} subStep + * @param {String | null} subStep * @returns {Promise} */ function setBankAccountSubStep(subStep) { diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js index 0ea09465d795..6c82561c16ee 100644 --- a/src/libs/actions/ReimbursementAccount/navigation.js +++ b/src/libs/actions/ReimbursementAccount/navigation.js @@ -7,10 +7,9 @@ import ROUTES from '@src/ROUTES'; * Navigate to a specific step in the VBA flow * * @param {String} stepID - * @param {Object} newAchData */ -function goToWithdrawalAccountSetupStep(stepID, newAchData) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newAchData, currentStep: stepID}}); +function goToWithdrawalAccountSetupStep(stepID) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {currentStep: stepID}}); } /** diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 3110c059d2fc..962800fb2e55 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -43,6 +43,11 @@ function resetFreePlanBankAccount(bankAccountID, session) { key: ONYXKEYS.ONFIDO_TOKEN, value: '', }, + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.ONFIDO_APPLICANT_ID, + value: '', + }, { onyxMethod: Onyx.METHOD.SET, key: ONYXKEYS.PLAID_DATA, diff --git a/src/pages/ReimbursementAccount/ACHContractStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js index 625a29ddc130..f2c2e5af85de 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -1,294 +1,16 @@ -import Str from 'expensify-common/lib/str'; -import lodashGet from 'lodash/get'; import PropTypes from 'prop-types'; -import React, {useState} from 'react'; -import {View} from 'react-native'; -import _ from 'underscore'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextLink from '@components/TextLink'; -import withLocalize from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as FormActions from '@userActions/FormActions'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import IdentityForm from './IdentityForm'; -import StepPropTypes from './StepPropTypes'; +import React from 'react'; +import CompleteVerification from './CompleteVerification/CompleteVerification'; const propTypes = { - ...StepPropTypes, - - /** Name of the company */ - companyName: PropTypes.string.isRequired, + /** Goes to the previous step */ + onBackButtonPress: PropTypes.func.isRequired, }; -function ACHContractStep(props) { - const styles = useThemeStyles(); - const [beneficialOwners, setBeneficialOwners] = useState(() => - lodashGet(props.reimbursementAccountDraft, 'beneficialOwners', lodashGet(props.reimbursementAccount, 'achData.beneficialOwners', [])), - ); - - /** - * @param {Object} values - input values passed by the Form component - * @returns {Object} - */ - const validate = (values) => { - const errors = {}; - - const errorKeys = { - street: 'address', - city: 'addressCity', - state: 'addressState', - }; - const requiredFields = ['firstName', 'lastName', 'dob', 'ssnLast4', 'street', 'city', 'zipCode', 'state']; - if (values.hasOtherBeneficialOwners) { - _.each(beneficialOwners, (ownerKey) => { - // eslint-disable-next-line rulesdir/prefer-early-return - _.each(requiredFields, (inputKey) => { - if (!ValidationUtils.isRequiredFulfilled(values[`beneficialOwner_${ownerKey}_${inputKey}`])) { - const errorKey = errorKeys[inputKey] || inputKey; - errors[`beneficialOwner_${ownerKey}_${inputKey}`] = `bankAccount.error.${errorKey}`; - } - }); - - if (values[`beneficialOwner_${ownerKey}_dob`]) { - if (!ValidationUtils.meetsMinimumAgeRequirement(values[`beneficialOwner_${ownerKey}_dob`])) { - errors[`beneficialOwner_${ownerKey}_dob`] = 'bankAccount.error.age'; - } else if (!ValidationUtils.meetsMaximumAgeRequirement(values[`beneficialOwner_${ownerKey}_dob`])) { - errors[`beneficialOwner_${ownerKey}_dob`] = 'bankAccount.error.dob'; - } - } - - if (values[`beneficialOwner_${ownerKey}_ssnLast4`] && !ValidationUtils.isValidSSNLastFour(values[`beneficialOwner_${ownerKey}_ssnLast4`])) { - errors[`beneficialOwner_${ownerKey}_ssnLast4`] = 'bankAccount.error.ssnLast4'; - } - - if (values[`beneficialOwner_${ownerKey}_street`] && !ValidationUtils.isValidAddress(values[`beneficialOwner_${ownerKey}_street`])) { - errors[`beneficialOwner_${ownerKey}_street`] = 'bankAccount.error.addressStreet'; - } - - if (values[`beneficialOwner_${ownerKey}_zipCode`] && !ValidationUtils.isValidZipCode(values[`beneficialOwner_${ownerKey}_zipCode`])) { - errors[`beneficialOwner_${ownerKey}_zipCode`] = 'bankAccount.error.zipCode'; - } - }); - } - - if (!ValidationUtils.isRequiredFulfilled(values.acceptTermsAndConditions)) { - errors.acceptTermsAndConditions = 'common.error.acceptTerms'; - } - - if (!ValidationUtils.isRequiredFulfilled(values.certifyTrueInformation)) { - errors.certifyTrueInformation = 'beneficialOwnersStep.error.certify'; - } - - return errors; - }; - - /** - * @param {Number} ownerKey - ID connected to the beneficial owner identity form - */ - const removeBeneficialOwner = (ownerKey) => { - setBeneficialOwners((previousBeneficialOwners) => { - const newBeneficialOwners = _.without(previousBeneficialOwners, ownerKey); - FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners: newBeneficialOwners}); - return newBeneficialOwners; - }); - }; - - const addBeneficialOwner = () => { - // Each beneficial owner is assigned a unique key that will connect it to an Identity Form. - // That way we can dynamically render each Identity Form based on which keys are present in the beneficial owners array. - setBeneficialOwners((previousBeneficialOwners) => { - const newBeneficialOwners = [...previousBeneficialOwners, Str.guid()]; - FormActions.setDraftValues(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {beneficialOwners: newBeneficialOwners}); - return newBeneficialOwners; - }); - }; - - /** - * @param {Boolean} ownsMoreThan25Percent - * @returns {Boolean} - */ - const canAddMoreBeneficialOwners = (ownsMoreThan25Percent) => _.size(beneficialOwners) < 3 || (_.size(beneficialOwners) === 3 && !ownsMoreThan25Percent); - - /** - * @param {Object} values - object containing form input values - */ - const submit = (values) => { - const bankAccountID = lodashGet(props.reimbursementAccount, 'achData.bankAccountID') || 0; - - const updatedBeneficialOwners = !values.hasOtherBeneficialOwners - ? [] - : _.map(beneficialOwners, (ownerKey) => ({ - firstName: lodashGet(values, `beneficialOwner_${ownerKey}_firstName`), - lastName: lodashGet(values, `beneficialOwner_${ownerKey}_lastName`), - dob: lodashGet(values, `beneficialOwner_${ownerKey}_dob`), - ssnLast4: lodashGet(values, `beneficialOwner_${ownerKey}_ssnLast4`), - street: lodashGet(values, `beneficialOwner_${ownerKey}_street`), - city: lodashGet(values, `beneficialOwner_${ownerKey}_city`), - state: lodashGet(values, `beneficialOwner_${ownerKey}_state`), - zipCode: lodashGet(values, `beneficialOwner_${ownerKey}_zipCode`), - })); - - BankAccounts.updateBeneficialOwnersForBankAccount({ - ownsMoreThan25Percent: values.ownsMoreThan25Percent, - hasOtherBeneficialOwners: values.hasOtherBeneficialOwners, - acceptTermsAndConditions: values.acceptTermsAndConditions, - certifyTrueInformation: values.certifyTrueInformation, - beneficialOwners: JSON.stringify(updatedBeneficialOwners), - bankAccountID, - }); - }; - - return ( - - - - {({inputValues}) => ( - <> - - {props.translate('beneficialOwnersStep.checkAllThatApply')} - - ( - - {props.translate('beneficialOwnersStep.iOwnMoreThan25Percent')} - {props.companyName} - - )} - // eslint-disable-next-line rulesdir/prefer-early-return - onValueChange={(ownsMoreThan25Percent) => { - if (ownsMoreThan25Percent && beneficialOwners.length > 3) { - // If the user owns more than 25% of the company, then there can only be a maximum of 3 other beneficial owners who owns more than 25%. - // We have to remove the 4th beneficial owner if the checkbox is checked. - setBeneficialOwners((previousBeneficialOwners) => previousBeneficialOwners.slice(0, -1)); - } - }} - defaultValue={props.getDefaultStateForField('ownsMoreThan25Percent', false)} - shouldSaveDraft - /> - ( - - {props.translate('beneficialOwnersStep.someoneOwnsMoreThan25Percent')} - {props.companyName} - - )} - // eslint-disable-next-line rulesdir/prefer-early-return - onValueChange={(hasOtherBeneficialOwners) => { - if (hasOtherBeneficialOwners && beneficialOwners.length === 0) { - addBeneficialOwner(); - } - }} - defaultValue={props.getDefaultStateForField('hasOtherBeneficialOwners', false)} - shouldSaveDraft - /> - {Boolean(inputValues.hasOtherBeneficialOwners) && ( - - {_.map(beneficialOwners, (ownerKey, index) => ( - - {props.translate('beneficialOwnersStep.additionalOwner')} - - {beneficialOwners.length > 1 && ( - removeBeneficialOwner(ownerKey)}>{props.translate('beneficialOwnersStep.removeOwner')} - )} - - ))} - {canAddMoreBeneficialOwners(inputValues.ownsMoreThan25Percent) && ( - - {props.translate('beneficialOwnersStep.addAnotherIndividual')} - {props.companyName} - - )} - - )} - {props.translate('beneficialOwnersStep.agreement')} - ( - - {props.translate('common.iAcceptThe')} - {`${props.translate('beneficialOwnersStep.termsAndConditions')}`} - - )} - defaultValue={props.getDefaultStateForField('acceptTermsAndConditions', false)} - shouldSaveDraft - /> - {props.translate('beneficialOwnersStep.certifyTrueAndAccurate')}} - defaultValue={props.getDefaultStateForField('certifyTrueInformation', false)} - shouldSaveDraft - /> - - )} - - - ); +function ACHContractStep({onBackButtonPress}) { + return ; } ACHContractStep.propTypes = propTypes; ACHContractStep.displayName = 'ACHContractStep'; -export default withLocalize(ACHContractStep); +export default ACHContractStep; diff --git a/src/pages/ReimbursementAccount/AddressForm.js b/src/pages/ReimbursementAccount/AddressForm.js index d09b03d9007f..16055265ddcc 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -102,7 +102,7 @@ function AddressForm(props) { inputID={props.inputKeys.street} shouldSaveDraft={props.shouldSaveDraft} label={props.translate(props.streetTranslationKey)} - containerStyles={[styles.mt4]} + containerStyles={[styles.mt6]} value={props.values.street} defaultValue={props.defaultValues.street} onInputChange={props.onFieldChange} @@ -124,10 +124,10 @@ function AddressForm(props) { defaultValue={props.defaultValues.city} onChangeText={(value) => props.onFieldChange({city: value})} errorText={props.errors.city ? 'bankAccount.error.addressCity' : ''} - containerStyles={[styles.mt4]} + containerStyles={[styles.mt6]} /> - + ); diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js deleted file mode 100644 index 8283631e6936..000000000000 --- a/src/pages/ReimbursementAccount/BankAccountManualStep.js +++ /dev/null @@ -1,146 +0,0 @@ -import lodashGet from 'lodash/get'; -import React, {useCallback} from 'react'; -import _ from 'underscore'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextInput from '@components/TextInput'; -import TextLink from '@components/TextLink'; -import {withLocalizePropTypes} from '@components/withLocalize'; -import useLocalize from '@hooks/useLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import shouldDelayFocus from '@libs/shouldDelayFocus'; -import * as ValidationUtils from '@libs/ValidationUtils'; -import * as BankAccounts from '@userActions/BankAccounts'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ExampleCheck from './ExampleCheck'; -import StepPropTypes from './StepPropTypes'; - -const propTypes = { - ..._.omit(StepPropTypes, _.keys(withLocalizePropTypes)), -}; - -function BankAccountManualStep(props) { - const styles = useThemeStyles(); - const {translate} = useLocalize(); - const {reimbursementAccount, reimbursementAccountDraft} = props; - - const shouldDisableInputs = Boolean(lodashGet(reimbursementAccount, 'achData.bankAccountID')); - - /** - * @param {Object} values - form input values passed by the Form component - * @returns {Object} - */ - const validate = useCallback( - (values) => { - const requiredFields = ['routingNumber', 'accountNumber']; - const errors = ValidationUtils.getFieldRequiredErrors(values, requiredFields); - const routingNumber = values.routingNumber && values.routingNumber.trim(); - - if ( - values.accountNumber && - !CONST.BANK_ACCOUNT.REGEX.US_ACCOUNT_NUMBER.test(values.accountNumber.trim()) && - !(shouldDisableInputs && CONST.BANK_ACCOUNT.REGEX.MASKED_US_ACCOUNT_NUMBER.test(values.accountNumber.trim())) - ) { - errors.accountNumber = 'bankAccount.error.accountNumber'; - } else if (values.accountNumber && values.accountNumber === routingNumber) { - errors.accountNumber = translate('bankAccount.error.routingAndAccountNumberCannotBeSame'); - } - if (routingNumber && (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(routingNumber) || !ValidationUtils.isValidRoutingNumber(routingNumber))) { - errors.routingNumber = 'bankAccount.error.routingNumber'; - } - if (!values.acceptTerms) { - errors.acceptTerms = 'common.error.acceptTerms'; - } - - return errors; - }, - [translate, shouldDisableInputs], - ); - - const submit = useCallback( - (values) => { - BankAccounts.connectBankAccountManually( - lodashGet(reimbursementAccount, 'achData.bankAccountID') || 0, - values.accountNumber, - values.routingNumber, - lodashGet(reimbursementAccountDraft, ['plaidMask']), - ); - }, - [reimbursementAccount, reimbursementAccountDraft], - ); - - return ( - - - - {translate('bankAccount.checkHelpLine')} - - - - ( - - {translate('common.iAcceptThe')} - {translate('common.expensifyTermsOfService')} - - )} - defaultValue={props.getDefaultStateForField('acceptTerms', false)} - shouldSaveDraft - /> - - - ); -} - -BankAccountManualStep.propTypes = propTypes; -BankAccountManualStep.displayName = 'BankAccountManualStep'; -export default BankAccountManualStep; diff --git a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js b/src/pages/ReimbursementAccount/BankAccountPlaidStep.js deleted file mode 100644 index 99375fe66ad8..000000000000 --- a/src/pages/ReimbursementAccount/BankAccountPlaidStep.js +++ /dev/null @@ -1,156 +0,0 @@ -import {useIsFocused} from '@react-navigation/native'; -import lodashGet from 'lodash/get'; -import PropTypes from 'prop-types'; -import React, {useCallback, useEffect} from 'react'; -import {withOnyx} from 'react-native-onyx'; -import _ from 'underscore'; -import AddPlaidBankAccount from '@components/AddPlaidBankAccount'; -import CheckboxWithLabel from '@components/CheckboxWithLabel'; -import FormProvider from '@components/Form/FormProvider'; -import InputWrapper from '@components/Form/InputWrapper'; -import HeaderWithBackButton from '@components/HeaderWithBackButton'; -import ScreenWrapper from '@components/ScreenWrapper'; -import Text from '@components/Text'; -import TextLink from '@components/TextLink'; -import withLocalize from '@components/withLocalize'; -import useThemeStyles from '@hooks/useThemeStyles'; -import compose from '@libs/compose'; -import * as BankAccounts from '@userActions/BankAccounts'; -import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import * as PlaidDataProps from './plaidDataPropTypes'; -import StepPropTypes from './StepPropTypes'; - -const propTypes = { - ...StepPropTypes, - - /** Contains plaid data */ - plaidData: PlaidDataProps.plaidDataPropTypes, - - /** The OAuth URI + stateID needed to re-initialize the PlaidLink after the user logs into their bank */ - receivedRedirectURI: PropTypes.string, - - /** During the OAuth flow we need to use the plaidLink token that we initially connected with */ - plaidLinkOAuthToken: PropTypes.string, -}; - -const defaultProps = { - plaidData: PlaidDataProps.plaidDataDefaultProps, - receivedRedirectURI: null, - plaidLinkOAuthToken: '', -}; - -function BankAccountPlaidStep(props) { - const styles = useThemeStyles(); - const {plaidData, receivedRedirectURI, plaidLinkOAuthToken, reimbursementAccount, reimbursementAccountDraft, onBackButtonPress, getDefaultStateForField, translate} = props; - const isFocused = useIsFocused(); - - const validate = useCallback((values) => { - const errorFields = {}; - if (!values.acceptTerms) { - errorFields.acceptTerms = 'common.error.acceptTerms'; - } - - return errorFields; - }, []); - - useEffect(() => { - const plaidBankAccounts = lodashGet(plaidData, 'bankAccounts') || []; - if (isFocused || plaidBankAccounts.length) { - return; - } - BankAccounts.setBankAccountSubStep(null); - }, [isFocused, plaidData]); - - const submit = useCallback(() => { - const selectedPlaidBankAccount = _.findWhere(lodashGet(plaidData, 'bankAccounts', []), { - plaidAccountID: lodashGet(reimbursementAccountDraft, 'plaidAccountID', ''), - }); - - const bankAccountData = { - routingNumber: selectedPlaidBankAccount.routingNumber, - accountNumber: selectedPlaidBankAccount.accountNumber, - plaidMask: selectedPlaidBankAccount.mask, - isSavings: selectedPlaidBankAccount.isSavings, - bankName: lodashGet(plaidData, 'bankName') || '', - plaidAccountID: selectedPlaidBankAccount.plaidAccountID, - plaidAccessToken: lodashGet(plaidData, 'plaidAccessToken') || '', - }; - ReimbursementAccount.updateReimbursementAccountDraft(bankAccountData); - - const bankAccountID = lodashGet(reimbursementAccount, 'achData.bankAccountID') || 0; - BankAccounts.connectBankAccountWithPlaid(bankAccountID, bankAccountData); - }, [reimbursementAccount, reimbursementAccountDraft, plaidData]); - - const bankAccountID = lodashGet(reimbursementAccount, 'achData.bankAccountID') || 0; - const selectedPlaidAccountID = lodashGet(reimbursementAccountDraft, 'plaidAccountID', ''); - - return ( - - - - { - ReimbursementAccount.updateReimbursementAccountDraft({plaidAccountID}); - }} - plaidData={plaidData} - onExitPlaid={() => BankAccounts.setBankAccountSubStep(null)} - receivedRedirectURI={receivedRedirectURI} - plaidLinkOAuthToken={plaidLinkOAuthToken} - allowDebit - bankAccountID={bankAccountID} - selectedPlaidAccountID={selectedPlaidAccountID} - /> - {Boolean(selectedPlaidAccountID) && !_.isEmpty(lodashGet(plaidData, 'bankAccounts')) && ( - ( - - {translate('common.iAcceptThe')} - {translate('common.expensifyTermsOfService')} - - )} - defaultValue={getDefaultStateForField('acceptTerms', false)} - shouldSaveDraft - /> - )} - - - ); -} - -BankAccountPlaidStep.propTypes = propTypes; -BankAccountPlaidStep.defaultProps = defaultProps; -BankAccountPlaidStep.displayName = 'BankAccountPlaidStep'; -export default compose( - withLocalize, - withOnyx({ - plaidData: { - key: ONYXKEYS.PLAID_DATA, - }, - }), -)(BankAccountPlaidStep); diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index 408c0e46a47d..b8569436e5d0 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -22,13 +22,13 @@ import getPlaidDesktopMessage from '@libs/getPlaidDesktopMessage'; import variables from '@styles/variables'; import * as BankAccounts from '@userActions/BankAccounts'; import * as Link from '@userActions/Link'; +import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; import * as Session from '@userActions/Session'; import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import BankAccountManualStep from './BankAccountManualStep'; -import BankAccountPlaidStep from './BankAccountPlaidStep'; +import BankInfo from './BankInfo/BankInfo'; import StepPropTypes from './StepPropTypes'; const propTypes = { @@ -65,6 +65,8 @@ const defaultProps = { policyID: '', }; +const bankInfoStepKeys = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; + function BankAccountStep(props) { const theme = useTheme(); const styles = useThemeStyles(); @@ -80,26 +82,21 @@ function BankAccountStep(props) { ROUTES.WORKSPACE_INITIAL.getRoute(props.policyID), )}`; - if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { - return ( - - ); - } + const removeExistingBankAccountDetails = () => { + const bankAccountData = { + [bankInfoStepKeys.ROUTING_NUMBER]: '', + [bankInfoStepKeys.ACCOUNT_NUMBER]: '', + [bankInfoStepKeys.PLAID_MASK]: '', + [bankInfoStepKeys.IS_SAVINGS]: '', + [bankInfoStepKeys.BANK_NAME]: '', + [bankInfoStepKeys.PLAID_ACCOUNT_ID]: '', + [bankInfoStepKeys.PLAID_ACCESS_TOKEN]: '', + }; + ReimbursementAccount.updateReimbursementAccountDraft(bankAccountData); + }; - if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { - return ( - - ); + if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID || subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + return ; } return ( @@ -136,6 +133,7 @@ function BankAccountStep(props) { if (props.isPlaidDisabled || !props.user.validated) { return; } + removeExistingBankAccountDetails(); BankAccounts.openPlaidView(); }} isDisabled={props.isPlaidDisabled || !props.user.validated} @@ -151,7 +149,10 @@ function BankAccountStep(props) { icon={Expensicons.Connect} title={props.translate('bankAccount.connectManually')} disabled={!props.user.validated} - onPress={() => BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL)} + onPress={() => { + removeExistingBankAccountDetails(); + BankAccounts.setBankAccountSubStep(CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL); + }} shouldShowRightIcon wrapperStyle={[styles.cardMenuItem]} /> diff --git a/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx new file mode 100644 index 000000000000..94b196176b7e --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -0,0 +1,162 @@ +import React, {useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import InteractiveStepSubHeader from '@components/InteractiveStepSubHeader'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import useSubStep from '@hooks/useSubStep'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as ReimbursementAccountUtils from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccount, ReimbursementAccountForm} from '@src/types/onyx'; +import Confirmation from './substeps/Confirmation'; +import Manual from './substeps/Manual'; +import Plaid from './substeps/Plaid'; + +type BankInfoOnyxProps = { + /** Plaid SDK token to use to initialize the widget */ + plaidLinkToken: OnyxEntry; + + /** Reimbursement account from ONYX */ + reimbursementAccount: OnyxEntry; + + /** The draft values of the bank account being setup */ + reimbursementAccountDraft: OnyxEntry; +}; + +type BankInfoProps = BankInfoOnyxProps & { + /** Goes to the previous step */ + onBackButtonPress: () => void; +}; + +const BANK_INFO_STEP_KEYS = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; +const manualSubsteps: Array> = [Manual, Confirmation]; +const plaidSubsteps: Array> = [Plaid, Confirmation]; +const receivedRedirectURI = getPlaidOAuthReceivedRedirectURI(); + +function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkToken, onBackButtonPress}: BankInfoProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [redirectedFromPlaidToManual, setRedirectedFromPlaidToManual] = React.useState(false); + const values = useMemo(() => getSubstepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft ?? {}, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]); + + let setupType = reimbursementAccount?.achData?.subStep ?? ''; + + const shouldReinitializePlaidLink = plaidLinkToken && receivedRedirectURI && setupType !== CONST.BANK_ACCOUNT.SUBSTEP.MANUAL; + if (shouldReinitializePlaidLink) { + setupType = CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID; + } + + const policyID = reimbursementAccount?.achData?.policyID ?? ''; + const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); + const submit = useCallback(() => { + if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + BankAccounts.connectBankAccountManually( + bankAccountID, + values[BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER], + values[BANK_INFO_STEP_KEYS.ROUTING_NUMBER], + values[BANK_INFO_STEP_KEYS.PLAID_MASK], + policyID, + ); + } else if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { + BankAccounts.connectBankAccountWithPlaid( + bankAccountID, + { + [BANK_INFO_STEP_KEYS.ROUTING_NUMBER]: values[BANK_INFO_STEP_KEYS.ROUTING_NUMBER] ?? '', + [BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER]: values[BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER] ?? '', + [BANK_INFO_STEP_KEYS.BANK_NAME]: values[BANK_INFO_STEP_KEYS.BANK_NAME] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]: values[BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID] ?? '', + [BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: values[BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN] ?? '', + }, + policyID, + ); + } + }, [setupType, values, bankAccountID, policyID]); + + const bodyContent = setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID ? plaidSubsteps : manualSubsteps; + const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo} = useSubStep({bodyContent, startFrom: 0, onFinished: submit}); + + // Some services user connects to via Plaid return dummy account numbers and routing numbers e.g. Chase + // In this case we need to redirect user to manual flow to enter real account number and routing number + // and we need to do it only once so redirectedFromPlaidToManual flag is used + useEffect(() => { + if (redirectedFromPlaidToManual) { + return; + } + + if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL && values.bankName !== '' && !redirectedFromPlaidToManual) { + setRedirectedFromPlaidToManual(true); + moveTo(0); + } + }, [moveTo, redirectedFromPlaidToManual, setupType, values]); + + const handleBackButtonPress = () => { + if (screenIndex === 0) { + if (bankAccountID) { + onBackButtonPress(); + } else { + const bankAccountData = { + [BANK_INFO_STEP_KEYS.ROUTING_NUMBER]: '', + [BANK_INFO_STEP_KEYS.ACCOUNT_NUMBER]: '', + [BANK_INFO_STEP_KEYS.PLAID_MASK]: '', + [BANK_INFO_STEP_KEYS.IS_SAVINGS]: '', + [BANK_INFO_STEP_KEYS.BANK_NAME]: '', + [BANK_INFO_STEP_KEYS.PLAID_ACCOUNT_ID]: '', + [BANK_INFO_STEP_KEYS.PLAID_ACCESS_TOKEN]: '', + }; + ReimbursementAccountUtils.updateReimbursementAccountDraft(bankAccountData); + BankAccounts.setBankAccountSubStep(null); + } + } else { + prevScreen(); + } + }; + + return ( + + + + + + + + ); +} + +BankInfo.displayName = 'BankInfo'; + +export default withOnyx({ + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + reimbursementAccountDraft: { + key: ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM_DRAFT, + }, + plaidLinkToken: { + key: ONYXKEYS.PLAID_LINK_TOKEN, + }, +})(BankInfo); diff --git a/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx new file mode 100644 index 000000000000..c1fc3f82b277 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx @@ -0,0 +1,110 @@ +import React, {useMemo} from 'react'; +import {ScrollView, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import {withOnyx} from 'react-native-onyx'; +import Button from '@components/Button'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScreenWrapper from '@components/ScreenWrapper'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccount, ReimbursementAccountForm} from '@src/types/onyx'; + +type ConfirmationOnyxProps = { + /** Reimbursement account from ONYX */ + reimbursementAccount: OnyxEntry; + + /** The draft values of the bank account being setup */ + reimbursementAccountDraft: OnyxEntry; +}; + +type ConfirmationProps = ConfirmationOnyxProps & SubStepProps; + +const BANK_INFO_STEP_KEYS = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; +const BANK_INFO_STEP_INDEXES = CONST.REIMBURSEMENT_ACCOUNT_SUBSTEP_INDEX.BANK_ACCOUNT; + +function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext, onMove}: ConfirmationProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const isLoading = reimbursementAccount?.isLoading ?? false; + const setupType = reimbursementAccount?.achData?.subStep ?? ''; + const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); + const values = useMemo(() => getSubstepValues(BANK_INFO_STEP_KEYS, reimbursementAccountDraft ?? {}, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]); + const error = ErrorUtils.getLatestErrorMessage(reimbursementAccount ?? {}); + + const handleModifyAccountNumbers = () => { + onMove(BANK_INFO_STEP_INDEXES.ACCOUNT_NUMBERS); + }; + + return ( + + + {translate('bankAccount.letsDoubleCheck')} + {translate('bankAccount.thisBankAccount')} + {setupType === CONST.BANK_ACCOUNT.SUBSTEP.MANUAL && ( + + + + + + )} + {setupType === CONST.BANK_ACCOUNT.SUBSTEP.PLAID && ( + + )} + + {error && error.length > 0 && ( + + )} +