diff --git a/src/CONST.js b/src/CONST.js
index d340757cd11..83e3d8d324b 100755
--- a/src/CONST.js
+++ b/src/CONST.js
@@ -426,13 +426,14 @@ 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',
},
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 fcfc43376e3..8dafcca121e 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 c6554b7ac0a..c75a8d9fbe9 100644
--- a/src/components/DatePicker/index.js
+++ b/src/components/DatePicker/index.js
@@ -47,8 +47,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 2e1deb369b4..9151244d002 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',
},
attachmentPicker: {
cameraPermissionRequired: 'Camera permission required',
@@ -561,10 +562,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 93211173aa1..3a1a3e2892f 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',
},
attachmentPicker: {
cameraPermissionRequired: 'Se necesita permiso para usar la cámara',
@@ -561,10 +562,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 b76675cc9df..fc6b3ef0b72 100644
--- a/src/libs/API.js
+++ b/src/libs/API.js
@@ -922,6 +922,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 3fd7ca2253f..dad089feff7 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/Wallet.js b/src/libs/actions/Wallet.js
index a691f0a0917..e89437e7218 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 = _.get(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 (_.get(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,5 @@ export {
setAdditionalDetailsErrors,
updateAdditionalDetailsDraft,
setAdditionalDetailsErrorMessage,
+ setAdditionalDetailsQuestions,
};
diff --git a/src/pages/EnablePayments/AdditionalDetailsStep.js b/src/pages/EnablePayments/AdditionalDetailsStep.js
index a15d70ddd3e..9ca4327e7d5 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,22 @@ 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,
}),
};
@@ -47,6 +65,10 @@ const defaultProps = {
errorFields: {},
loading: false,
additionalErrorMessage: '',
+ questions: [],
+ idNumber: '',
+ shouldShowFailedKYC: false,
+ shouldAskForFullSSN: false,
},
};
@@ -179,6 +201,39 @@ class AdditionalDetailsStep extends React.Component {
}
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;
@@ -237,6 +292,36 @@ class AdditionalDetailsStep extends React.Component {
{this.props.translate('common.noPO')}
+
+ {/** 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.state.addressStreet && (
+ this.clearErrorAndSetValue('addressCity', val)}
+ value={this.state.addressCity}
+ errorText={this.getErrorText('addressCity')}
+ />
+ )}
+ {this.state.addressStreet && (
+ this.clearErrorAndSetValue('addressState', val)}
+ value={this.state.addressState}
+ errorText={this.getErrorText('addressState')}
+ />
+ )}
+ {this.state.addressStreet && (
+ this.clearErrorAndSetValue('addressZip', val)}
+ value={this.state.addressZip}
+ errorText={this.getErrorText('addressZip')}
+ />
+ )}
{
+ 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 possibleAnwers = _.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);