From 430ba0fb040fcdbb4c4d9053d5039d97da78d662 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 14:18:27 -1000 Subject: [PATCH 01/15] Break up BankAccounts.js into smaller modules and make Plaid a mini-store --- src/components/AddPlaidBankAccount.js | 4 +- src/libs/ValidationUtils.js | 23 + src/libs/actions/BankAccounts.js | 888 +----------------- src/libs/actions/Plaid.js | 113 +++ src/libs/actions/ReimbursementAccount.js | 667 +++++++++++++ src/libs/actions/Wallet.js | 167 ++++ .../ReimbursementAccount/BankAccountStep.js | 4 +- 7 files changed, 1007 insertions(+), 859 deletions(-) create mode 100644 src/libs/actions/Plaid.js create mode 100644 src/libs/actions/ReimbursementAccount.js create mode 100644 src/libs/actions/Wallet.js diff --git a/src/components/AddPlaidBankAccount.js b/src/components/AddPlaidBankAccount.js index 8ce5a6cc4a1..07eb40c008f 100644 --- a/src/components/AddPlaidBankAccount.js +++ b/src/components/AddPlaidBankAccount.js @@ -12,7 +12,7 @@ import PlaidLink from './PlaidLink'; import { clearPlaidBankAccountsAndToken, fetchPlaidLinkToken, - getPlaidBankAccounts, + fetchPlaidBankAccounts, setBankAccountFormValidationErrors, showBankAccountErrorModal, } from '../libs/actions/BankAccounts'; @@ -165,7 +165,7 @@ class AddPlaidBankAccount extends React.Component { token={this.props.plaidLinkToken} onSuccess={({publicToken, metadata}) => { Log.info('[PlaidLink] Success!'); - getPlaidBankAccounts(publicToken, metadata.institution.name); + 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 13d1c250132..f96c164b682 100644 --- a/src/libs/ValidationUtils.js +++ b/src/libs/ValidationUtils.js @@ -248,6 +248,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, @@ -265,4 +287,5 @@ export { isNumericWithSpecialChars, isValidLengthForFirstOrLastName, isValidPaypalUsername, + isValidRoutingNumber, }; diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index 91988e7cdd1..ae99c81b1e9 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -1,141 +1,35 @@ -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 {translateLocal} from '../translate'; - -/** - * 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(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); -} +import { + setupWithdrawalAccount, + fetchFreePlanVerifiedBankAccount, + goToWithdrawalAccountSetupStep, + showBankAccountErrorModal, + showBankAccountFormValidationError, + setBankAccountFormValidationErrors, + resetFreePlanBankAccount, + validateBankAccount, + hideBankAccountErrors, + setWorkspaceIDForReimbursementAccount, + setBankAccountSubStep, + updateReimbursementAccountDraft, + requestResetFreePlanBankAccount, + cancelResetFreePlanBankAccount, +} from './ReimbursementAccount'; +import { + fetchPlaidBankAccounts, + clearPlaidBankAccountsAndToken, + fetchPlaidLinkToken, + getPlaidBankAccounts, + getBankName, + getPlaidAccessToken, +} from './Plaid'; +import { + fetchOnfidoToken, + activateWallet, + fetchUserWallet, +} from './Wallet'; /** * Adds a bank account via Plaid @@ -145,7 +39,7 @@ function clearPlaidBankAccountsAndToken() { * @param {String} plaidLinkToken */ function addPersonalBankAccount(account, password, plaidLinkToken) { - const unmaskedAccount = _.find(plaidBankAccounts, bankAccount => ( + const unmaskedAccount = _.find(getPlaidBankAccounts(), bankAccount => ( bankAccount.plaidAccountID === account.plaidAccountID )); API.BankAccount_Create({ @@ -164,14 +58,14 @@ function addPersonalBankAccount(account, password, plaidLinkToken) { isInSetup: true, bankAccountInReview: null, currentStep: 'AccountOwnerInformationStep', - bankName, + bankName: getBankName(), plaidAccountID: unmaskedAccount.plaidAccountID, ownershipType: '', acceptTerms: true, country: 'US', currency: CONST.CURRENCY.USD, fieldsType: 'local', - plaidAccessToken, + plaidAccessToken: getPlaidAccessToken(), }), }) .then((response) => { @@ -184,721 +78,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(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 = translateLocal('bankAccount.error.incorporationState'); - } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { - error = 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(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; - } - - // Clear reimbursement account, draft user input, and the bank account list - Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {}); - 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, @@ -907,7 +86,7 @@ export { fetchOnfidoToken, fetchPlaidLinkToken, fetchUserWallet, - getPlaidBankAccounts, + fetchPlaidBankAccounts, goToWithdrawalAccountSetupStep, setupWithdrawalAccount, validateBankAccount, @@ -921,5 +100,4 @@ export { requestResetFreePlanBankAccount, cancelResetFreePlanBankAccount, resetFreePlanBankAccount, - validateRoutingNumber, }; diff --git a/src/libs/actions/Plaid.js b/src/libs/actions/Plaid.js new file mode 100644 index 00000000000..eab70d95957 --- /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 {translateLocal} from '../translate'; + +/** + * 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(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.js b/src/libs/actions/ReimbursementAccount.js new file mode 100644 index 00000000000..c5e5d536be3 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount.js @@ -0,0 +1,667 @@ +import Onyx from 'react-native-onyx'; +import _ from 'underscore'; +import lodashHas from 'lodash/has'; +import lodashGet from 'lodash/get'; +import * as API from '../API'; +import CONST from '../../CONST'; +import ONYXKEYS from '../../ONYXKEYS'; +import {translateLocal} from '../translate'; +import BankAccount from '../models/BankAccount'; +import Growl from '../Growl'; +import {getPlaidBankAccounts} from './Plaid'; + +/** 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; + }, +}); + +/** + * @private + * @param {Number} bankAccountID + */ +function setFreePlanVerifiedBankAccountID(bankAccountID) { + API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: bankAccountID}); +} + +/** + * 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}}); +} + +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); +} + +/** + * 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}); +} + +/** + * 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 error message. + * + * @param {String} error + */ +function showBankAccountFormValidationError(error) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error}); +} + +/** + * Create or update the bank account in db with the updated data. + * + * @param {Object} [data] + * + * // BankAccountStep + * @param {Boolean} [data.acceptTerms] + * @param {String} [data.accountNumber] + * @param {String} [data.routingNumber] + * @param {String} [data.setupType] + * @param {String} [data.country] + * @param {String} [data.currency] + * @param {String} [data.fieldsType] + * @param {String} [data.plaidAccessToken] + * @param {String} [data.plaidAccountID] + * @param {String} [data.ownershipType] + * @param {Boolean} [data.isSavings] + * @param {String} [data.addressName] + * + * // BeneficialOwnersStep + * @param {Boolean} [data.ownsMoreThan25Percent] + * @param {Boolean} [data.hasOtherBeneficialOwners] + * @param {Boolean} [data.acceptTermsAndConditions] + * @param {Boolean} [data.certifyTrueInformation] + * @param {Array} [data.beneficialOwners] + * + * // CompanyStep + * @param {String} [data.companyName] + * @param {String} [data.addressStreet] + * @param {String} [data.addressCity] + * @param {String} [data.addressState] + * @param {String} [data.addressZipCode] + * @param {String} [data.companyPhone] + * @param {String} [data.website] + * @param {String} [data.companyTaxID] + * @param {String} [data.incorporationType] + * @param {String} [data.incorporationState] + * @param {String} [data.incorporationDate] + * @param {Boolean} [data.hasNoConnectionToCannabis] + * + * // RequestorStep + * @param {String} [data.dob] + * @param {String} [data.firstName] + * @param {String} [data.lastName] + * @param {String} [data.requestorAddressStreet] + * @param {String} [data.requestorAddressCity] + * @param {String} [data.requestorAddressState] + * @param {String} [data.requestorAddressZipCode] + * @param {String} [data.ssnLast4] + * @param {String} [data.isControllingOfficer] + * @param {Object} [data.onfidoData] + * @param {Boolean} [data.isOnfidoSetupComplete] + */ +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(getPlaidBankAccounts(), 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 = translateLocal('bankAccount.error.incorporationState'); + } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { + error = 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(translateLocal('common.genericErrorMessage')); + }); +} + +/** + * @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); +} + +/** + * 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}); + }); + }); +} + +/** + * 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; + } + + // Clear reimbursement account, draft user input, and the bank account list + Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {}); + 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: ''}); + }); +} + +/** + * @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(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 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, + goToWithdrawalAccountSetupStep, + showBankAccountErrorModal, + showBankAccountFormValidationError, + setBankAccountFormValidationErrors, + resetFreePlanBankAccount, + validateBankAccount, + setBankAccountSubStep, + hideBankAccountErrors, + setWorkspaceIDForReimbursementAccount, + updateReimbursementAccountDraft, + requestResetFreePlanBankAccount, + cancelResetFreePlanBankAccount, +}; 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/BankAccountStep.js b/src/pages/ReimbursementAccount/BankAccountStep.js index d302ead8dca..04d88aa0b91 100644 --- a/src/pages/ReimbursementAccount/BankAccountStep.js +++ b/src/pages/ReimbursementAccount/BankAccountStep.js @@ -25,7 +25,6 @@ import { setupWithdrawalAccount, showBankAccountErrorModal, updateReimbursementAccountDraft, - validateRoutingNumber, } from '../../libs/actions/BankAccounts'; import ONYXKEYS from '../../ONYXKEYS'; import compose from '../../libs/compose'; @@ -34,6 +33,7 @@ import ReimbursementAccountForm from './ReimbursementAccountForm'; import reimbursementAccountPropTypes from './reimbursementAccountPropTypes'; import WorkspaceSection from '../workspace/WorkspaceSection'; import {BankMouseGreen} from '../../components/Icon/Illustrations'; +import {isValidRoutingNumber} from '../../libs/ValidationUtils'; const propTypes = { /** Bank account currently in setup */ @@ -87,7 +87,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()) || !validateRoutingNumber(this.state.routingNumber.trim())) { + if (!CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(this.state.routingNumber.trim()) || !isValidRoutingNumber(this.state.routingNumber.trim())) { errors.routingNumber = true; } if (!this.state.hasAcceptedTerms) { From 7a91e72e9bb5928cecddcfcf2abd30112503cd7f Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 14:25:41 -1000 Subject: [PATCH 02/15] Start breaking ReimbursementAccount up --- .../index.js} | 60 ++++++------------- .../actions/ReimbursementAccount/store.js | 46 ++++++++++++++ 2 files changed, 64 insertions(+), 42 deletions(-) rename src/libs/actions/{ReimbursementAccount.js => ReimbursementAccount/index.js} (94%) create mode 100644 src/libs/actions/ReimbursementAccount/store.js diff --git a/src/libs/actions/ReimbursementAccount.js b/src/libs/actions/ReimbursementAccount/index.js similarity index 94% rename from src/libs/actions/ReimbursementAccount.js rename to src/libs/actions/ReimbursementAccount/index.js index c5e5d536be3..4151e569001 100644 --- a/src/libs/actions/ReimbursementAccount.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -2,38 +2,14 @@ import Onyx from 'react-native-onyx'; import _ from 'underscore'; import lodashHas from 'lodash/has'; import lodashGet from 'lodash/get'; -import * as API from '../API'; -import CONST from '../../CONST'; -import ONYXKEYS from '../../ONYXKEYS'; -import {translateLocal} from '../translate'; -import BankAccount from '../models/BankAccount'; -import Growl from '../Growl'; -import {getPlaidBankAccounts} from './Plaid'; - -/** 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; - }, -}); +import * as API from '../../API'; +import CONST from '../../../CONST'; +import ONYXKEYS from '../../../ONYXKEYS'; +import {translateLocal} from '../../translate'; +import BankAccount from '../../models/BankAccount'; +import Growl from '../../Growl'; +import {getPlaidBankAccounts} from '../Plaid'; +import {getReimbursementAccountInSetup, getCredentials, getReimbursementAccountWorkspaceID} from './store'; /** * @private @@ -50,7 +26,7 @@ function setFreePlanVerifiedBankAccountID(bankAccountID) { * @param {Object} achData */ function goToWithdrawalAccountSetupStep(stepID, achData) { - const newACHData = {...reimbursementAccountInSetup}; + const newACHData = {...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) { @@ -114,7 +90,7 @@ function getIndexByStepID(stepID) { */ function getNextStepID(stepID) { const nextStepIndex = Math.min( - getIndexByStepID(stepID || reimbursementAccountInSetup.currentStep) + 1, + getIndexByStepID(stepID || getReimbursementAccountInSetup().currentStep) + 1, WITHDRAWAL_ACCOUNT_STEPS.length - 1, ); return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); @@ -208,7 +184,7 @@ function setupWithdrawalAccount(data) { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true, errorModalMessage: '', errors: null}); const newACHData = { - ...reimbursementAccountInSetup, + ...getReimbursementAccountInSetup(), ...data, // This param tells Web-Secure that this bank account is from NewDot so we can modify links back to the correct @@ -385,7 +361,7 @@ function getNextStepToComplete(achData) { */ 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 subStep = lodashGet(getReimbursementAccountInSetup(), '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 @@ -434,7 +410,7 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { // 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; + let currentStep = getReimbursementAccountInSetup().currentStep; const achData = bankAccount ? bankAccount.toACHData() : {}; if (!stepToOpen && achData.currentStep) { // eslint-disable-next-line no-use-before-define @@ -442,7 +418,7 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { } achData.useOnfido = true; - achData.policyID = reimbursementAccountWorkspaceID || ''; + achData.policyID = getReimbursementAccountWorkspaceID() || ''; achData.isInSetup = !bankAccount || bankAccount.isInSetup(); achData.bankAccountInReview = bankAccount && bankAccount.isVerifying(); achData.domainLimit = 0; @@ -535,19 +511,19 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { * Reset user's reimbursement account. This will delete the bank account. */ function resetFreePlanBankAccount() { - const bankAccountID = lodashGet(reimbursementAccountInSetup, 'bankAccountID'); + const bankAccountID = lodashGet(getReimbursementAccountInSetup(), 'bankAccountID'); if (!bankAccountID) { throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); } - if (!credentials || !credentials.login) { + if (!getCredentials() || !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 = {...reimbursementAccountInSetup}; + const previousACHData = {...getReimbursementAccountInSetup()}; Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: null, shouldShowResetModal: false}); - API.DeleteBankAccount({bankAccountID, ownerEmail: credentials.login}) + API.DeleteBankAccount({bankAccountID, ownerEmail: getCredentials().login}) .then((response) => { if (response.jsonCode !== 200) { // Unable to delete bank account so we restore the bank account details 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, +}; From 304ae7025f81fd2a99a6f2dfdf34b381cf4534bb Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 14:42:41 -1000 Subject: [PATCH 03/15] Break down more --- .../actions/ReimbursementAccount/errors.js | 38 ++ .../actions/ReimbursementAccount/index.js | 394 +----------------- .../ReimbursementAccount/navigation.js | 102 +++++ .../setupWithdrawalAccount.js | 237 +++++++++++ .../validateBankAccount.js | 54 +++ 5 files changed, 435 insertions(+), 390 deletions(-) create mode 100644 src/libs/actions/ReimbursementAccount/errors.js create mode 100644 src/libs/actions/ReimbursementAccount/navigation.js create mode 100644 src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js create mode 100644 src/libs/actions/ReimbursementAccount/validateBankAccount.js 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/index.js b/src/libs/actions/ReimbursementAccount/index.js index 4151e569001..b64e1cd4f69 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -1,357 +1,16 @@ import Onyx from 'react-native-onyx'; import _ from 'underscore'; -import lodashHas from 'lodash/has'; import lodashGet from 'lodash/get'; import * as API from '../../API'; import CONST from '../../../CONST'; import ONYXKEYS from '../../../ONYXKEYS'; -import {translateLocal} from '../../translate'; import BankAccount from '../../models/BankAccount'; import Growl from '../../Growl'; -import {getPlaidBankAccounts} from '../Plaid'; import {getReimbursementAccountInSetup, getCredentials, getReimbursementAccountWorkspaceID} from './store'; - -/** - * @private - * @param {Number} bankAccountID - */ -function setFreePlanVerifiedBankAccountID(bankAccountID) { - API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: bankAccountID}); -} - -/** - * Navigate to a specific step in the VBA flow - * - * @param {String} stepID - * @param {Object} achData - */ -function goToWithdrawalAccountSetupStep(stepID, achData) { - const newACHData = {...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}}); -} - -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 || getReimbursementAccountInSetup().currentStep) + 1, - WITHDRAWAL_ACCOUNT_STEPS.length - 1, - ); - return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); -} - -/** - * 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}); -} - -/** - * 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 error message. - * - * @param {String} error - */ -function showBankAccountFormValidationError(error) { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {error}); -} - -/** - * Create or update the bank account in db with the updated data. - * - * @param {Object} [data] - * - * // BankAccountStep - * @param {Boolean} [data.acceptTerms] - * @param {String} [data.accountNumber] - * @param {String} [data.routingNumber] - * @param {String} [data.setupType] - * @param {String} [data.country] - * @param {String} [data.currency] - * @param {String} [data.fieldsType] - * @param {String} [data.plaidAccessToken] - * @param {String} [data.plaidAccountID] - * @param {String} [data.ownershipType] - * @param {Boolean} [data.isSavings] - * @param {String} [data.addressName] - * - * // BeneficialOwnersStep - * @param {Boolean} [data.ownsMoreThan25Percent] - * @param {Boolean} [data.hasOtherBeneficialOwners] - * @param {Boolean} [data.acceptTermsAndConditions] - * @param {Boolean} [data.certifyTrueInformation] - * @param {Array} [data.beneficialOwners] - * - * // CompanyStep - * @param {String} [data.companyName] - * @param {String} [data.addressStreet] - * @param {String} [data.addressCity] - * @param {String} [data.addressState] - * @param {String} [data.addressZipCode] - * @param {String} [data.companyPhone] - * @param {String} [data.website] - * @param {String} [data.companyTaxID] - * @param {String} [data.incorporationType] - * @param {String} [data.incorporationState] - * @param {String} [data.incorporationDate] - * @param {Boolean} [data.hasNoConnectionToCannabis] - * - * // RequestorStep - * @param {String} [data.dob] - * @param {String} [data.firstName] - * @param {String} [data.lastName] - * @param {String} [data.requestorAddressStreet] - * @param {String} [data.requestorAddressCity] - * @param {String} [data.requestorAddressState] - * @param {String} [data.requestorAddressZipCode] - * @param {String} [data.ssnLast4] - * @param {String} [data.isControllingOfficer] - * @param {Object} [data.onfidoData] - * @param {Boolean} [data.isOnfidoSetupComplete] - */ -function setupWithdrawalAccount(data) { - let nextStep; - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true, errorModalMessage: '', errors: null}); - - const newACHData = { - ...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)) { - 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(getPlaidBankAccounts(), 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 = translateLocal('bankAccount.error.incorporationState'); - } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { - error = 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(translateLocal('common.genericErrorMessage')); - }); -} - -/** - * @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); -} +import {showBankAccountErrorModal, setBankAccountFormValidationErrors, showBankAccountFormValidationError} from './errors'; +import validateBankAccount from './validateBankAccount'; +import setupWithdrawalAccount from './setupWithdrawalAccount'; +import {goToWithdrawalAccountSetupStep, getNextStepToComplete} from './navigation'; /** * Fetch the bank account currently being set up by the user for the free plan if it exists. @@ -542,51 +201,6 @@ function resetFreePlanBankAccount() { }); } -/** - * @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(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 sub step in first step of adding withdrawal bank account * diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js new file mode 100644 index 00000000000..47b271fcb6d --- /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 {getReimbursementAccountInSetup} 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 || 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 = {...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/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js new file mode 100644 index 00000000000..656c1abb9cf --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -0,0 +1,237 @@ +import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import lodashGet from 'lodash/get'; +import BankAccount from '../../models/BankAccount'; +import {getPlaidBankAccounts} from '../Plaid'; +import CONST from '../../../CONST'; +import ONYXKEYS from '../../../ONYXKEYS'; +import {getReimbursementAccountInSetup} from './store'; +import * as API from '../../API'; +import {setBankAccountFormValidationErrors, showBankAccountErrorModal, showBankAccountFormValidationError} from './errors'; +import {translateLocal} from '../../translate'; +import {getNextStepID, goToWithdrawalAccountSetupStep} from './navigation'; + +/** + * @private + * @param {Number} bankAccountID + */ +function setFreePlanVerifiedBankAccountID(bankAccountID) { + API.SetNameValuePair({name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, value: bankAccountID}); +} + +/** + * Create or update the bank account in db with the updated data. + * + * @param {Object} [data] + * + * // BankAccountStep + * @param {Boolean} [data.acceptTerms] + * @param {String} [data.accountNumber] + * @param {String} [data.routingNumber] + * @param {String} [data.setupType] + * @param {String} [data.country] + * @param {String} [data.currency] + * @param {String} [data.fieldsType] + * @param {String} [data.plaidAccessToken] + * @param {String} [data.plaidAccountID] + * @param {String} [data.ownershipType] + * @param {Boolean} [data.isSavings] + * @param {String} [data.addressName] + * + * // BeneficialOwnersStep + * @param {Boolean} [data.ownsMoreThan25Percent] + * @param {Boolean} [data.hasOtherBeneficialOwners] + * @param {Boolean} [data.acceptTermsAndConditions] + * @param {Boolean} [data.certifyTrueInformation] + * @param {Array} [data.beneficialOwners] + * + * // CompanyStep + * @param {String} [data.companyName] + * @param {String} [data.addressStreet] + * @param {String} [data.addressCity] + * @param {String} [data.addressState] + * @param {String} [data.addressZipCode] + * @param {String} [data.companyPhone] + * @param {String} [data.website] + * @param {String} [data.companyTaxID] + * @param {String} [data.incorporationType] + * @param {String} [data.incorporationState] + * @param {String} [data.incorporationDate] + * @param {Boolean} [data.hasNoConnectionToCannabis] + * + * // RequestorStep + * @param {String} [data.dob] + * @param {String} [data.firstName] + * @param {String} [data.lastName] + * @param {String} [data.requestorAddressStreet] + * @param {String} [data.requestorAddressCity] + * @param {String} [data.requestorAddressState] + * @param {String} [data.requestorAddressZipCode] + * @param {String} [data.ssnLast4] + * @param {String} [data.isControllingOfficer] + * @param {Object} [data.onfidoData] + * @param {Boolean} [data.isOnfidoSetupComplete] + */ +function setupWithdrawalAccount(data) { + let nextStep; + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true, errorModalMessage: '', errors: null}); + + const newACHData = { + ...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)) { + 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(getPlaidBankAccounts(), 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 = translateLocal('bankAccount.error.incorporationState'); + } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { + error = 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(translateLocal('common.genericErrorMessage')); + }); +} + +export default setupWithdrawalAccount; diff --git a/src/libs/actions/ReimbursementAccount/validateBankAccount.js b/src/libs/actions/ReimbursementAccount/validateBankAccount.js new file mode 100644 index 00000000000..91bd426863c --- /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 {translateLocal} from '../../translate'; +import {showBankAccountErrorModal} 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) { + showBankAccountErrorModal(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}); + }); +} + +export default validateBankAccount; From a7f11cf2417d13314d4ba2f8a7cca7602614283a Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 15:19:31 -1000 Subject: [PATCH 04/15] Move large methods into separate files --- .../fetchFreePlanVerifiedBankAccount.js | 227 ++++++++++++++++++ .../actions/ReimbursementAccount/index.js | 200 +-------------- .../resetFreePlanBankAccount.js | 44 ++++ 3 files changed, 274 insertions(+), 197 deletions(-) create mode 100644 src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js create mode 100644 src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js new file mode 100644 index 00000000000..aa173a6c84b --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -0,0 +1,227 @@ +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 {getNextStepToComplete, goToWithdrawalAccountSetupStep} from './navigation'; +import {getReimbursementAccountInSetup, getReimbursementAccountWorkspaceID} 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 {Object} achData + * @param {BankAccount} bankAccount + * @param {Boolean} hasTriedToUpgrade + * @returns {String} + */ +function getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade) { + let currentStep = getReimbursementAccountInSetup().currentStep; + if (!stepToOpen && achData.currentStep) { + // eslint-disable-next-line no-use-before-define + currentStep = getNextStepToComplete(achData); + } + + // 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()) { + currentStep = hasTriedToUpgrade ? CONST.BANK_ACCOUNT.STEP.VALIDATION : CONST.BANK_ACCOUNT.STEP.COMPANY; + } 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; + } + + return currentStep; +} + +function setupACHData(bankAccount, hasTriedToUpgrade) { + // If we already have a substep stored locally then we will add that to the new achData + const subStep = lodashGet(getReimbursementAccountInSetup(), 'subStep', ''); + const achData = bankAccount ? bankAccount.toACHData() : {}; + achData.useOnfido = true; + achData.policyID = getReimbursementAccountWorkspaceID() || ''; + 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 (bankAccount && bankAccount.isOpen() && bankAccount.needsToPassLatestChecks()) { + achData.bankAccountInReview = hasTriedToUpgrade; + } + + return achData; +} + +/** + * 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, + }) => { + const achData = setupACHData(bankAccount); + const hasTriedToUpgrade = getHasTriedToUpgrade(bankAccount, kycVerificationsMigration); + const currentStep = getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade); + + // '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); + }); +} + +export default fetchFreePlanVerifiedBankAccount; diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index b64e1cd4f69..4bee1c11b72 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -1,205 +1,11 @@ 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 BankAccount from '../../models/BankAccount'; -import Growl from '../../Growl'; -import {getReimbursementAccountInSetup, getCredentials, getReimbursementAccountWorkspaceID} from './store'; import {showBankAccountErrorModal, setBankAccountFormValidationErrors, showBankAccountFormValidationError} from './errors'; +import {goToWithdrawalAccountSetupStep} from './navigation'; import validateBankAccount from './validateBankAccount'; import setupWithdrawalAccount from './setupWithdrawalAccount'; -import {goToWithdrawalAccountSetupStep, getNextStepToComplete} from './navigation'; - -/** - * 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(getReimbursementAccountInSetup(), '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 = getReimbursementAccountInSetup().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 = getReimbursementAccountWorkspaceID() || ''; - 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}); - }); - }); -} - -/** - * Reset user's reimbursement account. This will delete the bank account. - */ -function resetFreePlanBankAccount() { - const bankAccountID = lodashGet(getReimbursementAccountInSetup(), 'bankAccountID'); - if (!bankAccountID) { - throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); - } - if (!getCredentials() || !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 = {...getReimbursementAccountInSetup()}; - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: null, shouldShowResetModal: false}); - API.DeleteBankAccount({bankAccountID, ownerEmail: 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; - } - - // Clear reimbursement account, draft user input, and the bank account list - Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {}); - 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: ''}); - }); -} +import fetchFreePlanVerifiedBankAccount from './fetchFreePlanVerifiedBankAccount'; +import resetFreePlanBankAccount from './resetFreePlanBankAccount'; /** * Set the current sub step in first step of adding withdrawal bank account diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js new file mode 100644 index 00000000000..8d6a18ee465 --- /dev/null +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -0,0 +1,44 @@ +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 {getReimbursementAccountInSetup, getCredentials} from './store'; +import Growl from '../../Growl'; + +/** + * Reset user's reimbursement account. This will delete the bank account. + */ +function resetFreePlanBankAccount() { + const bankAccountID = lodashGet(getReimbursementAccountInSetup(), 'bankAccountID'); + if (!bankAccountID) { + throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); + } + if (!getCredentials() || !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 = {...getReimbursementAccountInSetup()}; + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: null, shouldShowResetModal: false}); + API.DeleteBankAccount({bankAccountID, ownerEmail: 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; + } + + // Clear reimbursement account, draft user input, and the bank account list + Onyx.set(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {}); + 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; From 19e753e9e967f5648197fc13f91bcd94550fd907 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 15:51:50 -1000 Subject: [PATCH 05/15] improve condtional logic for picking out step --- .../fetchFreePlanVerifiedBankAccount.js | 106 +++++++++--------- 1 file changed, 52 insertions(+), 54 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index aa173a6c84b..a278f81ec25 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -92,6 +92,24 @@ function getHasTriedToUpgrade(bankAccount, kycVerificationsMigration) { return bankAccount.getDateSigned() > (kycVerificationsMigration || '2020-01-13'); } +/** + * @param {Object} achData + * @returns {Boolean} + */ +function needsToCompleteOnfido(achData) { + if (!achData.useOnfido) { + return false; + } + + 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) { + return false; + } + + return true; +} + /** * @param {String} stepToOpen * @param {Object} achData @@ -100,73 +118,53 @@ function getHasTriedToUpgrade(bankAccount, kycVerificationsMigration) { * @returns {String} */ function getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade) { - let currentStep = getReimbursementAccountInSetup().currentStep; - if (!stepToOpen && achData.currentStep) { - // eslint-disable-next-line no-use-before-define - currentStep = getNextStepToComplete(achData); + // 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; } - // 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 = ''; - } + // 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 + ? getNextStepToComplete(achData) + : getReimbursementAccountInSetup().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; + if (achData.isInSetup) { + if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT && needsToCompleteOnfido(achData)) { + return CONST.BANK_ACCOUNT.STEP.REQUESTOR; } + + return currentStep; } - // 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()) { - currentStep = hasTriedToUpgrade ? CONST.BANK_ACCOUNT.STEP.VALIDATION : CONST.BANK_ACCOUNT.STEP.COMPANY; - } 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 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 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 (bankAccount.isPending() || bankAccount.isVerifying()) { + return CONST.BANK_ACCOUNT.STEP.VALIDATION; } - // 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; + // 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 currentStep; + return CONST.BANK_ACCOUNT.STEP.ENABLE; } +/** + * @param {BankAccount} bankAccount + * @param {Boolean} hasTriedToUpgrade + * @returns {Object} + */ function setupACHData(bankAccount, hasTriedToUpgrade) { // If we already have a substep stored locally then we will add that to the new achData const subStep = lodashGet(getReimbursementAccountInSetup(), 'subStep', ''); @@ -211,8 +209,8 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { .then(({ bankAccount, kycVerificationsMigration, throttledDate, maxAttemptsReached, isPlaidDisabled, }) => { - const achData = setupACHData(bankAccount); const hasTriedToUpgrade = getHasTriedToUpgrade(bankAccount, kycVerificationsMigration); + const achData = setupACHData(bankAccount, hasTriedToUpgrade); const currentStep = getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade); // 'error' displays any string set as an error encountered during the add Verified BBA flow. From 4da297e2cab16c323c928b5cba55b8cb875900d8 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 16:46:24 -1000 Subject: [PATCH 06/15] add unit test --- .../fetchFreePlanVerifiedBankAccount.js | 29 +-- .../fetchFreePlanVerifiedBankAccountTest.js | 180 ++++++++++++++++++ 2 files changed, 185 insertions(+), 24 deletions(-) create mode 100644 tests/unit/fetchFreePlanVerifiedBankAccountTest.js diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index a278f81ec25..a6dcacf70e8 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -92,32 +92,15 @@ function getHasTriedToUpgrade(bankAccount, kycVerificationsMigration) { return bankAccount.getDateSigned() > (kycVerificationsMigration || '2020-01-13'); } -/** - * @param {Object} achData - * @returns {Boolean} - */ -function needsToCompleteOnfido(achData) { - if (!achData.useOnfido) { - return false; - } - - 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) { - return false; - } - - return true; -} - /** * @param {String} stepToOpen + * @param {String} stepFromStorage * @param {Object} achData * @param {BankAccount} bankAccount * @param {Boolean} hasTriedToUpgrade * @returns {String} */ -function getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade) { +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 @@ -132,10 +115,6 @@ function getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade) { : getReimbursementAccountInSetup().currentStep; if (achData.isInSetup) { - if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT && needsToCompleteOnfido(achData)) { - return CONST.BANK_ACCOUNT.STEP.REQUESTOR; - } - return currentStep; } @@ -211,7 +190,8 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { }) => { const hasTriedToUpgrade = getHasTriedToUpgrade(bankAccount, kycVerificationsMigration); const achData = setupACHData(bankAccount, hasTriedToUpgrade); - const currentStep = getCurrentStep(stepToOpen, achData, bankAccount, hasTriedToUpgrade); + const stepFromStorage = getReimbursementAccountInSetup().currentStep; + const currentStep = getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade); // '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. @@ -223,3 +203,4 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { } export default fetchFreePlanVerifiedBankAccount; +export {getCurrentStep}; diff --git a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js new file mode 100644 index 00000000000..fdbe8269e5d --- /dev/null +++ b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js @@ -0,0 +1,180 @@ +import CONST from '../../src/CONST'; +import {getCurrentStep} from '../../src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount'; +import BankAccount from '../../src/libs/models/BankAccount'; + +describe('fetchFreePlanVerifiedBankAccount', () => { + 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 = getCurrentStep('', '', achData, nullBankAccount, false); + + // THEN it will be the BankAccountStep + expect(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, nullBankAccount, false); + + // THEN it will be whatever we set the stepToOpen to be + expect(CONST.BANK_ACCOUNT.STEP.COMPANY).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the logical next step + expect(CONST.BANK_ACCOUNT.STEP.REQUESTOR).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN we will stay on the requestor step + expect(CONST.BANK_ACCOUNT.STEP.REQUESTOR).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the validation step + expect(CONST.BANK_ACCOUNT.STEP.VALIDATION).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the validation step + expect(CONST.BANK_ACCOUNT.STEP.VALIDATION).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + + // THEN it will be the company step + expect(CONST.BANK_ACCOUNT.STEP.COMPANY).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, true); + + // THEN it will be the validation step + expect(CONST.BANK_ACCOUNT.STEP.VALIDATION).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, true); + + // THEN it will be the enable step + expect(CONST.BANK_ACCOUNT.STEP.ENABLE).toBe(currentStep); + }); + + 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 = getCurrentStep(stepToOpen, '', achData, bankAccount, true); + + // THEN it will be the bank account step + expect(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT).toBe(currentStep); + }); +}); From 7dea38df9d963e08a37ce77f59e7effc6b6cdea3 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 18:24:25 -1000 Subject: [PATCH 07/15] add unit test --- .../fetchFreePlanVerifiedBankAccount.js | 58 ++++++----- .../fetchFreePlanVerifiedBankAccountTest.js | 97 ++++++++++++++++--- 2 files changed, 120 insertions(+), 35 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index a6dcacf70e8..575def94478 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -142,32 +142,42 @@ function getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTr /** * @param {BankAccount} bankAccount * @param {Boolean} hasTriedToUpgrade - * @returns {Object} + * @returns {Boolean} */ -function setupACHData(bankAccount, hasTriedToUpgrade) { - // If we already have a substep stored locally then we will add that to the new achData - const subStep = lodashGet(getReimbursementAccountInSetup(), 'subStep', ''); - const achData = bankAccount ? bankAccount.toACHData() : {}; - achData.useOnfido = true; - achData.policyID = getReimbursementAccountWorkspaceID() || ''; - 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; +function getIsBankAccountInReview(bankAccount, hasTriedToUpgrade) { + if (!bankAccount) { + return false; } - if (bankAccount && bankAccount.isOpen() && bankAccount.needsToPassLatestChecks()) { - achData.bankAccountInReview = hasTriedToUpgrade; + if (bankAccount.isVerifying()) { + return true; } - return achData; + 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: getReimbursementAccountWorkspaceID() || '', + isInSetup: !bankAccount || bankAccount.isInSetup(), + bankAccountInReview: getIsBankAccountInReview(bankAccount, hasTriedToUpgrade), + domainLimit: 0, + subStep: bankAccount && bankAccount.isInSetup() + ? CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL + : subStep, + }; } /** @@ -188,8 +198,10 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { .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(getReimbursementAccountInSetup(), 'subStep', ''); const hasTriedToUpgrade = getHasTriedToUpgrade(bankAccount, kycVerificationsMigration); - const achData = setupACHData(bankAccount, hasTriedToUpgrade); + const achData = buildACHData(bankAccount, hasTriedToUpgrade, subStep); const stepFromStorage = getReimbursementAccountInSetup().currentStep; const currentStep = getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade); @@ -203,4 +215,4 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { } export default fetchFreePlanVerifiedBankAccount; -export {getCurrentStep}; +export {getCurrentStep, buildACHData}; diff --git a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js index fdbe8269e5d..98a62ee3837 100644 --- a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js +++ b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js @@ -1,8 +1,8 @@ import CONST from '../../src/CONST'; -import {getCurrentStep} from '../../src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount'; +import {getCurrentStep, buildACHData} from '../../src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount'; import BankAccount from '../../src/libs/models/BankAccount'; -describe('fetchFreePlanVerifiedBankAccount', () => { +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; @@ -12,7 +12,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep('', '', achData, nullBankAccount, false); // THEN it will be the BankAccountStep - expect(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT).toBe(currentStep); + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); }); it('Returns whatever step we give for stepToOpen', () => { @@ -25,7 +25,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, nullBankAccount, false); // THEN it will be whatever we set the stepToOpen to be - expect(CONST.BANK_ACCOUNT.STEP.COMPANY).toBe(currentStep); + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.COMPANY); }); it('Returns the logical next step if we have a currentStep in achData', () => { @@ -38,7 +38,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the logical next step - expect(CONST.BANK_ACCOUNT.STEP.REQUESTOR).toBe(currentStep); + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); }); it('Returns the requestor step if we have to do onfido', () => { @@ -55,7 +55,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN we will stay on the requestor step - expect(CONST.BANK_ACCOUNT.STEP.REQUESTOR).toBe(currentStep); + 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', () => { @@ -70,7 +70,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the validation step - expect(CONST.BANK_ACCOUNT.STEP.VALIDATION).toBe(currentStep); + 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', () => { @@ -85,7 +85,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the validation step - expect(CONST.BANK_ACCOUNT.STEP.VALIDATION).toBe(currentStep); + 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', () => { @@ -103,7 +103,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the company step - expect(CONST.BANK_ACCOUNT.STEP.COMPANY).toBe(currentStep); + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.COMPANY); }); it('Returns step based on open BankAccount that needs to pass checks and has attempted upgrade', () => { @@ -121,7 +121,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, true); // THEN it will be the validation step - expect(CONST.BANK_ACCOUNT.STEP.VALIDATION).toBe(currentStep); + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); }); it('Returns step based on open BankAccount that does not need to pass checks', () => { @@ -160,7 +160,7 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, true); // THEN it will be the enable step - expect(CONST.BANK_ACCOUNT.STEP.ENABLE).toBe(currentStep); + expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.ENABLE); }); it('Returns step based on deleted BankAccount and no currentStep', () => { @@ -175,6 +175,79 @@ describe('fetchFreePlanVerifiedBankAccount', () => { const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, true); // THEN it will be the bank account step - expect(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT).toBe(currentStep); + 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 = 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 = 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 = 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 = buildACHData(bankAccount, false); + expect(achData).toEqual({ + useOnfido: true, + policyID: '', + isInSetup: true, + bankAccountInReview: false, + domainLimit: 0, + }); }); }); From 447634df08675deb36ce3c5c7051b32cb06d6458 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 20:05:41 -1000 Subject: [PATCH 08/15] Fixup setupWithdrawalAccount --- .../fetchFreePlanVerifiedBankAccount.js | 2 +- .../setupWithdrawalAccount.js | 441 +++++++++++------- .../fetchFreePlanVerifiedBankAccountTest.js | 19 + 3 files changed, 286 insertions(+), 176 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index 575def94478..c9c57529a8e 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -115,7 +115,7 @@ function getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTr : getReimbursementAccountInSetup().currentStep; if (achData.isInSetup) { - return currentStep; + 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. diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index 656c1abb9cf..74a79eff58d 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -20,63 +20,150 @@ function setFreePlanVerifiedBankAccountID(bankAccountID) { } /** - * Create or update the bank account in db with the updated data. - * - * @param {Object} [data] - * - * // BankAccountStep - * @param {Boolean} [data.acceptTerms] - * @param {String} [data.accountNumber] - * @param {String} [data.routingNumber] - * @param {String} [data.setupType] - * @param {String} [data.country] - * @param {String} [data.currency] - * @param {String} [data.fieldsType] - * @param {String} [data.plaidAccessToken] - * @param {String} [data.plaidAccountID] - * @param {String} [data.ownershipType] - * @param {Boolean} [data.isSavings] - * @param {String} [data.addressName] - * - * // BeneficialOwnersStep - * @param {Boolean} [data.ownsMoreThan25Percent] - * @param {Boolean} [data.hasOtherBeneficialOwners] - * @param {Boolean} [data.acceptTermsAndConditions] - * @param {Boolean} [data.certifyTrueInformation] - * @param {Array} [data.beneficialOwners] - * - * // CompanyStep - * @param {String} [data.companyName] - * @param {String} [data.addressStreet] - * @param {String} [data.addressCity] - * @param {String} [data.addressState] - * @param {String} [data.addressZipCode] - * @param {String} [data.companyPhone] - * @param {String} [data.website] - * @param {String} [data.companyTaxID] - * @param {String} [data.incorporationType] - * @param {String} [data.incorporationState] - * @param {String} [data.incorporationDate] - * @param {Boolean} [data.hasNoConnectionToCannabis] + * @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; + + 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 getNextStepID(); +} + +/** * - * // RequestorStep - * @param {String} [data.dob] - * @param {String} [data.firstName] - * @param {String} [data.lastName] - * @param {String} [data.requestorAddressStreet] - * @param {String} [data.requestorAddressCity] - * @param {String} [data.requestorAddressState] - * @param {String} [data.requestorAddressZipCode] - * @param {String} [data.ssnLast4] - * @param {String} [data.isControllingOfficer] - * @param {Object} [data.onfidoData] - * @param {Boolean} [data.isOnfidoSetupComplete] + * @param {Object} response + * @param {String} verificationsError + * @param {Object} updatedACHData */ -function setupWithdrawalAccount(data) { - let nextStep; - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true, errorModalMessage: '', errors: null}); +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)) { + setBankAccountFormValidationErrors({routingNumber: true}); + showBankAccountErrorModal(); + } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_STATE) { + error = translateLocal('bankAccount.error.incorporationState'); + } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { + error = translateLocal('bankAccount.error.companyType'); + } else { + console.error(response.message); + } + } + + if (error) { + showBankAccountFormValidationError(error); + showBankAccountErrorModal(error, isErrorHTML); + } + + // Go to next step + goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { + ...responseACHData, + subStep: hasAccountOrRoutingError(response), + }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); +} - const newACHData = { +/** + * @param {Object} data + * @returns {Object} + */ +function mergeParamsWithLocalACHData(data) { + const updatedACHData = { ...getReimbursementAccountInSetup(), ...data, @@ -87,148 +174,152 @@ function setupWithdrawalAccount(data) { }; if (data && !_.isUndefined(data.isSavings)) { - newACHData.isSavings = Boolean(data.isSavings); + updatedACHData.isSavings = Boolean(data.isSavings); } - if (!newACHData.setupType) { - newACHData.setupType = newACHData.plaidAccountID + if (!updatedACHData.setupType) { + updatedACHData.setupType = updatedACHData.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(getPlaidBankAccounts(), bankAccount => ( bankAccount.plaidAccountID === data.plaidAccountID )); - newACHData.accountNumber = unmaskedAccount.accountNumber; + updatedACHData.accountNumber = unmaskedAccount.accountNumber; } + return updatedACHData; +} - API.BankAccount_SetupWithdrawal(newACHData) +/** + * @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)) { + goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { + ...achData, + sdkToken: getOnfidoTokenAndStatusFromACHData(achData).token, + }); + 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)) { + goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { + ...achData, + questions, + }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + return; + } + } + + goToWithdrawalAccountSetupStep(nextStep, { + ...achData, + }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); +} + +/** + * Create or update the bank account in db with the updated data. + * + * @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] + * + * // BeneficialOwnersStep + * @param {Boolean} [params.ownsMoreThan25Percent] + * @param {Boolean} [params.hasOtherBeneficialOwners] + * @param {Boolean} [params.acceptTermsAndConditions] + * @param {Boolean} [params.certifyTrueInformation] + * @param {Array} [params.beneficialOwners] + * + * // 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] + */ +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: {...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 = translateLocal('bankAccount.error.incorporationState'); - } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { - error = translateLocal('bankAccount.error.companyType'); - } else { - console.error(response.message); - } - } + 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; } - // Go to next step - goToWithdrawalAccountSetupStep(nextStep, achData); + // 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 (_.size(errors)) { - setBankAccountFormValidationErrors(errors); - showBankAccountErrorModal(); + if (currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR) { + checkDataAndMaybeStayOnRequestorStep(responseACHData, getNextStep(updatedACHData)); + return; } - if (error) { - showBankAccountFormValidationError(error); - showBankAccountErrorModal(error, isErrorHTML); + + if (currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT) { + getBankAccountListAndGoToValidateStep(responseACHData); + return; } + + // Go to next step + goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), responseACHData); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); }) .catch((response) => { - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...newACHData}}); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...updatedACHData}}); console.error(response.stack); showBankAccountErrorModal(translateLocal('common.genericErrorMessage')); }); diff --git a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js index 98a62ee3837..48a2a72c2b0 100644 --- a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js +++ b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js @@ -15,6 +15,25 @@ describe('getCurrentStep', () => { 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 = 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; From 1b125135177157d1e2d022aa7ee819f03fcb2168 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 20:10:42 -1000 Subject: [PATCH 09/15] Rename BeneficialOwners to ACHContractStep --- .../setupWithdrawalAccount.js | 15 ++++++++------- ...BeneficialOwnersStep.js => ACHContractStep.js} | 6 +++--- .../ReimbursementAccountPage.js | 4 ++-- 3 files changed, 13 insertions(+), 12 deletions(-) rename src/pages/ReimbursementAccount/{BeneficialOwnersStep.js => ACHContractStep.js} (99%) diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index 74a79eff58d..e141994af53 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -249,13 +249,6 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { * @param {Boolean} [params.isSavings] * @param {String} [params.addressName] * - * // BeneficialOwnersStep - * @param {Boolean} [params.ownsMoreThan25Percent] - * @param {Boolean} [params.hasOtherBeneficialOwners] - * @param {Boolean} [params.acceptTermsAndConditions] - * @param {Boolean} [params.certifyTrueInformation] - * @param {Array} [params.beneficialOwners] - * * // CompanyStep * @param {String} [params.companyName] * @param {String} [params.addressStreet] @@ -282,6 +275,14 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { * @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}); 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 faf2a67216c..fe7b621667d 100644 --- a/src/pages/ReimbursementAccount/BeneficialOwnersStep.js +++ b/src/pages/ReimbursementAccount/ACHContractStep.js @@ -41,7 +41,7 @@ const propTypes = { reimbursementAccount: reimbursementAccountPropTypes.isRequired, }; -class BeneficialOwnersStep extends React.Component { +class ACHContractStep extends React.Component { constructor(props) { super(props); @@ -302,7 +302,7 @@ class BeneficialOwnersStep extends React.Component { } } -BeneficialOwnersStep.propTypes = propTypes; +ACHContractStep.propTypes = propTypes; export default compose( withLocalize, withOnyx({ @@ -313,4 +313,4 @@ export default compose( key: ONYXKEYS.REIMBURSEMENT_ACCOUNT_DRAFT, }, }), -)(BeneficialOwnersStep); +)(ACHContractStep); diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js index 757fd7ab1fd..2261d5a7554 100644 --- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.js +++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.js @@ -27,7 +27,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'; @@ -223,7 +223,7 @@ class ReimbursementAccountPage extends React.Component { )} {currentStep === CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT && ( - + )} {currentStep === CONST.BANK_ACCOUNT.STEP.VALIDATION && ( From f6a4c606eef9cec732f9d804c6fd08c15aa9f9c1 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 3 Nov 2021 20:25:07 -1000 Subject: [PATCH 10/15] Add some comments --- .../fetchFreePlanVerifiedBankAccount.js | 11 ++++---- .../setupWithdrawalAccount.js | 27 ++++++++++++------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index c9c57529a8e..a5920964143 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -205,12 +205,13 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { const stepFromStorage = getReimbursementAccountInSetup().currentStep; const currentStep = getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade); - // '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, + throttledDate, + maxAttemptsReached, + error: '', + isPlaidDisabled, }); - goToWithdrawalAccountSetupStep(currentStep, achData); }); } diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index e141994af53..dcbe872d5bd 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -37,8 +37,10 @@ function getBankAccountListAndGoToValidateStep(updatedACHData) { achData.bankAccountInReview = needsToPassLatestChecks || achData.state === BankAccount.STATE.VERIFYING; - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, { + ...achData, + loading: false, + }); }); } @@ -154,8 +156,8 @@ function showSetupWithdrawalAccountErrors(response, verificationsError, updatedA goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { ...responseACHData, subStep: hasAccountOrRoutingError(response), + loading: false, }); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); } /** @@ -207,8 +209,8 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, sdkToken: getOnfidoTokenAndStatusFromACHData(achData).token, + loading: false, }); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); return; } } else if (requestorResponse) { @@ -218,22 +220,26 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, questions, + loading: false, }); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); return; } } goToWithdrawalAccountSetupStep(nextStep, { ...achData, + loading: false, }); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); } /** * Create or update the bank account in db with the updated data. * - * @param {Object} [params] + * 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] @@ -282,7 +288,6 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { * @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}); @@ -316,8 +321,10 @@ function setupWithdrawalAccount(params) { } // Go to next step - goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), responseACHData); - Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { + ...responseACHData, + loading: false, + }); }) .catch((response) => { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...updatedACHData}}); From 97e710970c3592193824116bf310f94dfddf3c94 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Fri, 5 Nov 2021 17:45:06 -1000 Subject: [PATCH 11/15] fix broken stuff --- .../fetchFreePlanVerifiedBankAccount.js | 4 +- .../setupWithdrawalAccount.js | 24 +- tests/actions/ReimbursementAccountTest.js | 371 ++++++++++++++++++ tests/utils/TestHelper.js | 2 +- 4 files changed, 383 insertions(+), 18 deletions(-) create mode 100644 tests/actions/ReimbursementAccountTest.js diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index a5920964143..49baf8ede11 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -205,8 +205,8 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { const stepFromStorage = getReimbursementAccountInSetup().currentStep; const currentStep = getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade); - goToWithdrawalAccountSetupStep(currentStep, { - ...achData, + goToWithdrawalAccountSetupStep(currentStep, achData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { throttledDate, maxAttemptsReached, error: '', diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index dcbe872d5bd..a39508ae052 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -37,10 +37,8 @@ function getBankAccountListAndGoToValidateStep(updatedACHData) { achData.bankAccountInReview = needsToPassLatestChecks || achData.state === BankAccount.STATE.VERIFYING; - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, { - ...achData, - loading: false, - }); + goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); }); } @@ -156,8 +154,8 @@ function showSetupWithdrawalAccountErrors(response, verificationsError, updatedA goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { ...responseACHData, subStep: hasAccountOrRoutingError(response), - loading: false, }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); } /** @@ -209,8 +207,8 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, sdkToken: getOnfidoTokenAndStatusFromACHData(achData).token, - loading: false, }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); return; } } else if (requestorResponse) { @@ -220,16 +218,14 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, questions, - loading: false, }); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); return; } } - goToWithdrawalAccountSetupStep(nextStep, { - ...achData, - loading: false, - }); + goToWithdrawalAccountSetupStep(nextStep, achData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); } /** @@ -321,10 +317,8 @@ function setupWithdrawalAccount(params) { } // Go to next step - goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { - ...responseACHData, - loading: false, - }); + goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), responseACHData); + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); }) .catch((response) => { Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false, achData: {...updatedACHData}}); diff --git a/tests/actions/ReimbursementAccountTest.js b/tests/actions/ReimbursementAccountTest.js new file mode 100644 index 00000000000..caea8c4616d --- /dev/null +++ b/tests/actions/ReimbursementAccountTest.js @@ -0,0 +1,371 @@ +import Onyx from 'react-native-onyx'; +import {fetchFreePlanVerifiedBankAccount, setupWithdrawalAccount} from '../../src/libs/actions/BankAccounts'; +import ONYXKEYS from '../../src/ONYXKEYS'; +import {signInWithTestUser} from '../utils/TestHelper'; +import HttpUtils from '../../src/libs/HttpUtils'; +import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; +import CONST from '../../src/CONST'; +import BankAccount from '../../src/libs/models/BankAccount'; +import PushNotification from '../../src/libs/Notification/PushNotification'; + +PushNotification.register = () => {}; +PushNotification.deregister = () => {}; + +const TEST_BANK_ACCOUNT_ID = 1; +const TEST_BANK_ACCOUNT_CITY = 'Opa-locka'; +const TEST_BANK_ACCOUNT_STATE = 'FL'; +const TEST_BANK_ACCOUNT_STREET = '1234 Sesame Street'; +const TEST_BANK_ACCOUNT_ZIP = '33054'; +const TEST_BANK_ACCOUNT_NUMBER = '1111222233331111'; +const TEST_BANK_ACCOUNT_NUMBER_MASKED = '111122XXXXXX1111'; +const TEST_BANK_ACCOUNT_ROUTING_NUMBER = '011401533'; +const TEST_BANK_ACCOUNT_WEBSITE = 'https://www.test.com'; + +const FREE_PLAN_NVP_RESPONSE = { + jsonCode: 200, + nameValuePairs: { + expensify_freePlanBankAccountID: TEST_BANK_ACCOUNT_ID, + }, +}; + +HttpUtils.xhr = jest.fn(); + +let reimbursementAccount; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: val => reimbursementAccount = val, +}); + +beforeEach(() => Onyx.clear() + .then(() => { + signInWithTestUser(); + return waitForPromisesToResolve(); + })); + +describe('actions/BankAccounts', () => { + // eslint-disable-next-line arrow-body-style + it('should fetch the correct initial state for a user with no account in setup. And direct them to the correct steps after calling SetupWithdrawalAccount', () => { + // GIVEN a mock response for a call to Get&returnValueList=nameValuePairs&name=expensify_freePlanBankAccountID that should return nothing + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: [], + })); + + // and a mock response for a call to Get&returnValueList=nameValuePairs,bankAccountList&nvpNames that should return no account + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: [], + bankAccountList: [], + })); + + // WHEN we fetch the bank account + fetchFreePlanVerifiedBankAccount(); + return waitForPromisesToResolve() + .then(() => { + // THEN we should expect it to stop loading and bring us to the BankAccountStep + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); + expect(reimbursementAccount.achData.isInSetup).toBe(true); + + // WHEN we mock a successful call to SetupWithdrawalAccount with a manual account + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + })); + setupWithdrawalAccount({ + acceptTerms: true, + country: 'US', + currency: 'USD', + accountNumber: TEST_BANK_ACCOUNT_NUMBER, + fieldsType: 'local', + routingNumber: TEST_BANK_ACCOUNT_ROUTING_NUMBER, + setupType: CONST.BANK_ACCOUNT.SUBSTEP.MANUAL, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN we should advance to the CompanyStep and the enableCardAfterVerified param should be added + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.COMPANY); + expect(reimbursementAccount.achData.enableCardAfterVerified).toBe(true); + expect(reimbursementAccount.achData.setupType).toBe(CONST.BANK_ACCOUNT.SUBSTEP.MANUAL); + + // GIVEN another mock response to simulate the user completing the CompanyStep + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + achData: { + bankAccountID: TEST_BANK_ACCOUNT_ID, + }, + })); + + // and a mock to SetNameValuePair call that updates the "free plan" bankAccountID + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + })); + + // WHEN we call setupWithdrawalAccount again with CompanyStep data + setupWithdrawalAccount({ + companyName: 'Alberta Bobbeth Charleson', + companyPhone: '5165671515', + companyTaxID: '123456789', + incorporationDate: '2021-01-01', + incorporationState: TEST_BANK_ACCOUNT_STATE, + incorporationType: 'LLC', + addressCity: TEST_BANK_ACCOUNT_CITY, + addressState: TEST_BANK_ACCOUNT_STATE, + addressStreet: TEST_BANK_ACCOUNT_STREET, + addressZipCode: TEST_BANK_ACCOUNT_ZIP, + hasNoConnectionToCannabis: true, + website: TEST_BANK_ACCOUNT_WEBSITE, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN we should advance to the RequestorStep + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + }); + }); + + it('fetch the correct step for account in setup that has completed the CompanyStep. Redirect to the ACHContract step after calling SetupWithdrawalAccount via RequestorStep', () => { + // GIVEN a mock response for a call to Get&returnValueList=nameValuePairs&name=expensify_freePlanBankAccountID that returns a bankAccountID + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve(FREE_PLAN_NVP_RESPONSE)); + + // and a mock response for a call to Get&returnValueList=nameValuePairs,bankAccountList&nvpNames that should return a bank account in the list + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: [], + bankAccountList: [{ + accountNumber: TEST_BANK_ACCOUNT_NUMBER_MASKED, + additionalData: { + currentStep: CONST.BANK_ACCOUNT.STEP.COMPANY, + }, + bankAccountID: TEST_BANK_ACCOUNT_ID, + state: BankAccount.STATE.SETUP, + routingNumber: TEST_BANK_ACCOUNT_ROUTING_NUMBER, + }], + })); + + // WHEN we fetch the bank account + fetchFreePlanVerifiedBankAccount(); + return waitForPromisesToResolve() + .then(() => { + // THEN we should to navigate to the RequestorStep + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + expect(reimbursementAccount.achData.bankAccountID).toBe(TEST_BANK_ACCOUNT_ID); + expect(reimbursementAccount.achData.isInSetup).toBe(true); + expect(reimbursementAccount.achData.accountNumber).toBe(TEST_BANK_ACCOUNT_NUMBER_MASKED); + expect(reimbursementAccount.achData.routingNumber).toBe(TEST_BANK_ACCOUNT_ROUTING_NUMBER); + expect(reimbursementAccount.achData.state).toBe(BankAccount.STATE.SETUP); + + // GIVEN a mocked response for SetupWithdrawalAccount + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + achData: { + bankAccountID: TEST_BANK_ACCOUNT_ID, + isOnfidoSetupComplete: true, + }, + })); + + // And mock resonse to SetNameValuePair + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({jsonCode: 200})); + + // WHEN we call setupWithdrawalAccount on the RequestorStep + setupWithdrawalAccount({ + dob: '1980-01-01', + firstName: 'Alberta', + isControllingOfficer: true, + lastName: 'Charleson', + onfidoData: '', + ssnLast4: '1234', + requestorAddressCity: TEST_BANK_ACCOUNT_CITY, + requestorAddressState: TEST_BANK_ACCOUNT_STATE, + requestorAddressStreet: TEST_BANK_ACCOUNT_STREET, + requestorAddressZipCode: TEST_BANK_ACCOUNT_ZIP, + isOnfidoSetupComplete: false, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN we should move to the ACHContract step and Onfido should be marked as complete + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT); + expect(reimbursementAccount.achData.isOnfidoSetupComplete).toBe(true); + }); + }); + + it('should fetch the correct initial state for a user with an account in setup (bailed after RequestorStep and did NOT complete Onfido)', () => { + // GIVEN a mock response for a call to Get&returnValueList=nameValuePairs&name=expensify_freePlanBankAccountID that returns a bankAccountID + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve(FREE_PLAN_NVP_RESPONSE)); + + // and a mock response for a call to Get&returnValueList=nameValuePairs,bankAccountList&nvpNames that should return a bank account that has completed both + // the RequestorStep and CompanyStep, but not completed Onfido + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: [], + bankAccountList: [{ + accountNumber: TEST_BANK_ACCOUNT_NUMBER_MASKED, + additionalData: { + currentStep: CONST.BANK_ACCOUNT.STEP.REQUESTOR, + isOnfidoSetupComplete: false, + }, + bankAccountID: TEST_BANK_ACCOUNT_ID, + state: BankAccount.STATE.SETUP, + routingNumber: TEST_BANK_ACCOUNT_ROUTING_NUMBER, + }], + })); + + // WHEN we fetch the bank account + fetchFreePlanVerifiedBankAccount(); + return waitForPromisesToResolve() + .then(() => { + // THEN we should expect it redirect the user back to the RequestorStep because they still need to do Onfido + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); + }); + }); + + it('should fetch the correct initial state for a user with an account in setup (bailed after RequestorStep - but completed Onfido)', () => { + // GIVEN a mock response for a call to Get&returnValueList=nameValuePairs&name=expensify_freePlanBankAccountID that returns a bankAccountID + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve(FREE_PLAN_NVP_RESPONSE)); + + // and a mock response for a call to Get&returnValueList=nameValuePairs,bankAccountList&nvpNames that should return a bank account + // that has completed both the RequestorStep and CompanyStep and has completed Onfido + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: [], + bankAccountList: [{ + accountNumber: TEST_BANK_ACCOUNT_NUMBER_MASKED, + additionalData: { + currentStep: CONST.BANK_ACCOUNT.STEP.REQUESTOR, + isOnfidoSetupComplete: true, + }, + bankAccountID: TEST_BANK_ACCOUNT_ID, + state: BankAccount.STATE.SETUP, + routingNumber: TEST_BANK_ACCOUNT_ROUTING_NUMBER, + }], + })); + + // WHEN we fetch the bank account + fetchFreePlanVerifiedBankAccount(); + return waitForPromisesToResolve() + .then(() => { + // THEN we should expect to be navigated to the ACHContractStep step + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT); + + // WHEN we mock a sucessful call to SetupWithdrawalAccount while on the ACHContractStep + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + achData: { + bankAccountID: TEST_BANK_ACCOUNT_ID, + }, + })); + + // And mock SetNameValuePair response + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({jsonCode: 200})); + + // And mock the response of Get&returnValueList=bankAccountList + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + bankAccountList: [{ + bankAccountID: TEST_BANK_ACCOUNT_ID, + state: BankAccount.STATE.PENDING, + }], + })); + + // WHEN we call setupWithdrawalAccount via the ACHContractStep + setupWithdrawalAccount({ + acceptTermsAndConditions: true, + beneficialOwners: [], + certifyTrueInformation: true, + hasOtherBeneficialOwners: false, + ownsMoreThan25Percent: true, + }); + return waitForPromisesToResolve(); + }) + .then(() => { + // THEN we should expect to have an account in the PENDING state and be brought to the ValidationStep + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); + expect(reimbursementAccount.achData.state).toBe(BankAccount.STATE.PENDING); + }); + }); + + it('should fetch the correct initial state for a user on the ACHContractStep in PENDING state', () => { + // GIVEN a mock response for a call to Get&returnValueList=nameValuePairs&name=expensify_freePlanBankAccountID that returns a bankAccountID + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve(FREE_PLAN_NVP_RESPONSE)); + + // and a mock response for a call to Get&returnValueList=nameValuePairs,bankAccountList&nvpNames that should return a bank account that has completed both + // the RequestorStep, CompanyStep, and ACHContractStep and is now PENDING + HttpUtils.xhr + .mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: [], + bankAccountList: [{ + accountNumber: TEST_BANK_ACCOUNT_NUMBER_MASKED, + additionalData: { + currentStep: CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT, + isOnfidoSetupComplete: true, + }, + bankAccountID: TEST_BANK_ACCOUNT_ID, + state: BankAccount.STATE.PENDING, + routingNumber: TEST_BANK_ACCOUNT_ROUTING_NUMBER, + }], + })); + + // WHEN we fetch the account + fetchFreePlanVerifiedBankAccount(); + return waitForPromisesToResolve() + .then(() => { + // THEN we should see that we are directed to the ValidationStep + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.achData.currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); + expect(reimbursementAccount.achData.state).toBe(BankAccount.STATE.PENDING); + }); + }); + + it('should return the correct state when a user has reached the max validation attempts', () => { + // GIVEN a mock response for a call to Get&returnValueList=nameValuePairs&name=expensify_freePlanBankAccountID that returns a bankAccountID + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve(FREE_PLAN_NVP_RESPONSE)); + + // and a mock response for a call to Get&returnValueList=nameValuePairs,bankAccountList&nvpNames that + // should return a bank account that has completed both the RequestorStep, CompanyStep, and ACHContractStep + // and is now PENDING - but has attempted to validate too many times. + HttpUtils.xhr.mockImplementationOnce(() => Promise.resolve({ + jsonCode: 200, + nameValuePairs: { + [`private_failedBankValidations_${TEST_BANK_ACCOUNT_ID}`]: 8, + }, + bankAccountList: [{ + accountNumber: TEST_BANK_ACCOUNT_NUMBER_MASKED, + additionalData: { + currentStep: CONST.BANK_ACCOUNT.STEP.ACH_CONTRACT, + isOnfidoSetupComplete: true, + }, + bankAccountID: TEST_BANK_ACCOUNT_ID, + state: BankAccount.STATE.PENDING, + routingNumber: TEST_BANK_ACCOUNT_ROUTING_NUMBER, + }], + })); + + // WHEN we fetch the account + fetchFreePlanVerifiedBankAccount(); + return waitForPromisesToResolve() + .then(() => { + // THEN it should have maxAttemptsReached set to true and show the correct data set in Onyx + expect(reimbursementAccount.loading).toBe(false); + expect(reimbursementAccount.error).toBe(''); + expect(reimbursementAccount.maxAttemptsReached).toBe(true); + }); + }); +}); diff --git a/tests/utils/TestHelper.js b/tests/utils/TestHelper.js index 1328d251bd6..f870cb61571 100644 --- a/tests/utils/TestHelper.js +++ b/tests/utils/TestHelper.js @@ -13,7 +13,7 @@ import waitForPromisesToResolve from './waitForPromisesToResolve'; * @param {String} authToken * @return {Promise} */ -function signInWithTestUser(accountID, login, password = 'Password1', authToken = 'asdfqwerty') { +function signInWithTestUser(accountID = 1, login = 'test@user.com', password = 'Password1', authToken = 'asdfqwerty') { const originalXhr = HttpUtils.xhr; HttpUtils.xhr = jest.fn(); HttpUtils.xhr.mockImplementation(() => Promise.resolve({ From c51e95d455bf93da06cb4c8265c5e352801bb133 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Tue, 9 Nov 2021 11:35:03 -0500 Subject: [PATCH 12/15] add Network.setIsReady() check --- tests/actions/ReimbursementAccountTest.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/actions/ReimbursementAccountTest.js b/tests/actions/ReimbursementAccountTest.js index caea8c4616d..7bfec45f29b 100644 --- a/tests/actions/ReimbursementAccountTest.js +++ b/tests/actions/ReimbursementAccountTest.js @@ -7,6 +7,7 @@ import waitForPromisesToResolve from '../utils/waitForPromisesToResolve'; import CONST from '../../src/CONST'; import BankAccount from '../../src/libs/models/BankAccount'; import PushNotification from '../../src/libs/Notification/PushNotification'; +import * as Network from '../../src/libs/Network'; PushNotification.register = () => {}; PushNotification.deregister = () => {}; @@ -38,6 +39,7 @@ Onyx.connect({ beforeEach(() => Onyx.clear() .then(() => { + Network.setIsReady(true); signInWithTestUser(); return waitForPromisesToResolve(); })); From bc1a7d6d65f3d9a3706aedf8bb087b09bfeb6b66 Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 24 Nov 2021 11:42:34 -1000 Subject: [PATCH 13/15] Fix lint errors --- src/libs/actions/BankAccounts.js | 34 ++++----------- src/libs/actions/Plaid.js | 4 +- .../fetchFreePlanVerifiedBankAccount.js | 16 +++---- .../actions/ReimbursementAccount/index.js | 10 ++--- .../ReimbursementAccount/navigation.js | 6 +-- .../resetFreePlanBankAccount.js | 10 ++--- .../setupWithdrawalAccount.js | 42 +++++++++---------- .../validateBankAccount.js | 8 ++-- .../fetchFreePlanVerifiedBankAccountTest.js | 32 +++++++------- 9 files changed, 71 insertions(+), 91 deletions(-) diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index ae99c81b1e9..587b33c94ae 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -1,7 +1,9 @@ import _ from 'underscore'; import CONST from '../../CONST'; import * as API from '../API'; -import { +import * as Plaid from './Plaid'; + +export { setupWithdrawalAccount, fetchFreePlanVerifiedBankAccount, goToWithdrawalAccountSetupStep, @@ -17,7 +19,7 @@ import { requestResetFreePlanBankAccount, cancelResetFreePlanBankAccount, } from './ReimbursementAccount'; -import { +export { fetchPlaidBankAccounts, clearPlaidBankAccountsAndToken, fetchPlaidLinkToken, @@ -25,7 +27,7 @@ import { getBankName, getPlaidAccessToken, } from './Plaid'; -import { +export { fetchOnfidoToken, activateWallet, fetchUserWallet, @@ -39,7 +41,7 @@ import { * @param {String} plaidLinkToken */ function addPersonalBankAccount(account, password, plaidLinkToken) { - const unmaskedAccount = _.find(getPlaidBankAccounts(), bankAccount => ( + const unmaskedAccount = _.find(Plaid.getPlaidBankAccounts(), bankAccount => ( bankAccount.plaidAccountID === account.plaidAccountID )); API.BankAccount_Create({ @@ -58,14 +60,14 @@ function addPersonalBankAccount(account, password, plaidLinkToken) { isInSetup: true, bankAccountInReview: null, currentStep: 'AccountOwnerInformationStep', - bankName: getBankName(), + bankName: Plaid.getBankName(), plaidAccountID: unmaskedAccount.plaidAccountID, ownershipType: '', acceptTerms: true, country: 'US', currency: CONST.CURRENCY.USD, fieldsType: 'local', - plaidAccessToken: getPlaidAccessToken(), + plaidAccessToken: Plaid.getPlaidAccessToken(), }), }) .then((response) => { @@ -79,25 +81,5 @@ function addPersonalBankAccount(account, password, plaidLinkToken) { } export { - activateWallet, addPersonalBankAccount, - clearPlaidBankAccountsAndToken, - fetchFreePlanVerifiedBankAccount, - fetchOnfidoToken, - fetchPlaidLinkToken, - fetchUserWallet, - fetchPlaidBankAccounts, - goToWithdrawalAccountSetupStep, - setupWithdrawalAccount, - validateBankAccount, - hideBankAccountErrors, - showBankAccountErrorModal, - showBankAccountFormValidationError, - setBankAccountFormValidationErrors, - setWorkspaceIDForReimbursementAccount, - setBankAccountSubStep, - updateReimbursementAccountDraft, - requestResetFreePlanBankAccount, - cancelResetFreePlanBankAccount, - resetFreePlanBankAccount, }; diff --git a/src/libs/actions/Plaid.js b/src/libs/actions/Plaid.js index eab70d95957..cd8e7cb6ef6 100644 --- a/src/libs/actions/Plaid.js +++ b/src/libs/actions/Plaid.js @@ -5,7 +5,7 @@ import ONYXKEYS from '../../ONYXKEYS'; import CONST from '../../CONST'; import * as API from '../API'; import Growl from '../Growl'; -import {translateLocal} from '../translate'; +import * as Localize from '../Localize'; /** * List of bank accounts. This data should not be stored in Onyx since it contains unmasked PANs. @@ -64,7 +64,7 @@ function fetchPlaidBankAccounts(publicToken, bank) { plaidBankAccounts = _.filter(response.accounts, account => !account.alreadyExists); if (plaidBankAccounts.length === 0) { - Growl.error(translateLocal('bankAccount.error.noBankAccountAvailable')); + Growl.error(Localize.translateLocal('bankAccount.error.noBankAccountAvailable')); } Onyx.merge(ONYXKEYS.PLAID_BANK_ACCOUNTS, { diff --git a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js index 49baf8ede11..6fcbb370301 100644 --- a/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount.js @@ -4,8 +4,8 @@ import lodashGet from 'lodash/get'; import * as API from '../../API'; import CONST from '../../../CONST'; import ONYXKEYS from '../../../ONYXKEYS'; -import {getNextStepToComplete, goToWithdrawalAccountSetupStep} from './navigation'; -import {getReimbursementAccountInSetup, getReimbursementAccountWorkspaceID} from './store'; +import * as navigation from './navigation'; +import * as store from './store'; import BankAccount from '../../models/BankAccount'; /** @@ -111,8 +111,8 @@ function getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTr // 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 - ? getNextStepToComplete(achData) - : getReimbursementAccountInSetup().currentStep; + ? navigation.getNextStepToComplete(achData) + : store.getReimbursementAccountInSetup().currentStep; if (achData.isInSetup) { return currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; @@ -170,7 +170,7 @@ function buildACHData(bankAccount, hasTriedToUpgrade, subStep) { return { ...(bankAccount ? bankAccount.toACHData() : {}), useOnfido: true, - policyID: getReimbursementAccountWorkspaceID() || '', + policyID: store.getReimbursementAccountWorkspaceID() || '', isInSetup: !bankAccount || bankAccount.isInSetup(), bankAccountInReview: getIsBankAccountInReview(bankAccount, hasTriedToUpgrade), domainLimit: 0, @@ -199,13 +199,13 @@ function fetchFreePlanVerifiedBankAccount(stepToOpen, localBankAccountState) { 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(getReimbursementAccountInSetup(), 'subStep', ''); + const subStep = lodashGet(store.getReimbursementAccountInSetup(), 'subStep', ''); const hasTriedToUpgrade = getHasTriedToUpgrade(bankAccount, kycVerificationsMigration); const achData = buildACHData(bankAccount, hasTriedToUpgrade, subStep); - const stepFromStorage = getReimbursementAccountInSetup().currentStep; + const stepFromStorage = store.getReimbursementAccountInSetup().currentStep; const currentStep = getCurrentStep(stepToOpen, stepFromStorage, achData, bankAccount, hasTriedToUpgrade); - goToWithdrawalAccountSetupStep(currentStep, achData); + navigation.goToWithdrawalAccountSetupStep(currentStep, achData); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, { throttledDate, maxAttemptsReached, diff --git a/src/libs/actions/ReimbursementAccount/index.js b/src/libs/actions/ReimbursementAccount/index.js index 4bee1c11b72..8587ca38f3c 100644 --- a/src/libs/actions/ReimbursementAccount/index.js +++ b/src/libs/actions/ReimbursementAccount/index.js @@ -1,12 +1,14 @@ import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../../ONYXKEYS'; -import {showBankAccountErrorModal, setBankAccountFormValidationErrors, showBankAccountFormValidationError} from './errors'; -import {goToWithdrawalAccountSetupStep} from './navigation'; 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 * @@ -48,10 +50,6 @@ function cancelResetFreePlanBankAccount() { export { setupWithdrawalAccount, fetchFreePlanVerifiedBankAccount, - goToWithdrawalAccountSetupStep, - showBankAccountErrorModal, - showBankAccountFormValidationError, - setBankAccountFormValidationErrors, resetFreePlanBankAccount, validateBankAccount, setBankAccountSubStep, diff --git a/src/libs/actions/ReimbursementAccount/navigation.js b/src/libs/actions/ReimbursementAccount/navigation.js index 47b271fcb6d..a4638f001a3 100644 --- a/src/libs/actions/ReimbursementAccount/navigation.js +++ b/src/libs/actions/ReimbursementAccount/navigation.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; import lodashHas from 'lodash/has'; import Onyx from 'react-native-onyx'; -import {getReimbursementAccountInSetup} from './store'; +import * as store from './store'; import CONST from '../../../CONST'; import ONYXKEYS from '../../../ONYXKEYS'; @@ -50,7 +50,7 @@ function getIndexByStepID(stepID) { */ function getNextStepID(stepID) { const nextStepIndex = Math.min( - getIndexByStepID(stepID || getReimbursementAccountInSetup().currentStep) + 1, + getIndexByStepID(stepID || store.getReimbursementAccountInSetup().currentStep) + 1, WITHDRAWAL_ACCOUNT_STEPS.length - 1, ); return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); @@ -75,7 +75,7 @@ function getNextStepToComplete(achData) { * @param {Object} achData */ function goToWithdrawalAccountSetupStep(stepID, achData) { - const newACHData = {...getReimbursementAccountInSetup()}; + 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) { diff --git a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js index 910fce31df2..2993fac460c 100644 --- a/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/resetFreePlanBankAccount.js @@ -3,26 +3,26 @@ import lodashGet from 'lodash/get'; import ONYXKEYS from '../../../ONYXKEYS'; import * as API from '../../API'; import CONST from '../../../CONST'; -import {getReimbursementAccountInSetup, getCredentials} from './store'; +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(getReimbursementAccountInSetup(), 'bankAccountID'); + const bankAccountID = lodashGet(store.getReimbursementAccountInSetup(), 'bankAccountID'); if (!bankAccountID) { throw new Error('Missing bankAccountID when attempting to reset free plan bank account'); } - if (!getCredentials() || !getCredentials().login) { + 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 = {...getReimbursementAccountInSetup()}; + const previousACHData = {...store.getReimbursementAccountInSetup()}; Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: null, shouldShowResetModal: false}); - API.DeleteBankAccount({bankAccountID, ownerEmail: getCredentials().login}) + 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 diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index a39508ae052..87ee28a44d0 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -2,14 +2,14 @@ import _ from 'underscore'; import Onyx from 'react-native-onyx'; import lodashGet from 'lodash/get'; import BankAccount from '../../models/BankAccount'; -import {getPlaidBankAccounts} from '../Plaid'; +import * as Plaid from '../Plaid'; import CONST from '../../../CONST'; import ONYXKEYS from '../../../ONYXKEYS'; -import {getReimbursementAccountInSetup} from './store'; +import * as store from './store'; import * as API from '../../API'; -import {setBankAccountFormValidationErrors, showBankAccountErrorModal, showBankAccountFormValidationError} from './errors'; -import {translateLocal} from '../../translate'; -import {getNextStepID, goToWithdrawalAccountSetupStep} from './navigation'; +import * as errors from './errors'; +import * as Localize from '../../Localize'; +import * as navigation from './navigation'; /** * @private @@ -37,7 +37,7 @@ function getBankAccountListAndGoToValidateStep(updatedACHData) { achData.bankAccountInReview = needsToPassLatestChecks || achData.state === BankAccount.STATE.VERIFYING; - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData); + navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.VALIDATION, achData); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); }); } @@ -112,7 +112,7 @@ function getNextStep(updatedACHData) { return currentStep; } - return getNextStepID(); + return navigation.getNextStepID(); } /** @@ -134,24 +134,24 @@ function showSetupWithdrawalAccountErrors(response, verificationsError, updatedA if (response.jsonCode === 402) { if (hasAccountOrRoutingError(response)) { - setBankAccountFormValidationErrors({routingNumber: true}); - showBankAccountErrorModal(); + errors.setBankAccountFormValidationErrors({routingNumber: true}); + errors.showBankAccountErrorModal(); } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_STATE) { - error = translateLocal('bankAccount.error.incorporationState'); + error = Localize.translateLocal('bankAccount.error.incorporationState'); } else if (response.message === CONST.BANK_ACCOUNT.ERROR.MISSING_INCORPORATION_TYPE) { - error = translateLocal('bankAccount.error.companyType'); + error = Localize.translateLocal('bankAccount.error.companyType'); } else { console.error(response.message); } } if (error) { - showBankAccountFormValidationError(error); - showBankAccountErrorModal(error, isErrorHTML); + errors.showBankAccountFormValidationError(error); + errors.showBankAccountErrorModal(error, isErrorHTML); } // Go to next step - goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { + navigation.goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), { ...responseACHData, subStep: hasAccountOrRoutingError(response), }); @@ -164,7 +164,7 @@ function showSetupWithdrawalAccountErrors(response, verificationsError, updatedA */ function mergeParamsWithLocalACHData(data) { const updatedACHData = { - ...getReimbursementAccountInSetup(), + ...store.getReimbursementAccountInSetup(), ...data, // This param tells Web-Secure that this bank account is from NewDot so we can modify links back to the correct @@ -184,7 +184,7 @@ function mergeParamsWithLocalACHData(data) { // If we are setting up a Plaid account replace the accountNumber with the unmasked number if (data.plaidAccountID) { - const unmaskedAccount = _.find(getPlaidBankAccounts(), bankAccount => ( + const unmaskedAccount = _.find(Plaid.getPlaidBankAccounts(), bankAccount => ( bankAccount.plaidAccountID === data.plaidAccountID )); updatedACHData.accountNumber = unmaskedAccount.accountNumber; @@ -204,7 +204,7 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { if (achData.useOnfido) { if (needsToDoOnfido(achData)) { - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { + navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, sdkToken: getOnfidoTokenAndStatusFromACHData(achData).token, }); @@ -215,7 +215,7 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { // Don't go to next step if Requestor Step needs to ask some questions const questions = getRequestorQuestions(requestorResponse); if (!_.isEmpty(questions)) { - goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { + navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, questions, }); @@ -224,7 +224,7 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { } } - goToWithdrawalAccountSetupStep(nextStep, achData); + navigation.goToWithdrawalAccountSetupStep(nextStep, achData); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); } @@ -317,13 +317,13 @@ function setupWithdrawalAccount(params) { } // Go to next step - goToWithdrawalAccountSetupStep(getNextStep(updatedACHData), responseACHData); + 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); - showBankAccountErrorModal(translateLocal('common.genericErrorMessage')); + errors.showBankAccountErrorModal(Localize.translateLocal('common.genericErrorMessage')); }); } diff --git a/src/libs/actions/ReimbursementAccount/validateBankAccount.js b/src/libs/actions/ReimbursementAccount/validateBankAccount.js index 91bd426863c..dd313dbb33a 100644 --- a/src/libs/actions/ReimbursementAccount/validateBankAccount.js +++ b/src/libs/actions/ReimbursementAccount/validateBankAccount.js @@ -3,8 +3,8 @@ import ONYXKEYS from '../../../ONYXKEYS'; import * as API from '../../API'; import BankAccount from '../../models/BankAccount'; import CONST from '../../../CONST'; -import {translateLocal} from '../../translate'; -import {showBankAccountErrorModal} from './errors'; +import * as Localize from '../../Localize'; +import * as errors from './errors'; /** * @param {Number} bankAccountID @@ -40,13 +40,13 @@ function validateBankAccount(bankAccountID, validateCode) { // If the validation amounts entered were incorrect, show specific error if (response.message === CONST.BANK_ACCOUNT.ERROR.INCORRECT_VALIDATION_AMOUNTS) { - showBankAccountErrorModal(translateLocal('bankAccount.error.validationAmounts')); + 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 - showBankAccountErrorModal(response.message); + errors.showBankAccountErrorModal(response.message); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); }); } diff --git a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js index 48a2a72c2b0..21f44ec58c3 100644 --- a/tests/unit/fetchFreePlanVerifiedBankAccountTest.js +++ b/tests/unit/fetchFreePlanVerifiedBankAccountTest.js @@ -1,5 +1,5 @@ import CONST from '../../src/CONST'; -import {getCurrentStep, buildACHData} from '../../src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount'; +import * as fetchFreePlanVerifiedBankAccount from '../../src/libs/actions/ReimbursementAccount/fetchFreePlanVerifiedBankAccount'; import BankAccount from '../../src/libs/models/BankAccount'; describe('getCurrentStep', () => { @@ -9,7 +9,7 @@ describe('getCurrentStep', () => { const achData = {}; // WHEN we get the current step - const currentStep = getCurrentStep('', '', achData, nullBankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep('', '', achData, nullBankAccount, false); // THEN it will be the BankAccountStep expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); @@ -28,7 +28,7 @@ describe('getCurrentStep', () => { }; // WHEN we get the current step - const currentStep = getCurrentStep('', '', achData, nullBankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep('', '', achData, nullBankAccount, false); // THEN it will be the BankAccountStep expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); @@ -41,7 +41,7 @@ describe('getCurrentStep', () => { const stepToOpen = CONST.BANK_ACCOUNT.STEP.COMPANY; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, nullBankAccount, false); + 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); @@ -54,7 +54,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the logical next step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); @@ -71,7 +71,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN we will stay on the requestor step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.REQUESTOR); @@ -86,7 +86,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the validation step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); @@ -101,7 +101,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the validation step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); @@ -119,7 +119,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, false); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, false); // THEN it will be the company step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.COMPANY); @@ -137,7 +137,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, true); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, true); // THEN it will be the validation step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.VALIDATION); @@ -176,7 +176,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, true); + const currentStep = fetchFreePlanVerifiedBankAccount.getCurrentStep(stepToOpen, '', achData, bankAccount, true); // THEN it will be the enable step expect(currentStep).toBe(CONST.BANK_ACCOUNT.STEP.ENABLE); @@ -191,7 +191,7 @@ describe('getCurrentStep', () => { const stepToOpen = ''; // WHEN we get the current step - const currentStep = getCurrentStep(stepToOpen, '', achData, bankAccount, true); + 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); @@ -203,7 +203,7 @@ describe('buildACHData()', () => { const bankAccount = new BankAccount({ state: BankAccount.STATE.SETUP, }); - const achData = buildACHData(bankAccount, false); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, false); expect(achData).toEqual({ useOnfido: true, policyID: '', @@ -225,7 +225,7 @@ describe('buildACHData()', () => { hasFullSSN: false, }, }); - const achData = buildACHData(bankAccount, true); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, true); expect(achData).toEqual({ useOnfido: true, policyID: '', @@ -244,7 +244,7 @@ describe('buildACHData()', () => { const bankAccount = new BankAccount({ state: BankAccount.STATE.VERIFYING, }); - const achData = buildACHData(bankAccount, false); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, false); expect(achData).toEqual({ useOnfido: true, policyID: '', @@ -260,7 +260,7 @@ describe('buildACHData()', () => { it('Returns the correct shape for no account', () => { const bankAccount = undefined; - const achData = buildACHData(bankAccount, false); + const achData = fetchFreePlanVerifiedBankAccount.buildACHData(bankAccount, false); expect(achData).toEqual({ useOnfido: true, policyID: '', From 6d017785c0b2bc8467982fd2b39fce20e65c87bc Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Wed, 24 Nov 2021 11:51:58 -1000 Subject: [PATCH 14/15] fix typo --- src/components/AddPlaidBankAccount.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) => { From 8f5a450b14515f3954e50480954a8b76e3318f6d Mon Sep 17 00:00:00 2001 From: Marc Glasser Date: Tue, 30 Nov 2021 12:36:42 -1000 Subject: [PATCH 15/15] use sdkToken not token --- src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index 87ee28a44d0..e1b5cd0ab08 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -206,7 +206,7 @@ function checkDataAndMaybeStayOnRequestorStep(achData, nextStep) { if (needsToDoOnfido(achData)) { navigation.goToWithdrawalAccountSetupStep(CONST.BANK_ACCOUNT.STEP.REQUESTOR, { ...achData, - sdkToken: getOnfidoTokenAndStatusFromACHData(achData).token, + sdkToken: getOnfidoTokenAndStatusFromACHData(achData).sdkToken, }); Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); return;