diff --git a/src/CONST.ts b/src/CONST.ts index 86f0dcd870e1..e19c2005722c 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -168,8 +168,21 @@ const CONST = { DOMAIN: '@expensify.sms', }, BANK_ACCOUNT: { + BANK_INFO_STEP: { + INPUT_KEY: { + BANK_ACCOUNT_ID: 'bankAccountID', + 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: { + BANK_ACCOUNT_ID: 'bankAccountID', FIRST_NAME: 'firstName', LAST_NAME: 'lastName', DOB: 'dob', @@ -223,6 +236,7 @@ const CONST = { }, SUBSTEP: { MANUAL: 'manual', + PLAID: 'plaid', }, VERIFICATIONS: { ERROR_MESSAGE: 'verifications.errorMessage', diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index ec4ddd623929..334b3203948a 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(); 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 ( @@ -229,6 +254,34 @@ function AddPlaidBankAccount({ ); } + 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/NewDatePicker/index.js b/src/components/NewDatePicker/index.js index 01f21af4db7e..eacad86931e8 100644 --- a/src/components/NewDatePicker/index.js +++ b/src/components/NewDatePicker/index.js @@ -8,8 +8,8 @@ import * as Expensicons from '@components/Icon/Expensicons'; import TextInput from '@components/TextInput'; import {propTypes as baseTextInputPropTypes, defaultProps as defaultBaseTextInputPropTypes} from '@components/TextInput/BaseTextInput/baseTextInputPropTypes'; import withLocalize, {withLocalizePropTypes} from '@components/withLocalize'; -import * as FormActions from '@userActions/FormActions'; import useThemeStyles from '@styles/useThemeStyles'; +import * as FormActions from '@userActions/FormActions'; import CONST from '@src/CONST'; import CalendarPicker from './CalendarPicker'; @@ -53,7 +53,24 @@ const datePickerDefaultProps = { formID: '', }; -function NewDatePicker({containerStyles, defaultValue, disabled, errorText, inputID, isSmallScreenWidth, label, maxDate, minDate, onInputChange, onTouched, placeholder, translate, value, shouldSaveDraft, formID}) { +function NewDatePicker({ + containerStyles, + defaultValue, + disabled, + errorText, + inputID, + isSmallScreenWidth, + label, + maxDate, + minDate, + onInputChange, + onTouched, + placeholder, + translate, + value, + shouldSaveDraft, + formID, +}) { const styles = useThemeStyles(); const [selectedDate, setSelectedDate] = useState(value || defaultValue || undefined); diff --git a/src/components/RadioButtons.tsx b/src/components/RadioButtons.tsx index f8f9b345f855..ceba41ea7618 100644 --- a/src/components/RadioButtons.tsx +++ b/src/components/RadioButtons.tsx @@ -12,13 +12,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/languages/en.ts b/src/languages/en.ts index fdeb1dd7b205..7b9e94a53305 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1140,8 +1140,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', diff --git a/src/languages/es.ts b/src/languages/es.ts index 2e932a2a0182..8719076ce283 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1154,8 +1154,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', diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index 898af1ce7108..e89296cc2e2b 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -21,12 +21,14 @@ import useTheme from '@styles/themes/useTheme'; import useThemeStyles from '@styles/useThemeStyles'; import * as BankAccounts from '@userActions/BankAccounts'; import * as Link from '@userActions/Link'; +import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; 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 = { @@ -63,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(); @@ -78,6 +82,23 @@ function BankAccountStep(props) { ROUTES.WORKSPACE_INITIAL.getRoute(props.policyID), )}`; + 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 || subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { + return ; + } + if (subStep === CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL) { return ( 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.js b/src/pages/ReimbursementAccount/BankInfo/BankInfo.js new file mode 100644 index 000000000000..06f9e0e10c45 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/BankInfo.js @@ -0,0 +1,148 @@ +import PropTypes from 'prop-types'; +import React, {useCallback, useEffect, useMemo} from 'react'; +import {View} from 'react-native'; +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 getPlaidOAuthReceivedRedirectURI from '@libs/getPlaidOAuthReceivedRedirectURI'; +import reimbursementAccountDraftPropTypes from '@pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes'; +import {reimbursementAccountPropTypes} from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; +import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; +import getDefaultValueForReimbursementAccountField from '@pages/ReimbursementAccount/utils/getDefaultValueForReimbursementAccountField'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import styles from '@styles/styles'; +import * as BankAccounts from '@userActions/BankAccounts'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import Confirmation from './substeps/Confirmation'; +import Manual from './substeps/Manual'; +import Plaid from './substeps/Plaid'; + +const propTypes = { + /** Plaid SDK token to use to initialize the widget */ + plaidLinkToken: PropTypes.string, + + /** Reimbursement account from ONYX */ + reimbursementAccount: reimbursementAccountPropTypes, + + /** The draft values of the bank account being setup */ + reimbursementAccountDraft: reimbursementAccountDraftPropTypes, +}; + +const defaultProps = { + plaidLinkToken: '', + reimbursementAccount: ReimbursementAccountProps.reimbursementAccountDefaultProps, + reimbursementAccountDraft: {}, +}; + +const STEPS_HEADER_HEIGHT = 40; +// TODO Will most likely come from different place +const STEP_NAMES = ['1', '2', '3', '4', '5']; + +const bankInfoStepKeys = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; +const manualSubsteps = [Manual, Confirmation]; +const plaidSubsteps = [Plaid, Confirmation]; +const receivedRedirectURI = getPlaidOAuthReceivedRedirectURI(); + +function BankInfo({reimbursementAccount, reimbursementAccountDraft, plaidLinkToken}) { + const {translate} = useLocalize(); + + const [redirectedFromPlaidToManual, setRedirectedFromPlaidToManual] = React.useState(false); + const values = useMemo(() => getSubstepValues(bankInfoStepKeys, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + + let setupType = getDefaultValueForReimbursementAccountField(reimbursementAccount, '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(getDefaultValueForReimbursementAccountField(reimbursementAccount, bankInfoStepKeys.BANK_ACCOUNT_ID, '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(getDefaultValueForReimbursementAccountField(reimbursementAccount, bankInfoStepKeys.BANK_ACCOUNT_ID, '0')), { + [bankInfoStepKeys.ROUTING_NUMBER]: values[bankInfoStepKeys.ROUTING_NUMBER], + [bankInfoStepKeys.ACCOUNT_NUMBER]: values[bankInfoStepKeys.ACCOUNT_NUMBER], + [bankInfoStepKeys.PLAID_MASK]: values[bankInfoStepKeys.PLAID_MASK], + [bankInfoStepKeys.IS_SAVINGS]: values[bankInfoStepKeys.IS_SAVINGS], + [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[bankInfoStepKeys.BANK_NAME] !== '' && !redirectedFromPlaidToManual) { + setRedirectedFromPlaidToManual(true); + moveTo(0); + } + }, [moveTo, redirectedFromPlaidToManual, setupType, values]); + + const handleBackButtonPress = () => { + if (screenIndex === 0) { + // TODO replace it with navigation to ReimbursementAccountPage once base is updated + BankAccounts.setBankAccountSubStep(null); + } else { + prevScreen(); + } + }; + + return ( + + + + {}} + // TODO Will be replaced with proper values + startStep={0} + stepNames={STEP_NAMES} + /> + + + + ); +} + +BankInfo.propTypes = propTypes; +BankInfo.defaultProps = defaultProps; +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.js b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.js new file mode 100644 index 000000000000..ca69a4483953 --- /dev/null +++ b/src/pages/ReimbursementAccount/BankInfo/substeps/Confirmation.js @@ -0,0 +1,146 @@ +import lodashGet from 'lodash/get'; +import React, {useMemo} from 'react'; +import {ScrollView, Text, View} from 'react-native'; +import {withOnyx} from 'react-native-onyx'; +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 * as ErrorUtils from '@libs/ErrorUtils'; +import reimbursementAccountDraftPropTypes from '@pages/ReimbursementAccount/ReimbursementAccountDraftPropTypes'; +import {reimbursementAccountPropTypes} from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; +import * as ReimbursementAccountProps from '@pages/ReimbursementAccount/reimbursementAccountPropTypes'; +import subStepPropTypes from '@pages/ReimbursementAccount/subStepPropTypes'; +import getDefaultValueForReimbursementAccountField from '@pages/ReimbursementAccount/utils/getDefaultValueForReimbursementAccountField'; +import getSubstepValues from '@pages/ReimbursementAccount/utils/getSubstepValues'; +import styles from '@styles/styles'; +import themeColors from '@styles/themes/default'; +import * as BankAccounts from '@userActions/BankAccounts'; +import * as ReimbursementAccount from '@userActions/ReimbursementAccount'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +const propTypes = { + /** Reimbursement account from ONYX */ + reimbursementAccount: reimbursementAccountPropTypes, + + /** The draft values of the bank account being setup */ + reimbursementAccountDraft: reimbursementAccountDraftPropTypes, + + ...subStepPropTypes, +}; + +const defaultProps = { + reimbursementAccount: ReimbursementAccountProps.reimbursementAccountDefaultProps, + reimbursementAccountDraft: {}, +}; + +const bankInfoStepKeys = CONST.BANK_ACCOUNT.BANK_INFO_STEP.INPUT_KEY; + +function Confirmation({reimbursementAccount, reimbursementAccountDraft, onNext}) { + const {translate} = useLocalize(); + + const isLoading = lodashGet(reimbursementAccount, 'isLoading', false); + const setupType = getDefaultValueForReimbursementAccountField(reimbursementAccount, 'subStep'); + const values = useMemo(() => getSubstepValues(bankInfoStepKeys, reimbursementAccountDraft, reimbursementAccount), [reimbursementAccount, reimbursementAccountDraft]); + const error = ErrorUtils.getLatestErrorMessage(reimbursementAccount); + + const handleConnectDifferentAccount = () => { + 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 && ( + + )} +