diff --git a/src/CONST.js b/src/CONST.js index 3161aed8fa85..28485036da96 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -63,6 +63,7 @@ const CONST = { IBAN: /^[A-Za-z0-9]{2,30}$/, SWIFT_BIC: /^[A-Za-z0-9]{8,11}$/, }, + VERIFICATION_MAX_ATTEMPTS: 7, }, INCORPORATION_TYPES: { LLC: 'LLC', @@ -191,6 +192,7 @@ const CONST = { TIMEZONE: 'timeZone', FREE_PLAN_BANK_ACCOUNT_ID: 'expensify_freePlanBankAccountID', ACH_DATA_THROTTLED: 'expensify_ACHData_throttled', + FAILED_BANK_ACCOUNT_VALIDATIONS_PREFIX: 'private_failedBankValidations_', BANK_ACCOUNT_GET_THROTTLED: 'private_throttledHistory_BankAccount_Get', }, DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, diff --git a/src/languages/en.js b/src/languages/en.js index 073a3a69b177..fe3879154b05 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -379,6 +379,14 @@ export default { ssnLast4: 'Last 4 Digits of SSN', isAuthorized: 'I am authorized to use my company bank account for business spend', }, + validationStep: { + headerTitle: 'Validate', + buttonText: 'Finish Setup', + maxAttemptError: 'Validation for this bank account has been disabled due to too many incorrect attempts. Please contact us.', + 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', + verifyingDescription: 'We\'re taking a look at your information and will have you onto next steps in just a few seconds.', + }, beneficialOwnersStep: { beneficialOwners: 'Beneficial Owners', additionalInformation: 'Additional Information', diff --git a/src/languages/es.js b/src/languages/es.js index 2c1e0cedcf1e..0604364f6577 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -368,6 +368,14 @@ export default { incorporationDatePlaceholder: 'Fecha de inicio (aaaa-mm-dd)', companyPhonePlaceholder: '10 dígitos, sin guiones', }, + validationStep: { + headerTitle: 'Validar', + buttonText: 'Finalizar Configuración', + maxAttemptError: 'Se ha inhabilitado la validación de esta cuenta bancaria, debido a demasiados intentos incorrectos. Por favor contáctenos.', + description: 'Uno o dos días después de agregar su cuenta a Expensify, enviamos tres (3) transacciones a su cuenta. Tienen una línea comercial como "Expensify, Inc. Validation"', + descriptionCTA: 'Ingrese el monto de cada transacción en los campos a continuación. Ejemplo: 1.51', + verifyingDescription: 'Estamos revisando su información y lo llevaremos a los siguientes pasos en solo unos segundos.', + }, requestCallPage: { requestACall: 'Llámame por teléfono', description: '¿Necesitas ayuda configurando tu cuenta? Nuestro equipo de guías puede ayudarte.', diff --git a/src/libs/API.js b/src/libs/API.js index c97e708f4e41..f4b7feace7a5 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -853,6 +853,12 @@ function BankAccount_Create(parameters) { return Network.post(commandName, parameters, CONST.NETWORK.METHOD.POST, true); } +function BankAccount_Validate(parameters) { + const commandName = 'ValidateBankAccount'; + requireParameters(['bankAccountID', 'validateCode'], parameters, commandName); + return Network.post(commandName, parameters, CONST.NETWORK.METHOD.POST); +} + /** * @param {*} parameters * @returns {Promise} @@ -960,6 +966,7 @@ export { BankAccount_Create, BankAccount_Get, BankAccount_SetupWithdrawal, + BankAccount_Validate, ChangePassword, CreateChatReport, CreateLogin, diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index a7529b1059cf..ea499937d5eb 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -9,6 +9,7 @@ import * as API from '../API'; import BankAccount from '../models/BankAccount'; import promiseAllSettled from '../promiseAllSettled'; import Growl from '../Growl'; +import Navigation from '../Navigation/Navigation'; import {translateLocal} from '../translate'; /** @@ -329,121 +330,147 @@ function fetchFreePlanVerifiedBankAccount() { // We are using set here since we will rely on data from the server (not local data) to populate the VBA flow // and determine which step to navigate to. Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true}); - promiseAllSettled([ - API.Get({ - returnValueList: 'nameValuePairs', - name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, - }), - API.Get({ - returnValueList: 'nameValuePairs', - name: 'expensify_migration_2020_04_28_RunKycVerifications', - }), - API.Get({ - returnValueList: 'nameValuePairs', - name: CONST.NVP.ACH_DATA_THROTTLED, - }), - API.Get({returnValueList: 'bankAccountList'}), - API.Get({ - returnValueList: 'nameValuePairs', - name: CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, - }), - ]) - .then(([ - freePlanBankAccountIDResponse, - kycVerificationsMigrationResponse, - achDataThrottledResponse, - bankAccountListResponse, - throttledBankAccountGetResponse, - ]) => { - const bankAccountID = lodashGet(freePlanBankAccountIDResponse, [ - 'value', 'nameValuePairs', CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, - ], ''); - const kycVerificationsMigration = lodashGet(kycVerificationsMigrationResponse, [ - 'value', 'nameValuePairs', 'expensify_migration_2020_04_28_RunKycVerifications', - ], ''); - const throttledDate = lodashGet(achDataThrottledResponse, [ - 'value', 'nameValuePairs', CONST.NVP.ACH_DATA_THROTTLED, + let bankAccountID; + + API.Get({ + returnValueList: 'nameValuePairs', + name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, + }) + .then((response) => { + bankAccountID = lodashGet(response, ['nameValuePairs', CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, ], ''); - const bankAccountJSON = _.find( - lodashGet(bankAccountListResponse, ['value', 'bankAccountList'], []), account => ( - account.bankAccountID === bankAccountID - ), - ); - const bankAccount = bankAccountJSON ? new BankAccount(bankAccountJSON) : null; - const throttledHistoryCount = lodashGet(throttledBankAccountGetResponse, [ - 'value', 'nameValuePairs', CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, - ], 0); - const isPlaidDisabled = throttledHistoryCount > CONST.BANK_ACCOUNT.PLAID.ALLOWED_THROTTLED_COUNT; - - // Next we'll build the achData and save it to Onyx - // If the user is already setting up a bank account we will continue the flow for them - let currentStep = reimbursementAccountInSetup.currentStep; - const achData = bankAccount ? bankAccount.toACHData() : {}; - achData.useOnfido = true; - achData.policyID = ''; - achData.isInSetup = !bankAccount || bankAccount.isInSetup(); - achData.bankAccountInReview = bankAccount && bankAccount.isVerifying(); - achData.domainLimit = 0; - - // If the bank account has already been created in the db and is not yet open let's show the manual form - // with the previously added values - achData.subStep = bankAccount && bankAccount.isInSetup() && CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; - - // If we're not in setup, it means we already have a withdrawal account and we're upgrading it to a business - // bank account. So let the user review all steps with all info prefilled and editable, unless a specific - // step was passed. - if (!achData.isInSetup) { - // @TODO Not sure if we need to do this since for NewDot none of the accounts are pre-existing ones - currentStep = ''; - } + const failedValidationAttemptsName = CONST.NVP.FAILED_BANK_ACCOUNT_VALIDATIONS_PREFIX + bankAccountID; + + // Now that we have the bank account. Lets grab the rest of the bank info we need + promiseAllSettled([ + API.Get({ + returnValueList: 'nameValuePairs', + name: failedValidationAttemptsName, + }), + API.Get({ + returnValueList: 'nameValuePairs', + name: 'expensify_migration_2020_04_28_RunKycVerifications', + }), + API.Get({ + returnValueList: 'nameValuePairs', + name: CONST.NVP.ACH_DATA_THROTTLED, + }), + API.Get({returnValueList: 'bankAccountList'}), + API.Get({ + returnValueList: 'nameValuePairs', + name: CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, + }), + ]) + .then(([ + failedValidationAttemptsResponse, + kycVerificationsMigrationResponse, + achDataThrottledResponse, + bankAccountListResponse, + throttledBankAccountGetResponse, + ]) => { + // Users have a limited amount of attempts to get the validations amounts correct. + // Once exceeded, we need to block them from attempting to validate. + const failedValidationAttempts = lodashGet(failedValidationAttemptsResponse, [ + 'value', 'nameValuePairs', failedValidationAttemptsName, + ], 0); + const maxAttemptsReached = failedValidationAttempts > CONST.BANK_ACCOUNT.VERIFICATION_MAX_ATTEMPTS; + + const kycVerificationsMigration = lodashGet(kycVerificationsMigrationResponse, [ + 'value', 'nameValuePairs', 'expensify_migration_2020_04_28_RunKycVerifications', + ], ''); + const throttledDate = lodashGet(achDataThrottledResponse, [ + 'value', 'nameValuePairs', CONST.NVP.ACH_DATA_THROTTLED, + ], ''); + const bankAccountJSON = _.find( + lodashGet(bankAccountListResponse, ['value', 'bankAccountList'], []), account => ( + account.bankAccountID === bankAccountID + ), + ); + const bankAccount = bankAccountJSON ? new BankAccount(bankAccountJSON) : null; + const throttledHistoryCount = lodashGet(throttledBankAccountGetResponse, [ + 'value', 'nameValuePairs', CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, + ], 0); + const isPlaidDisabled = throttledHistoryCount > CONST.BANK_ACCOUNT.PLAID.ALLOWED_THROTTLED_COUNT; + + // Next we'll build the achData and save it to Onyx + // If the user is already setting up a bank account we will continue the flow for them + let currentStep = reimbursementAccountInSetup.currentStep; + const achData = bankAccount ? bankAccount.toACHData() : {}; + achData.useOnfido = true; + achData.policyID = ''; + achData.isInSetup = !bankAccount || bankAccount.isInSetup(); + achData.bankAccountInReview = bankAccount && bankAccount.isVerifying(); + achData.domainLimit = 0; + + // If the bank account has already been created in the db and is not yet open + // let's show the manual form with the previously added values + achData.subStep = bankAccount && bankAccount.isInSetup() + && CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; + + // If we're not in setup, it means we already have a withdrawal account + // and we're upgrading it to a business bank account. So let the user + // review all steps with all info prefilled and editable, unless a specific step was passed. + if (!achData.isInSetup) { + // @TODO Not sure if we need to do this since for + // NewDot none of the accounts are pre-existing ones + currentStep = ''; + } - // Temporary fix for Onfido flow. Can be removed by nkuoch after Sept 1 2020. - // @TODO not sure if we still need this or what this is about, but seems like maybe yes... - if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT && achData.useOnfido) { - const onfidoResponse = lodashGet(achData, CONST.BANK_ACCOUNT.VERIFICATIONS.REQUESTOR_IDENTITY_ONFIDO); - const sdkToken = lodashGet(onfidoResponse, CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.SDK_TOKEN); - if (sdkToken && !achData.isOnfidoSetupComplete - && onfidoResponse.status !== CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.PASS - ) { - currentStep = CONST.BANK_ACCOUNT.STEP.REQUESTOR; - } - } + // Temporary fix for Onfido flow. Can be removed by nkuoch after Sept 1 2020. + // @TODO not sure if we still need this or what this is about, but seems like maybe yes... + if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT && achData.useOnfido) { + const onfidoResponse = lodashGet( + achData, + CONST.BANK_ACCOUNT.VERIFICATIONS.REQUESTOR_IDENTITY_ONFIDO, + ); + const sdkToken = lodashGet(onfidoResponse, CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.SDK_TOKEN); + if (sdkToken && !achData.isOnfidoSetupComplete + && onfidoResponse.status !== CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.PASS + ) { + currentStep = CONST.BANK_ACCOUNT.STEP.REQUESTOR; + } + } - // Ensure we route the user to the correct step based on the status of their bank account - if (bankAccount && !currentStep) { - currentStep = bankAccount.isPending() || bankAccount.isVerifying() - ? CONST.BANK_ACCOUNT.STEP.VALIDATION - : CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; - - // @TODO Again, not sure how much of this logic is needed right now as we shouldn't be handling any - // open accounts in E.cash yet that need to pass any more checks or can be upgraded, but leaving in for - // possible future compatibility. - if (bankAccount.isOpen()) { - if (bankAccount.needsToPassLatestChecks()) { - const hasTriedToUpgrade = bankAccount.getDateSigned() - > (kycVerificationsMigration || '2020-01-13'); - currentStep = hasTriedToUpgrade - ? CONST.BANK_ACCOUNT.STEP.VALIDATION : CONST.BANK_ACCOUNT.STEP.COMPANY; - achData.bankAccountInReview = hasTriedToUpgrade; - } else { - // In Expensify.cash we do not show a specific view for the EnableStep since we will enable the - // Expensify card automatically. However, we will still handle that step and show the Validate - // view. - currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE; + // Ensure we route the user to the correct step based on the status of their bank account + if (bankAccount && !currentStep) { + currentStep = bankAccount.isPending() || bankAccount.isVerifying() + ? CONST.BANK_ACCOUNT.STEP.VALIDATION + : CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + + // @TODO Again, not sure how much of this logic is needed right now + // as we shouldn't be handling any open accounts in E.cash yet that need to pass any more + // checks or can be upgraded, but leaving in for possible future compatibility. + if (bankAccount.isOpen()) { + if (bankAccount.needsToPassLatestChecks()) { + const hasTriedToUpgrade = bankAccount.getDateSigned() + > (kycVerificationsMigration || '2020-01-13'); + currentStep = hasTriedToUpgrade + ? CONST.BANK_ACCOUNT.STEP.VALIDATION : CONST.BANK_ACCOUNT.STEP.COMPANY; + achData.bankAccountInReview = hasTriedToUpgrade; + } else { + // In Expensify.cash we do not show a specific view for the EnableStep since we + // will enable the Expensify card automatically. However, we will still handle + // that step and show the Validate view. + currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE; + } + } } - } - } - // If at this point we still don't have a current step, default to the BankAccountStep - if (!currentStep) { - currentStep = CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; - } + // If at this point we still don't have a current step, default to the BankAccountStep + if (!currentStep) { + currentStep = CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + } - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {throttledDate, isPlaidDisabled}); - goToWithdrawalAccountSetupStep(currentStep, achData); - }) - .finally(() => { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + // 'error' displays any string set as an error encountered during the add Verified BBA flow. + // If we are fetching a bank account, clear the error to reset. + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { + throttledDate, maxAttemptsReached, error: '', isPlaidDisabled, + }); + goToWithdrawalAccountSetupStep(currentStep, achData); + }) + .finally(() => { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + }); }); } @@ -504,6 +531,25 @@ function setFreePlanVerifiedBankAccountID(bankAccountID) { API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: bankAccountID}); } +/** + * @param {Number} bankAccountID + * @param {String} validateCode + */ +function validateBankAccount(bankAccountID, validateCode) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true}); + + API.BankAccount_Validate({bankAccountID, validateCode}) + .then((response) => { + if (response.jsonCode === 200) { + Growl.show('Bank Account successfully validated!', CONST.GROWL.SUCCESS, 3000); + Navigation.dismissModal(); + return; + } + + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, error: response.message}); + }); +} + /** * Create or update the bank account in db with the updated data. * @@ -657,14 +703,15 @@ function setupWithdrawalAccount(data) { } export { - fetchPlaidLinkToken, + activateWallet, addPersonalBankAccount, - getPlaidBankAccounts, clearPlaidBankAccountsAndToken, + fetchFreePlanVerifiedBankAccount, fetchOnfidoToken, - activateWallet, + fetchPlaidLinkToken, fetchUserWallet, - fetchFreePlanVerifiedBankAccount, - setupWithdrawalAccount, + getPlaidBankAccounts, goToWithdrawalAccountSetupStep, + setupWithdrawalAccount, + validateBankAccount, }; diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index d03b2f385177..fda32c64912d 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -44,6 +44,9 @@ const propTypes = { /** Step of the setup flow that we are on. Determines which view is presented. */ currentStep: PropTypes.string, }), + + /** Disable validation button if max attempts exceeded */ + maxAttemptsReached: PropTypes.bool, }), /** Current session for the user */ @@ -111,11 +114,11 @@ class ReimbursementAccountPage extends React.Component { return ; } - let error; + let errorComponent; const userHasPhonePrimaryEmail = Str.endsWith(this.props.session.email, CONST.SMS.DOMAIN); if (userHasPhonePrimaryEmail) { - error = ( + errorComponent = ( {this.props.translate('bankAccount.hasPhoneLoginError')} @@ -126,7 +129,7 @@ class ReimbursementAccountPage extends React.Component { if (throttledDate) { const throttledEnd = moment().add(24, 'hours'); if (moment() < throttledEnd) { - error = ( + errorComponent = ( {this.props.translate('bankAccount.hasBeenThrottledError', { @@ -138,18 +141,21 @@ class ReimbursementAccountPage extends React.Component { } } - if (error) { + if (errorComponent) { return ( - {error} + {errorComponent} ); } + const error = lodashGet(this.props, 'reimbursementAccount.error'); + const maxAttemptsReached = lodashGet(this.props, 'reimbursementAccount.maxAttemptsReached'); + // We grab the currentStep from the achData to determine which view to display. The SetupWithdrawalAccount flow // allows us to continue the flow from various points depending on where the user left off. We can also // specify a specific step to navigate to by using route params. @@ -174,7 +180,11 @@ class ReimbursementAccountPage extends React.Component { )} {currentStep === CONST.BANK_ACCOUNT.STEP.VALIDATION && ( - + )} diff --git a/src/pages/ReimbursementAccount/ValidationStep.js b/src/pages/ReimbursementAccount/ValidationStep.js index 43628ea79677..bfb0a4822d30 100644 --- a/src/pages/ReimbursementAccount/ValidationStep.js +++ b/src/pages/ReimbursementAccount/ValidationStep.js @@ -1,5 +1,181 @@ import React from 'react'; -import {View} from 'react-native'; +import {Image, View} from 'react-native'; +import PropTypes from 'prop-types'; +import Str from 'expensify-common/lib/str'; +import _ from 'underscore'; +import styles from '../../styles/styles'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -const ValidationStep = () => ; -export default ValidationStep; +import {validateBankAccount} from '../../libs/actions/BankAccounts'; + +import Button from '../../components/Button'; +import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; +import Navigation from '../../libs/Navigation/Navigation'; +import TextInputWithLabel from '../../components/TextInputWithLabel'; +import Text from '../../components/Text'; +import BankAccount from '../../libs/models/BankAccount'; +import CONST from '../../CONST'; + +const propTypes = { + ...withLocalizePropTypes, + + /** Additional data for the account in setup */ + achData: PropTypes.shape({ + + /** Bank account ID of the VBA that we are validating is required */ + bankAccountID: PropTypes.number.isRequired, + + /** State of bank account */ + state: PropTypes.string, + }).isRequired, + + /** Error message to display to user */ + error: PropTypes.string, + + /** Disable validation button if max attempts exceeded */ + maxAttemptsReached: PropTypes.bool, +}; + +const defaultProps = { + error: '', + maxAttemptsReached: false, +}; + +class ValidationStep extends React.Component { + constructor(props) { + super(props); + + this.submit = this.submit.bind(this); + + this.verifyingUrl = `${CONST.CLOUDFRONT_URL}/images/icons/emptystates/emptystate_reviewing.gif`; + + this.state = { + amount1: '', + amount2: '', + amount3: '', + error: '', + }; + } + + submit() { + const amount1 = this.filterInput(this.state.amount1); + const amount2 = this.filterInput(this.state.amount2); + const amount3 = this.filterInput(this.state.amount3); + + // If amounts are all non-zeros, submit amounts to API + if (amount1 && amount2 && amount3) { + const validateCode = [amount1, amount2, amount3].join(','); + + // Send valid amounts to BankAccountAPI::validateBankAccount in Web-Expensify + validateBankAccount(this.props.achData.bankAccountID, validateCode); + return; + } + + // If any values are falsey, indicate to user that inputs are invalid + this.setState({error: 'Invalid amounts'}); + } + + /** + * Filter input for validation amount + * Anything that isn't a number is returned as an empty string + * Any dollar amount (e.g. 1.12) will be returned as 112 + * + * @param {String} amount field input + * + * @returns {String} + */ + filterInput(amount) { + let value = amount.trim(); + if (value === '' || !Math.abs(Str.fromUSDToNumber(value)) || _.isNaN(Number(value))) { + return ''; + } + + // If the user enters the values in dollars, convert it to the respective cents amount + if (_.contains(value, '.')) { + value = Str.fromUSDToNumber(value); + } + + return value; + } + + render() { + let errorMessage = this.state.error ? this.state.error : this.props.error; + if (this.props.maxAttemptsReached) { + errorMessage = this.props.translate('validationStep.maxAttemptError'); + } + + const state = this.props.achData.state; + return ( + + + {state === BankAccount.STATE.PENDING && ( + + + + {this.props.translate('validationStep.description')} + + + {this.props.translate('validationStep.descriptionCTA')} + + + + this.setState({amount1})} + /> + this.setState({amount2})} + /> + this.setState({amount3})} + /> + {errorMessage && ( + + {errorMessage} + + )} + +