diff --git a/src/CONST.js b/src/CONST.js index 44d5b29519ed..ef7f2109060e 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -6,8 +6,41 @@ const CONST = { IOS: 'https://apps.apple.com/us/app/expensify-cash/id1530278510', DESKTOP: 'https://expensify.cash/Expensify.cash.dmg', }, + SMS: { + DOMAIN: '@expensify.sms', + }, BANK_ACCOUNT: { - ADD_METHOD: { + STEP: { + // In the order they appear in the VBA flow + BANK_ACCOUNT: 'BankAccountStep', + COMPANY: 'CompanyStep', + REQUESTOR: 'RequestorStep', + ACH_CONTRACT: 'ACHContractStep', + VALIDATION: 'ValidationStep', + ENABLE: 'EnableStep', + }, + SUBSTEP: { + MANUAL: 'manual', + }, + VERIFICATIONS: { + ERROR_MESSAGE: 'verifications.errorMessage', + EXTERNAL_API_RESPONSES: 'verifications.externalApiResponses', + REQUESTOR_IDENTITY_ID: 'verifications.externalApiResponses.requestorIdentityID', + REQUESTOR_IDENTITY_ONFIDO: 'verifications.externalApiResponses.requestorIdentityOnfido', + THROTTLED: 'verifications.throttled', + }, + FIELDS_TYPE: { + LOCAL: 'local', + }, + ONFIDO_RESPONSE: { + SDK_TOKEN: 'apiResult.sdkToken', + PASS: 'pass', + }, + QUESTIONS: { + QUESTION: 'apiResult.questions.question', + DIFFERENTIATOR_QUESTION: 'apiResult.differentiator-question', + }, + SETUP_TYPE: { MANUAL: 'manual', PLAID: 'plaid', }, @@ -30,6 +63,12 @@ const CONST = { PRESSED: 'pressed', COMPLETE: 'complete', }, + COUNTRY: { + US: 'US', + MX: 'MX', + AU: 'AU', + CA: 'CA', + }, PLATFORM: { IOS: 'ios', ANDROID: 'android', @@ -120,6 +159,8 @@ const CONST = { PAYPAL_ME_ADDRESS: 'expensify_payPalMeAddress', PRIORITY_MODE: 'priorityMode', TIMEZONE: 'timeZone', + FREE_PLAN_BANK_ACCOUNT_ID: 'expensify_freePlanBankAccountID', + ACH_DATA_THROTTLED: 'expensify_ACHData_throttled', }, DEFAULT_TIME_ZONE: {automatic: true, selected: 'America/Los_Angeles'}, DEFAULT_ACCOUNT_DATA: {error: '', success: '', loading: false}, diff --git a/src/ONYXKEYS.js b/src/ONYXKEYS.js index d7fcd65074f3..f32362de4b68 100755 --- a/src/ONYXKEYS.js +++ b/src/ONYXKEYS.js @@ -98,4 +98,7 @@ export default { // Object containing Wallet terms step state WALLET_TERMS: 'walletTerms', + + // Stores information about the active reimbursement account being set up + REIMBURSEMENT_ACCOUNT: 'reimbursementAccount', }; diff --git a/src/ROUTES.js b/src/ROUTES.js index b46703870e4a..8af98287e5d8 100644 --- a/src/ROUTES.js +++ b/src/ROUTES.js @@ -9,7 +9,7 @@ const REPORT = 'r'; export default { ADD_PERSONAL_BANK_ACCOUNT: 'add-personal-bank-account', - BANK_ACCOUNT_NEW: 'bank-account/new', + ADD_VERIFIED_BANK_ACCOUNT: 'add-verified-bank-account', HOME: '', SETTINGS: 'settings', SETTINGS_PROFILE: 'settings/profile', diff --git a/src/languages/en.js b/src/languages/en.js index 14029b389eeb..fea831a19833 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -273,6 +273,8 @@ export default { plaidBodyCopy: 'Give your employees an easier way to pay - and get paid back - for company expenses.', checkHelpLine: 'Your routing number and account number can be found on a check for the account.', iAcceptThe: 'I accept the ', + hasPhoneLoginError: 'To add a verified bank account please ensure your primary login is a valid email and try again. You can add your phone number as a secondary login.', + hasBeenThrottledError: ({fromNow}) => `For security reasons, we're taking a break from bank account setup so you can double-check your company information. Please try again ${fromNow}. Sorry!`, }, addPersonalBankAccountPage: { enterPassword: 'Enter password', diff --git a/src/languages/es.js b/src/languages/es.js index a49bb131d0ad..00e53087b9d2 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -265,6 +265,8 @@ export default { plaidBodyCopy: 'Ofrezca a sus empleados una forma más sencilla de pagar - y recuperar - los gastos de la empresa.', checkHelpLine: 'Su número de ruta y número de cuenta se pueden encontrar en un cheque para la cuenta.', iAcceptThe: 'Acepto los ', + hasPhoneLoginError: 'Para agregar una cuenta bancaria verificada, asegúrese de que su inicio de sesión principal sea un correo electrónico válido y vuelva a intentarlo. Puede agregar su número de teléfono como inicio de sesión secundario.', + hasBeenThrottledError: ({fromNow}) => `Por razones de seguridad, nos tomamos un descanso de la configuración de la cuenta bancaria para que pueda verificar la información de su empresa. Inténtalo de nuevo ${fromNow}. ¡Lo siento!`, }, addPersonalBankAccountPage: { enterPassword: 'Escribe una contraseña', diff --git a/src/libs/API.js b/src/libs/API.js index 72acc4efe6f8..391c02db97ac 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -812,6 +812,48 @@ function BankAccount_Create(parameters) { return Network.post(commandName, parameters, CONST.NETWORK.METHOD.POST, true); } +/** + * @param {*} parameters + * @returns {Promise} + */ +function BankAccount_SetupWithdrawal(parameters) { + const commandName = 'BankAccount_SetupWithdrawal'; + let allowedParameters = [ + 'currentStep', 'policyID', 'bankAccountID', 'useOnfido', 'errorAttemptsCount', + + // data from bankAccount step: + 'setupType', 'routingNumber', 'accountNumber', 'addressName', 'plaidAccountID', 'ownershipType', 'isSavings', + 'acceptTerms', 'bankName', 'plaidAccessToken', 'alternateRoutingNumber', + + // data from company step: + 'companyName', 'companyTaxID', 'addressStreet', 'addressCity', 'addressState', 'addressZipCode', + 'hasNoConnectionToCannabis', 'incorporationType', 'incorporationState', 'incorporationDate', 'industryCode', + 'website', 'companyPhone', 'ficticiousBusinessName', + + // data from requestor step: + 'firstName', 'lastName', 'dob', 'requestorAddressStreet', 'requestorAddressCity', 'requestorAddressState', + 'requestorAddressZipCode', 'isOnfidoSetupComplete', 'onfidoData', 'isControllingOfficer', 'ssnLast4', + + // data from ACHContract step (which became the "Beneficial Owners" step, but the key is still ACHContract as + // it's used in several logic: + 'ownsMoreThan25Percent', 'beneficialOwners', 'acceptTermsAndConditions', 'certifyTrueInformation', + ]; + + if (!parameters.useOnfido) { + allowedParameters = allowedParameters.concat(['passport', 'answers']); + } + + // Only keep allowed parameters in the additionalData object + const additionalData = _.pick(parameters, allowedParameters); + + requireParameters(['currentStep'], parameters, commandName); + return Network.post( + commandName, {additionalData: JSON.stringify(additionalData), password: parameters.password}, + CONST.NETWORK.METHOD.POST, + true, + ); +} + /** * @param {Object} parameters * @param {String[]} data @@ -850,6 +892,7 @@ export { Authenticate, BankAccount_Create, BankAccount_Get, + BankAccount_SetupWithdrawal, ChangePassword, CreateChatReport, CreateLogin, diff --git a/src/libs/Navigation/AppNavigator/AuthScreens.js b/src/libs/Navigation/AppNavigator/AuthScreens.js index 58449c765160..349311267c6c 100644 --- a/src/libs/Navigation/AppNavigator/AuthScreens.js +++ b/src/libs/Navigation/AppNavigator/AuthScreens.js @@ -49,8 +49,8 @@ import { NewChatModalStackNavigator, SettingsModalStackNavigator, EnablePaymentsStackNavigator, - BusinessBankAccountModalStackNavigator, AddPersonalBankAccountModalStackNavigator, + ReimbursementAccountModalStackNavigator, NewWorkspaceStackNavigator, } from './ModalStackNavigators'; import SCREENS from '../../../SCREENS'; @@ -281,11 +281,12 @@ class AuthScreens extends React.Component { name="NewWorkspace" options={modalScreenOptions} component={NewWorkspaceStackNavigator} + listeners={modalScreenListeners} /> diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js index 238629c640f6..90c941ec2f1f 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.js +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.js @@ -20,8 +20,8 @@ import SettingsAddSecondaryLoginPage from '../../../pages/settings/AddSecondaryL import IOUCurrencySelection from '../../../pages/iou/IOUCurrencySelection'; import ReportParticipantsPage from '../../../pages/ReportParticipantsPage'; import EnablePaymentsPage from '../../../pages/EnablePayments'; -import BusinessBankAccountNewPage from '../../../pages/BusinessBankAccount/NewPage'; import AddPersonalBankAccountPage from '../../../pages/AddPersonalBankAccountPage'; +import ReimbursementAccountPage from '../../../pages/ReimbursementAccount/ReimbursementAccountPage'; import NewWorkspacePage from '../../../pages/workspace/NewWorkspacePage'; const defaultSubRouteOptions = { @@ -154,18 +154,16 @@ const AddPersonalBankAccountModalStackNavigator = createModalStackNavigator([{ name: 'AddPersonalBankAccount_Root', }]); +const ReimbursementAccountModalStackNavigator = createModalStackNavigator([{ + Component: ReimbursementAccountPage, + name: 'ReimbursementAccount_Root', +}]); + const NewWorkspaceStackNavigator = createModalStackNavigator([{ Component: NewWorkspacePage, name: 'NewWorkspace_Root', }]); -const BusinessBankAccountModalStackNavigator = createModalStackNavigator([ - { - Component: BusinessBankAccountNewPage, - name: 'BusinessBankAccount_New', - }, -]); - export { IOUBillStackNavigator, IOURequestModalStackNavigator, @@ -177,7 +175,7 @@ export { NewChatModalStackNavigator, SettingsModalStackNavigator, EnablePaymentsStackNavigator, - BusinessBankAccountModalStackNavigator, AddPersonalBankAccountModalStackNavigator, + ReimbursementAccountModalStackNavigator, NewWorkspaceStackNavigator, }; diff --git a/src/libs/Navigation/linkingConfig.js b/src/libs/Navigation/linkingConfig.js index 58ca48d1cae9..e18b0b75ae1c 100644 --- a/src/libs/Navigation/linkingConfig.js +++ b/src/libs/Navigation/linkingConfig.js @@ -109,14 +109,14 @@ export default { AddPersonalBankAccount_Root: ROUTES.ADD_PERSONAL_BANK_ACCOUNT, }, }, - BusinessBankAccount: { + EnablePayments: { screens: { - BusinessBankAccount_New: ROUTES.BANK_ACCOUNT_NEW, + EnablePayments_Root: ROUTES.ENABLE_PAYMENTS, }, }, - EnablePayments: { + ReimbursementAccount: { screens: { - EnablePayments_Root: ROUTES.ENABLE_PAYMENTS, + ReimbursementAccount_Root: ROUTES.ADD_VERIFIED_BANK_ACCOUNT, }, }, NewWorkspace: { diff --git a/src/libs/actions/BankAccounts.js b/src/libs/actions/BankAccounts.js index 3312d0c0723c..fde8be7c0c24 100644 --- a/src/libs/actions/BankAccounts.js +++ b/src/libs/actions/BankAccounts.js @@ -1,10 +1,14 @@ 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 promiseAllSettled from '../promiseAllSettled'; +import Growl from '../Growl'; /** * Gets the Plaid Link token used to initialize the Plaid SDK @@ -76,7 +80,7 @@ function clearPlaidBankAccountsAndToken() { * @param {String} password * @param {String} plaidLinkToken */ -function addPlaidBankAccount(account, password, plaidLinkToken) { +function addPersonalBankAccount(account, password, plaidLinkToken) { const unmaskedAccount = _.find(plaidBankAccounts, bankAccount => ( bankAccount.plaidAccountID === account.plaidAccountID )); @@ -270,12 +274,359 @@ function fetchUserWallet() { }); } +let previousACHData = {}; +Onyx.connect({ + key: ONYXKEYS.REIMBURSEMENT_ACCOUNT, + callback: (val) => { + previousACHData = lodashGet(val, 'achData', {}); + }, +}); + +/** + * Navigate to a specific step in the VBA flow + * + * @param {String} stepID + * @param {Object} achData + */ +function goToWithdrawalAccountSetupStep(stepID, achData) { + const newACHData = {...previousACHData}; + + // 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(achData, 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 = 'manual'; + } + + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {achData: {...newACHData, ...achData, currentStep: stepID}}); +} + +/** + * Fetch the bank account currently being set up by the user for the free plan if it exists. + */ +function fetchFreePlanVerifiedBankAccount() { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true}); + promiseAllSettled([ + API.Get({ + returnValueList: 'nameValuePairs', + name: CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, + }), + API.Get({ + returnValueList: 'nameValuePairs', + name: 'expensify_migration_2020_04_28_RunKycVerifications', + }), + API.Get({ + returnValueList: 'nameValuePairs', + name: CONST.NVP.ACH_DATA_THROTTLED, + }), + API.Get({returnValueList: 'bankAccountList'}), + ]) + .then(([ + freePlanBankAccountIDResponse, + kycVerificationsMigrationResponse, + achDataThrottledResponse, + bankAccountListResponse, + ]) => { + const bankAccountID = lodashGet(freePlanBankAccountIDResponse, [ + 'value', 'nameValuePairs', CONST.NVP.FREE_PLAN_BANK_ACCOUNT_ID, + ], ''); + const kycVerificationsMigration = lodashGet(kycVerificationsMigrationResponse, [ + 'value', 'nameValuePairs', 'expensify_migration_2020_04_28_RunKycVerifications', + ], ''); + const throttledDate = lodashGet(achDataThrottledResponse, [ + 'value', 'nameValuePairs', CONST.NVP.ACH_DATA_THROTTLED, + ], ''); + const bankAccountJSON = _.find( + lodashGet(bankAccountListResponse, ['value', 'bankAccountList'], []), account => ( + account.bankAccountID === bankAccountID + ), + ); + const bankAccount = bankAccountJSON ? new BankAccount(bankAccountJSON) : null; + + // 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 = previousACHData.currentStep; + const achData = bankAccount ? bankAccount.toACHData() : {}; + achData.useOnfido = true; + achData.policyID = ''; + achData.isInSetup = !bankAccount || bankAccount.isInSetup(); + achData.bankAccountInReview = bankAccount && bankAccount.isVerifying(); + achData.domainLimit = 0; + achData.isDomainUsingExpensifyCard = false; // @TODO - Not actually sure if we need this + + // @TODO This subStep is used to either show the Plaid "login" view or the "manual" view - but not sure if + // we need to implement it yet... + // eslint-disable-next-line max-len + // See Web-Secure: https://github.com/Expensify/Web-Expensify/blob/896941794f68d7dce64466d83a3e86a5f8122e45/site/app/settings/reimbursement/bankAccountView.jsx#L356-L357 + achData.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 E.cash yet that need to pass any more checks or can be upgraded, but leaving in for + // possible future compatibility. + if (bankAccount.isOpen()) { + if (bankAccount.needsToPassLatestChecks()) { + const hasTriedToUpgrade = bankAccount.getDateSigned() + > (kycVerificationsMigration || '2020-01-13'); + currentStep = hasTriedToUpgrade + ? CONST.BANK_ACCOUNT.STEP.VALIDATION : CONST.BANK_ACCOUNT.STEP.COMPANY; + achData.bankAccountInReview = hasTriedToUpgrade; + } else { + // In Expensify.cash we do not show a specific view for the EnableStep since we will enable the + // Expensify card automatically. However, we will still handle that step and show the Validate + // view. + currentStep = CONST.BANK_ACCOUNT.STEP.ENABLE; + } + } + } + + // If at this point we still don't have a current step, default to the BankAccountStep + if (!currentStep) { + currentStep = CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT; + } + + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {throttledDate}); + 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 + * @return {String} + */ +function getNextStepID() { + const nextStepIndex = Math.min( + getIndexByStepID(previousACHData.currentStep) + 1, + WITHDRAWAL_ACCOUNT_STEPS.length - 1, + ); + return lodashGet(WITHDRAWAL_ACCOUNT_STEPS, [nextStepIndex, 'id'], CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT); +} + +/** + * @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] + */ +function setupWithdrawalAccount(data) { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: true}); + + previousACHData = {...previousACHData, ...data}; + if (data && !_.isUndefined(data.isSavings)) { + previousACHData.isSavings = Boolean(data.isSavings); + } + if (!previousACHData.setupType) { + previousACHData.setupType = previousACHData.plaidAccountID + ? CONST.BANK_ACCOUNT.SETUP_TYPE.PLAID + : CONST.BANK_ACCOUNT.SETUP_TYPE.MANUAL; + } + + let nextStep = previousACHData.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 + )); + previousACHData.accountNumber = unmaskedAccount.accountNumber; + } + + API.BankAccount_SetupWithdrawal(previousACHData) + .then((response) => { + Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {loading: false}); + + const currentStep = previousACHData.currentStep; + let achData = lodashGet(response, 'achData', {}); + let error = lodashGet(achData, CONST.BANK_ACCOUNT.VERIFICATIONS.ERROR_MESSAGE); + + 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); + } + + // Show warning if another account already set up this bank account and promote share + if (response.existingOwners) { + // @TODO Show better error in UI about existing owners + console.error('Cannot set up withdrawal account due to existing owners'); + return; + } + + if (currentStep === CONST.BANK_ACCOUNT.STEP.REQUESTOR) { + const requestorResponse = lodashGet( + achData, + CONST.BANK_ACCOUNT.VERIFICATIONS.REQUESTOR_IDENTITY_ID, + ); + if (previousACHData.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 && !previousACHData.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); + 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); + 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: previousACHData.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); + }); + return; + } + + if ((currentStep === CONST.BANK_ACCOUNT.STEP.VALIDATION && previousACHData.bankAccountInReview) + || currentStep === CONST.BANK_ACCOUNT.STEP.ENABLE + ) { + // Setup done! + } else { + nextStep = getNextStepID(); + } + } else { + if (response.jsonCode === 666) { + error = response.message; + } + if (lodashGet(achData, CONST.BANK_ACCOUNT.VERIFICATIONS.THROTTLED)) { + achData.disableFields = true; + } + } + + // Go to next step + goToWithdrawalAccountSetupStep(nextStep, achData); + + if (error) { + Growl.show(`Error setting up account: ${error}`, CONST.GROWL.ERROR, 5000); + } + }); +} + export { fetchPlaidLinkToken, - addPlaidBankAccount, + addPersonalBankAccount, getPlaidBankAccounts, clearPlaidBankAccountsAndToken, fetchOnfidoToken, activateWallet, fetchUserWallet, + fetchFreePlanVerifiedBankAccount, + setupWithdrawalAccount, + goToWithdrawalAccountSetupStep, }; diff --git a/src/libs/models/BankAccount.js b/src/libs/models/BankAccount.js new file mode 100644 index 000000000000..c2a68747e85b --- /dev/null +++ b/src/libs/models/BankAccount.js @@ -0,0 +1,327 @@ +import _ from 'underscore'; +import Onyx from 'react-native-onyx'; +import Str from 'expensify-common/lib/str'; +import lodashGet from 'lodash/get'; +import lodashHas from 'lodash/has'; +import CONST from '../../CONST'; +import ONYXKEYS from '../../ONYXKEYS'; + +let currentUserLogin; + +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: val => currentUserLogin = val && val.email, +}); + +class BankAccount { + static STATE = { + PENDING: 'PENDING', + OPEN: 'OPEN', + DELETED: 'DELETED', + LOCKED: 'LOCKED', + SETUP: 'SETUP', + VERIFYING: 'VERIFYING', + }; + + constructor(accountJSON) { + this.json = accountJSON; + } + + /** + * Return the ID of the reimbursement account + * + * @returns {Number} + */ + getID() { + return this.json.bankAccountID; + } + + /** + * Return the account number, which has been obfuscate by the back end + * example "XXXXXX3956" + * + * @returns {String} + */ + getMaskedAccountNumber() { + return this.json.accountNumber; + } + + /** + * Used as the display name for the account... + * @returns {String} + */ + getAddressName() { + return this.json.addressName; + } + + /** + * @returns {String} + */ + getProcessor() { + return this.json.processor; + } + + /** + * @returns {String} + */ + getRoutingNumber() { + return this.json.routingNumber; + } + + /** + * Get all user emails that have access to this bank account + * @return {String[]} + */ + getSharees() { + return this.json.sharees; + } + + /** + * @returns {String} + * @private + */ + getState() { + return this.json.state; + } + + /** + * @returns {Boolean} + */ + isOpen() { + return this.getState() === BankAccount.STATE.OPEN; + } + + /** + * @deprecated Use !isPending instead. + * @returns {Boolean} + */ + isVerified() { + return !this.isPending(); + } + + /** + * If the user still needs to enter the 3 micro deposit amounts. + * @returns {Boolean} + */ + isPending() { + return this.getState() === BankAccount.STATE.PENDING; + } + + /** + * If success team is currently verifying the bank account data provided by the user. + * @returns {Boolean} + */ + isVerifying() { + return Boolean(this.json.validating) || this.getState() === BankAccount.STATE.VERIFYING; + } + + /** + * If the user didn't finish entering all his info. + * @returns {Boolean} + */ + isInSetup() { + return this.getState() === BankAccount.STATE.SETUP; + } + + /** + * @returns {Boolean} + */ + isLocked() { + return this.getState() === BankAccount.STATE.LOCKED; + } + + /** + * If someone asked to share the bank account with me, and the request is still pending + * @returns {Boolean} + */ + isSharePending() { + return Boolean(this.json.shareComplete === false && this.getOwner() !== currentUserLogin); + } + + /** + * Who shared this account with me? + * @returns {String} + */ + getOwner() { + return this.json.ownedBy; + } + + /** + * Is it the account to use by default to receive money? + * + * @returns {Boolean} + */ + isDefaultCredit() { + return this.json.defaultCredit === true; + } + + /** + * Can we use this account to pay other people? + * + * @returns {Boolean} + */ + isWithdrawal() { + return this.json.allowDebit === true; + } + + /** + * Get when the user last updated their bank account. + * @return {*|String} + */ + getDateSigned() { + return lodashGet(this.json, ['additionalData', 'dateSigned']) || ''; + } + + /** + * Return the client ID of this bank account + * + * @NOTE WARNING KEEP IN SYNC WITH THE PHP + * @returns {String} + */ + getClientID() { + // eslint-disable-next-line max-len + return `${Str.makeID(this.getMaskedAccountNumber())}${Str.makeID(this.getAddressName())}${Str.makeID(this.getRoutingNumber())}${this.getType()}`; + } + + /** + * @returns {String} + * @private + */ + getType() { + return this.isWithdrawal() ? 'withdrawal' : 'direct-deposit'; + } + + /** + * Return the internal json data structure used by auth + * @returns {Object} + */ + getJSON() { + return this.json; + } + + /** + * Return whether or not this bank account has been risk checked + * @returns {Boolean} + */ + isRiskChecked() { + return Boolean(this.json.riskChecked); + } + + /** + * Return when the 3 micro amounts for validation were supposed to reach the bank account. + * @returns {String} + */ + getValidateCodeExpectedDate() { + return this.json.validateCodeExpectedDate || ''; + } + + /** + * In which country is the bank account? + * @returns {string} + */ + getCountry() { + return lodashGet(this.json, ['additionalData', 'country'], CONST.COUNTRY.US); + } + + /** + * In which currency is the bank account? + * @returns {String} + */ + getCurrency() { + return lodashGet(this.json, ['additionalData', 'currency'], 'USD'); + } + + /** + * In which bank is the bank account? + * @returns {String} + */ + getBankName() { + return lodashGet(this.json, ['additionalData', 'bankName'], lodashGet(this.json, 'bankName')); + } + + /** + * Did we get bank account details for local transfer or international wire? + * @returns {Boolean} + */ + hasInternationalWireDetails() { + return lodashGet(this.json, ['additionalData', 'fieldsType'], 'local') === 'international'; + } + + /** + * Get the additional data of a bankAccount + * @returns {Object} + */ + getAdditionalData() { + return this.json.additionalData || {}; + } + + /** + * Return a map needed to setup a withdrawal account + * @returns {Object} + */ + toACHData() { + return _.extend({ + routingNumber: this.getRoutingNumber(), + accountNumber: this.getMaskedAccountNumber(), + addressName: this.getAddressName(), + isSavings: this.json.isSavings, + bankAccountID: this.getID(), + state: this.getState(), + validateCodeExpectedDate: this.getValidateCodeExpectedDate(), + needsToPassLatestChecks: this.needsToPassLatestChecks(), + needsToUpgrade: this.needsToUpgrade(), + }, this.getAdditionalData()); + } + + + /** + * Check if user hasn't upgraded their bank account yet. + * @return {Boolean} + */ + needsToUpgrade() { + return !this.isInSetup() && !lodashHas(this.json, ['additionalData', 'beneficialOwners']); + } + + /** + * Check if we've performed the most recently implemented checks on the bank account, and they all passed. + * Same logic as in BankAccount.php needsToPassLatestChecks + * @return {Boolean} + */ + needsToPassLatestChecks() { + if (!lodashGet(this.json, ['additionalData', 'hasFullSSN'])) { + return true; + } + + const beneficialOwners = lodashGet(this.json, ['additionalData', 'beneficialOwners']); + if (!beneficialOwners) { + return true; + } + + const city = lodashGet(this.json, ['additionalData', 'requestorAddressCity']); + if (!city) { + return true; + } + + if (_.isArray(beneficialOwners)) { + const hasBeneficialOwnerError = _.any(beneficialOwners, (beneficialOwner) => { + const hasFullSSN = lodashGet(beneficialOwner, 'hasFullSSN') + || !_.isEmpty(lodashGet(beneficialOwner, 'ssn')); + return !lodashGet(beneficialOwner, 'isRequestor') + && (lodashGet(beneficialOwner, ['expectIDPA', 'status']) !== 'pass' + || !lodashGet(beneficialOwner, 'city') || !hasFullSSN + ); + }); + if (hasBeneficialOwnerError) { + return true; + } + } + + return _.any(['realSearchResult', 'lexisNexisInstantIDResult', 'requestorIdentityID'], field => ( + lodashGet(this.json, [ + 'additionalData', 'verifications', 'externalApiResponses', field, 'status', + ]) !== 'pass' + )); + } +} + +export default BankAccount; diff --git a/src/pages/AddPersonalBankAccountPage.js b/src/pages/AddPersonalBankAccountPage.js index f82546dc1f73..e6e7f22e63e0 100644 --- a/src/pages/AddPersonalBankAccountPage.js +++ b/src/pages/AddPersonalBankAccountPage.js @@ -3,7 +3,7 @@ import HeaderWithCloseButton from '../components/HeaderWithCloseButton'; import ScreenWrapper from '../components/ScreenWrapper'; import Navigation from '../libs/Navigation/Navigation'; import { - addPlaidBankAccount, + addPersonalBankAccount, } from '../libs/actions/BankAccounts'; import withLocalize, {withLocalizePropTypes} from '../components/withLocalize'; import AddPlaidBankAccount from '../components/AddPlaidBankAccount'; @@ -20,7 +20,7 @@ const AddPersonalBankAccountPage = props => ( /> { - addPlaidBankAccount(account, password, plaidLinkToken); + addPersonalBankAccount(account, password, plaidLinkToken); }} onExitPlaid={Navigation.dismissModal} /> diff --git a/src/pages/BusinessBankAccount/NewPage.js b/src/pages/BusinessBankAccount/NewPage.js deleted file mode 100644 index 22fad1997dbb..000000000000 --- a/src/pages/BusinessBankAccount/NewPage.js +++ /dev/null @@ -1,201 +0,0 @@ -import _ from 'underscore'; -import React from 'react'; -import PropTypes from 'prop-types'; -import {View, Image} from 'react-native'; -import {withOnyx} from 'react-native-onyx'; -import ScreenWrapper from '../../components/ScreenWrapper'; -import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; -import MenuItem from '../../components/MenuItem'; -import {Paycheck, Bank, Lock} from '../../components/Icon/Expensicons'; -import styles from '../../styles/styles'; -import TextLink from '../../components/TextLink'; -import Button from '../../components/Button'; -import Icon from '../../components/Icon'; -import colors from '../../styles/colors'; -import Navigation from '../../libs/Navigation/Navigation'; -import Permissions from '../../libs/Permissions'; -import CONST from '../../CONST'; -import TextInputWithLabel from '../../components/TextInputWithLabel'; -import AddPlaidBankAccount from '../../components/AddPlaidBankAccount'; -import ONYXKEYS from '../../ONYXKEYS'; -import CheckboxWithLabel from '../../components/CheckboxWithLabel'; -import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; -import compose from '../../libs/compose'; -import exampleCheckImage from '../../../assets/images/example-check-image.png'; -import Text from '../../components/Text'; - -const propTypes = { - /** List of betas */ - betas: PropTypes.arrayOf(PropTypes.string).isRequired, - - ...withLocalizePropTypes, -}; - -class BusinessBankAccountNewPage extends React.Component { - constructor(props) { - super(props); - - this.toggleTerms = this.toggleTerms.bind(this); - this.addManualAccount = this.addManualAccount.bind(this); - - this.state = { - // One of CONST.BANK_ACCOUNT.ADD_METHOD - bankAccountAddMethod: undefined, - hasAcceptedTerms: false, - routingNumber: '', - accountNumber: '', - }; - } - - toggleTerms() { - this.setState(prevState => ({ - hasAcceptedTerms: !prevState.hasAcceptedTerms, - })); - } - - /** - * @returns {Boolean} - */ - canSubmitManually() { - return this.state.hasAcceptedTerms - - // These are taken from BankCountry.js in Web-Secure - && CONST.BANK_ACCOUNT.REGEX.IBAN.test(this.state.accountNumber.trim()) - && CONST.BANK_ACCOUNT.REGEX.SWIFT_BIC.test(this.state.routingNumber.trim()); - } - - addManualAccount() { - // @TODO call API to add account manually - } - - render() { - if (!Permissions.canUseFreePlan(this.props.betas)) { - console.debug('Not showing new bank account page because user is not on free plan beta'); - return ; - } - - return ( - - - this.setState({bankAccountAddMethod: undefined})} - shouldShowBackButton={!_.isUndefined(this.state.bankAccountAddMethod)} - /> - {!this.state.bankAccountAddMethod && ( - <> - - - {this.props.translate('bankAccount.toGetStarted')} - - { - this.setState({bankAccountAddMethod: CONST.BANK_ACCOUNT.ADD_METHOD.PLAID}); - }} - shouldShowRightIcon - /> - { - this.setState({bankAccountAddMethod: CONST.BANK_ACCOUNT.ADD_METHOD.MANUAL}); - }} - shouldShowRightIcon - /> - - - {this.props.translate('common.privacy')} - - - - {this.props.translate('bankAccount.yourDataIsSecure')} - - - - - - - - - )} - {this.state.bankAccountAddMethod === CONST.BANK_ACCOUNT.ADD_METHOD.PLAID && ( - { - console.debug(args); - }} - onExitPlaid={() => { - this.setState({bankAccountAddMethod: undefined}); - }} - /> - )} - {this.state.bankAccountAddMethod === CONST.BANK_ACCOUNT.ADD_METHOD.MANUAL && ( - <> - - - {this.props.translate('bankAccount.checkHelpLine')} - - - this.setState({routingNumber})} - /> - this.setState({accountNumber})} - /> - ( - - - {this.props.translate('bankAccount.iAcceptThe')} - - - {`Expensify ${this.props.translate('common.termsOfService')}`} - - - )} - /> - -