diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index af3f95c3b45..d19c8c4fe2f 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -159,7 +159,7 @@ class AddPlaidBankAccount extends React.Component { token={this.props.plaidLinkToken} onSuccess={({publicToken, metadata}) => { Log.info('[PlaidLink] Success!'); - BankAccounts.getPlaidBankAccounts(publicToken, metadata.institution.name); + BankAccounts.fetchPlaidBankAccounts(publicToken, metadata.institution.name); this.setState({institution: metadata.institution}); }} onError={(error) => { diff --git a/src/libs/ValidationUtils.js b/src/libs/ValidationUtils.js index df8fa3b5338..c782868a343 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -274,6 +274,28 @@ function isValidLengthForFirstOrLastName(name) { return name.length <= 50; } +/** + * Checks the given number is a valid US Routing Number + * using ABA routingNumber checksum algorithm: http://www.brainjar.com/js/validation/ + * @param {String} number + * @returns {Boolean} + */ +function isValidRoutingNumber(number) { + let n = 0; + for (let i = 0; i < number.length; i += 3) { + n += (parseInt(number.charAt(i), 10) * 3) + + (parseInt(number.charAt(i + 1), 10) * 7) + + parseInt(number.charAt(i + 2), 10); + } + + // If the resulting sum is an even multiple of ten (but not zero), + // the ABA routing number is valid. + if (n !== 0 && n % 10 === 0) { + return true; + } + return false; +} + export { meetsAgeRequirements, isValidAddress, @@ -293,4 +315,5 @@ export { isNumericWithSpecialChars, isValidLengthForFirstOrLastName, isValidPaypalUsername, + isValidRoutingNumber, }; diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index 2c681f1650f..587b33c94ae 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -1,141 +1,37 @@ -import lodashGet from 'lodash/get'; -import lodashHas from 'lodash/has'; -import Str from 'expensify-common/lib/str'; -import Onyx from 'react-native-onyx'; import _ from 'underscore'; import CONST from '../../CONST'; -import ONYXKEYS from '../../ONYXKEYS'; import * as API from '../API'; -import BankAccount from '../models/BankAccount'; -import Growl from '../Growl'; -import * as Localize from '../Localize'; +import * as Plaid from './Plaid'; -/** - * List of bank accounts. This data should not be stored in Onyx since it contains unmasked PANs. - * - * @private - */ -let plaidBankAccounts = []; -let bankName = ''; -let plaidAccessToken = ''; - -/** Reimbursement account actively being set up */ -let reimbursementAccountInSetup = {}; -Onyx.connect({ - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, - callback: (val) => { - reimbursementAccountInSetup = lodashGet(val, 'achData', {}); - }, -}); - -let reimbursementAccountWorkspaceID = null; -Onyx.connect({ - key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID, - callback: (val) => { - reimbursementAccountWorkspaceID = val; - }, -}); - -let credentials; -Onyx.connect({ - key: ONYXKEYS.CREDENTIALS, - callback: (val) => { - credentials = val; - }, -}); - -/** - * Gets the Plaid Link token used to initialize the Plaid SDK - */ -function fetchPlaidLinkToken() { - API.Plaid_GetLinkToken() - .then((response) => { - if (response.jsonCode !== 200) { - return; - } - - Onyx.merge(ONYXKEYS.PLAID_LINK_TOKEN, response.linkToken); - }); -} - -/** - * Navigate to a specific step in the VBA flow - * - * @param {String} stepID - * @param {Object} achData - */ -function goToWithdrawalAccountSetupStep(stepID, achData) { - const newACHData = {...reimbursementAccountInSetup}; - - // If we go back to Requestor Step, reset any validation and previously answered questions from expectID. - if (!newACHData.useOnfido && stepID === CONST.BANK_ACCOUNT.STEP.REQUESTOR) { - delete newACHData.questions; - delete newACHData.answers; - if (lodashHas(newACHData, CONST.BANK_ACCOUNT.VERIFICATIONS.EXTERNAL_API_RESPONSES)) { - delete newACHData.verifications.externalApiResponses.requestorIdentityID; - delete newACHData.verifications.externalApiResponses.requestorIdentityKBA; - } - } - - // When going back to the BankAccountStep from the Company Step, show the manual form instead of Plaid - if (newACHData.currentStep === CONST.BANK_ACCOUNT.STEP.COMPANY && stepID === CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT) { - newACHData.subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; - } - - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newACHData, ...achData, currentStep: stepID}}); -} - -/** - * @param {String} publicToken - * @param {String} bank - */ -function getPlaidBankAccounts(publicToken, bank) { - bankName = bank; - - Onyx.merge(ONYXKEYS.PLAID_BANK_ACCOUNTS, {loading: true}); - API.BankAccount_Get({ - publicToken, - allowDebit: false, - bank, - }) - .then((response) => { - if (response.jsonCode === 666 && response.title === CONST.BANK_ACCOUNT.PLAID.ERROR.TOO_MANY_ATTEMPTS) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isPlaidDisabled: true}); - } - - plaidAccessToken = response.plaidAccessToken; - - // Filter out any accounts that already exist since they cannot be used again. - plaidBankAccounts = _.filter(response.accounts, account => !account.alreadyExists); - - if (plaidBankAccounts.length === 0) { - Growl.error(Localize.translateLocal('bankAccount.error.noBankAccountAvailable')); - } - - Onyx.merge(ONYXKEYS.PLAID_BANK_ACCOUNTS, { - error: { - title: response.title, - message: response.message, - }, - loading: false, - accounts: _.map(plaidBankAccounts, account => ({ - ...account, - accountNumber: Str.maskPAN(account.accountNumber), - })), - bankName, - }); - }); -} - -/** - * We clear these out of storage once we are done with them so the user must re-enter Plaid credentials upon returning. - */ -function clearPlaidBankAccountsAndToken() { - plaidBankAccounts = []; - bankName = ''; - Onyx.set(ONYXKEYS.PLAID_BANK_ACCOUNTS, {}); - Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, null); -} +export { + setupWithdrawalAccount, + fetchFreePlanVerifiedBankAccount, + goToWithdrawalAccountSetupStep, + showBankAccountErrorModal, + showBankAccountFormValidationError, + setBankAccountFormValidationErrors, + resetFreePlanBankAccount, + validateBankAccount, + hideBankAccountErrors, + setWorkspaceIDForReimbursementAccount, + setBankAccountSubStep, + updateReimbursementAccountDraft, + requestResetFreePlanBankAccount, + cancelResetFreePlanBankAccount, +} from './ReimbursementAccount'; +export { + fetchPlaidBankAccounts, + clearPlaidBankAccountsAndToken, + fetchPlaidLinkToken, + getPlaidBankAccounts, + getBankName, + getPlaidAccessToken, +} from './Plaid'; +export { + fetchOnfidoToken, + activateWallet, + fetchUserWallet, +} from './Wallet'; /** * Adds a bank account via Plaid @@ -145,7 +41,7 @@ function clearPlaidBankAccountsAndToken() { * @param {String} plaidLinkToken */ function addPersonalBankAccount(account, password, plaidLinkToken) { - const unmaskedAccount = _.find(plaidBankAccounts, bankAccount => ( + const unmaskedAccount = _.find(Plaid.getPlaidBankAccounts(), bankAccount => ( bankAccount.plaidAccountID === account.plaidAccountID )); API.BankAccount_Create({ @@ -164,14 +60,14 @@ function addPersonalBankAccount(account, password, plaidLinkToken) { isInSetup: true, bankAccountInReview: null, currentStep: 'AccountOwnerInformationStep', - bankName, + bankName: Plaid.getBankName(), plaidAccountID: unmaskedAccount.plaidAccountID, ownershipType: '', acceptTerms: true, country: 'US', currency: CONST.CURRENCY.USD, fieldsType: 'local', - plaidAccessToken, + plaidAccessToken: Plaid.getPlaidAccessToken(), }), }) .then((response) => { @@ -184,749 +80,6 @@ function addPersonalBankAccount(account, password, plaidLinkToken) { }); } -/** - * Fetch and save locally the Onfido SDK token and applicantID - * - The sdkToken is used to initialize the Onfido SDK client - * - The applicantID is combined with the data returned from the Onfido SDK as we need both to create an - * identity check. Note: This happens in Web-Secure when we call Activate_Wallet during the OnfidoStep. - */ -function fetchOnfidoToken() { - // Use Onyx.set() since we are resetting the Onfido flow completely. - Onyx.set(ONYXKEYS.WALLET_ONFIDO, {loading: true}); - API.Wallet_GetOnfidoSDKToken() - .then((response) => { - const apiResult = lodashGet(response, ['requestorIdentityOnfido', 'apiResult'], {}); - Onyx.merge(ONYXKEYS.WALLET_ONFIDO, { - applicantID: apiResult.applicantID, - sdkToken: apiResult.sdkToken, - loading: false, - hasAcceptedPrivacyPolicy: true, - }); - }) - .catch(() => Onyx.set(ONYXKEYS.WALLET_ONFIDO, {loading: false, error: CONST.WALLET.ERROR.UNEXPECTED})); -} - -/** - * Privately used to update the additionalDetails object in Onyx (which will have various effects on the UI) - * - * @param {Boolean} loading whether we are making the API call to validate the user's provided personal details - * @param {String[]} [errorFields] an array of field names that should display errors in the UI - * @param {String} [additionalErrorMessage] an additional error message to display in the UI - * @private - */ -function setAdditionalDetailsStep(loading, errorFields = null, additionalErrorMessage = '') { - Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {loading, errorFields, additionalErrorMessage}); -} - -/** - * This action can be called repeatedly with different steps until an Expensify Wallet has been activated. - * - * Possible steps: - * - * - OnfidoStep - Creates an identity check by calling Onfido's API (via Web-Secure) with data returned from the SDK - * - AdditionalDetailsStep - Validates a user's provided details against a series of checks - * - TermsStep - Ensures that a user has agreed to all of the terms and conditions - * - * The API will always return the updated userWallet in the response as a convenience so we can avoid calling - * Get&returnValueList=userWallet after we call Wallet_Activate. - * - * @param {String} currentStep - * @param {Object} parameters - * @param {String} [parameters.onfidoData] - JSON string - * @param {Object} [parameters.personalDetails] - JSON string - * @param {Boolean} [parameters.hasAcceptedTerms] - */ -function activateWallet(currentStep, parameters) { - let personalDetails; - let onfidoData; - let hasAcceptedTerms; - - if (!_.contains(CONST.WALLET.STEP, currentStep)) { - throw new Error('Invalid currentStep passed to activateWallet()'); - } - - if (currentStep === CONST.WALLET.STEP.ONFIDO) { - onfidoData = parameters.onfidoData; - Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); - } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - setAdditionalDetailsStep(true); - - // Personal details are heavily validated on the API side. We will only do a quick check to ensure the values - // exist in some capacity and then stringify them. - const errorFields = _.reduce(CONST.WALLET.REQUIRED_ADDITIONAL_DETAILS_FIELDS, (missingFields, fieldName) => ( - !personalDetails[fieldName] ? [...missingFields, fieldName] : missingFields - ), []); - - if (!_.isEmpty(errorFields)) { - setAdditionalDetailsStep(false, errorFields); - return; - } - - personalDetails = JSON.stringify(parameters.personalDetails); - } else if (currentStep === CONST.WALLET.STEP.TERMS) { - hasAcceptedTerms = parameters.hasAcceptedTerms; - Onyx.merge(ONYXKEYS.WALLET_TERMS, {loading: true}); - } - - API.Wallet_Activate({ - currentStep, - personalDetails, - onfidoData, - hasAcceptedTerms, - }) - .then((response) => { - if (response.jsonCode !== 200) { - if (currentStep === CONST.WALLET.STEP.ONFIDO) { - Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: response.message, loading: false}); - return; - } - - if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - if (response.title === CONST.WALLET.ERROR.MISSING_FIELD) { - setAdditionalDetailsStep(false, response.data.fieldNames); - return; - } - - const errorTitles = [ - CONST.WALLET.ERROR.IDENTITY_NOT_FOUND, - CONST.WALLET.ERROR.INVALID_SSN, - CONST.WALLET.ERROR.UNEXPECTED, - CONST.WALLET.ERROR.UNABLE_TO_VERIFY, - ]; - - if (_.contains(errorTitles, response.title)) { - setAdditionalDetailsStep(false, null, response.message); - return; - } - - setAdditionalDetailsStep(false); - return; - } - - return; - } - - Onyx.merge(ONYXKEYS.USER_WALLET, response.userWallet); - - if (currentStep === CONST.WALLET.STEP.ONFIDO) { - Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); - } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - setAdditionalDetailsStep(false); - } else if (currentStep === CONST.WALLET.STEP.TERMS) { - Onyx.merge(ONYXKEYS.WALLET_TERMS, {loading: false}); - } - }); -} - -/** - * Fetches information about a user's Expensify Wallet - * - * @typedef {Object} UserWallet - * @property {Number} availableBalance - * @property {Number} currentBalance - * @property {String} currentStep - used to track which step of the "activate wallet" flow a user is in - * @property {('SILVER'|'GOLD')} tierName - will be GOLD when fully activated. SILVER is able to recieve funds only. - */ -function fetchUserWallet() { - API.Get({returnValueList: 'userWallet'}) - .then((response) => { - if (response.jsonCode !== 200) { - return; - } - - Onyx.merge(ONYXKEYS.USER_WALLET, response.userWallet); - }); -} - -/** - * Fetch the bank account currently being set up by the user for the free plan if it exists. - * - * @param {String} [stepToOpen] - * @param {String} [localBankAccountState] - */ -function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { - // Remember which account BankAccountStep subStep the user had before so we can set it later - const subStep = lodashGet(reimbursementAccountInSetup, 'subStep', ''); - const initialData = {loading: true, error: ''}; - - // Some UI needs to know the bank account state during the loading process, so we are keeping it in Onyx if passed - if (localBankAccountState) { - initialData.achData = {state: localBankAccountState}; - } - - // 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, initialData); - 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 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 - API.Get({ - returnValueList: 'nameValuePairs, bankAccountList', - nvpNames: [ - failedValidationAttemptsName, - CONST.NVP.KYC_MIGRATION, - CONST.NVP.ACH_DATA_THROTTLED, - CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, - ].join(), - }) - .then(({bankAccountList, nameValuePairs}) => { - // 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(nameValuePairs, failedValidationAttemptsName, 0); - const maxAttemptsReached = failedValidationAttempts > CONST.BANK_ACCOUNT.VERIFICATION_MAX_ATTEMPTS; - - const kycVerificationsMigration = lodashGet(nameValuePairs, CONST.NVP.KYC_MIGRATION, ''); - const throttledDate = lodashGet(nameValuePairs, CONST.NVP.ACH_DATA_THROTTLED, ''); - const bankAccountJSON = _.find(bankAccountList, account => ( - account.bankAccountID === bankAccountID - )); - const bankAccount = bankAccountJSON ? new BankAccount(bankAccountJSON) : null; - const throttledHistoryCount = lodashGet(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() : {}; - if (!stepToOpen && achData.currentStep) { - // eslint-disable-next-line no-use-before-define - currentStep = getNextStepToComplete(achData); - } - - achData.useOnfido = true; - achData.policyID = reimbursementAccountWorkspaceID || ''; - 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. Otherwise, we will - // make the subStep the previous value. - if (bankAccount && bankAccount.isInSetup()) { - achData.subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; - } else { - achData.subStep = subStep; - } - - // 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; - } - } - - // 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 New Expensify 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 { - // 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 we are providing a stepToOpen via a deep link then we will always navigate to that step. This - // should be used with caution as it is possible to drop a user into a flow they can't complete e.g. - // if we drop the user into the CompanyStep, but they have no accountNumber or routing Number in - // their achData. - if (stepToOpen) { - currentStep = stepToOpen; - } - - // '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}); - }); - }); -} - -const WITHDRAWAL_ACCOUNT_STEPS = [ - { - id: CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, - title: 'Bank Account', - }, - { - id: CONST.BANK_ACCOUNT.STEP.COMPANY, - title: 'Company Information', - }, - { - id: CONST.BANK_ACCOUNT.STEP.REQUESTOR, - title: 'Requestor Information', - }, - { - id: CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT, - title: 'Beneficial Owners', - }, - { - id: CONST.BANK_ACCOUNT.STEP.VALIDATION, - title: 'Validate', - }, - { - id: CONST.BANK_ACCOUNT.STEP.ENABLE, - title: 'Enable', - }, -]; - -/** - * Get step position in the array - * @private - * @param {String} stepID - * @return {Number} - */ -function getIndexByStepID(stepID) { - return _.findIndex(WITHDRAWAL_ACCOUNT_STEPS, step => step.id === stepID); -} - -/** - * Get next step ID - * @param {String} [stepID] - * @return {String} - */ -function getNextStepID(stepID) { - const nextStepIndex = Math.min( - getIndexByStepID(stepID || reimbursementAccountInSetup.currentStep) + 1, - WITHDRAWAL_ACCOUNT_STEPS.length - 1, - ); - return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); -} - -/** - * @param {Object} achData - * @returns {String} - */ -function getNextStepToComplete(achData) { - if (achData.currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR && !achData.isOnfidoSetupComplete) { - return CONST.BANK_ACCOUNT.STEP.REQUESTOR; - } - - return getNextStepID(achData.currentStep); -} - -/** - * @private - * @param {Number} bankAccountID - */ -function setFreePlanVerifiedBankAccountID(bankAccountID) { - API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: bankAccountID}); -} - -/** - * Show error modal and optionally a specific error message - * - * @param {String} errorModalMessage The error message to be displayed in the modal's body. - * @param {Boolean} isErrorModalMessageHtml if @errorModalMessage is in html format or not - */ -function showBankAccountErrorModal(errorModalMessage = null, isErrorModalMessageHtml = false) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errorModalMessage, isErrorModalMessageHtml}); -} - -/** - * @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) { - Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, null); - API.User_IsUsingExpensifyCard() - .then(({isUsingExpensifyCard}) => { - const reimbursementAccount = { - loading: false, - error: '', - achData: {state: BankAccount.STATE.OPEN}, - }; - - reimbursementAccount.achData.currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE; - Onyx.merge(ONYXKEYS.USER, {isUsingExpensifyCard}); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, reimbursementAccount); - }); - return; - } - - // User has input the validate code incorrectly many times so we will return early in this case and not let them enter the amounts again. - if (response.message === CONST.BANK_ACCOUNT.ERROR.MAX_VALIDATION_ATTEMPTS_REACHED) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, maxAttemptsReached: true}); - return; - } - - // If the validation amounts entered were incorrect, show specific error - if (response.message === CONST.BANK_ACCOUNT.ERROR.INCORRECT_VALIDATION_AMOUNTS) { - showBankAccountErrorModal(Localize.translateLocal('bankAccount.error.validationAmounts')); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); - return; - } - - // We are generically showing any other backend errors that might pop up in the validate step - showBankAccountErrorModal(response.message); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); - }); -} - -/** - * Set the current fields with errors. - * - * @param {String} errors - */ -function setBankAccountFormValidationErrors(errors) { - // We set 'errors' to null first because we don't have a way yet to replace a specific property like 'errors' without merging it - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errors: null}); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errors}); -} - -/** - * Set the current error message. - * - * @param {String} error - */ -function showBankAccountFormValidationError(error) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error}); -} - -/** - * Set the current sub step in first step of adding withdrawal bank account - * - * @param {String} subStep - One of {CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL, CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID, null} - */ -function setBankAccountSubStep(subStep) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {subStep}}); -} - -/** - * Create or update the bank account in db with the updated data. - * - * @param {Object} [data] - */ -function setupWithdrawalAccount(data) { - let nextStep; - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true, errorModalMessage: '', errors: null}); - - const newACHData = { - ...reimbursementAccountInSetup, - ...data, - - // This param tells Web-Secure that this bank account is from NewDot so we can modify links back to the correct - // app in any communications. It also will be used to provision a customer for the Expensify card automatically - // once their bank account is successfully validated. - enableCardAfterVerified: true, - }; - - if (data && !_.isUndefined(data.isSavings)) { - newACHData.isSavings = Boolean(data.isSavings); - } - if (!newACHData.setupType) { - newACHData.setupType = newACHData.plaidAccountID - ? CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID - : CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; - } - - nextStep = newACHData.currentStep; - - // If we are setting up a Plaid account replace the accountNumber with the unmasked number - if (data.plaidAccountID) { - const unmaskedAccount = _.find(plaidBankAccounts, bankAccount => ( - bankAccount.plaidAccountID === data.plaidAccountID - )); - newACHData.accountNumber = unmaskedAccount.accountNumber; - } - - API.BankAccount_SetupWithdrawal(newACHData) - .then((response) => { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newACHData}}); - const currentStep = newACHData.currentStep; - let achData = lodashGet(response, 'achData', {}); - let error = lodashGet(achData, CONST.BANK_ACCOUNT.VERIFICATIONS.ERROR_MESSAGE); - let isErrorHTML = false; - const errors = {}; - - if (response.jsonCode === 200 && !error) { - // Save an NVP with the bankAccountID for this account. This is temporary since we are not showing lists - // of accounts yet and must have some kind of record of which account is the one the user is trying to - // set up for the free plan. - if (achData.bankAccountID) { - setFreePlanVerifiedBankAccountID(achData.bankAccountID); - } - - if (currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR) { - const requestorResponse = lodashGet( - achData, - CONST.BANK_ACCOUNT.VERIFICATIONS.REQUESTOR_IDENTITY_ID, - ); - if (newACHData.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 && !newACHData.isOnfidoSetupComplete - && onfidoResponse.status !== CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.PASS - ) { - // Requestor Step still needs to run Onfido - achData.sdkToken = sdkToken; - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, achData); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); - return; - } - } else if (requestorResponse) { - // Don't go to next step if Requestor Step needs to ask some questions - let questions = lodashGet(requestorResponse, CONST.BANK_ACCOUNT.QUESTIONS.QUESTION) || []; - if (_.isEmpty(questions)) { - const differentiatorQuestion = lodashGet( - requestorResponse, - CONST.BANK_ACCOUNT.QUESTIONS.DIFFERENTIATOR_QUESTION, - ); - if (differentiatorQuestion) { - questions = [differentiatorQuestion]; - } - } - if (!_.isEmpty(questions)) { - achData.questions = questions; - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, achData); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); - return; - } - } - } - - if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT) { - // Get an up-to-date bank account list so that we can allow the user to validate their newly - // generated bank account - API.Get({returnValueList: 'bankAccountList'}) - .then((bankAccountListResponse) => { - const bankAccountJSON = _.findWhere(bankAccountListResponse.bankAccountList, { - bankAccountID: newACHData.bankAccountID, - }); - const bankAccount = new BankAccount(bankAccountJSON); - achData = bankAccount.toACHData(); - const needsToPassLatestChecks = achData.state === BankAccount.STATE.OPEN - && achData.needsToPassLatestChecks; - achData.bankAccountInReview = needsToPassLatestChecks - || achData.state === BankAccount.STATE.VERIFYING; - - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); - }); - return; - } - - if ((currentStep === CONST.BANK_ACCOUNT.STEP.VALIDATION && newACHData.bankAccountInReview) - || currentStep === CONST.BANK_ACCOUNT.STEP.ENABLE - ) { - // Setup done! - } else { - nextStep = getNextStepID(); - } - } else { - if (response.jsonCode === 666 || response.jsonCode === 404) { - // Since these specific responses can have an error message in html format with richer content, give priority to the html error. - error = response.htmlMessage || response.message; - isErrorHTML = Boolean(response.htmlMessage); - } - - if (response.jsonCode === 402) { - if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_ROUTING_NUMBER - || response.message === CONST.BANK_ACCOUNT.ERROR.MAX_ROUTING_NUMBER - ) { - errors.routingNumber = true; - achData.subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; - } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_STATE) { - error = Localize.translateLocal('bankAccount.error.incorporationState'); - } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { - error = Localize.translateLocal('bankAccount.error.companyType'); - } else { - console.error(response.message); - } - } - } - - // Go to next step - goToWithdrawalAccountSetupStep(nextStep, achData); - - if (_.size(errors)) { - setBankAccountFormValidationErrors(errors); - showBankAccountErrorModal(); - } - if (error) { - showBankAccountFormValidationError(error); - showBankAccountErrorModal(error, isErrorHTML); - } - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); - }) - .catch((response) => { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...newACHData}}); - console.error(response.stack); - showBankAccountErrorModal(Localize.translateLocal('common.genericErrorMessage')); - }); -} - -function hideBankAccountErrors() { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error: '', errors: null}); -} - -function setWorkspaceIDForReimbursementAccount(workspaceID) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID, workspaceID); -} - -/** - * @param {Object} bankAccountData - */ -function updateReimbursementAccountDraft(bankAccountData) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, bankAccountData); -} - -/** - * Triggers a modal to open allowing the user to reset their bank account - */ -function requestResetFreePlanBankAccount() { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {shouldShowResetModal: true}); -} - -/** - * Hides modal allowing the user to reset their bank account - */ -function cancelResetFreePlanBankAccount() { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {shouldShowResetModal: false}); -} - -/** - * Reset user's reimbursement account. This will delete the bank account. - */ -function resetFreePlanBankAccount() { - const bankAccountID = lodashGet(reimbursementAccountInSetup, 'bankAccountID'); - if (!bankAccountID) { - throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); - } - if (!credentials || !credentials.login) { - throw new Error('Missing credentials when attempting to reset free plan bank account'); - } - - // Create a copy of the reimbursementAccount data since we are going to optimistically wipe it so the UI changes quickly. - // If the API request fails we will set this data back into Onyx. - const previousACHData = {...reimbursementAccountInSetup}; - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: null, shouldShowResetModal: false}); - API.DeleteBankAccount({bankAccountID, ownerEmail: credentials.login}) - .then((response) => { - if (response.jsonCode !== 200) { - // Unable to delete bank account so we restore the bank account details - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: previousACHData}); - Growl.error('Sorry we were unable to delete this bank account. Please try again later'); - return; - } - - // Reset reimbursement account, and clear draft user input, and the bank account list - const achData = { - useOnfido: true, - policyID: '', - isInSetup: true, - domainLimit: 0, - currentStep: CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, - }; - Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData}); - Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, null); - Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, []); - - // Clear the NVP for the bank account so the user can add a new one - API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: ''}); - }); -} - -/* - * Checks the given number is a valid US Routing Number - * using ABA routingNumber checksum algorithm: http://www.brainjar.com/js/validation/ - * @param {String} number - * @returns {Boolean} - */ -function validateRoutingNumber(number) { - let n = 0; - for (let i = 0; i < number.length; i += 3) { - n += (parseInt(number.charAt(i), 10) * 3) - + (parseInt(number.charAt(i + 1), 10) * 7) - + parseInt(number.charAt(i + 2), 10); - } - - // If the resulting sum is an even multiple of ten (but not zero), - // the ABA routing number is valid. - if (n !== 0 && n % 10 === 0) { - return true; - } - return false; -} - export { - activateWallet, addPersonalBankAccount, - clearPlaidBankAccountsAndToken, - fetchFreePlanVerifiedBankAccount, - fetchOnfidoToken, - fetchPlaidLinkToken, - fetchUserWallet, - getPlaidBankAccounts, - goToWithdrawalAccountSetupStep, - setupWithdrawalAccount, - validateBankAccount, - hideBankAccountErrors, - showBankAccountErrorModal, - showBankAccountFormValidationError, - setBankAccountFormValidationErrors, - setWorkspaceIDForReimbursementAccount, - setBankAccountSubStep, - updateReimbursementAccountDraft, - requestResetFreePlanBankAccount, - cancelResetFreePlanBankAccount, - resetFreePlanBankAccount, - validateRoutingNumber, }; diff --git a/src/libs/actions/Plaid.js b/src/libs/actions/Plaid.js new file mode 100644 index 00000000000..cd8e7cb6ef6 --- /dev/null +++ b/src/libs/actions/Plaid.js @@ -0,0 +1,113 @@ +import Onyx from 'react-native-onyx'; +import Str from 'expensify-common/lib/str'; +import _ from 'underscore'; +import ONYXKEYS from '../../ONYXKEYS'; +import CONST from '../../CONST'; +import * as API from '../API'; +import Growl from '../Growl'; +import * as Localize from '../Localize'; + +/** + * List of bank accounts. This data should not be stored in Onyx since it contains unmasked PANs. + * + * @private + */ +let plaidBankAccounts = []; +let bankName = ''; +let plaidAccessToken = ''; + +/** + * We clear these out of storage once we are done with them so the user must re-enter Plaid credentials upon returning. + */ +function clearPlaidBankAccountsAndToken() { + plaidBankAccounts = []; + bankName = ''; + Onyx.set(ONYXKEYS.PLAID_BANK_ACCOUNTS, {}); + Onyx.set(ONYXKEYS.PLAID_LINK_TOKEN, null); +} + +/** + * Gets the Plaid Link token used to initialize the Plaid SDK + */ +function fetchPlaidLinkToken() { + API.Plaid_GetLinkToken() + .then((response) => { + if (response.jsonCode !== 200) { + return; + } + + Onyx.merge(ONYXKEYS.PLAID_LINK_TOKEN, response.linkToken); + }); +} + +/** + * @param {String} publicToken + * @param {String} bank + */ +function fetchPlaidBankAccounts(publicToken, bank) { + bankName = bank; + + Onyx.merge(ONYXKEYS.PLAID_BANK_ACCOUNTS, {loading: true}); + API.BankAccount_Get({ + publicToken, + allowDebit: false, + bank, + }) + .then((response) => { + if (response.jsonCode === 666 && response.title === CONST.BANK_ACCOUNT.PLAID.ERROR.TOO_MANY_ATTEMPTS) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {isPlaidDisabled: true}); + } + + plaidAccessToken = response.plaidAccessToken; + + // Filter out any accounts that already exist since they cannot be used again. + plaidBankAccounts = _.filter(response.accounts, account => !account.alreadyExists); + + if (plaidBankAccounts.length === 0) { + Growl.error(Localize.translateLocal('bankAccount.error.noBankAccountAvailable')); + } + + Onyx.merge(ONYXKEYS.PLAID_BANK_ACCOUNTS, { + error: { + title: response.title, + message: response.message, + }, + loading: false, + accounts: _.map(plaidBankAccounts, account => ({ + ...account, + accountNumber: Str.maskPAN(account.accountNumber), + })), + bankName, + }); + }); +} + +/** + * @returns {String} + */ +function getPlaidAccessToken() { + return plaidAccessToken; +} + +/** + * @returns {Array} + */ +function getPlaidBankAccounts() { + return plaidBankAccounts; +} + +/** + * @returns {String} + */ +function getBankName() { + return bankName; +} + +export { + clearPlaidBankAccountsAndToken, + fetchPlaidBankAccounts, + fetchPlaidLinkToken, + getPlaidAccessToken, + getPlaidBankAccounts, + getBankName, +}; diff --git a/src/libs/actions/ReimbursementAccount/errors.js b/src/libs/actions/ReimbursementAccount/errors.js new file mode 100644 index 00000000000..ffec84f8cc0 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/errors.js @@ -0,0 +1,38 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; + +/** + * Show error modal and optionally a specific error message + * + * @param {String} errorModalMessage The error message to be displayed in the modal's body. + * @param {Boolean} isErrorModalMessageHtml if @errorModalMessage is in html format or not + */ +function showBankAccountErrorModal(errorModalMessage = null, isErrorModalMessageHtml = false) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errorModalMessage, isErrorModalMessageHtml}); +} + +/** + * Set the current fields with errors. + * + * @param {String} errors + */ +function setBankAccountFormValidationErrors(errors) { + // We set 'errors' to null first because we don't have a way yet to replace a specific property like 'errors' without merging it + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errors: null}); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {errors}); +} + +/** + * Set the current error message. + * + * @param {String} error + */ +function showBankAccountFormValidationError(error) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error}); +} + +export { + showBankAccountErrorModal, + setBankAccountFormValidationErrors, + showBankAccountFormValidationError, +}; diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js new file mode 100644 index 00000000000..6fcbb370301 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -0,0 +1,219 @@ +import Onyx from 'react-native-onyx'; +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import * as API from '../../API'; +import CONST from '../../../CONST'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as navigation from './navigation'; +import * as store from './store'; +import BankAccount from '../../models/BankAccount'; + +/** + * @param {Object} localBankAccountState + * @returns {Object} + */ +function getInitialData(localBankAccountState) { + const initialData = {loading: true, error: ''}; + + // Some UI needs to know the bank account state during the loading process, so we are keeping it in Onyx if passed + if (localBankAccountState) { + initialData.achData = {state: localBankAccountState}; + } + + return initialData; +} + +/** + * @returns {Promise} + */ +function fetchNameValuePairsAndBankAccount() { + let bankAccountID; + let failedValidationAttemptsName; + + return 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, + ], ''); + 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 + return API.Get({ + returnValueList: 'nameValuePairs, bankAccountList', + nvpNames: [ + failedValidationAttemptsName, + CONST.NVP.KYC_MIGRATION, + CONST.NVP.ACH_DATA_THROTTLED, + CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, + ].join(), + }); + }) + .then(({bankAccountList, nameValuePairs}) => { + // 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(nameValuePairs, failedValidationAttemptsName, 0); + const maxAttemptsReached = failedValidationAttempts > CONST.BANK_ACCOUNT.VERIFICATION_MAX_ATTEMPTS; + + const kycVerificationsMigration = lodashGet(nameValuePairs, CONST.NVP.KYC_MIGRATION, ''); + const throttledDate = lodashGet(nameValuePairs, CONST.NVP.ACH_DATA_THROTTLED, ''); + const bankAccountJSON = _.find(bankAccountList, account => ( + account.bankAccountID === bankAccountID + )); + const bankAccount = bankAccountJSON ? new BankAccount(bankAccountJSON) : null; + const throttledHistoryCount = lodashGet(nameValuePairs, CONST.NVP.BANK_ACCOUNT_GET_THROTTLED, 0); + const isPlaidDisabled = throttledHistoryCount > CONST.BANK_ACCOUNT.PLAID.ALLOWED_THROTTLED_COUNT; + + return { + maxAttemptsReached, + kycVerificationsMigration, + throttledDate, + bankAccount, + isPlaidDisabled, + bankAccountID, + }; + }) + .finally(() => { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + }); +} + +/** + * @param {BankAccount} bankAccount + * @param {String} kycVerificationsMigration + * @returns {Boolean} + */ +function getHasTriedToUpgrade(bankAccount, kycVerificationsMigration) { + if (!bankAccount) { + return false; + } + + return bankAccount.getDateSigned() > (kycVerificationsMigration || '2020-01-13'); +} + +/** + * @param {String} stepToOpen + * @param {String} stepFromStorage + * @param {Object} achData + * @param {BankAccount} bankAccount + * @param {Boolean} hasTriedToUpgrade + * @returns {String} + */ +function getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade) { + // If we are providing a stepToOpen via a deep link then we will always navigate to that step. This + // should be used with caution as it is possible to drop a user into a flow they can't complete e.g. + // if we drop the user into the CompanyStep, but they have no accountNumber or routing Number in + // their achData. + if (stepToOpen) { + return stepToOpen; + } + + // To determine if there's any step we can go to we will look at the data from the server first then whatever is in device storage. + const currentStep = achData.currentStep + ? navigation.getNextStepToComplete(achData) + : store.getReimbursementAccountInSetup().currentStep; + + if (achData.isInSetup) { + return currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + } + + // If we don't have a bank account then take the user to the BankAccountStep so they can create one. + if (!bankAccount) { + return CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + } + + if (bankAccount.isPending() || bankAccount.isVerifying()) { + return CONST.BANK_ACCOUNT.STEP.VALIDATION; + } + + // No clear place to direct this user so we'll go with the bank account step + if (!bankAccount.isOpen()) { + return CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + } + + if (bankAccount.needsToPassLatestChecks()) { + return hasTriedToUpgrade ? CONST.BANK_ACCOUNT.STEP.VALIDATION : CONST.BANK_ACCOUNT.STEP.COMPANY; + } + + return CONST.BANK_ACCOUNT.STEP.ENABLE; +} + +/** + * @param {BankAccount} bankAccount + * @param {Boolean} hasTriedToUpgrade + * @returns {Boolean} + */ +function getIsBankAccountInReview(bankAccount, hasTriedToUpgrade) { + if (!bankAccount) { + return false; + } + + if (bankAccount.isVerifying()) { + return true; + } + + if (bankAccount.isOpen() && bankAccount.needsToPassLatestChecks()) { + return hasTriedToUpgrade; + } + + return false; +} + +/** + * @param {BankAccount} bankAccount + * @param {Boolean} hasTriedToUpgrade + * @param {String} subStep + * @returns {Object} + */ +function buildACHData(bankAccount, hasTriedToUpgrade, subStep) { + return { + ...(bankAccount ? bankAccount.toACHData() : {}), + useOnfido: true, + policyID: store.getReimbursementAccountWorkspaceID() || '', + isInSetup: !bankAccount || bankAccount.isInSetup(), + bankAccountInReview: getIsBankAccountInReview(bankAccount, hasTriedToUpgrade), + domainLimit: 0, + subStep: bankAccount && bankAccount.isInSetup() + ? CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL + : subStep, + }; +} + +/** + * Fetch the bank account currently being set up by the user for the free plan if it exists. + * + * @param {String} [stepToOpen] + * @param {String} [localBankAccountState] + */ +function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { + const initialData = getInitialData(localBankAccountState); + + // 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, initialData); + + // Fetch the various NVPs we need to show any initial errors and the bank account itself + fetchNameValuePairsAndBankAccount() + .then(({ + bankAccount, kycVerificationsMigration, throttledDate, maxAttemptsReached, isPlaidDisabled, + }) => { + // If we already have a substep stored locally then we will add that to the new achData + const subStep = lodashGet(store.getReimbursementAccountInSetup(), 'subStep', ''); + const hasTriedToUpgrade = getHasTriedToUpgrade(bankAccount, kycVerificationsMigration); + const achData = buildACHData(bankAccount, hasTriedToUpgrade, subStep); + const stepFromStorage = store.getReimbursementAccountInSetup().currentStep; + const currentStep = getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade); + + navigation.goToWithdrawalAccountSetupStep(currentStep, achData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { + throttledDate, + maxAttemptsReached, + error: '', + isPlaidDisabled, + }); + }); +} + +export default fetchFreePlanVerifiedBankAccount; +export {getCurrentStep, buildACHData}; diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js new file mode 100644 index 00000000000..8587ca38f3c --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -0,0 +1,61 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; +import validateBankAccount from './validateBankAccount'; +import setupWithdrawalAccount from './setupWithdrawalAccount'; +import fetchFreePlanVerifiedBankAccount from './fetchFreePlanVerifiedBankAccount'; +import resetFreePlanBankAccount from './resetFreePlanBankAccount'; + + +export {goToWithdrawalAccountSetupStep} from './navigation'; +export {showBankAccountErrorModal, setBankAccountFormValidationErrors, showBankAccountFormValidationError} from './errors'; + +/** + * Set the current sub step in first step of adding withdrawal bank account + * + * @param {String} subStep - One of {CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL, CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID, null} + */ +function setBankAccountSubStep(subStep) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {subStep}}); +} + +function hideBankAccountErrors() { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error: '', errors: null}); +} + +function setWorkspaceIDForReimbursementAccount(workspaceID) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID, workspaceID); +} + +/** + * @param {Object} bankAccountData + */ +function updateReimbursementAccountDraft(bankAccountData) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, bankAccountData); +} + +/** + * Triggers a modal to open allowing the user to reset their bank account + */ +function requestResetFreePlanBankAccount() { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {shouldShowResetModal: true}); +} + +/** + * Hides modal allowing the user to reset their bank account + */ +function cancelResetFreePlanBankAccount() { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {shouldShowResetModal: false}); +} + +export { + setupWithdrawalAccount, + fetchFreePlanVerifiedBankAccount, + resetFreePlanBankAccount, + validateBankAccount, + setBankAccountSubStep, + hideBankAccountErrors, + setWorkspaceIDForReimbursementAccount, + updateReimbursementAccountDraft, + requestResetFreePlanBankAccount, + cancelResetFreePlanBankAccount, +}; diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js new file mode 100644 index 00000000000..a4638f001a3 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/navigation.js @@ -0,0 +1,102 @@ +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import lodashHas from 'lodash/has'; +import Onyx from 'react-native-onyx'; +import * as store from './store'; +import CONST from '../../../CONST'; +import ONYXKEYS from '../../../ONYXKEYS'; + +const WITHDRAWAL_ACCOUNT_STEPS = [ + { + id: CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, + title: 'Bank Account', + }, + { + id: CONST.BANK_ACCOUNT.STEP.COMPANY, + title: 'Company Information', + }, + { + id: CONST.BANK_ACCOUNT.STEP.REQUESTOR, + title: 'Requestor Information', + }, + { + id: CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT, + title: 'Beneficial Owners', + }, + { + id: CONST.BANK_ACCOUNT.STEP.VALIDATION, + title: 'Validate', + }, + { + id: CONST.BANK_ACCOUNT.STEP.ENABLE, + title: 'Enable', + }, +]; + +/** + * Get step position in the array + * @private + * @param {String} stepID + * @return {Number} + */ +function getIndexByStepID(stepID) { + return _.findIndex(WITHDRAWAL_ACCOUNT_STEPS, step => step.id === stepID); +} + +/** + * Get next step ID + * @param {String} [stepID] + * @return {String} + */ +function getNextStepID(stepID) { + const nextStepIndex = Math.min( + getIndexByStepID(stepID || store.getReimbursementAccountInSetup().currentStep) + 1, + WITHDRAWAL_ACCOUNT_STEPS.length - 1, + ); + return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); +} + +/** + * @param {Object} achData + * @returns {String} + */ +function getNextStepToComplete(achData) { + if (achData.currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR && !achData.isOnfidoSetupComplete) { + return CONST.BANK_ACCOUNT.STEP.REQUESTOR; + } + + return getNextStepID(achData.currentStep); +} + +/** + * Navigate to a specific step in the VBA flow + * + * @param {String} stepID + * @param {Object} achData + */ +function goToWithdrawalAccountSetupStep(stepID, achData) { + const newACHData = {...store.getReimbursementAccountInSetup()}; + + // If we go back to Requestor Step, reset any validation and previously answered questions from expectID. + if (!newACHData.useOnfido && stepID === CONST.BANK_ACCOUNT.STEP.REQUESTOR) { + delete newACHData.questions; + delete newACHData.answers; + if (lodashHas(newACHData, CONST.BANK_ACCOUNT.VERIFICATIONS.EXTERNAL_API_RESPONSES)) { + delete newACHData.verifications.externalApiResponses.requestorIdentityID; + delete newACHData.verifications.externalApiResponses.requestorIdentityKBA; + } + } + + // When going back to the BankAccountStep from the Company Step, show the manual form instead of Plaid + if (newACHData.currentStep === CONST.BANK_ACCOUNT.STEP.COMPANY && stepID === CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT) { + newACHData.subStep = CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; + } + + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newACHData, ...achData, currentStep: stepID}}); +} + +export { + goToWithdrawalAccountSetupStep, + getNextStepToComplete, + getNextStepID, +}; diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js new file mode 100644 index 00000000000..2993fac460c --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -0,0 +1,51 @@ +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as API from '../../API'; +import CONST from '../../../CONST'; +import * as store from './store'; +import Growl from '../../Growl'; + +/** + * Reset user's reimbursement account. This will delete the bank account. + */ +function resetFreePlanBankAccount() { + const bankAccountID = lodashGet(store.getReimbursementAccountInSetup(), 'bankAccountID'); + if (!bankAccountID) { + throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); + } + if (!store.getCredentials() || !store.getCredentials().login) { + throw new Error('Missing credentials when attempting to reset free plan bank account'); + } + + // Create a copy of the reimbursementAccount data since we are going to optimistically wipe it so the UI changes quickly. + // If the API request fails we will set this data back into Onyx. + const previousACHData = {...store.getReimbursementAccountInSetup()}; + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: null, shouldShowResetModal: false}); + API.DeleteBankAccount({bankAccountID, ownerEmail: store.getCredentials().login}) + .then((response) => { + if (response.jsonCode !== 200) { + // Unable to delete bank account so we restore the bank account details + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: previousACHData}); + Growl.error('Sorry we were unable to delete this bank account. Please try again later'); + return; + } + + // Reset reimbursement account, and clear draft user input, and the bank account list + const achData = { + useOnfido: true, + policyID: '', + isInSetup: true, + domainLimit: 0, + currentStep: CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, + }; + Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData}); + Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, null); + Onyx.set(ONYXKEYS.BANK_ACCOUNT_LIST, []); + + // Clear the NVP for the bank account so the user can add a new one + API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: ''}); + }); +} + +export default resetFreePlanBankAccount; diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js new file mode 100644 index 00000000000..e1b5cd0ab08 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -0,0 +1,330 @@ +import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import BankAccount from '../../models/BankAccount'; +import * as Plaid from '../Plaid'; +import CONST from '../../../CONST'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as store from './store'; +import * as API from '../../API'; +import * as errors from './errors'; +import * as Localize from '../../Localize'; +import * as navigation from './navigation'; + +/** + * @private + * @param {Number} bankAccountID + */ +function setFreePlanVerifiedBankAccountID(bankAccountID) { + API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: bankAccountID}); +} + +/** + * @param {Object} updatedACHData + */ +function getBankAccountListAndGoToValidateStep(updatedACHData) { + // Get an up-to-date bank account list so that we can allow the user to validate their newly + // generated bank account + API.Get({returnValueList: 'bankAccountList'}) + .then((bankAccountListResponse) => { + const bankAccountJSON = _.findWhere(bankAccountListResponse.bankAccountList, { + bankAccountID: updatedACHData.bankAccountID, + }); + const bankAccount = new BankAccount(bankAccountJSON); + const achData = bankAccount.toACHData(); + const needsToPassLatestChecks = achData.state === BankAccount.STATE.OPEN + && achData.needsToPassLatestChecks; + achData.bankAccountInReview = needsToPassLatestChecks + || achData.state === BankAccount.STATE.VERIFYING; + + navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + }); +} + +/** + * @param {Object} achData + * @returns {Object} + */ +function getOnfidoTokenAndStatusFromACHData(achData) { + const onfidoResponse = lodashGet( + achData, + CONST.BANK_ACCOUNT.VERIFICATIONS.REQUESTOR_IDENTITY_ONFIDO, + ); + + return { + sdkToken: lodashGet(onfidoResponse, CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.SDK_TOKEN), + status: onfidoResponse.status, + }; +} + +/** + * @param {Object} achData + * @returns {Boolean} + */ +function needsToDoOnfido(achData) { + const {sdkToken, status} = getOnfidoTokenAndStatusFromACHData(achData); + if (!sdkToken || achData.isOnfidoSetupComplete || status === CONST.BANK_ACCOUNT.ONFIDO_RESPONSE.PASS) { + return false; + } + + return true; +} + +/** + * @param {Object} requestorResponse + * @returns {Array} + */ +function getRequestorQuestions(requestorResponse) { + const questions = lodashGet(requestorResponse, CONST.BANK_ACCOUNT.QUESTIONS.QUESTION) || []; + if (!_.isEmpty(questions)) { + return questions; + } + + const differentiatorQuestion = lodashGet( + requestorResponse, + CONST.BANK_ACCOUNT.QUESTIONS.DIFFERENTIATOR_QUESTION, + ); + + return differentiatorQuestion ? [differentiatorQuestion] : []; +} + +/** + * @param {Object} response + * @returns {Boolean} + */ +function hasAccountOrRoutingError(response) { + return response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_ROUTING_NUMBER + || response.message === CONST.BANK_ACCOUNT.ERROR.MAX_ROUTING_NUMBER; +} + +/** + * @param {Object} updatedACHData + * @returns {String} + */ +function getNextStep(updatedACHData) { + const currentStep = updatedACHData.currentStep; + if (currentStep === CONST.BANK_ACCOUNT.STEP.ENABLE) { + return currentStep; + } + + if (currentStep === CONST.BANK_ACCOUNT.STEP.VALIDATION && updatedACHData.bankAccountInReview) { + return currentStep; + } + + return navigation.getNextStepID(); +} + +/** + * + * @param {Object} response + * @param {String} verificationsError + * @param {Object} updatedACHData + */ +function showSetupWithdrawalAccountErrors(response, verificationsError, updatedACHData) { + let error = verificationsError; + let isErrorHTML = false; + const responseACHData = lodashGet(response, 'achData', {}); + + if (response.jsonCode === 666 || response.jsonCode === 404) { + // Since these specific responses can have an error message in html format with richer content, give priority to the html error. + error = response.htmlMessage || response.message; + isErrorHTML = Boolean(response.htmlMessage); + } + + if (response.jsonCode === 402) { + if (hasAccountOrRoutingError(response)) { + errors.setBankAccountFormValidationErrors({routingNumber: true}); + errors.showBankAccountErrorModal(); + } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_STATE) { + error = Localize.translateLocal('bankAccount.error.incorporationState'); + } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { + error = Localize.translateLocal('bankAccount.error.companyType'); + } else { + console.error(response.message); + } + } + + if (error) { + errors.showBankAccountFormValidationError(error); + errors.showBankAccountErrorModal(error, isErrorHTML); + } + + // Go to next step + navigation.goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { + ...responseACHData, + subStep: hasAccountOrRoutingError(response), + }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); +} + +/** + * @param {Object} data + * @returns {Object} + */ +function mergeParamsWithLocalACHData(data) { + const updatedACHData = { + ...store.getReimbursementAccountInSetup(), + ...data, + + // This param tells Web-Secure that this bank account is from NewDot so we can modify links back to the correct + // app in any communications. It also will be used to provision a customer for the Expensify card automatically + // once their bank account is successfully validated. + enableCardAfterVerified: true, + }; + + if (data && !_.isUndefined(data.isSavings)) { + updatedACHData.isSavings = Boolean(data.isSavings); + } + if (!updatedACHData.setupType) { + updatedACHData.setupType = updatedACHData.plaidAccountID + ? CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID + : CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; + } + + // If we are setting up a Plaid account replace the accountNumber with the unmasked number + if (data.plaidAccountID) { + const unmaskedAccount = _.find(Plaid.getPlaidBankAccounts(), bankAccount => ( + bankAccount.plaidAccountID === data.plaidAccountID + )); + updatedACHData.accountNumber = unmaskedAccount.accountNumber; + } + return updatedACHData; +} + +/** + * @param {Object} achData + * @param {String} nextStep + */ +function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { + const requestorResponse = lodashGet( + achData, + CONST.BANK_ACCOUNT.VERIFICATIONS.REQUESTOR_IDENTITY_ID, + ); + + if (achData.useOnfido) { + if (needsToDoOnfido(achData)) { + navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { + ...achData, + sdkToken: getOnfidoTokenAndStatusFromACHData(achData).sdkToken, + }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + return; + } + } else if (requestorResponse) { + // Don't go to next step if Requestor Step needs to ask some questions + const questions = getRequestorQuestions(requestorResponse); + if (!_.isEmpty(questions)) { + navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { + ...achData, + questions, + }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + return; + } + } + + navigation.goToWithdrawalAccountSetupStep(nextStep, achData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); +} + +/** + * Create or update the bank account in db with the updated data. + * + * This action is called by several steps in the Verified Bank Account flow and is coupled tightly with SetupWithdrawalAccount in Auth + * Each time the command is called the state of the bank account progresses a bit further and when handling the response we redirect + * to the appropriate next step in the flow. + * + * @param {Object} params + * + * // BankAccountStep + * @param {Boolean} [params.acceptTerms] + * @param {String} [params.accountNumber] + * @param {String} [params.routingNumber] + * @param {String} [params.setupType] + * @param {String} [params.country] + * @param {String} [params.currency] + * @param {String} [params.fieldsType] + * @param {String} [params.plaidAccessToken] + * @param {String} [params.plaidAccountID] + * @param {String} [params.ownershipType] + * @param {Boolean} [params.isSavings] + * @param {String} [params.addressName] + * + * // CompanyStep + * @param {String} [params.companyName] + * @param {String} [params.addressStreet] + * @param {String} [params.addressCity] + * @param {String} [params.addressState] + * @param {String} [params.addressZipCode] + * @param {String} [params.companyPhone] + * @param {String} [params.website] + * @param {String} [params.companyTaxID] + * @param {String} [params.incorporationType] + * @param {String} [params.incorporationState] + * @param {String} [params.incorporationDate] + * @param {Boolean} [params.hasNoConnectionToCannabis] + * + * // RequestorStep + * @param {String} [params.dob] + * @param {String} [params.firstName] + * @param {String} [params.lastName] + * @param {String} [params.requestorAddressStreet] + * @param {String} [params.requestorAddressCity] + * @param {String} [params.requestorAddressState] + * @param {String} [params.requestorAddressZipCode] + * @param {String} [params.ssnLast4] + * @param {String} [params.isControllingOfficer] + * @param {Object} [params.onfidoData] + * @param {Boolean} [params.isOnfidoSetupComplete] + * + * // ACHContractStep + * @param {Boolean} [params.ownsMoreThan25Percent] + * @param {Boolean} [params.hasOtherBeneficialOwners] + * @param {Boolean} [params.acceptTermsAndConditions] + * @param {Boolean} [params.certifyTrueInformation] + * @param {Array} [params.beneficialOwners] + */ +function setupWithdrawalAccount(params) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true, errorModalMessage: '', errors: null}); + const updatedACHData = mergeParamsWithLocalACHData(params); + API.BankAccount_SetupWithdrawal(updatedACHData) + .then((response) => { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...updatedACHData}}); + const currentStep = updatedACHData.currentStep; + const responseACHData = lodashGet(response, 'achData', {}); + const verificationsError = lodashGet(responseACHData, CONST.BANK_ACCOUNT.VERIFICATIONS.ERROR_MESSAGE); + if (response.jsonCode !== 200 || verificationsError) { + showSetupWithdrawalAccountErrors(response, verificationsError, updatedACHData); + return; + } + + // Save an NVP with the bankAccountID for this account. This is temporary since we are not showing lists + // of accounts yet and must have some kind of record of which account is the one the user is trying to + // set up for the free plan. + if (responseACHData.bankAccountID) { + setFreePlanVerifiedBankAccountID(responseACHData.bankAccountID); + } + + if (currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR) { + checkDataAndMaybeStayOnRequestorStep(responseACHData, getNextStep(updatedACHData)); + return; + } + + if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT) { + getBankAccountListAndGoToValidateStep(responseACHData); + return; + } + + // Go to next step + navigation.goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), responseACHData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + }) + .catch((response) => { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...updatedACHData}}); + console.error(response.stack); + errors.showBankAccountErrorModal(Localize.translateLocal('common.genericErrorMessage')); + }); +} + +export default setupWithdrawalAccount; diff --git a/src/libs/actions/ReimbursementAccount/store.js b/src/libs/actions/ReimbursementAccount/store.js new file mode 100644 index 00000000000..37bff984090 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/store.js @@ -0,0 +1,46 @@ +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import ONYXKEYS from '../../../ONYXKEYS'; + +/** Reimbursement account actively being set up */ +let reimbursementAccountInSetup = {}; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => { + reimbursementAccountInSetup = lodashGet(val, 'achData', {}); + }, +}); + +let reimbursementAccountWorkspaceID = null; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_WORKSPACE_ID, + callback: (val) => { + reimbursementAccountWorkspaceID = val; + }, +}); + +let credentials; +Onyx.connect({ + key: ONYXKEYS.CREDENTIALS, + callback: (val) => { + credentials = val; + }, +}); + +function getReimbursementAccountInSetup() { + return reimbursementAccountInSetup; +} + +function getCredentials() { + return credentials; +} + +function getReimbursementAccountWorkspaceID() { + return reimbursementAccountWorkspaceID; +} + +export { + getReimbursementAccountInSetup, + getCredentials, + getReimbursementAccountWorkspaceID, +}; diff --git a/src/libs/actions/ReimbursementAccount/validateBankAccount.js b/src/libs/actions/ReimbursementAccount/validateBankAccount.js new file mode 100644 index 00000000000..dd313dbb33a --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/validateBankAccount.js @@ -0,0 +1,54 @@ +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../../ONYXKEYS'; +import * as API from '../../API'; +import BankAccount from '../../models/BankAccount'; +import CONST from '../../../CONST'; +import * as Localize from '../../Localize'; +import * as errors from './errors'; + +/** + * @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) { + Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, null); + API.User_IsUsingExpensifyCard() + .then(({isUsingExpensifyCard}) => { + const reimbursementAccount = { + loading: false, + error: '', + achData: {state: BankAccount.STATE.OPEN}, + }; + + reimbursementAccount.achData.currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE; + Onyx.merge(ONYXKEYS.USER, {isUsingExpensifyCard}); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, reimbursementAccount); + }); + return; + } + + // User has input the validate code incorrectly many times so we will return early in this case and not let them enter the amounts again. + if (response.message === CONST.BANK_ACCOUNT.ERROR.MAX_VALIDATION_ATTEMPTS_REACHED) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, maxAttemptsReached: true}); + return; + } + + // If the validation amounts entered were incorrect, show specific error + if (response.message === CONST.BANK_ACCOUNT.ERROR.INCORRECT_VALIDATION_AMOUNTS) { + errors.showBankAccountErrorModal(Localize.translateLocal('bankAccount.error.validationAmounts')); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + return; + } + + // We are generically showing any other backend errors that might pop up in the validate step + errors.showBankAccountErrorModal(response.message); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + }); +} + +export default validateBankAccount; diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js new file mode 100644 index 00000000000..3f2a4aa74a3 --- /dev/null +++ b/src/libs/actions/Wallet.js @@ -0,0 +1,167 @@ +import _ from 'underscore'; +import lodashGet from 'lodash/get'; +import Onyx from 'react-native-onyx'; +import ONYXKEYS from '../../ONYXKEYS'; +import * as API from '../API'; +import CONST from '../../CONST'; + +/** + * Fetch and save locally the Onfido SDK token and applicantID + * - The sdkToken is used to initialize the Onfido SDK client + * - The applicantID is combined with the data returned from the Onfido SDK as we need both to create an + * identity check. Note: This happens in Web-Secure when we call Activate_Wallet during the OnfidoStep. + */ +function fetchOnfidoToken() { + // Use Onyx.set() since we are resetting the Onfido flow completely. + Onyx.set(ONYXKEYS.WALLET_ONFIDO, {loading: true}); + API.Wallet_GetOnfidoSDKToken() + .then((response) => { + const apiResult = lodashGet(response, ['requestorIdentityOnfido', 'apiResult'], {}); + Onyx.merge(ONYXKEYS.WALLET_ONFIDO, { + applicantID: apiResult.applicantID, + sdkToken: apiResult.sdkToken, + loading: false, + hasAcceptedPrivacyPolicy: true, + }); + }) + .catch(() => Onyx.set(ONYXKEYS.WALLET_ONFIDO, {loading: false, error: CONST.WALLET.ERROR.UNEXPECTED})); +} + +/** + * Privately used to update the additionalDetails object in Onyx (which will have various effects on the UI) + * + * @param {Boolean} loading whether we are making the API call to validate the user's provided personal details + * @param {String[]} [errorFields] an array of field names that should display errors in the UI + * @param {String} [additionalErrorMessage] an additional error message to display in the UI + * @private + */ +function setAdditionalDetailsStep(loading, errorFields = null, additionalErrorMessage = '') { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {loading, errorFields, additionalErrorMessage}); +} + +/** + * This action can be called repeatedly with different steps until an Expensify Wallet has been activated. + * + * Possible steps: + * + * - OnfidoStep - Creates an identity check by calling Onfido's API (via Web-Secure) with data returned from the SDK + * - AdditionalDetailsStep - Validates a user's provided details against a series of checks + * - TermsStep - Ensures that a user has agreed to all of the terms and conditions + * + * The API will always return the updated userWallet in the response as a convenience so we can avoid calling + * Get&returnValueList=userWallet after we call Wallet_Activate. + * + * @param {String} currentStep + * @param {Object} parameters + * @param {String} [parameters.onfidoData] - JSON string + * @param {Object} [parameters.personalDetails] - JSON string + * @param {Boolean} [parameters.hasAcceptedTerms] + */ +function activateWallet(currentStep, parameters) { + let personalDetails; + let onfidoData; + let hasAcceptedTerms; + + if (!_.contains(CONST.WALLET.STEP, currentStep)) { + throw new Error('Invalid currentStep passed to activateWallet()'); + } + + if (currentStep === CONST.WALLET.STEP.ONFIDO) { + onfidoData = parameters.onfidoData; + Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); + } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { + setAdditionalDetailsStep(true); + + // Personal details are heavily validated on the API side. We will only do a quick check to ensure the values + // exist in some capacity and then stringify them. + const errorFields = _.reduce(CONST.WALLET.REQUIRED_ADDITIONAL_DETAILS_FIELDS, (missingFields, fieldName) => ( + !personalDetails[fieldName] ? [...missingFields, fieldName] : missingFields + ), []); + + if (!_.isEmpty(errorFields)) { + setAdditionalDetailsStep(false, errorFields); + return; + } + + personalDetails = JSON.stringify(parameters.personalDetails); + } else if (currentStep === CONST.WALLET.STEP.TERMS) { + hasAcceptedTerms = parameters.hasAcceptedTerms; + Onyx.merge(ONYXKEYS.WALLET_TERMS, {loading: true}); + } + + API.Wallet_Activate({ + currentStep, + personalDetails, + onfidoData, + hasAcceptedTerms, + }) + .then((response) => { + if (response.jsonCode !== 200) { + if (currentStep === CONST.WALLET.STEP.ONFIDO) { + Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: response.message, loading: false}); + return; + } + + if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { + if (response.title === CONST.WALLET.ERROR.MISSING_FIELD) { + setAdditionalDetailsStep(false, response.data.fieldNames); + return; + } + + const errorTitles = [ + CONST.WALLET.ERROR.IDENTITY_NOT_FOUND, + CONST.WALLET.ERROR.INVALID_SSN, + CONST.WALLET.ERROR.UNEXPECTED, + CONST.WALLET.ERROR.UNABLE_TO_VERIFY, + ]; + + if (_.contains(errorTitles, response.title)) { + setAdditionalDetailsStep(false, null, response.message); + return; + } + + setAdditionalDetailsStep(false); + return; + } + + return; + } + + Onyx.merge(ONYXKEYS.USER_WALLET, response.userWallet); + + if (currentStep === CONST.WALLET.STEP.ONFIDO) { + Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); + } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { + setAdditionalDetailsStep(false); + } else if (currentStep === CONST.WALLET.STEP.TERMS) { + Onyx.merge(ONYXKEYS.WALLET_TERMS, {loading: false}); + } + }); +} + +/** + * Fetches information about a user's Expensify Wallet + * + * @typedef {Object} UserWallet + * @property {Number} availableBalance + * @property {Number} currentBalance + * @property {String} currentStep - used to track which step of the "activate wallet" flow a user is in + * @property {('SILVER'|'GOLD')} tierName - will be GOLD when fully activated. SILVER is able to recieve funds only. + */ +function fetchUserWallet() { + API.Get({returnValueList: 'userWallet'}) + .then((response) => { + if (response.jsonCode !== 200) { + return; + } + + Onyx.merge(ONYXKEYS.USER_WALLET, response.userWallet); + }); +} + +export { + fetchOnfidoToken, + setAdditionalDetailsStep, + activateWallet, + fetchUserWallet, +}; diff --git a/src/pages/ReimbursementAccount/BeneficialOwnersStep.js b/src/pages/ReimbursementAccount/ACHContractStep.js similarity index 99% rename from src/pages/ReimbursementAccount/BeneficialOwnersStep.js rename to src/pages/ReimbursementAccount/ACHContractStep.js index a8c282d00b4..be970f2407d 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnersStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -32,7 +32,7 @@ const propTypes = { reimbursementAccount: reimbursementAccountPropTypes.isRequired, }; -class BeneficialOwnersStep extends React.Component { +class ACHContractStep extends React.Component { constructor(props) { super(props); @@ -295,7 +295,7 @@ class BeneficialOwnersStep extends React.Component { } } -BeneficialOwnersStep.propTypes = propTypes; +ACHContractStep.propTypes = propTypes; export default compose( withLocalize, withOnyx({ @@ -306,4 +306,4 @@ export default compose( key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, }, }), -)(BeneficialOwnersStep); +)(ACHContractStep); diff --git a/src/pages/ReimbursementAccount/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index 9b9411ded77..593573d6c75 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -24,6 +24,7 @@ import * as ReimbursementAccountUtils from '../../libs/ReimbursementAccountUtils import ReimbursementAccountForm from './ReimbursementAccountForm'; import reimbursementAccountPropTypes from './reimbursementAccountPropTypes'; import WorkspaceSection from '../workspace/WorkspaceSection'; +import * as ValidationUtils from '../../libs/ValidationUtils'; import * as Illustrations from '../../components/Icon/Illustrations'; const propTypes = { @@ -78,7 +79,7 @@ class BankAccountStep extends React.Component { if (!CONST.BANK_ACCOUNT.REGEX.IBAN.test(this.state.accountNumber.trim())) { errors.accountNumber = true; } - if (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(this.state.routingNumber.trim()) || !BankAccounts.validateRoutingNumber(this.state.routingNumber.trim())) { + if (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(this.state.routingNumber.trim()) || !ValidationUtils.isValidRoutingNumber(this.state.routingNumber.trim())) { errors.routingNumber = true; } if (!this.state.hasAcceptedTerms) { diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index 331880fc29b..70583f967dc 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -24,7 +24,7 @@ import BankAccountStep from './BankAccountStep'; import CompanyStep from './CompanyStep'; import RequestorStep from './RequestorStep'; import ValidationStep from './ValidationStep'; -import BeneficialOwnersStep from './BeneficialOwnersStep'; +import ACHContractStep from './ACHContractStep'; import EnableStep from './EnableStep'; import ROUTES from '../../ROUTES'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; @@ -220,7 +220,7 @@ class ReimbursementAccountPage extends React.Component { )} {currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT && ( - + )} {currentStep === CONST.BANK_ACCOUNT.STEP.VALIDATION && ( diff --git a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js new file mode 100644 index 00000000000..21f44ec58c3 --- /dev/null +++ b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js @@ -0,0 +1,272 @@ +import CONST from '../../src/CONST'; +import * as fetchFreePlanVerifiedBankAccount from '../../src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount'; +import BankAccount from '../../src/libs/models/BankAccount'; + +describe('getCurrentStep', () => { + it('Returns BankAccountStep when there is no step in storage, achData, bankAccount, etc', () => { + // GIVEN a bank account that doesn't yet exist and no stepToOpen + const nullBankAccount = null; + const achData = {}; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep('', '', achData, nullBankAccount, false); + + // THEN it will be the BankAccountStep + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); + }); + + it('Returns BankAccountStep when there is no step in storage or bankAccount but is achData', () => { + // GIVEN a bank account that doesn't yet exist and no stepToOpen + const nullBankAccount = null; + const achData = { + bankAccountInReview: false, + domainLimit: 0, + isInSetup: true, + policyID: '', + subStep: '', + useOnfido: true, + }; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep('', '', achData, nullBankAccount, false); + + // THEN it will be the BankAccountStep + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); + }); + + it('Returns whatever step we give for stepToOpen', () => { + // GIVEN a bank account that doesn't yet exist and has a stepToOpen + const nullBankAccount = null; + const achData = {}; + const stepToOpen = CONST.BANK_ACCOUNT.STEP.COMPANY; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, nullBankAccount, false); + + // THEN it will be whatever we set the stepToOpen to be + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.COMPANY); + }); + + it('Returns the logical next step if we have a currentStep in achData', () => { + // GIVEN a bank account that does exist and has no stepToOpen and "isInSetup" + const bankAccount = new BankAccount({}); + const achData = {currentStep: CONST.BANK_ACCOUNT.STEP.COMPANY, isInSetup: true}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the logical next step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + }); + + it('Returns the requestor step if we have to do onfido', () => { + // GIVEN a bank account that does exist and has no stepToOpen and "isInSetup" and must do Onfido + const bankAccount = new BankAccount({}); + const achData = { + currentStep: CONST.BANK_ACCOUNT.STEP.REQUESTOR, + isInSetup: true, + isOnfidoSetupComplete: false, + }; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN we will stay on the requestor step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + }); + + it('Returns steps based on pending BankAccount if there is no current step in achData or device storage', () => { + // GIVEN a pending bank account, but no currentStep in achData or device storage + const bankAccount = new BankAccount({ + state: BankAccount.STATE.PENDING, + }); + const achData = {}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the validation step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); + }); + + it('Returns steps based on verifying BankAccount if there is no current step in achData or device storage', () => { + // GIVEN a pending bank account, but no currentStep in achData or device storage + const bankAccount = new BankAccount({ + state: BankAccount.STATE.VERIFYING, + }); + const achData = {}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the validation step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); + }); + + it('Returns step based on open BankAccount that needs to pass checks and has not yet attempted upgrade', () => { + // GIVEN an open bank account that needs to pass checks and has not yet tried to upgrade + const bankAccount = new BankAccount({ + state: BankAccount.STATE.OPEN, + additionalData: { + hasFullSSN: false, + }, + }); + const achData = {}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the company step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.COMPANY); + }); + + it('Returns step based on open BankAccount that needs to pass checks and has attempted upgrade', () => { + // GIVEN an open bank account that needs to pass checks and has tried to upgrade + const bankAccount = new BankAccount({ + state: BankAccount.STATE.OPEN, + additionalData: { + hasFullSSN: false, + }, + }); + const achData = {}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, true); + + // THEN it will be the validation step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); + }); + + it('Returns step based on open BankAccount that does not need to pass checks', () => { + // GIVEN an open bank account that does not need to pass checks + const bankAccount = new BankAccount({ + state: BankAccount.STATE.OPEN, + additionalData: { + hasFullSSN: true, + beneficialOwners: [{ + hasFullSSN: true, + isRequestor: true, + expectIDPA: { + status: 'pass', + }, + }], + requestorAddressCity: 'Portland', + verifications: { + externalApiResponses: { + realSearchResult: { + status: 'pass', + }, + lexisNexisInstantIDResult: { + status: 'pass', + }, + requestorIdentityID: { + status: 'pass', + }, + }, + }, + }, + }); + const achData = {}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, true); + + // THEN it will be the enable step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.ENABLE); + }); + + it('Returns step based on deleted BankAccount and no currentStep', () => { + // GIVEN a deleted bank account + const bankAccount = new BankAccount({ + state: BankAccount.STATE.DELETED, + }); + const achData = {}; + const stepToOpen = ''; + + // WHEN we get the current step + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, true); + + // THEN it will be the bank account step + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); + }); +}); + +describe('buildACHData()', () => { + it('Returns the correct shape for a bank account in setup', () => { + const bankAccount = new BankAccount({ + state: BankAccount.STATE.SETUP, + }); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, false); + expect(achData).toEqual({ + useOnfido: true, + policyID: '', + isInSetup: true, + bankAccountInReview: false, + domainLimit: 0, + needsToUpgrade: false, + subStep: CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL, + state: BankAccount.STATE.SETUP, + validateCodeExpectedDate: '', + needsToPassLatestChecks: true, + }); + }); + + it('Returns the correct shape for an open account that has tried to upgrade', () => { + const bankAccount = new BankAccount({ + state: BankAccount.STATE.OPEN, + additionalData: { + hasFullSSN: false, + }, + }); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, true); + expect(achData).toEqual({ + useOnfido: true, + policyID: '', + isInSetup: false, + bankAccountInReview: true, + domainLimit: 0, + hasFullSSN: false, + needsToUpgrade: true, + state: BankAccount.STATE.OPEN, + validateCodeExpectedDate: '', + needsToPassLatestChecks: true, + }); + }); + + it('Returns the correct shape for a verifying account', () => { + const bankAccount = new BankAccount({ + state: BankAccount.STATE.VERIFYING, + }); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, false); + expect(achData).toEqual({ + useOnfido: true, + policyID: '', + isInSetup: false, + bankAccountInReview: true, + domainLimit: 0, + needsToUpgrade: true, + state: BankAccount.STATE.VERIFYING, + validateCodeExpectedDate: '', + needsToPassLatestChecks: true, + }); + }); + + it('Returns the correct shape for no account', () => { + const bankAccount = undefined; + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, false); + expect(achData).toEqual({ + useOnfido: true, + policyID: '', + isInSetup: true, + bankAccountInReview: false, + domainLimit: 0, + }); + }); +});