diff --git a/src/CONST.ts b/src/CONST.ts index b1a6b6895de7f..3150b87db19d9 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -187,6 +187,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: { @@ -197,6 +262,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', @@ -206,14 +278,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', @@ -477,6 +553,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:', EXPENSIFY_INBOX_URL: 'https://www.expensify.com/inbox', diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 98e3856f45441..5b2171af73f20 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -127,6 +127,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', @@ -398,6 +399,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; diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index a5160a13f8e98..14c8c27c67d37 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'; @@ -20,6 +20,7 @@ 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 +56,9 @@ const propTypes = { /** Are we adding a withdrawal account? */ allowDebit: PropTypes.bool, + + /** Is displayed in new VBBA */ + isDisplayedInNewVBBA: PropTypes.bool, }; const defaultProps = { @@ -68,6 +72,7 @@ const defaultProps = { allowDebit: false, bankAccountID: 0, isPlaidDisabled: false, + isDisplayedInNewVBBA: false, }; function AddPlaidBankAccount({ @@ -82,11 +87,21 @@ function AddPlaidBankAccount({ bankAccountID, allowDebit, isPlaidDisabled, + isDisplayedInNewVBBA, }) { 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 +177,27 @@ 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); + }; + if (isPlaidDisabled) { return ( @@ -239,6 +264,34 @@ 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/Button/index.tsx b/src/components/Button/index.tsx index 5fb1346481342..ebfa83af91c8c 100644 --- a/src/components/Button/index.tsx +++ b/src/components/Button/index.tsx @@ -344,5 +344,4 @@ function Button( } Button.displayName = 'Button'; - export default withNavigationFallback(React.forwardRef(Button)); diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a2ca930690acc..759908a4647ff 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/FormProvider.js b/src/components/Form/FormProvider.js index 50b24e368fc6a..4760710eb41fb 100644 --- a/src/components/Form/FormProvider.js +++ b/src/components/Form/FormProvider.js @@ -48,7 +48,7 @@ const propTypes = { }), /** Contains draft values for each input in the form */ - draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date)])), + draftValues: PropTypes.objectOf(PropTypes.oneOfType([PropTypes.string, PropTypes.bool, PropTypes.number, PropTypes.objectOf(Date), PropTypes.arrayOf(PropTypes.string)])), /** Should the button be enabled when offline */ enabledWhenOffline: PropTypes.bool, diff --git a/src/components/Form/FormWrapper.js b/src/components/Form/FormWrapper.js index da34262a8af82..cb68a4c3e19d3 100644 --- a/src/components/Form/FormWrapper.js +++ b/src/components/Form/FormWrapper.js @@ -57,8 +57,9 @@ const propTypes = { /** Container styles */ style: stylePropTypes, - /** Submit button styles */ - submitButtonStyles: stylePropTypes, + /** Submit button container styles */ + // eslint-disable-next-line react/forbid-prop-types + submitButtonStyles: PropTypes.arrayOf(PropTypes.object), /** Custom content to display in the footer after submit button */ footerContent: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), @@ -93,10 +94,10 @@ function FormWrapper(props) { footerContent, isSubmitButtonVisible, style, - submitButtonStyles, enabledWhenOffline, isSubmitActionDangerous, formID, + submitButtonStyles, } = props; const formRef = useRef(null); const formContentRef = useRef(null); diff --git a/src/components/InteractiveStepSubHeader.tsx b/src/components/InteractiveStepSubHeader.tsx new file mode 100644 index 0000000000000..1886f5962331c --- /dev/null +++ b/src/components/InteractiveStepSubHeader.tsx @@ -0,0 +1,106 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useImperativeHandle, useState} from 'react'; +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(); + + 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 default forwardRef(InteractiveStepSubHeader); diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index 8aa3ef7e8ffe4..1c1fe87bc1562 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -11,13 +11,16 @@ 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; }; -function RadioButtons({items, onPress}: RadioButtonsProps) { +function RadioButtons({items, onPress, defaultCheckedValue = ''}: RadioButtonsProps) { const styles = useThemeStyles(); - const [checkedValue, setCheckedValue] = useState(''); + const [checkedValue, setCheckedValue] = useState(defaultCheckedValue); return ( <> diff --git a/src/components/ReimbursementAccountLoadingIndicator.js b/src/components/ReimbursementAccountLoadingIndicator.js index bc0e70e644198..141e056afd936 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.js b/src/components/StatePicker/index.js index a2a8af1635eb5..d6e225a644238 100644 --- a/src/components/StatePicker/index.js +++ b/src/components/StatePicker/index.js @@ -8,6 +8,7 @@ import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import refPropTypes from '@components/refPropTypes'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import stylePropTypes from '@styles/stylePropTypes'; import StateSelectorModal from './StateSelectorModal'; const propTypes = { @@ -26,6 +27,9 @@ const propTypes = { /** Label to display on field */ label: PropTypes.string, + /** Any additional styles to apply */ + wrapperStyle: stylePropTypes, + /** Callback to call when the picker modal is dismissed */ onBlur: PropTypes.func, }; @@ -36,10 +40,11 @@ const defaultProps = { errorText: '', onInputChange: () => {}, label: undefined, + wrapperStyle: {}, onBlur: () => {}, }; -function StatePicker({value, errorText, onInputChange, forwardedRef, label, onBlur}) { +function StatePicker({value, errorText, onInputChange, forwardedRef, label, onBlur, wrapperStyle}) { const styles = useThemeStyles(); const {translate} = useLocalize(); const [isPickerVisible, setIsPickerVisible] = useState(false); @@ -77,6 +82,7 @@ function StatePicker({value, errorText, onInputChange, forwardedRef, label, onBl description={label || translate('common.state')} descriptionTextStyle={descStyle} onPress={showPickerModal} + wrapperStyle={wrapperStyle} /> diff --git a/src/hooks/useSubStep/index.ts b/src/hooks/useSubStep/index.ts new file mode 100644 index 0000000000000..e215cea4d8de8 --- /dev/null +++ b/src/hooks/useSubStep/index.ts @@ -0,0 +1,54 @@ +import {useCallback, useRef, useState} from 'react'; +import type {UseSubStep} from './types'; + +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( + (data?: Record) => { + if (isEditing.current) { + isEditing.current = false; + + setScreenIndex(bodyContent.length - 1); + + return; + } + + const nextScreenIndex = screenIndex + 1; + + if (nextScreenIndex === bodyContent.length) { + onFinished(data); + } 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 0000000000000..8097aec75a36d --- /dev/null +++ b/src/hooks/useSubStep/types.ts @@ -0,0 +1,32 @@ +import type {ComponentType} from 'react'; +import type {OnfidoData} from '@src/types/onyx/ReimbursementAccountDraft'; + +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: (data?: OnfidoData) => 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 c57b1ce310b58..7b4027dce36ba 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -188,6 +188,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', @@ -293,6 +294,7 @@ export default { tbd: 'TBD', selectCurrency: 'Select a currency', card: 'Card', + whyDoWeAskForThis: 'Why do we ask for this?', required: 'Required', showing: 'Showing', of: 'of', @@ -1240,8 +1242,16 @@ 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', + connectDifferentAccount: 'Connect a different account', accountNumber: 'Account number', routingNumber: 'Routing number', + chooseAnAccountBelow: 'Choose an account below', addBankAccount: 'Add bank account', chooseAnAccount: 'Choose an account', connectOnlineWithPlaid: 'Connect online with Plaid', @@ -1452,12 +1462,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', @@ -1490,6 +1562,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 8969ce91a9a55..4a6040f899e21 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -178,6 +178,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', @@ -283,6 +284,7 @@ export default { tbd: 'Por determinar', selectCurrency: 'Selecciona una moneda', card: 'Tarjeta', + whyDoWeAskForThis: '¿Por qué pedimos esto?', required: 'Obligatorio', showing: 'Mostrando', of: 'de', @@ -1256,8 +1258,16 @@ export default { return result; }, bankAccount: { + bankInfo: 'Información bancaria', + confirmBankInfo: 'Confirmar información bancaria', + manuallyAdd: 'Agregar 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', + connectDifferentAccount: 'Conectar una cuenta diferente', 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', @@ -1474,12 +1484,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: 'Ingrese su Nombre y Apellido', + legalFirstName: 'Nombre', + legalLastName: 'Apellido', + legalName: 'Nombre legal', + enterYourDateOfBirth: 'Ingrese su fecha de Cumple años', + enterTheLast4: 'Ingrese los últimos 4 dígitos de su NSS', + dontWorry: 'No se preocupe, no hacemos ninguna verificación de créditos', + last4SSN: 'Últimos 4 dígitos de su Número de Seguro Social', + enterYourAddress: 'Ingrese su dirección', + address: 'Dirección', + letsDoubleCheck: 'Revisemos que todo esté bien', + byAddingThisBankAccount: 'Agregando esta cuenta bancaria, confirmas que as leído, entendido y aceptado', + }, + businessInfoStep: { + businessInfo: 'Información de Negocio', + enterTheNameOfYourBusiness: 'Introduzca el nombre de su negocio.', + businessName: 'Nombre del Negocio', + enterYourCompanysTaxIdNumber: 'Introduzca el número de identificación fiscal.', + taxIDNumber: 'Número de identificación fiscal', + taxIDNumberPlaceholder: '9 dígitos', + enterYourCompanysWebsite: 'Introduzca el sitio web de su compañia.', + companyWebsite: 'Sitio web de la compañia', + enterYourCompanysPhoneNumber: 'Introduzca el número de teléfono de su compañia.', + enterYourCompanysAddress: 'Introduzca el la dirección de su compañia.', + selectYourCompanysType: 'Seleccione el tipo de compañia.', + companyType: 'Tipo de compañia', + incorporationType: { + LLC: 'SRL', + CORPORATION: 'Corporación', + PARTNERSHIP: 'Asociación', + COOPERATIVE: 'Cooperativa', + SOLE_PROPRIETORSHIP: 'Empresa unipersonal', + OTHER: 'Otros', + }, + selectYourCompanysIncorporationDate: 'Seleccione la fecha de constitución de su empresa.', + incorporationDate: 'Fecha de constitución', + incorporationDatePlaceholder: 'Fecha de inicio (yyyy-mm-dd)', + incorporationState: 'Estado de constitución', + pleaseSelectTheStateYourCompanyWasIncorporatedIn: 'Seleccione el estado en el que se constituyó su 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 regulación nos exige verificar la identidad de cualquier persona que posea más del 25% de la empresa.', + companyOwner: 'Dueño de la empresa', + enterLegalFirstAndLastName: 'Ingresa el nombre y apellido legal del dueño.', + legalFirstName: 'Nombre legal', + legalLastName: 'Apellido legal', + enterTheDateOfBirthOfTheOwner: 'Ingresa la fecha de nacimiento del dueño.', + enterTheLast4: 'Ingresa los últimos 4 dígitos del NSS del dueño.', + last4SSN: 'Últimos 4 dígitos del Número de Seguro Social', + dontWorry: 'No te preocupes, ¡no realizamos verificaciones de crédito personales!', + enterTheOwnersAddress: 'Ingresa la dirección del dueño.', + letsDoubleCheck: 'Vamos a verificar que todo esté correcto.', + legalName: 'Nombre legal', + address: 'Dirección', + byAddingThisBankAccount: 'Al agregar esta cuenta bancaria, confirmas que has leído, comprendido y aceptado', + owners: 'Dueños', }, validationStep: { headerTitle: 'Validar cuenta bancaria', @@ -1513,6 +1585,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 a continuación.', + 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 comerciales', + isAuthorizedToUseBankAccountError: 'Debes ser un oficial controlador 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 para esta cuenta bancaria se ha desactivado debido a demasiados intentos incorrectos.', + description: + 'Un día o dos después de agregar tu cuenta a Expensify, enviamos tres (3) transacciones a tu cuenta. Tienen un nombre de comerciante similar a "Expensify, Inc. Validation".', + descriptionCTA: 'Ingresa el importe de cada transacción en los campos a continuación. 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 resolver esto rápidamente a través del chat. ¿Estás Listo?', + letsChatTitle: '¡Vamos a chatear!', + enable2FATitle: '¡Evita fraudes, habilita 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/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index f7b7ec89c6706..f55991e2c48b6 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -8,7 +8,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type PlaidBankAccount from '@src/types/onyx/PlaidBankAccount'; import type {BankAccountStep, BankAccountSubStep} from '@src/types/onyx/ReimbursementAccount'; -import type {ACHContractStepProps, BankAccountStepProps, CompanyStepProps, OnfidoData, ReimbursementAccountProps, RequestorStepProps} 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'; @@ -27,12 +27,26 @@ export { export {openPlaidBankAccountSelector, openPlaidBankLogin} from './Plaid'; export {openOnfidoFlow, answerQuestionsForWallet, verifyIdentity, acceptWalletTerms} from './Wallet'; -type BankAccountCompanyInformation = BankAccountStepProps & CompanyStepProps & ReimbursementAccountProps; - type ReimbursementAccountStep = BankAccountStep | ''; type ReimbursementAccountSubStep = BankAccountSubStep | ''; +type PlaidBankAccountToConnect = Omit; + +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); @@ -73,6 +87,7 @@ function clearPersonalBankAccount() { function clearOnfidoToken() { Onyx.merge(ONYXKEYS.ONFIDO_TOKEN, ''); + Onyx.merge(ONYXKEYS.ONFIDO_APPLICANT_ID, ''); } /** @@ -119,10 +134,22 @@ function getVBBADataForOnyx(currentStep?: BankAccountStep): OnyxData { }; } +function addBusinessWebsiteForDraft(website: string) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, {website}); +} + +function addBusinessAddressForDraft(businessAddress: BusinessAddress) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, businessAddress); +} + +function addPersonalAddressForDraft(personalAddress: {requestorAddressStreet?: string; requestorAddressCity?: string; requestorAddressState?: string; requestorAddressZipCode?: string}) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, personalAddress); +} + /** * Submit Bank Account step with Plaid data so php can perform some checks. */ -function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount) { +function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccountToConnect) { const commandName = 'ConnectBankAccountWithPlaid'; type ConnectBankAccountWithPlaidParams = { @@ -132,6 +159,7 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc bank?: string; plaidAccountID: string; plaidAccessToken: string; + canUseNewVbbaFlow: boolean; }; const parameters: ConnectBankAccountWithPlaidParams = { @@ -141,6 +169,7 @@ function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAcc bank: selectedPlaidBankAccount.bankName, plaidAccountID: selectedPlaidBankAccount.plaidAccountID, plaidAccessToken: selectedPlaidBankAccount.plaidAccessToken, + canUseNewVbbaFlow: true, }; API.write(commandName, parameters, getVBBADataForOnyx()); @@ -247,8 +276,16 @@ function deletePaymentBankAccount(bankAccountID: number) { * * This action is called by the requestor step in the Verified Bank Account flow */ -function updatePersonalInformationForBankAccount(params: RequestorStepProps) { - API.write('UpdatePersonalInformationForBankAccount', params, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR)); +function updatePersonalInformationForBankAccount(bankAccountID: number, params: RequestorStepProps) { + API.write( + 'UpdatePersonalInformationForBankAccount', + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.REQUESTOR), + ); } function validateBankAccount(bankAccountID: number, validateCode: string) { @@ -349,19 +386,46 @@ function openReimbursementAccountPage(stepToOpen: ReimbursementAccountStep, subS /** * Updates the bank account in the database with the company step data */ -function updateCompanyInformationForBankAccount(bankAccount: BankAccountCompanyInformation, policyID: string) { - type UpdateCompanyInformationForBankAccountParams = BankAccountCompanyInformation & {policyID: string}; - - const parameters: UpdateCompanyInformationForBankAccountParams = {...bankAccount, policyID}; +function updateCompanyInformationForBankAccount(bankAccountID: number, params: CompanyStepProps) { + API.write( + 'UpdateCompanyInformationForBankAccount', + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY), + ); +} - API.write('UpdateCompanyInformationForBankAccount', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.COMPANY)); +/** + * Add beneficial owners for the bank account and verify the accuracy of the information provided + */ +function updateBeneficialOwnersForBankAccount(bankAccountID: number, params: BeneficialOwnersStepProps) { + API.write( + 'UpdateBeneficialOwnersForBankAccount', + { + ...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 */ -function updateBeneficialOwnersForBankAccount(params: ACHContractStepProps) { - API.write('UpdateBeneficialOwnersForBankAccount', params, getVBBADataForOnyx()); +function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContractStepProps) { + API.write( + 'AcceptACHContractForBankAccount', + { + ...params, + bankAccountID, + canUseNewVbbaFlow: true, + }, + getVBBADataForOnyx(), + ); } /** @@ -374,6 +438,7 @@ function connectBankAccountManually(bankAccountID: number, accountNumber?: strin accountNumber?: string; routingNumber?: string; plaidMask?: string; + canUseNewVbbaFlow: boolean; }; const parameters: ConnectBankAccountManuallyParams = { @@ -381,6 +446,7 @@ function connectBankAccountManually(bankAccountID: number, accountNumber?: strin accountNumber, routingNumber, plaidMask, + canUseNewVbbaFlow: true, }; API.write('ConnectBankAccountManually', parameters, getVBBADataForOnyx(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT)); @@ -393,11 +459,13 @@ function verifyIdentityForBankAccount(bankAccountID: number, onfidoData: OnfidoD type VerifyIdentityForBankAccountParams = { bankAccountID: number; onfidoData: string; + canUseNewVbbaFlow: boolean; }; const parameters: VerifyIdentityForBankAccountParams = { bankAccountID, onfidoData: JSON.stringify(onfidoData), + canUseNewVbbaFlow: true, }; API.write('VerifyIdentityForBankAccount', parameters, getVBBADataForOnyx()); @@ -465,6 +533,10 @@ function setReimbursementAccountLoading(isLoading: boolean) { } export { + acceptACHContractForBankAccount, + addBusinessWebsiteForDraft, + addBusinessAddressForDraft, + addPersonalAddressForDraft, addPersonalBankAccount, clearOnfidoToken, clearPersonalBankAccount, @@ -487,3 +559,5 @@ export { verifyIdentityForBankAccount, setReimbursementAccountLoading, }; + +export type {BusinessAddress, PersonalAddress}; diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index 0404115f086b7..da55a73ae6e68 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 */ function setBankAccountSubStep(subStep) { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {subStep}}); diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js index ebc1862e9c74b..28ad60747904f 100644 --- a/src/libs/actions/ReimbursementAccount/navigation.js +++ b/src/libs/actions/ReimbursementAccount/navigation.js @@ -7,7 +7,7 @@ import ROUTES from '@src/ROUTES'; * Navigate to a specific step in the VBA flow * * @param {String} stepID - * @param {Object} newAchData + * @param {Object} [newAchData] */ function goToWithdrawalAccountSetupStep(stepID, newAchData) { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newAchData, currentStep: stepID}}); diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 14c9880336897..476f80a4af4f2 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 806e438d0397f..5204ba7d3fc3b 100644 --- a/src/pages/ReimbursementAccount/ACHContractStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -1,294 +1,24 @@ -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, + /** Goes to the previous step */ + onBackButtonPress: PropTypes.func.isRequired, - /** Name of the company */ - companyName: PropTypes.string.isRequired, + /** Exits flow and goes back to the workspace initial page */ + onCloseButtonPress: 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, - }); - }; - +function ACHContractStep({onBackButtonPress, onCloseButtonPress}) { 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 - /> - - )} - - + ); } 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 d1d43f6a41082..92360eb813e8a 100644 --- a/src/pages/ReimbursementAccount/AddressForm.js +++ b/src/pages/ReimbursementAccount/AddressForm.js @@ -107,7 +107,6 @@ function AddressForm(props) { defaultValue={props.defaultValues.street} onInputChange={props.onFieldChange} errorText={props.errors.street ? props.translate('bankAccount.error.addressStreet') : ''} - hint={props.translate('common.noPO')} renamedInputKeys={props.inputKeys} maxInputLength={CONST.FORM_CHARACTER_LIMIT} isLimitedToUSA @@ -151,7 +150,6 @@ function AddressForm(props) { onChangeText={(value) => props.onFieldChange({zipCode: value})} errorText={props.errors.zipCode ? props.translate('bankAccount.error.zipCode') : ''} maxLength={CONST.BANK_ACCOUNT.MAX_LENGTH.ZIP_CODE} - hint={props.translate('common.zipCodeExampleFormat', {zipSampleFormat: CONST.COUNTRY_ZIP_REGEX_DATA.US.samples})} containerStyles={[styles.mt2]} /> diff --git a/src/pages/ReimbursementAccount/BankAccountManualStep.js b/src/pages/ReimbursementAccount/BankAccountManualStep.js deleted file mode 100644 index 8e79a492bfe3d..0000000000000 --- 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 c235a31f626fc..0000000000000 --- 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 408c0e46a47de..21b2ee991be14 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 0000000000000..d9b036739af25 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.tsx @@ -0,0 +1,132 @@ +import React, {useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx/lib/types'; +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 CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReimbursementAccount, ReimbursementAccountDraft} 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; + +const bankInfoStepKeys = 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}: BankInfoProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + + const [redirectedFromPlaidToManual, setRedirectedFromPlaidToManual] = React.useState(false); + const values = useMemo(() => getSubstepValues(bankInfoStepKeys, 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 submit = useCallback(() => { + if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + BankAccounts.connectBankAccountManually( + Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), + values[bankInfoStepKeys.ACCOUNT_NUMBER], + values[bankInfoStepKeys.ROUTING_NUMBER], + values[bankInfoStepKeys.PLAID_MASK], + ); + } else if (setupType === CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID) { + BankAccounts.connectBankAccountWithPlaid(Number(reimbursementAccount?.achData?.bankAccountID ?? '0'), { + [bankInfoStepKeys.ROUTING_NUMBER]: values[bankInfoStepKeys.ROUTING_NUMBER] ?? '', + [bankInfoStepKeys.ACCOUNT_NUMBER]: values[bankInfoStepKeys.ACCOUNT_NUMBER] ?? '', + [bankInfoStepKeys.BANK_NAME]: values[bankInfoStepKeys.BANK_NAME] ?? '', + [bankInfoStepKeys.PLAID_ACCOUNT_ID]: values[bankInfoStepKeys.PLAID_ACCOUNT_ID] ?? '', + [bankInfoStepKeys.PLAID_ACCESS_TOKEN]: values[bankInfoStepKeys.PLAID_ACCESS_TOKEN] ?? '', + }); + } + }, [reimbursementAccount, setupType, values]); + + 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) { + BankAccounts.setBankAccountSubStep(null); + } else { + prevScreen(); + } + }; + + return ( + + + + + + + + ); +} + +BankInfo.displayName = 'BankInfo'; + +export default withOnyx({ + reimbursementAccount: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + }, + reimbursementAccountDraft: { + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_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 0000000000000..9115969b8d308 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.tsx @@ -0,0 +1,145 @@ +import React, {useMemo} from 'react'; +import {ScrollView, Text, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +import type {OnyxEntry} from 'react-native-onyx/lib/types'; +import Button from '@components/Button'; +import DotIndicatorMessage from '@components/DotIndicatorMessage'; +import Icon from '@components/Icon'; +import * as Expensicons from '@components/Icon/Expensicons'; +import MenuItem from '@components/MenuItem'; +import ScreenWrapper from '@components/ScreenWrapper'; +import useLocalize from '@hooks/useLocalize'; +import type {SubStepProps} from '@hooks/useSubStep/types'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import * as ErrorUtils from '@libs/ErrorUtils'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type * as OnyxTypes 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 bankInfoStepKeys = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; + +function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext}: ConfirmationProps) { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const theme = useTheme(); + + const isLoading = reimbursementAccount?.isLoading ?? false; + const setupType = reimbursementAccount?.achData?.subStep ?? ''; + const bankAccountID = Number(reimbursementAccount?.achData?.bankAccountID ?? '0'); + const values = useMemo(() => getSubstepValues(bankInfoStepKeys, reimbursementAccountDraft ?? {}, reimbursementAccount ?? {}), [reimbursementAccount, reimbursementAccountDraft]); + const error = ErrorUtils.getLatestErrorMessage(reimbursementAccount ?? {}); + + const handleConnectDifferentAccount = () => { + if (bankAccountID) { + ReimbursementAccount.requestResetFreePlanBankAccount(); + return; + } + 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); + + BankAccounts.setBankAccountSubStep(null); + }; + + return ( + + + {translate('bankAccount.letsDoubleCheck')} + + {setupType === CONST.BANK_ACCOUNT.SUBSTEP.MANUAL && ( + + + + + {translate('bankAccount.routingNumber')} + {values[bankInfoStepKeys.ROUTING_NUMBER]} + + + {translate('bankAccount.accountNumber')} + {values[bankInfoStepKeys.ACCOUNT_NUMBER]} + + + + )} + {setupType === CONST.BANK_ACCOUNT.SUBSTEP.PLAID && ( + + )} + {translate('bankAccount.thisBankAccount')} + + + + {error.length > 0 && ( + + )} +