Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

P2P KYC - Handle Idology questions and errors #7813

Merged
merged 5 commits into from
Mar 14, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -426,11 +426,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',
Expand All @@ -439,6 +439,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',
Expand Down
7 changes: 5 additions & 2 deletions src/components/AddressSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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});
Expand Down
3 changes: 1 addition & 2 deletions src/components/DatePicker/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
}

Expand Down
6 changes: 6 additions & 0 deletions src/languages/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -567,10 +568,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.',
},
Expand Down
6 changes: 6 additions & 0 deletions src/languages/es.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -567,10 +568,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.',
},
Expand Down
1 change: 1 addition & 0 deletions src/libs/API.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
Expand Down
20 changes: 20 additions & 0 deletions src/libs/Localize.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
169 changes: 154 additions & 15 deletions src/libs/actions/Wallet.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
*/
Expand All @@ -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.
*
Expand All @@ -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;

Expand All @@ -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});
Expand All @@ -101,19 +197,29 @@ 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});
return;
}

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)
Expand All @@ -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);
}

Expand Down Expand Up @@ -199,4 +336,6 @@ export {
setAdditionalDetailsErrors,
updateAdditionalDetailsDraft,
setAdditionalDetailsErrorMessage,
setAdditionalDetailsQuestions,
buildIdologyError,
};
Loading