Skip to content

Commit

Permalink
Merge pull request #7813 from Expensify/nat-p2pkycquestions
Browse files Browse the repository at this point in the history
P2P KYC - Handle Idology questions and errors
  • Loading branch information
marcaaron authored Mar 14, 2022
2 parents e4cad4c + 42459e3 commit c1bb574
Show file tree
Hide file tree
Showing 12 changed files with 534 additions and 60 deletions.
9 changes: 5 additions & 4 deletions src/CONST.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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',
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 @@ -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.',
},
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 @@ -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.',
},
Expand Down
1 change: 1 addition & 0 deletions src/libs/API.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
*/
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

0 comments on commit c1bb574

Please sign in to comment.