From 27b57771e4d4a47f2f1fed47cc12e728c32dcaf6 Mon Sep 17 00:00:00 2001 From: Nathalie Kuoch Date: Fri, 18 Feb 2022 10:59:49 +0100 Subject: [PATCH] P2P KYC - Handle Idology errors and questions --- src/CONST.js | 9 +- src/components/AddressSearch.js | 7 +- src/components/DatePicker/index.js | 3 +- src/languages/en.js | 6 + src/languages/es.js | 6 + src/libs/API.js | 1 + src/libs/Localize.js | 20 ++ src/libs/actions/Wallet.js | 168 +++++++++++++++-- .../EnablePayments/AdditionalDetailsStep.js | 85 +++++++++ src/pages/EnablePayments/IdologyQuestions.js | 175 ++++++++++++++++++ 10 files changed, 457 insertions(+), 23 deletions(-) create mode 100644 src/pages/EnablePayments/IdologyQuestions.js 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);