diff --git a/src/CONST.js b/src/CONST.js index 1c65f2924487..5c1fd52f8798 100755 --- a/src/CONST.js +++ b/src/CONST.js @@ -428,11 +428,11 @@ const CONST = { }, ERROR: { FULL_SSN_NOT_FOUND: 'Full SSN not found', - IDENTITY_NOT_FOUND: 'Identity not found', - INVALID_SSN: 'Invalid SSN', - UNEXPECTED: 'Unexpected error', MISSING_FIELD: 'Missing required additional details fields', - UNABLE_TO_VERIFY: 'Unable to verify identity', + WRONG_ANSWERS: 'Wrong answers', + + // KBA stands for Knowledge Based Answers (requiring us to show Idology questions) + KBA_NEEDED: 'KBA needed', NO_ACCOUNT_TO_LINK: '405 No account to link to wallet', INVALID_WALLET: '405 Invalid wallet account', NOT_OWNER_OF_BANK_ACCOUNT: '401 Wallet owner does not own linked bank account', @@ -441,6 +441,7 @@ const CONST = { INVALID_FUND: '405 Attempting to link an invalid fund to a wallet', }, STEP: { + // In the order they appear in the Wallet flow ONFIDO: 'OnfidoStep', ADDITIONAL_DETAILS: 'AdditionalDetailsStep', TERMS: 'TermsStep', diff --git a/src/components/AddressSearch.js b/src/components/AddressSearch.js index fcfc43376e3a..8dafcca121ef 100644 --- a/src/components/AddressSearch.js +++ b/src/components/AddressSearch.js @@ -91,8 +91,10 @@ const AddressSearch = (props) => { const zipCode = GooglePlacesUtils.getAddressComponent(addressComponents, 'postal_code', 'long_name'); const state = GooglePlacesUtils.getAddressComponent(addressComponents, 'administrative_area_level_1', 'short_name'); - const values = {}; - if (street && props.value && street.length > props.value.trim().length) { + const values = { + street: props.value ? props.value.trim() : '', + }; + if (street && street.length >= values.street.length) { // We are only passing the street number and name if the combined length is longer than the value // that was initially passed to the autocomplete component. Google Places can truncate details // like Apt # and this is the best way we have to tell that the new value it's giving us is less @@ -158,6 +160,7 @@ const AddressSearch = (props) => { inputID: props.inputID, shouldSaveDraft: props.shouldSaveDraft, onBlur: props.onBlur, + autoComplete: 'none', onChangeText: (text) => { if (skippedFirstOnChangeTextRef.current) { props.onChange({street: text}); diff --git a/src/components/DatePicker/index.js b/src/components/DatePicker/index.js index a8a9697632e9..a324c25ae878 100644 --- a/src/components/DatePicker/index.js +++ b/src/components/DatePicker/index.js @@ -48,8 +48,7 @@ class Datepicker extends React.Component { const asMoment = moment(text); if (asMoment.isValid()) { - const asDate = asMoment.toDate(); - this.props.onChange(asDate); + this.props.onChange(asMoment.format(CONST.DATE.MOMENT_FORMAT_STRING)); } } diff --git a/src/languages/en.js b/src/languages/en.js index 56aab993560f..a17924c1f7dc 100755 --- a/src/languages/en.js +++ b/src/languages/en.js @@ -98,6 +98,7 @@ export default { enterManually: 'Enter it manually', message: 'Message ', leaveRoom: 'Leave room', + your: 'your', conciergeHelp: 'Please reach out to Concierge for help.', }, attachmentPicker: { @@ -570,10 +571,15 @@ export default { additionalDetailsStep: { headerTitle: 'Additional details', helpText: 'We need to confirm the following information before we can process this payment.', + helpTextIdologyQuestions: 'We need to ask you just a few more questions to finish validating your identity.', helpLink: 'Learn more about why we need this.', legalFirstNameLabel: 'Legal first name', legalMiddleNameLabel: 'Legal middle name', legalLastNameLabel: 'Legal last name', + selectAnswer: 'You need to select a response to proceed.', + needSSNFull9: 'We\'re having trouble verifying your SSN. Please enter the full 9 digits of your SSN.', + weCouldNotVerify: 'We could not verify', + pleaseFixIt: 'Please fix this information before continuing.', failedKYCTextBefore: 'We weren\'t able to successfully verify your identity. Please try again later and reach out to ', failedKYCTextAfter: ' if you have any questions.', }, diff --git a/src/languages/es.js b/src/languages/es.js index d6b40f3495c5..a247568bb3e5 100644 --- a/src/languages/es.js +++ b/src/languages/es.js @@ -98,6 +98,7 @@ export default { enterManually: 'Ingresar manualmente', message: 'Chatear con ', leaveRoom: 'Salir de la sala de chat', + your: 'tu', conciergeHelp: 'Por favor contacta con Concierge para obtener ayuda.', }, attachmentPicker: { @@ -570,10 +571,15 @@ export default { additionalDetailsStep: { headerTitle: 'Detalles adicionales', helpText: 'Necesitamos confirmar la siguiente información antes de que podamos procesar este pago.', + helpTextIdologyQuestions: 'Tenemos que preguntarte unas preguntas mas para terminar de verificar tu identidad', helpLink: 'Obtenga más información sobre por qué necesitamos esto.', legalFirstNameLabel: 'Primer nombre legal', legalMiddleNameLabel: 'Segundo nombre legal', legalLastNameLabel: 'Apellido legal', + selectAnswer: 'Selecciona una respuesta.', + needSSNFull9: 'Estamos teniendo problemas para verificar su SSN. Ingresa los 9 dígitos del SSN.', + weCouldNotVerify: 'No pudimos verificar', + pleaseFixIt: 'Corrije esta información antes de continuar.', failedKYCTextBefore: 'No pudimos verificar correctamente su identidad. Vuelva a intentarlo más tarde y comuníquese con ', failedKYCTextAfter: ' si tiene alguna pregunta.', }, diff --git a/src/libs/API.js b/src/libs/API.js index 1dfbd2f5ed15..1fbfe935e5bf 100644 --- a/src/libs/API.js +++ b/src/libs/API.js @@ -921,6 +921,7 @@ function Plaid_GetLinkToken() { * @param {String} parameters.currentStep * @param {String} [parameters.onfidoData] - JSON string * @param {String} [parameters.personalDetails] - JSON string + * @param {String} [parameters.idologyAnswers] - JSON string * @param {Boolean} [parameters.hasAcceptedTerms] * @returns {Promise} */ diff --git a/src/libs/Localize.js b/src/libs/Localize.js index 3fd7ca2253f5..dad089feff79 100644 --- a/src/libs/Localize.js +++ b/src/libs/Localize.js @@ -78,7 +78,27 @@ function translateLocal(phrase, variables) { return translate(preferredLocale, phrase, variables); } +/** + * Format an array into a string with comma and "and" ("a dog, a cat and a chicken") + * + * @param {Array} anArray + * @return {String} + */ +function arrayToString(anArray) { + const and = this.translateLocal('common.and'); + let aString = ''; + if (_.size(anArray) === 1) { + aString = anArray[0]; + } else if (_.size(anArray) === 2) { + aString = anArray.join(` ${and} `); + } else if (_.size(anArray) > 2) { + aString = `${anArray.slice(0, -1).join(', ')} ${and} ${anArray.slice(-1)}`; + } + return aString; +} + export { translate, translateLocal, + arrayToString, }; diff --git a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js index fbd85d89ee31..c3c713c512c6 100644 --- a/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js +++ b/src/libs/actions/ReimbursementAccount/setupWithdrawalAccount.js @@ -231,7 +231,7 @@ function setupWithdrawalAccount(params) { // Example 1: When forcing manual step after adding Chase bank account via Plaid, so we can ask for the real numbers instead of the plaid substitutes // Example 2: When on the requestor step, showing Onfido view after submitting the identity and retrieving the sdkToken if (_.has(responseACHData, 'nextStepValues')) { - navigation.goToWithdrawalAccountSetupStep(_.get(responseACHData.nextStepValues, 'currentStep') || nextStep, { + navigation.goToWithdrawalAccountSetupStep(lodashGet(responseACHData, 'nextStepValues.currentStep') || nextStep, { ...updatedACHData, ...(_.omit(responseACHData, 'nextStepValues')), ...responseACHData.nextStepValues, diff --git a/src/libs/actions/Wallet.js b/src/libs/actions/Wallet.js index a691f0a09170..05fb2e8dc7b8 100644 --- a/src/libs/actions/Wallet.js +++ b/src/libs/actions/Wallet.js @@ -1,10 +1,12 @@ import _ from 'underscore'; import lodashGet from 'lodash/get'; +import Str from 'expensify-common/lib/str'; import Onyx from 'react-native-onyx'; import ONYXKEYS from '../../ONYXKEYS'; import * as API from '../API'; import CONST from '../../CONST'; import * as PaymentMethods from './PaymentMethods'; +import * as Localize from '../Localize'; /** * Fetch and save locally the Onfido SDK token and applicantID @@ -36,6 +38,14 @@ function setAdditionalDetailsLoading(loading) { Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {loading}); } +/** + * @param {Array} questions + * @param {String} [idNumber] + */ +function setAdditionalDetailsQuestions(questions, idNumber) { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {questions, idNumber}); +} + /** * @param {Object} errorFields */ @@ -58,6 +68,84 @@ function setAdditionalDetailsShouldAskForFullSSN(shouldAskForFullSSN) { Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {shouldAskForFullSSN}); } +/** + * @param {Boolean} shouldShowFailedKYC + */ +function setAdditionalDetailsShouldShowFailedKYC(shouldShowFailedKYC) { + Onyx.merge(ONYXKEYS.WALLET_ADDITIONAL_DETAILS, {shouldShowFailedKYC}); +} + +/** + * Transforms a list of Idology errors to a translated displayable error string. + * @param {Array} idologyErrors + * @return {String} + */ +function buildIdologyError(idologyErrors) { + if (_.isEmpty(idologyErrors)) { + return ''; + } + const addressErrors = [ + 'resultcode.address.does.not.match', + 'resultcode.street.name.does.not.match', + 'resultcode.street.number.does.not.match', + 'resultcode.zip.does.not.match', + 'resultcode.alternate.address.alert', + 'resultcode.state.does.not.match', + 'resultcode.input.address.is.po.box', + 'resultcode.located.address.is.po.box', + 'resultcode.warm.address.alert', + ]; + const dobErrors = [ + 'resultcode.coppa.alert', + 'resultcode.age.below.minimum', + 'resultcode.dob.does.not.match', + 'resultcode.yob.does.not.match', + 'resultcode.yob.within.one.year', + 'resultcode.mob.does.not.match', + 'resultcode.no.mob.available', + 'resultcode.no.dob.available', + 'resultcode.ssn.issued.prior.to.dob', + ]; + const ssnErrors = [ + 'resultcode.ssn.does.not.match', + 'resultcode.ssn.within.one.digit', + 'resultcode.ssn.not.valid', + 'resultcode.ssn.issued.prior.to.dob', + 'resultcode.input.ssn.is.itin', + 'resultcode.located.itin', + ]; + const nameErrors = [ + 'resultcode.last.name.does.not.match', + ]; + + // List of translated errors + const errorsTranslated = _.uniq(_.reduce(idologyErrors, (memo, error) => { + const your = Localize.translateLocal('common.your'); + if (_.contains(addressErrors, error)) { + memo.push(`${your} ${Localize.translateLocal('common.personalAddress').toLowerCase()}`); + } + if (_.contains(dobErrors, error)) { + memo.push(`${your} ${Localize.translateLocal('common.dob').toLowerCase()}`); + } + if (_.contains(ssnErrors, error)) { + memo.push(`${your} SSN`); + } + if (_.contains(nameErrors, error)) { + memo.push(`${your} ${Localize.translateLocal('additionalDetailsStep.legalLastNameLabel').toLowerCase()}`); + } + + return memo; + }, [])); + + if (_.isEmpty(errorsTranslated)) { + return ''; + } + + const errorStart = Localize.translateLocal('additionalDetailsStep.weCouldNotVerify'); + const errorEnd = Localize.translateLocal('additionalDetailsStep.pleaseFixIt'); + return `${errorStart} ${Localize.arrayToString(errorsTranslated)}. ${errorEnd}`; +} + /** * This action can be called repeatedly with different steps until an Expensify Wallet has been activated. * @@ -73,11 +161,13 @@ function setAdditionalDetailsShouldAskForFullSSN(shouldAskForFullSSN) { * @param {String} currentStep * @param {Object} parameters * @param {String} [parameters.onfidoData] - JSON string - * @param {Object} [parameters.personalDetails] - JSON string + * @param {Object} [parameters.personalDetails] + * @param {Object} [parameters.idologyAnswers] * @param {Boolean} [parameters.hasAcceptedTerms] */ function activateWallet(currentStep, parameters) { let personalDetails; + let idologyAnswers; let onfidoData; let hasAcceptedTerms; @@ -89,10 +179,16 @@ function activateWallet(currentStep, parameters) { onfidoData = parameters.onfidoData; Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: '', loading: true}); } else if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - setAdditionalDetailsLoading(true); - setAdditionalDetailsErrors(null); - setAdditionalDetailsErrorMessage(''); - personalDetails = JSON.stringify(parameters.personalDetails); + if (parameters.personalDetails) { + setAdditionalDetailsLoading(true); + setAdditionalDetailsErrors(null); + setAdditionalDetailsErrorMessage(''); + setAdditionalDetailsShouldShowFailedKYC(false); + personalDetails = JSON.stringify(parameters.personalDetails); + } + if (parameters.idologyAnswers) { + idologyAnswers = JSON.stringify(parameters.idologyAnswers); + } } else if (currentStep === CONST.WALLET.STEP.TERMS) { hasAcceptedTerms = parameters.hasAcceptedTerms; Onyx.merge(ONYXKEYS.WALLET_TERMS, {loading: true}); @@ -101,10 +197,19 @@ function activateWallet(currentStep, parameters) { API.Wallet_Activate({ currentStep, personalDetails, + idologyAnswers, onfidoData, hasAcceptedTerms, }) .then((response) => { + if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { + // Hide the loader + setAdditionalDetailsLoading(false); + + // Make sure we remove any questions from Onyx once we've answered them + setAdditionalDetailsQuestions(null); + } + if (response.jsonCode !== 200) { if (currentStep === CONST.WALLET.STEP.ONFIDO) { Onyx.merge(ONYXKEYS.WALLET_ONFIDO, {error: response.message, loading: false}); @@ -112,8 +217,9 @@ function activateWallet(currentStep, parameters) { } if (currentStep === CONST.WALLET.STEP.ADDITIONAL_DETAILS) { - // Hide the loader - setAdditionalDetailsLoading(false); + if (response.title === CONST.WALLET.ERROR.KBA_NEEDED) { + setAdditionalDetailsQuestions(response.data.questions, response.data.idNumber); + } if (response.title === CONST.WALLET.ERROR.MISSING_FIELD) { // Convert array of strings to object with field names as keys and boolean for values (true if error, false if not) @@ -126,17 +232,48 @@ function activateWallet(currentStep, parameters) { if (response.title === CONST.WALLET.ERROR.FULL_SSN_NOT_FOUND) { setAdditionalDetailsShouldAskForFullSSN(true); + setAdditionalDetailsErrorMessage(Localize.translateLocal('additionalDetailsStep.needSSNFull9')); + return; } - const errorTitles = [ - CONST.WALLET.ERROR.FULL_SSN_NOT_FOUND, - CONST.WALLET.ERROR.IDENTITY_NOT_FOUND, - CONST.WALLET.ERROR.INVALID_SSN, - CONST.WALLET.ERROR.UNEXPECTED, - CONST.WALLET.ERROR.UNABLE_TO_VERIFY, - ]; + let qualifiers = lodashGet(response, 'data.requestorIdentityID.apiResult.qualifiers.qualifier', []); - if (_.contains(errorTitles, response.title)) { + // ExpectID sometimes returns qualifier as an object when there is only one, or as an array if there are several + if (qualifiers.key) { + qualifiers = [qualifiers]; + } + const idologyErrors = _.map(qualifiers, error => error.key); + + if (!_.isEmpty(idologyErrors)) { + // These errors should redirect to the KYC failure page + const hardFailures = [ + 'resultcode.newer.record.found', + 'resultcode.high.risk.address.alert', + 'resultcode.ssn.not.available', + 'resultcode.subject.deceased', + 'resultcode.thin.file', + 'resultcode.pa.dob.match', + 'resultcode.pa.dob.not.available', + 'resultcode.pa.dob.does.not.match', + ]; + if (_.some(hardFailures, hardFailure => _.contains(idologyErrors, hardFailure))) { + setAdditionalDetailsShouldShowFailedKYC(true); + return; + } + + const identityError = buildIdologyError(idologyErrors); + if (identityError) { + setAdditionalDetailsErrorMessage(identityError); + return; + } + } + + if (lodashGet(response, 'data.requestorIdentityID.apiResult.results.key') === 'result.no.match' + || response.title === CONST.WALLET.ERROR.WRONG_ANSWERS) { + setAdditionalDetailsShouldShowFailedKYC(true); + return; + } + if (Str.endsWith(response.type, 'AutoVerifyFailure')) { setAdditionalDetailsErrorMessage(response.message); } @@ -199,4 +336,6 @@ export { setAdditionalDetailsErrors, updateAdditionalDetailsDraft, setAdditionalDetailsErrorMessage, + setAdditionalDetailsQuestions, + buildIdologyError, }; diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js index a15d70ddd3ed..1fc82c82354e 100644 --- a/src/pages/EnablePayments/AdditionalDetailsStep.js +++ b/src/pages/EnablePayments/AdditionalDetailsStep.js @@ -6,6 +6,7 @@ import {withOnyx} from 'react-native-onyx'; import { View, KeyboardAvoidingView, } from 'react-native'; +import IdologyQuestions from './IdologyQuestions'; import ScreenWrapper from '../../components/ScreenWrapper'; import HeaderWithCloseButton from '../../components/HeaderWithCloseButton'; import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; @@ -25,6 +26,7 @@ import * as ValidationUtils from '../../libs/ValidationUtils'; import AddressSearch from '../../components/AddressSearch'; import DatePicker from '../../components/DatePicker'; import FormHelper from '../../libs/FormHelper'; +import FailedKYC from './FailedKYC'; const propTypes = { ...withLocalizePropTypes, @@ -39,6 +41,35 @@ const propTypes = { /** Any additional error message to show */ additionalErrorMessage: PropTypes.string, + + /** Questions returned by Idology */ + questions: PropTypes.arrayOf(PropTypes.shape({ + prompt: PropTypes.string, + type: PropTypes.string, + answer: PropTypes.arrayOf(PropTypes.string), + })), + + /** ExpectID ID number related to those questions */ + idNumber: PropTypes.string, + + /** If we should show the FailedKYC view after the user submitted the form with a non fixable error */ + shouldShowFailedKYC: PropTypes.bool, + + /** If we should ask for the full SSN (when LexisNexis failed retrieving the first 5 from the last 4) */ + shouldAskForFullSSN: PropTypes.bool, + }), + + /** Stores the personal details typed by the user */ + walletAdditionalDetailsDraft: PropTypes.shape({ + legalFirstName: PropTypes.string, + legalLastName: PropTypes.string, + addressStreet: PropTypes.string, + addressCity: PropTypes.string, + addressState: PropTypes.string, + addressZip: PropTypes.string, + phoneNumber: PropTypes.string, + dob: PropTypes.string, + ssn: PropTypes.string, }), }; @@ -47,6 +78,21 @@ const defaultProps = { errorFields: {}, loading: false, additionalErrorMessage: '', + questions: [], + idNumber: '', + shouldShowFailedKYC: false, + shouldAskForFullSSN: false, + }, + walletAdditionalDetailsDraft: { + legalFirstName: '', + legalLastName: '', + addressStreet: '', + addressCity: '', + addressState: '', + addressZip: '', + phoneNumber: '', + dob: '', + ssn: '', }, }; @@ -81,18 +127,6 @@ class AdditionalDetailsStep extends React.Component { ssnFull9: 'common.ssnFull9', }; - this.state = { - legalFirstName: lodashGet(props.walletAdditionalDetailsDraft, 'legalFirstName', ''), - legalLastName: lodashGet(props.walletAdditionalDetailsDraft, 'legalLastName', ''), - addressStreet: lodashGet(props.walletAdditionalDetailsDraft, 'addressStreet', ''), - addressCity: lodashGet(props.walletAdditionalDetailsDraft, 'addressCity', ''), - addressState: lodashGet(props.walletAdditionalDetailsDraft, 'addressState', ''), - addressZip: lodashGet(props.walletAdditionalDetailsDraft, 'addressZip', ''), - phoneNumber: lodashGet(props.walletAdditionalDetailsDraft, 'phoneNumber', ''), - dob: lodashGet(props.walletAdditionalDetailsDraft, 'dob', ''), - ssn: lodashGet(props.walletAdditionalDetailsDraft, 'ssn', ''), - }; - this.formHelper = new FormHelper({ errorPath: 'walletAdditionalDetails.errorFields', setErrors: Wallet.setAdditionalDetailsErrors, @@ -134,20 +168,20 @@ class AdditionalDetailsStep extends React.Component { const errors = {}; - if (!ValidationUtils.isValidPastDate(this.state.dob)) { + if (!ValidationUtils.isValidPastDate(this.props.walletAdditionalDetailsDraft.dob)) { errors.dob = true; } - if (!ValidationUtils.isValidAddress(this.state.addressStreet)) { + if (!ValidationUtils.isValidAddress(this.props.walletAdditionalDetailsDraft.addressStreet)) { errors.addressStreet = true; } - if (!ValidationUtils.isValidSSNLastFour(this.state.ssn) && !ValidationUtils.isValidSSNFullNine(this.state.ssn)) { + if (!ValidationUtils.isValidSSNLastFour(this.props.walletAdditionalDetailsDraft.ssn) && !ValidationUtils.isValidSSNFullNine(this.props.walletAdditionalDetailsDraft.ssn)) { errors.ssn = true; } _.each(this.requiredFields, (requiredField) => { - if (ValidationUtils.isRequiredFulfilled(this.state[requiredField])) { + if (ValidationUtils.isRequiredFulfilled(this.props.walletAdditionalDetailsDraft[requiredField])) { return; } @@ -164,7 +198,7 @@ class AdditionalDetailsStep extends React.Component { } BankAccounts.activateWallet(CONST.WALLET.STEP.ADDITIONAL_DETAILS, { - personalDetails: this.state, + personalDetails: this.props.walletAdditionalDetailsDraft, }); } @@ -173,12 +207,44 @@ class AdditionalDetailsStep extends React.Component { * @param {String} value */ clearErrorAndSetValue(fieldName, value) { - this.setState({[fieldName]: value}); Wallet.updateAdditionalDetailsDraft({[fieldName]: value}); this.clearError(fieldName); } render() { + if (this.props.walletAdditionalDetails.shouldShowFailedKYC) { + return ( + + + Navigation.dismissModal()} + /> + + + + ); + } + + if (!_.isEmpty(this.props.walletAdditionalDetails.questions)) { + return ( + + + Navigation.dismissModal()} + shouldShowBackButton + onBackButtonPress={() => Wallet.setAdditionalDetailsQuestions(null)} + /> + + + + ); + } + const isErrorVisible = _.size(this.getErrors()) > 0 || lodashGet(this.props, 'walletAdditionalDetails.additionalErrorMessage', '').length > 0; const shouldAskForFullSSN = this.props.walletAdditionalDetails.shouldAskForFullSSN; @@ -202,24 +268,25 @@ class AdditionalDetailsStep extends React.Component { this.form = el}> - this.clearErrorAndSetValue('legalFirstName', val)} - value={this.state.legalFirstName} - errorText={this.getErrorText('legalFirstName')} - /> - this.clearErrorAndSetValue('legalLastName', val)} - value={this.state.legalLastName} - errorText={this.getErrorText('legalLastName')} - /> + this.clearErrorAndSetValue('legalFirstName', val)} + value={this.props.walletAdditionalDetailsDraft.legalFirstName || ''} + errorText={this.getErrorText('legalFirstName')} + /> + this.clearErrorAndSetValue('legalLastName', val)} + value={this.props.walletAdditionalDetailsDraft.legalLastName || ''} + errorText={this.getErrorText('legalLastName')} + /> { const renamedFields = { street: 'addressStreet', @@ -237,19 +304,46 @@ class AdditionalDetailsStep extends React.Component { {this.props.translate('common.noPO')} + {this.props.walletAdditionalDetailsDraft.addressStreet ? ( + <> + {/** Once the user has started entering his address, show the other address fields (city, state, zip) */} + {/** We'll autofill them when the user selects a full address from the google autocomplete */} + this.clearErrorAndSetValue('addressCity', val)} + value={this.props.walletAdditionalDetailsDraft.addressCity || ''} + errorText={this.getErrorText('addressCity')} + /> + this.clearErrorAndSetValue('addressState', val)} + value={this.props.walletAdditionalDetailsDraft.addressState || ''} + errorText={this.getErrorText('addressState')} + /> + this.clearErrorAndSetValue('addressZip', val)} + value={this.props.walletAdditionalDetailsDraft.addressZip || ''} + errorText={this.getErrorText('addressZip')} + /> + + ) : null} this.clearErrorAndSetValue('phoneNumber', val)} - value={this.state.phoneNumber} + value={this.props.walletAdditionalDetailsDraft.phoneNumber || ''} errorText={this.getErrorText('phoneNumber')} /> this.clearErrorAndSetValue('dob', val)} - value={this.state.dob} + value={this.props.walletAdditionalDetailsDraft.dob || ''} placeholder={this.props.translate('common.dob')} errorText={this.getErrorText('dob')} maximumDate={new Date()} @@ -258,7 +352,7 @@ class AdditionalDetailsStep extends React.Component { containerStyles={[styles.mt4]} label={this.props.translate(this.fieldNameTranslationKeys[shouldAskForFullSSN ? 'ssnFull9' : 'ssn'])} onChangeText={val => this.clearErrorAndSetValue('ssn', val)} - value={this.state.ssn} + value={this.props.walletAdditionalDetailsDraft.ssn || ''} errorText={this.getErrorText('ssn')} maxLength={shouldAskForFullSSN ? 9 : 4} keyboardType={CONST.KEYBOARD_TYPE.NUMBER_PAD} diff --git a/src/pages/EnablePayments/IdologyQuestions.js b/src/pages/EnablePayments/IdologyQuestions.js new file mode 100644 index 000000000000..cd8ff54411bd --- /dev/null +++ b/src/pages/EnablePayments/IdologyQuestions.js @@ -0,0 +1,175 @@ +import _ from 'underscore'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { + View, +} from 'react-native'; +import RadioButtons from '../../components/RadioButtons'; +import withLocalize, {withLocalizePropTypes} from '../../components/withLocalize'; +import styles from '../../styles/styles'; +import * as BankAccounts from '../../libs/actions/BankAccounts'; +import CONST from '../../CONST'; +import Text from '../../components/Text'; +import TextLink from '../../components/TextLink'; +import FormScrollView from '../../components/FormScrollView'; +import FormAlertWithSubmitButton from '../../components/FormAlertWithSubmitButton'; + +const MAX_SKIP = 1; +const SKIP_QUESTION_TEXT = 'Skip Question'; + +const propTypes = { + ...withLocalizePropTypes, + + /** Questions returned by Idology */ + /** example: [{"answer":["1251","6253","113","None of the above","Skip Question"],"prompt":"Which number goes with your address on MASONIC AVE?","type":"street.number.b"}, ...] */ + questions: PropTypes.arrayOf(PropTypes.shape({ + prompt: PropTypes.string, + type: PropTypes.string, + answer: PropTypes.arrayOf(PropTypes.string), + })), + + /** ID from Idology, referencing those questions */ + idNumber: PropTypes.string, +}; + +const defaultProps = { + questions: [], + idNumber: '', +}; + +class IdologyQuestions extends React.Component { + constructor(props) { + super(props); + this.submitAnswers = this.submitAnswers.bind(this); + + this.state = { + /** Current question index to display. */ + questionNumber: 0, + + /** Should we hide the "Skip question" answer? Yes if the user already skipped MAX_SKIP questions. */ + hideSkip: false, + + /** Answers from the user */ + answers: [], + + /** Any error message */ + errorMessage: '', + + /** Did the user just submitted all his answers? */ + isLoading: false, + }; + } + + /** + * Put question answer in the state. + * @param {Number} questionIndex + * @param {String} answer + */ + chooseAnswer(questionIndex, answer) { + this.setState((prevState) => { + const answers = prevState.answers; + const question = this.props.questions[questionIndex]; + answers[questionIndex] = {question: question.type, answer}; + return { + answers, + errorMessage: '', + }; + }); + } + + /** + * Show next question or send all answers for Idology verifications when we've answered enough + */ + submitAnswers() { + this.setState((prevState) => { + // User must pick an answer + if (!prevState.answers[prevState.questionNumber]) { + return { + errorMessage: this.props.translate('additionalDetailsStep.selectAnswer'), + }; + } + + // Get the number of questions that were skipped by the user. + const skippedQuestionsCount = _.filter(prevState.answers, answer => answer.answer === SKIP_QUESTION_TEXT).length; + + // We have enough answers, let's call expectID KBA to verify them + if ((prevState.answers.length - skippedQuestionsCount) >= (this.props.questions.length - MAX_SKIP)) { + const answers = prevState.answers; + + // Auto skip any remaining questions + if (answers.length < this.props.questions.length) { + for (let i = answers.length; i < this.props.questions.length; i++) { + answers[i] = {question: this.props.questions[i].type, answer: SKIP_QUESTION_TEXT}; + } + } + + BankAccounts.activateWallet(CONST.WALLET.STEP.ADDITIONAL_DETAILS, { + idologyAnswers: { + answers, + idNumber: this.props.idNumber, + }, + }); + return {answers, isLoading: true}; + } + + // Else, show next question + return { + questionNumber: prevState.questionNumber + 1, + hideSkip: skippedQuestionsCount >= MAX_SKIP, + }; + }); + } + + render() { + const questionIndex = this.state.questionNumber; + const question = this.props.questions[questionIndex] || {}; + const possibleAnswers = _.filter(_.map(question.answer, (answer) => { + if (this.state.hideSkip && answer === SKIP_QUESTION_TEXT) { + return; + } + + return { + label: answer, + value: answer, + }; + })); + + return ( + + + {this.props.translate('additionalDetailsStep.helpTextIdologyQuestions')} + + {this.props.translate('additionalDetailsStep.helpLink')} + + + this.form = el}> + + {question.prompt} + this.chooseAnswer(questionIndex, answer)} + /> + + + { + this.form.scrollTo({y: 0, animated: true}); + }} + message={this.state.errorMessage} + isLoading={this.state.isLoading} + buttonText={this.props.translate('common.saveAndContinue')} + /> + + + ); + } +} + +IdologyQuestions.propTypes = propTypes; +IdologyQuestions.defaultProps = defaultProps; +export default withLocalize(IdologyQuestions); diff --git a/tests/unit/WalletTest.js b/tests/unit/WalletTest.js new file mode 100644 index 000000000000..464a48e09db5 --- /dev/null +++ b/tests/unit/WalletTest.js @@ -0,0 +1,30 @@ +const wallet = require('../../src/libs/actions/Wallet'); + +describe('Wallet', () => { + it('Test buildIdologyError returning the right error copy based on Idology errors passed', () => { + // Test address error + expect(wallet.buildIdologyError(['resultcode.address.does.not.match'])).toBe('We could not verify your personal address. Please fix this information before continuing.'); + + // Test date of birth error + expect(wallet.buildIdologyError(['resultcode.age.below.minimum'])).toBe('We could not verify your date of birth. Please fix this information before continuing.'); + + // Test SSN error + expect(wallet.buildIdologyError(['resultcode.ssn.does.not.match'])).toBe('We could not verify your SSN. Please fix this information before continuing.'); + + // Test lastName error + expect(wallet.buildIdologyError(['resultcode.last.name.does.not.match'])).toBe('We could not verify your legal last name. Please fix this information before continuing.'); + + // Test multiple errors + expect(wallet.buildIdologyError([ + 'resultcode.zip.does.not.match', + 'resultcode.yob.within.one.year', + 'resultcode.ssn.not.valid', + ])).toBe('We could not verify your personal address, your date of birth and your SSN. Please fix this information before continuing.'); + + // Test unknown errors + expect(wallet.buildIdologyError([ + 'whatever1', + 'whatever2', + ])).toBe(''); + }); +});