feat: implement sumsub API module
feat: add 3rd party services splash screen feat: add sumsub as a configurable 3rd party service feat: sumsub config loader fix: small fixes feat: add external validation as a compliance trigger feat: add external validation route in l-s feat: add external validation graphql module feat: integrate sumsub SDK feat: improve sumsub form to allow adding multiple applicant levels with enhanced UX feat: added support for array fields in FormRenderer feat: allow external validation triggers to dynamically use levels setup in the services page fix: multiple small fixes feat: get external compliance customer info fix: small fixes feat: add informational card in customer profile regarding external service info feat: send external customer data for machine trigger verification feat: restrictions to the creation of custom info requests and external validation triggers fix: allow for a single applicant level to be setup fix: account instance access fix: small fixes fix: development-only log
This commit is contained in:
parent
6c8ced3c1f
commit
6ba0632067
31 changed files with 1730 additions and 67 deletions
55
lib/compliance-external.js
Normal file
55
lib/compliance-external.js
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const configManager = require('./new-config-manager')
|
||||||
|
const ph = require('./plugin-helper')
|
||||||
|
|
||||||
|
const getPlugin = settings => {
|
||||||
|
const pluginCodes = ['sumsub']
|
||||||
|
const enabledAccounts = _.filter(_plugin => _plugin.enabled, _.map(code => ph.getAccountInstance(settings.accounts[code], code), pluginCodes))
|
||||||
|
if (_.isEmpty(enabledAccounts)) {
|
||||||
|
throw new Error('No external compliance services are active. Please check your 3rd party service configuration')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_.size(enabledAccounts) > 1) {
|
||||||
|
throw new Error('Multiple external compliance services are active. Please check your 3rd party service configuration')
|
||||||
|
}
|
||||||
|
const account = _.head(enabledAccounts)
|
||||||
|
const plugin = ph.load(ph.COMPLIANCE, account.code, account.enabled)
|
||||||
|
|
||||||
|
return ({ plugin, account })
|
||||||
|
}
|
||||||
|
|
||||||
|
const createApplicant = (settings, customer, applicantLevel) => {
|
||||||
|
const { plugin } = getPlugin(settings)
|
||||||
|
const { id } = customer
|
||||||
|
return plugin.createApplicant({ levelName: applicantLevel, externalUserId: id })
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApplicant = (settings, customer) => {
|
||||||
|
try {
|
||||||
|
const { plugin } = getPlugin(settings)
|
||||||
|
const { id } = customer
|
||||||
|
return plugin.getApplicant({ externalUserId: id }, false)
|
||||||
|
.then(res => ({
|
||||||
|
provider: plugin.CODE,
|
||||||
|
...res.data
|
||||||
|
}))
|
||||||
|
.catch(() => ({}))
|
||||||
|
} catch (e) {
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createApplicantAccessToken = (settings, customerId, triggerId) => {
|
||||||
|
const triggers = configManager.getTriggers(settings.config)
|
||||||
|
const trigger = _.find(it => it.id === triggerId)(triggers)
|
||||||
|
const { plugin } = getPlugin(settings)
|
||||||
|
return plugin.createApplicantAccessToken({ levelName: trigger.externalServiceApplicantLevel, userId: customerId })
|
||||||
|
.then(r => r.data.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createApplicant,
|
||||||
|
getApplicant,
|
||||||
|
createApplicantAccessToken
|
||||||
|
}
|
||||||
|
|
@ -17,6 +17,7 @@ const NUM_RESULTS = 1000
|
||||||
const sms = require('./sms')
|
const sms = require('./sms')
|
||||||
const settingsLoader = require('./new-settings-loader')
|
const settingsLoader = require('./new-settings-loader')
|
||||||
const logger = require('./logger')
|
const logger = require('./logger')
|
||||||
|
const externalCompliance = require('./compliance-external')
|
||||||
|
|
||||||
const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel', 'scoreThresholdReached', 'walletScoringError']
|
const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel', 'scoreThresholdReached', 'walletScoringError']
|
||||||
|
|
||||||
|
|
@ -243,7 +244,7 @@ function deleteEditedData (id, data) {
|
||||||
'id_card_data',
|
'id_card_data',
|
||||||
'id_card_photo',
|
'id_card_photo',
|
||||||
'us_ssn',
|
'us_ssn',
|
||||||
'subcriber_info',
|
'subscriber_info',
|
||||||
'name'
|
'name'
|
||||||
]
|
]
|
||||||
const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, data))
|
const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, data))
|
||||||
|
|
@ -322,6 +323,7 @@ function getById (id) {
|
||||||
return db.oneOrNone(sql, [id])
|
return db.oneOrNone(sql, [id])
|
||||||
.then(assignCustomerData)
|
.then(assignCustomerData)
|
||||||
.then(getCustomInfoRequestsData)
|
.then(getCustomInfoRequestsData)
|
||||||
|
.then(getExternalCustomerData)
|
||||||
.then(camelize)
|
.then(camelize)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -589,6 +591,7 @@ function getCustomerById (id) {
|
||||||
.then(getCustomInfoRequestsData)
|
.then(getCustomInfoRequestsData)
|
||||||
.then(camelizeDeep)
|
.then(camelizeDeep)
|
||||||
.then(formatSubscriberInfo)
|
.then(formatSubscriberInfo)
|
||||||
|
.then(getExternalCustomerData)
|
||||||
}
|
}
|
||||||
|
|
||||||
function assignCustomerData (customer) {
|
function assignCustomerData (customer) {
|
||||||
|
|
@ -927,6 +930,15 @@ function updateLastAuthAttempt (customerId) {
|
||||||
return db.none(sql, [customerId])
|
return db.none(sql, [customerId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExternalCustomerData (customer) {
|
||||||
|
return settingsLoader.loadLatest()
|
||||||
|
.then(settings => externalCompliance.getApplicant(settings, customer))
|
||||||
|
.then(externalCompliance => {
|
||||||
|
customer.externalCompliance = externalCompliance
|
||||||
|
return customer
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
add,
|
add,
|
||||||
addWithEmail,
|
addWithEmail,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
const externalCompliance = require('../../../compliance-external')
|
||||||
|
const { loadLatest } = require('../../../new-settings-loader')
|
||||||
|
|
||||||
|
const resolvers = {
|
||||||
|
Query: {
|
||||||
|
getApplicantAccessToken: (...[, { customerId, triggerId }]) => loadLatest()
|
||||||
|
.then(settings => externalCompliance.createApplicantAccessToken(settings, customerId, triggerId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = resolvers
|
||||||
|
|
@ -7,6 +7,7 @@ const config = require('./config.resolver')
|
||||||
const currency = require('./currency.resolver')
|
const currency = require('./currency.resolver')
|
||||||
const customer = require('./customer.resolver')
|
const customer = require('./customer.resolver')
|
||||||
const customInfoRequests = require('./customInfoRequests.resolver')
|
const customInfoRequests = require('./customInfoRequests.resolver')
|
||||||
|
const externalCompliance = require('./externalCompliance.resolver')
|
||||||
const funding = require('./funding.resolver')
|
const funding = require('./funding.resolver')
|
||||||
const log = require('./log.resolver')
|
const log = require('./log.resolver')
|
||||||
const loyalty = require('./loyalty.resolver')
|
const loyalty = require('./loyalty.resolver')
|
||||||
|
|
@ -30,6 +31,7 @@ const resolvers = [
|
||||||
currency,
|
currency,
|
||||||
customer,
|
customer,
|
||||||
customInfoRequests,
|
customInfoRequests,
|
||||||
|
externalCompliance,
|
||||||
funding,
|
funding,
|
||||||
log,
|
log,
|
||||||
loyalty,
|
loyalty,
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const typeDef = gql`
|
||||||
customInfoRequests: [CustomRequestData]
|
customInfoRequests: [CustomRequestData]
|
||||||
notes: [CustomerNote]
|
notes: [CustomerNote]
|
||||||
isTestCustomer: Boolean
|
isTestCustomer: Boolean
|
||||||
|
externalCompliance: JSONObject
|
||||||
}
|
}
|
||||||
|
|
||||||
input CustomerInput {
|
input CustomerInput {
|
||||||
|
|
|
||||||
9
lib/new-admin/graphql/types/externalCompliance.type.js
Normal file
9
lib/new-admin/graphql/types/externalCompliance.type.js
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
const { gql } = require('apollo-server-express')
|
||||||
|
|
||||||
|
const typeDef = gql`
|
||||||
|
type Query {
|
||||||
|
getApplicantAccessToken(customerId: ID, triggerId: ID): String
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
module.exports = typeDef
|
||||||
|
|
@ -7,6 +7,7 @@ const config = require('./config.type')
|
||||||
const currency = require('./currency.type')
|
const currency = require('./currency.type')
|
||||||
const customer = require('./customer.type')
|
const customer = require('./customer.type')
|
||||||
const customInfoRequests = require('./customInfoRequests.type')
|
const customInfoRequests = require('./customInfoRequests.type')
|
||||||
|
const externalCompliance = require('./externalCompliance.type')
|
||||||
const funding = require('./funding.type')
|
const funding = require('./funding.type')
|
||||||
const log = require('./log.type')
|
const log = require('./log.type')
|
||||||
const loyalty = require('./loyalty.type')
|
const loyalty = require('./loyalty.type')
|
||||||
|
|
@ -30,6 +31,7 @@ const types = [
|
||||||
currency,
|
currency,
|
||||||
customer,
|
customer,
|
||||||
customInfoRequests,
|
customInfoRequests,
|
||||||
|
externalCompliance,
|
||||||
funding,
|
funding,
|
||||||
log,
|
log,
|
||||||
loyalty,
|
loyalty,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,9 @@ const SECRET_FIELDS = [
|
||||||
'vonage.apiSecret',
|
'vonage.apiSecret',
|
||||||
'galoy.walletId',
|
'galoy.walletId',
|
||||||
'galoy.apiSecret',
|
'galoy.apiSecret',
|
||||||
'bitfinex.secret'
|
'bitfinex.secret',
|
||||||
|
'sumsub.apiToken',
|
||||||
|
'sumsub.privateKey'
|
||||||
]
|
]
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,8 @@ const pluginCodes = {
|
||||||
LAYER2: 'layer2',
|
LAYER2: 'layer2',
|
||||||
SMS: 'sms',
|
SMS: 'sms',
|
||||||
EMAIL: 'email',
|
EMAIL: 'email',
|
||||||
ZERO_CONF: 'zero-conf'
|
ZERO_CONF: 'zero-conf',
|
||||||
|
COMPLIANCE: 'compliance'
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = _.assign({load}, pluginCodes)
|
module.exports = _.assign({load}, pluginCodes)
|
||||||
|
|
|
||||||
41
lib/plugins/compliance/sumsub/request.js
Normal file
41
lib/plugins/compliance/sumsub/request.js
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
const axios = require('axios')
|
||||||
|
const crypto = require('crypto')
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
const FormData = require('form-data')
|
||||||
|
const settingsLoader = require('../../../new-settings-loader')
|
||||||
|
|
||||||
|
const ph = require('../../../plugin-helper')
|
||||||
|
|
||||||
|
const axiosConfig = {
|
||||||
|
baseURL: 'https://api.sumsub.com'
|
||||||
|
}
|
||||||
|
|
||||||
|
const axiosInstance = axios.create(axiosConfig)
|
||||||
|
|
||||||
|
const buildSignature = config => {
|
||||||
|
return settingsLoader.loadLatest()
|
||||||
|
.then(({ accounts }) => ph.getAccountInstance(accounts.sumsub, 'sumsub'))
|
||||||
|
.then(({ secretKey, apiToken }) => {
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000)
|
||||||
|
const signature = crypto.createHmac('sha256', secretKey)
|
||||||
|
|
||||||
|
signature.update(`${timestamp}${_.toUpper(config.method)}${config.url}`)
|
||||||
|
if (config.data instanceof FormData) {
|
||||||
|
signature.update(config.data.getBuffer())
|
||||||
|
} else if (config.data) {
|
||||||
|
signature.update(config.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
config.headers['X-App-Token'] = apiToken
|
||||||
|
config.headers['X-App-Access-Sig'] = signature.digest('hex')
|
||||||
|
config.headers['X-App-Access-Ts'] = timestamp
|
||||||
|
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
axiosInstance.interceptors.request.use(buildSignature, Promise.reject)
|
||||||
|
|
||||||
|
const request = config => axiosInstance(config)
|
||||||
|
|
||||||
|
module.exports = request
|
||||||
461
lib/plugins/compliance/sumsub/sumsub.js
Normal file
461
lib/plugins/compliance/sumsub/sumsub.js
Normal file
|
|
@ -0,0 +1,461 @@
|
||||||
|
const _ = require('lodash/fp')
|
||||||
|
|
||||||
|
const request = require('./request')
|
||||||
|
|
||||||
|
const CODE = 'sumsub'
|
||||||
|
|
||||||
|
const hasRequiredFields = fields => obj => _.every(_.partial(_.has, [_, obj]), fields)
|
||||||
|
const getMissingRequiredFields = (fields, obj) =>
|
||||||
|
_.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
if (!_.has(value, obj)) {
|
||||||
|
acc.push(value)
|
||||||
|
}
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
fields
|
||||||
|
)
|
||||||
|
|
||||||
|
const createApplicantAccessToken = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['userId', 'levelName']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/accessTokens?userId=${opts.userId}&levelName=${opts.levelName}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const createApplicant = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['levelName', 'externalUserId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants?levelName=${opts.levelName}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
externalUserId: opts.externalUserId
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeRequiredLevel = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'levelName']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/moveToLevel?name=${opts.levelName}`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApplicant = (opts, knowsApplicantId = true) => {
|
||||||
|
const REQUIRED_FIELDS = knowsApplicantId
|
||||||
|
? ['applicantId']
|
||||||
|
: ['externalUserId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'GET',
|
||||||
|
url: knowsApplicantId ? `/resources/applicants/${opts.applicantId}/one` : `/resources/applicants/-;externalUserId=${opts.externalUserId}/one`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApplicantStatus = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/status`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApplicantIdDocsStatus = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/requiredIdDocsStatus`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addIdDocument = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'metadata', 'metadata.idDocType', 'metadata.country']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new FormData()
|
||||||
|
form.append('metadata', opts.metadata)
|
||||||
|
form.append('content', opts.content)
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/info/idDoc`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'multipart/form-data',
|
||||||
|
'X-Return-Doc-Warnings': 'true'
|
||||||
|
},
|
||||||
|
data: form
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const changeApplicantFixedInfo = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'newData']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'PATCH',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/fixedInfo`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: opts.newData
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApplicantRejectReasons = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/resources/moderationStates/-;applicantId=${opts.applicantId}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestApplicantCheck = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/status/pending${!_.isNil(opts.reason) ? `?reason=${opts.reason}` : ``}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestApplicantCheckDiffVerificationType = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'reasonCode']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/status/pending${!_.isNil(opts.reasonCode) ? `?reasonCode=${opts.reasonCode}` : ``}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDocumentImages = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['inspectionId', 'imageId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/resources/inspections/${opts.inspectionId}/resources/${opts.imageId}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'image/jpeg, image/png, application/pdf, video/mp4, video/webm, video/quicktime',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockApplicant = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'note']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/blacklist?note=${opts.note}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateShareToken = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'clientId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/accessTokens/-/shareToken?applicantId=${opts.applicantId}&forClientId=${opts.clientId}${!_.isNil(opts.ttlInSecs) ? `&ttlInSecs=${opts.ttlInSecs}` : ``}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const importRawApplicant = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantObj']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/-/ingestCompleted`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: opts.applicantObj
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const importApplicantFromPartnerService = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['shareToken']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/-/import?shareToken=${opts.shareToken}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetVerificationStep = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'idDocSetType']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/resetStep/${opts.idDocSetType}`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetApplicant = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/reset`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchApplicantTopLevelInfo = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/`,
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
id: opts.applicantId,
|
||||||
|
externalUserId: opts.externalUserId,
|
||||||
|
email: opts.email,
|
||||||
|
phone: opts.phone,
|
||||||
|
sourceKey: opts.sourceKey,
|
||||||
|
type: opts.type,
|
||||||
|
lang: opts.lang,
|
||||||
|
questionnaires: opts.questionnaires,
|
||||||
|
metadata: opts.metadata,
|
||||||
|
deleted: opts.deleted
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const setApplicantRiskLevel = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'comment', 'riskLevel']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/riskLevel/entries`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
comment: opts.comment,
|
||||||
|
riskLevel: opts.riskLevel
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const addApplicantTags = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['applicantId', 'tags']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/resources/applicants/${opts.applicantId}/tags`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
data: opts.tags
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const markImageAsInactive = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['inspectionId', 'imageId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/resources/inspections/${opts.inspectionId}/resources/${opts.imageId}?revert=false`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const markImageAsActive = opts => {
|
||||||
|
const REQUIRED_FIELDS = ['inspectionId', 'imageId']
|
||||||
|
|
||||||
|
if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) {
|
||||||
|
return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return request({
|
||||||
|
method: 'DELETE',
|
||||||
|
url: `/resources/inspections/${opts.inspectionId}/resources/${opts.imageId}?revert=true`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getApiHealth = () => {
|
||||||
|
return request({
|
||||||
|
method: 'GET',
|
||||||
|
url: `/resources/status/api`
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CODE,
|
||||||
|
createApplicantAccessToken,
|
||||||
|
createApplicant,
|
||||||
|
getApplicant,
|
||||||
|
addIdDocument,
|
||||||
|
changeApplicantFixedInfo,
|
||||||
|
getApplicantStatus,
|
||||||
|
getApplicantIdDocsStatus,
|
||||||
|
getApplicantRejectReasons,
|
||||||
|
requestApplicantCheck,
|
||||||
|
requestApplicantCheckDiffVerificationType,
|
||||||
|
getDocumentImages,
|
||||||
|
blockApplicant,
|
||||||
|
generateShareToken,
|
||||||
|
importRawApplicant,
|
||||||
|
importApplicantFromPartnerService,
|
||||||
|
resetVerificationStep,
|
||||||
|
resetApplicant,
|
||||||
|
patchApplicantTopLevelInfo,
|
||||||
|
setApplicantRiskLevel,
|
||||||
|
addApplicantTags,
|
||||||
|
markImageAsInactive,
|
||||||
|
markImageAsActive,
|
||||||
|
getApiHealth,
|
||||||
|
changeRequiredLevel
|
||||||
|
}
|
||||||
455
lib/plugins/compliance/sumsub/utils.js
Normal file
455
lib/plugins/compliance/sumsub/utils.js
Normal file
|
|
@ -0,0 +1,455 @@
|
||||||
|
const ADD_ID_DOCUMENT_WARNINGS = {
|
||||||
|
badSelfie: 'Make sure that your face and the photo in the document are clearly visible',
|
||||||
|
dataReadability: 'Please make sure that the information in the document is easy to read',
|
||||||
|
inconsistentDocument: 'Please ensure that all uploaded photos are of the same document',
|
||||||
|
maybeExpiredDoc: 'Your document appears to be expired',
|
||||||
|
documentTooMuchOutside: 'Please ensure that the document completely fits the photo'
|
||||||
|
}
|
||||||
|
|
||||||
|
const ADD_ID_DOCUMENT_ERRORS = {
|
||||||
|
forbiddenDocument: 'Unsupported or unacceptable type/country of document',
|
||||||
|
differentDocTypeOrCountry: 'Document type or country mismatches ones that was sent with metadata',
|
||||||
|
missingImportantInfo: 'Not all required document data can be recognized',
|
||||||
|
dataNotReadable: 'There is no available data to recognize from image',
|
||||||
|
expiredDoc: 'Document validity date is expired',
|
||||||
|
documentWayTooMuchOutside: 'Not all parts of the documents are visible',
|
||||||
|
grayscale: 'Black and white image',
|
||||||
|
noIdDocFacePhoto: 'Face is not clearly visible on the document',
|
||||||
|
selfieFaceBadQuality: 'Face is not clearly visible on the selfie',
|
||||||
|
screenRecapture: 'Image might be a photo of screen',
|
||||||
|
screenshot: 'Image is a screenshot',
|
||||||
|
sameSides: 'Image of the same side of document was uploaded as front and back sides',
|
||||||
|
shouldBeMrzDocument: 'Sent document type should have an MRZ, but there is no readable MRZ on the image',
|
||||||
|
shouldBeDoubleSided: 'Two sides of the sent document should be presented',
|
||||||
|
shouldBeDoublePaged: 'The full double-page of the document are required',
|
||||||
|
documentDeclinedBefore: 'The same image was uploaded and declined earlier'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPPORTED_DOCUMENT_TYPES = {
|
||||||
|
ID_CARD: {
|
||||||
|
code: 'ID_CARD',
|
||||||
|
description: 'An ID card'
|
||||||
|
},
|
||||||
|
PASSPORT: {
|
||||||
|
code: 'PASSPORT',
|
||||||
|
description: 'A passport'
|
||||||
|
},
|
||||||
|
DRIVERS: {
|
||||||
|
code: 'DRIVERS',
|
||||||
|
description: 'A driving license'
|
||||||
|
},
|
||||||
|
RESIDENCE_PERMIT: {
|
||||||
|
code: 'RESIDENCE_PERMIT',
|
||||||
|
description: 'Residence permit or registration document in the foreign city/country'
|
||||||
|
},
|
||||||
|
UTILITY_BILL: {
|
||||||
|
code: 'UTILITY_BILL',
|
||||||
|
description: 'Proof of address document'
|
||||||
|
},
|
||||||
|
SELFIE: {
|
||||||
|
code: 'SELFIE',
|
||||||
|
description: 'A selfie with a document'
|
||||||
|
},
|
||||||
|
VIDEO_SELFIE: {
|
||||||
|
code: 'VIDEO_SELFIE',
|
||||||
|
description: 'A selfie video'
|
||||||
|
},
|
||||||
|
PROFILE_IMAGE: {
|
||||||
|
code: 'PROFILE_IMAGE',
|
||||||
|
description: 'A profile image, i.e. avatar'
|
||||||
|
},
|
||||||
|
ID_DOC_PHOTO: {
|
||||||
|
code: 'ID_DOC_PHOTO',
|
||||||
|
description: 'Photo from an ID doc (like a photo from a passport)'
|
||||||
|
},
|
||||||
|
AGREEMENT: {
|
||||||
|
code: 'AGREEMENT',
|
||||||
|
description: 'Agreement of some sort, e.g. for processing personal info'
|
||||||
|
},
|
||||||
|
CONTRACT: {
|
||||||
|
code: 'CONTRACT',
|
||||||
|
description: 'Some sort of contract'
|
||||||
|
},
|
||||||
|
DRIVERS_TRANSLATION: {
|
||||||
|
code: 'DRIVERS_TRANSLATION',
|
||||||
|
description: 'Translation of the driving license required in the target country'
|
||||||
|
},
|
||||||
|
INVESTOR_DOC: {
|
||||||
|
code: 'INVESTOR_DOC',
|
||||||
|
description: 'A document from an investor, e.g. documents which disclose assets of the investor'
|
||||||
|
},
|
||||||
|
VEHICLE_REGISTRATION_CERTIFICATE: {
|
||||||
|
code: 'VEHICLE_REGISTRATION_CERTIFICATE',
|
||||||
|
description: 'Certificate of vehicle registration'
|
||||||
|
},
|
||||||
|
INCOME_SOURCE: {
|
||||||
|
code: 'INCOME_SOURCE',
|
||||||
|
description: 'A proof of income'
|
||||||
|
},
|
||||||
|
PAYMENT_METHOD: {
|
||||||
|
code: 'PAYMENT_METHOD',
|
||||||
|
description: 'Entity confirming payment (like bank card, crypto wallet, etc)'
|
||||||
|
},
|
||||||
|
BANK_CARD: {
|
||||||
|
code: 'BANK_CARD',
|
||||||
|
description: 'A bank card, like Visa or Maestro'
|
||||||
|
},
|
||||||
|
COVID_VACCINATION_FORM: {
|
||||||
|
code: 'COVID_VACCINATION_FORM',
|
||||||
|
description: 'COVID vaccination document'
|
||||||
|
},
|
||||||
|
OTHER: {
|
||||||
|
code: 'OTHER',
|
||||||
|
description: 'Should be used only when nothing else applies'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const VERIFICATION_RESULTS = {
|
||||||
|
GREEN: {
|
||||||
|
code: 'GREEN',
|
||||||
|
description: 'Everything is fine'
|
||||||
|
},
|
||||||
|
RED: {
|
||||||
|
code: 'RED',
|
||||||
|
description: 'Some violations found'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVIEW_REJECT_TYPES = {
|
||||||
|
FINAL: {
|
||||||
|
code: 'FINAL',
|
||||||
|
description: 'Final reject, e.g. when a person is a fraudster, or a client does not want to accept such kinds of clients in their system'
|
||||||
|
},
|
||||||
|
RETRY: {
|
||||||
|
code: 'RETRY',
|
||||||
|
description: 'Decline that can be fixed, e.g. by uploading an image of better quality'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVIEW_REJECT_LABELS = {
|
||||||
|
FORGERY: {
|
||||||
|
code: 'FORGERY',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Forgery attempt has been made'
|
||||||
|
},
|
||||||
|
DOCUMENT_TEMPLATE: {
|
||||||
|
code: 'DOCUMENT_TEMPLATE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Documents supplied are templates, downloaded from internet'
|
||||||
|
},
|
||||||
|
LOW_QUALITY: {
|
||||||
|
code: 'LOW_QUALITY',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Documents have low-quality that does not allow definitive conclusions to be made'
|
||||||
|
},
|
||||||
|
SPAM: {
|
||||||
|
code: 'SPAM',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'An applicant has been created by mistake or is just a spam user (irrelevant images were supplied)'
|
||||||
|
},
|
||||||
|
NOT_DOCUMENT: {
|
||||||
|
code: 'NOT_DOCUMENT',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Documents supplied are not relevant for the verification procedure'
|
||||||
|
},
|
||||||
|
SELFIE_MISMATCH: {
|
||||||
|
code: 'SELFIE_MISMATCH',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'A user photo (profile image) does not match a photo on the provided documents'
|
||||||
|
},
|
||||||
|
ID_INVALID: {
|
||||||
|
code: 'ID_INVALID',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'A document that identifies a person (like a passport or an ID card) is not valid'
|
||||||
|
},
|
||||||
|
FOREIGNER: {
|
||||||
|
code: 'FOREIGNER',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'When a client does not accept applicants from a different country or e.g. without a residence permit'
|
||||||
|
},
|
||||||
|
DUPLICATE: {
|
||||||
|
code: 'DUPLICATE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'This applicant was already created for this client, and duplicates are not allowed by the regulations'
|
||||||
|
},
|
||||||
|
BAD_AVATAR: {
|
||||||
|
code: 'BAD_AVATAR',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'When avatar does not meet the client\'s requirements'
|
||||||
|
},
|
||||||
|
WRONG_USER_REGION: {
|
||||||
|
code: 'WRONG_USER_REGION',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'When applicants from certain regions/countries are not allowed to be registered'
|
||||||
|
},
|
||||||
|
INCOMPLETE_DOCUMENT: {
|
||||||
|
code: 'INCOMPLETE_DOCUMENT',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Some information is missing from the document, or it\'s partially visible'
|
||||||
|
},
|
||||||
|
BLACKLIST: {
|
||||||
|
code: 'BLACKLIST',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'User is blocklisted'
|
||||||
|
},
|
||||||
|
UNSATISFACTORY_PHOTOS: {
|
||||||
|
code: 'UNSATISFACTORY_PHOTOS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'There were problems with the photos, like poor quality or masked information'
|
||||||
|
},
|
||||||
|
DOCUMENT_PAGE_MISSING: {
|
||||||
|
code: 'DOCUMENT_PAGE_MISSING',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Some pages of a document are missing (if applicable)'
|
||||||
|
},
|
||||||
|
DOCUMENT_DAMAGED: {
|
||||||
|
code: 'DOCUMENT_DAMAGED',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Document is damaged'
|
||||||
|
},
|
||||||
|
REGULATIONS_VIOLATIONS: {
|
||||||
|
code: 'REGULATIONS_VIOLATIONS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Regulations violations'
|
||||||
|
},
|
||||||
|
INCONSISTENT_PROFILE: {
|
||||||
|
code: 'INCONSISTENT_PROFILE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Data or documents of different persons were uploaded to one applicant'
|
||||||
|
},
|
||||||
|
PROBLEMATIC_APPLICANT_DATA: {
|
||||||
|
code: 'PROBLEMATIC_APPLICANT_DATA',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Applicant data does not match the data in the documents'
|
||||||
|
},
|
||||||
|
ADDITIONAL_DOCUMENT_REQUIRED: {
|
||||||
|
code: 'ADDITIONAL_DOCUMENT_REQUIRED',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Additional documents required to pass the check'
|
||||||
|
},
|
||||||
|
AGE_REQUIREMENT_MISMATCH: {
|
||||||
|
code: 'AGE_REQUIREMENT_MISMATCH',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Age requirement is not met (e.g. cannot rent a car to a person below 25yo)'
|
||||||
|
},
|
||||||
|
EXPERIENCE_REQUIREMENT_MISMATCH: {
|
||||||
|
code: 'EXPERIENCE_REQUIREMENT_MISMATCH',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Not enough experience (e.g. driving experience is not enough)'
|
||||||
|
},
|
||||||
|
CRIMINAL: {
|
||||||
|
code: 'CRIMINAL',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'The user is involved in illegal actions'
|
||||||
|
},
|
||||||
|
WRONG_ADDRESS: {
|
||||||
|
code: 'WRONG_ADDRESS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The address from the documents doesn\'t match the address that the user entered'
|
||||||
|
},
|
||||||
|
GRAPHIC_EDITOR: {
|
||||||
|
code: 'GRAPHIC_EDITOR',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The document has been edited by a graphical editor'
|
||||||
|
},
|
||||||
|
DOCUMENT_DEPRIVED: {
|
||||||
|
code: 'DOCUMENT_DEPRIVED',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user has been deprived of the document'
|
||||||
|
},
|
||||||
|
COMPROMISED_PERSONS: {
|
||||||
|
code: 'COMPROMISED_PERSONS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'The user does not correspond to Compromised Person Politics'
|
||||||
|
},
|
||||||
|
PEP: {
|
||||||
|
code: 'PEP',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'The user belongs to the PEP category'
|
||||||
|
},
|
||||||
|
ADVERSE_MEDIA: {
|
||||||
|
code: 'ADVERSE_MEDIA',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'The user was found in the adverse media'
|
||||||
|
},
|
||||||
|
FRAUDULENT_PATTERNS: {
|
||||||
|
code: 'FRAUDULENT_PATTERNS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'Fraudulent behavior was detected'
|
||||||
|
},
|
||||||
|
SANCTIONS: {
|
||||||
|
code: 'SANCTIONS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'The user was found on sanction lists'
|
||||||
|
},
|
||||||
|
NOT_ALL_CHECKS_COMPLETED: {
|
||||||
|
code: 'NOT_ALL_CHECKS_COMPLETED',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'All checks were not completed'
|
||||||
|
},
|
||||||
|
FRONT_SIDE_MISSING: {
|
||||||
|
code: 'FRONT_SIDE_MISSING',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Front side of the document is missing'
|
||||||
|
},
|
||||||
|
BACK_SIDE_MISSING: {
|
||||||
|
code: 'BACK_SIDE_MISSING',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Back side of the document is missing'
|
||||||
|
},
|
||||||
|
SCREENSHOTS: {
|
||||||
|
code: 'SCREENSHOTS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded screenshots'
|
||||||
|
},
|
||||||
|
BLACK_AND_WHITE: {
|
||||||
|
code: 'BLACK_AND_WHITE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded black and white photos of documents'
|
||||||
|
},
|
||||||
|
INCOMPATIBLE_LANGUAGE: {
|
||||||
|
code: 'INCOMPATIBLE_LANGUAGE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user should upload translation of his document'
|
||||||
|
},
|
||||||
|
EXPIRATION_DATE: {
|
||||||
|
code: 'EXPIRATION_DATE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded expired document'
|
||||||
|
},
|
||||||
|
UNFILLED_ID: {
|
||||||
|
code: 'UNFILLED_ID',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded the document without signatures and stamps'
|
||||||
|
},
|
||||||
|
BAD_SELFIE: {
|
||||||
|
code: 'BAD_SELFIE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded a bad selfie'
|
||||||
|
},
|
||||||
|
BAD_VIDEO_SELFIE: {
|
||||||
|
code: 'BAD_VIDEO_SELFIE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded a bad video selfie'
|
||||||
|
},
|
||||||
|
BAD_FACE_MATCHING: {
|
||||||
|
code: 'BAD_FACE_MATCHING',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Face check between document and selfie failed'
|
||||||
|
},
|
||||||
|
BAD_PROOF_OF_IDENTITY: {
|
||||||
|
code: 'BAD_PROOF_OF_IDENTITY',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded a bad ID document'
|
||||||
|
},
|
||||||
|
BAD_PROOF_OF_ADDRESS: {
|
||||||
|
code: 'BAD_PROOF_OF_ADDRESS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded a bad proof of address'
|
||||||
|
},
|
||||||
|
BAD_PROOF_OF_PAYMENT: {
|
||||||
|
code: 'BAD_PROOF_OF_PAYMENT',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user uploaded a bad proof of payment'
|
||||||
|
},
|
||||||
|
SELFIE_WITH_PAPER: {
|
||||||
|
code: 'SELFIE_WITH_PAPER',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'The user should upload a special selfie (e.g. selfie with paper and date on it)'
|
||||||
|
},
|
||||||
|
FRAUDULENT_LIVENESS: {
|
||||||
|
code: 'FRAUDULENT_LIVENESS',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.FINAL,
|
||||||
|
description: 'There was an attempt to bypass liveness check'
|
||||||
|
},
|
||||||
|
OTHER: {
|
||||||
|
code: 'OTHER',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Some unclassified reason'
|
||||||
|
},
|
||||||
|
REQUESTED_DATA_MISMATCH: {
|
||||||
|
code: 'REQUESTED_DATA_MISMATCH',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Provided info doesn\'t match with recognized from document data'
|
||||||
|
},
|
||||||
|
OK: {
|
||||||
|
code: 'OK',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Custom reject label'
|
||||||
|
},
|
||||||
|
COMPANY_NOT_DEFINED_STRUCTURE: {
|
||||||
|
code: 'COMPANY_NOT_DEFINED_STRUCTURE',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Could not establish the entity\'s control structure'
|
||||||
|
},
|
||||||
|
COMPANY_NOT_DEFINED_BENEFICIARIES: {
|
||||||
|
code: 'COMPANY_NOT_DEFINED_BENEFICIARIES',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Could not identify and duly verify the entity\'s beneficial owners'
|
||||||
|
},
|
||||||
|
COMPANY_NOT_VALIDATED_BENEFICIARIES: {
|
||||||
|
code: 'COMPANY_NOT_VALIDATED_BENEFICIARIES',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Beneficiaries are not validated'
|
||||||
|
},
|
||||||
|
COMPANY_NOT_DEFINED_REPRESENTATIVES: {
|
||||||
|
code: 'COMPANY_NOT_DEFINED_REPRESENTATIVES',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Representatives are not defined'
|
||||||
|
},
|
||||||
|
COMPANY_NOT_VALIDATED_REPRESENTATIVES: {
|
||||||
|
code: 'COMPANY_NOT_VALIDATED_REPRESENTATIVES',
|
||||||
|
rejectType: REVIEW_REJECT_TYPES.RETRY,
|
||||||
|
description: 'Representatives are not validated'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const REVIEW_STATUS = {
|
||||||
|
init: 'Initial registration has started. A client is still in the process of filling out the applicant profile. Not all required documents are currently uploaded',
|
||||||
|
pending: 'An applicant is ready to be processed',
|
||||||
|
prechecked: 'The check is in a half way of being finished',
|
||||||
|
queued: 'The checks have been started for the applicant',
|
||||||
|
completed: 'The check has been completed',
|
||||||
|
onHold: 'Applicant waits for a final decision from compliance officer (manual check was initiated) or waits for all beneficiaries to pass KYC in case of company verification',
|
||||||
|
}
|
||||||
|
|
||||||
|
const RESETTABLE_VERIFICATION_STEPS = {
|
||||||
|
PHONE_VERIFICATION: {
|
||||||
|
code: 'PHONE_VERIFICATION',
|
||||||
|
description: 'Phone verification step'
|
||||||
|
},
|
||||||
|
EMAIL_VERIFICATION: {
|
||||||
|
code: 'EMAIL_VERIFICATION',
|
||||||
|
description: 'Email verification step'
|
||||||
|
},
|
||||||
|
QUESTIONNAIRE: {
|
||||||
|
code: 'QUESTIONNAIRE',
|
||||||
|
description: 'Questionnaire'
|
||||||
|
},
|
||||||
|
APPLICANT_DATA: {
|
||||||
|
code: 'APPLICANT_DATA',
|
||||||
|
description: 'Applicant data'
|
||||||
|
},
|
||||||
|
IDENTITY: {
|
||||||
|
code: 'IDENTITY',
|
||||||
|
description: 'Identity step'
|
||||||
|
},
|
||||||
|
PROOF_OF_RESIDENCE: {
|
||||||
|
code: 'PROOF_OF_RESIDENCE',
|
||||||
|
description: 'Proof of residence'
|
||||||
|
},
|
||||||
|
SELFIE: {
|
||||||
|
code: 'SELFIE',
|
||||||
|
description: 'Selfie step'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ADD_ID_DOCUMENT_WARNINGS,
|
||||||
|
ADD_ID_DOCUMENT_ERRORS,
|
||||||
|
SUPPORTED_DOCUMENT_TYPES,
|
||||||
|
VERIFICATION_RESULTS,
|
||||||
|
REVIEW_REJECT_LABELS,
|
||||||
|
REVIEW_STATUS,
|
||||||
|
RESETTABLE_VERIFICATION_STEPS
|
||||||
|
}
|
||||||
|
|
@ -18,13 +18,14 @@ const notifier = require('../notifier')
|
||||||
const respond = require('../respond')
|
const respond = require('../respond')
|
||||||
const { getTx } = require('../new-admin/services/transactions.js')
|
const { getTx } = require('../new-admin/services/transactions.js')
|
||||||
const machineLoader = require('../machine-loader')
|
const machineLoader = require('../machine-loader')
|
||||||
const { loadLatestConfig } = require('../new-settings-loader')
|
const { loadLatest, loadLatestConfig } = require('../new-settings-loader')
|
||||||
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
|
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
|
||||||
const T = require('../time')
|
const T = require('../time')
|
||||||
const plugins = require('../plugins')
|
const plugins = require('../plugins')
|
||||||
const Tx = require('../tx')
|
const Tx = require('../tx')
|
||||||
const loyalty = require('../loyalty')
|
const loyalty = require('../loyalty')
|
||||||
const logger = require('../logger')
|
const logger = require('../logger')
|
||||||
|
const externalCompliance = require('../compliance-external')
|
||||||
|
|
||||||
function updateCustomerCustomInfoRequest (customerId, patch) {
|
function updateCustomerCustomInfoRequest (customerId, patch) {
|
||||||
const promise = _.isNil(patch.data) ?
|
const promise = _.isNil(patch.data) ?
|
||||||
|
|
@ -234,6 +235,23 @@ function sendSmsReceipt (req, res, next) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getExternalComplianceLink (req, res, next) {
|
||||||
|
const customerId = req.query.customer
|
||||||
|
const triggerId = req.query.trigger
|
||||||
|
if (_.isNil(customerId) || _.isNil(triggerId)) return next(httpError('Not Found', 404))
|
||||||
|
|
||||||
|
const settings = req.settings
|
||||||
|
const triggers = configManager.getTriggers(settings.config)
|
||||||
|
const trigger = _.find(it => it.id === triggerId)(triggers)
|
||||||
|
|
||||||
|
return externalCompliance.createApplicantAccessToken(settings, customerId, trigger.id)
|
||||||
|
.then(token => {
|
||||||
|
const url = `https://${process.env.NODE_ENV === 'production' ? `${process.env.HOSTNAME}` : `localhost:3001` }/${trigger.externalService}?customer=${customerId}&trigger=${trigger.id}&t=${token}`
|
||||||
|
process.env.NODE_ENV === 'development' && console.log(url)
|
||||||
|
return respond(req, res, { url: url })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function addOrUpdateCustomer (customerData, config, isEmailAuth) {
|
function addOrUpdateCustomer (customerData, config, isEmailAuth) {
|
||||||
const triggers = configManager.getTriggers(config)
|
const triggers = configManager.getTriggers(config)
|
||||||
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
|
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
|
||||||
|
|
@ -311,6 +329,7 @@ router.patch('/:id/suspend', triggerSuspend)
|
||||||
router.patch('/:id/photos/idcarddata', updateIdCardData)
|
router.patch('/:id/photos/idcarddata', updateIdCardData)
|
||||||
router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto)
|
router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto)
|
||||||
router.post('/:id/smsreceipt', sendSmsReceipt)
|
router.post('/:id/smsreceipt', sendSmsReceipt)
|
||||||
|
router.get('/external', getExternalComplianceLink)
|
||||||
router.post('/phone_code', getOrAddCustomerPhone)
|
router.post('/phone_code', getOrAddCustomerPhone)
|
||||||
router.post('/email_code', getOrAddCustomerEmail)
|
router.post('/email_code', getOrAddCustomerEmail)
|
||||||
|
|
||||||
|
|
|
||||||
13
migrations/1667945906700-integrate-sumsub.js
Normal file
13
migrations/1667945906700-integrate-sumsub.js
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
var db = require('./db')
|
||||||
|
|
||||||
|
exports.up = function (next) {
|
||||||
|
var sql = [
|
||||||
|
`ALTER TABLE customers ADD COLUMN applicant_id TEXT`
|
||||||
|
]
|
||||||
|
|
||||||
|
db.multi(sql, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
exports.down = function (next) {
|
||||||
|
next()
|
||||||
|
}
|
||||||
53
new-lamassu-admin/src/components/buttons/DeleteButton.js
Normal file
53
new-lamassu-admin/src/components/buttons/DeleteButton.js
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { makeStyles } from '@material-ui/core/styles'
|
||||||
|
import classnames from 'classnames'
|
||||||
|
import React, { memo } from 'react'
|
||||||
|
|
||||||
|
import typographyStyles from 'src/components/typography/styles'
|
||||||
|
import { ReactComponent as DeleteIcon } from 'src/styling/icons/button/cancel/zodiac.svg'
|
||||||
|
import { zircon, zircon2, comet, fontColor, white } from 'src/styling/variables'
|
||||||
|
|
||||||
|
const { p } = typographyStyles
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
button: {
|
||||||
|
extend: p,
|
||||||
|
border: 'none',
|
||||||
|
backgroundColor: zircon,
|
||||||
|
cursor: 'pointer',
|
||||||
|
outline: 0,
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 167,
|
||||||
|
height: 48,
|
||||||
|
color: fontColor,
|
||||||
|
'&:hover': {
|
||||||
|
backgroundColor: zircon2
|
||||||
|
},
|
||||||
|
'&:active': {
|
||||||
|
backgroundColor: comet,
|
||||||
|
color: white,
|
||||||
|
'& svg g *': {
|
||||||
|
stroke: white
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'& svg': {
|
||||||
|
marginRight: 8
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
|
const SimpleButton = memo(({ className, children, ...props }) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button className={classnames(classes.button, className)} {...props}>
|
||||||
|
<DeleteIcon />
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
export default SimpleButton
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import ActionButton from './ActionButton'
|
import ActionButton from './ActionButton'
|
||||||
import AddButton from './AddButton'
|
import AddButton from './AddButton'
|
||||||
import Button from './Button'
|
import Button from './Button'
|
||||||
|
import DeleteButton from './DeleteButton'
|
||||||
import FeatureButton from './FeatureButton'
|
import FeatureButton from './FeatureButton'
|
||||||
import IDButton from './IDButton'
|
import IDButton from './IDButton'
|
||||||
import IconButton from './IconButton'
|
import IconButton from './IconButton'
|
||||||
|
|
@ -19,5 +20,6 @@ export {
|
||||||
IDButton,
|
IDButton,
|
||||||
AddButton,
|
AddButton,
|
||||||
SupportLinkButton,
|
SupportLinkButton,
|
||||||
SubpageButton
|
SubpageButton,
|
||||||
|
DeleteButton
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
new-lamassu-admin/src/pages/Compliance/Sumsub.js
Normal file
52
new-lamassu-admin/src/pages/Compliance/Sumsub.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
|
import SumsubWebSdk from '@sumsub/websdk-react'
|
||||||
|
import gql from 'graphql-tag'
|
||||||
|
import React from 'react'
|
||||||
|
import { useLocation } from 'react-router-dom'
|
||||||
|
|
||||||
|
const QueryParams = () => new URLSearchParams(useLocation().search)
|
||||||
|
|
||||||
|
const CREATE_NEW_TOKEN = gql`
|
||||||
|
query getApplicantAccessToken($customerId: ID, $triggerId: ID) {
|
||||||
|
getApplicantAccessToken(customerId: $customerId, triggerId: $triggerId)
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|
||||||
|
const Sumsub = () => {
|
||||||
|
const token = QueryParams().get('t')
|
||||||
|
const customerId = QueryParams().get('customer')
|
||||||
|
const triggerId = QueryParams().get('trigger')
|
||||||
|
|
||||||
|
const { refetch: getNewToken } = useQuery(CREATE_NEW_TOKEN, {
|
||||||
|
skip: true,
|
||||||
|
variables: { customerId: customerId, triggerId: triggerId }
|
||||||
|
})
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
lang: 'en'
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
addViewportTag: true,
|
||||||
|
adaptIframeHeight: true
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAccessToken = () =>
|
||||||
|
getNewToken().then(res => {
|
||||||
|
const { getApplicantAccessToken: _token } = res.data
|
||||||
|
return _token
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SumsubWebSdk
|
||||||
|
accessToken={token}
|
||||||
|
expirationHandler={updateAccessToken}
|
||||||
|
config={config}
|
||||||
|
options={options}
|
||||||
|
onMessage={console.log}
|
||||||
|
onError={console.error}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Sumsub
|
||||||
|
|
@ -11,7 +11,8 @@ import { TextInput } from 'src/components/inputs/formik'
|
||||||
import { H3, Info3 } from 'src/components/typography'
|
import { H3, Info3 } from 'src/components/typography'
|
||||||
import {
|
import {
|
||||||
OVERRIDE_AUTHORIZED,
|
OVERRIDE_AUTHORIZED,
|
||||||
OVERRIDE_REJECTED
|
OVERRIDE_REJECTED,
|
||||||
|
OVERRIDE_PENDING
|
||||||
} from 'src/pages/Customers/components/propertyCard'
|
} from 'src/pages/Customers/components/propertyCard'
|
||||||
import { ReactComponent as CardIcon } from 'src/styling/icons/ID/card/comet.svg'
|
import { ReactComponent as CardIcon } from 'src/styling/icons/ID/card/comet.svg'
|
||||||
import { ReactComponent as PhoneIcon } from 'src/styling/icons/ID/phone/comet.svg'
|
import { ReactComponent as PhoneIcon } from 'src/styling/icons/ID/phone/comet.svg'
|
||||||
|
|
@ -25,7 +26,7 @@ import { URI } from 'src/utils/apollo'
|
||||||
import { onlyFirstToUpper } from 'src/utils/string'
|
import { onlyFirstToUpper } from 'src/utils/string'
|
||||||
|
|
||||||
import styles from './CustomerData.styles.js'
|
import styles from './CustomerData.styles.js'
|
||||||
import { EditableCard } from './components'
|
import { EditableCard, NonEditableCard } from './components'
|
||||||
import {
|
import {
|
||||||
customerDataElements,
|
customerDataElements,
|
||||||
customerDataSchemas,
|
customerDataSchemas,
|
||||||
|
|
@ -64,7 +65,7 @@ const Photo = ({ show, src }) => {
|
||||||
|
|
||||||
const CustomerData = ({
|
const CustomerData = ({
|
||||||
locale,
|
locale,
|
||||||
customer,
|
customer = {},
|
||||||
updateCustomer,
|
updateCustomer,
|
||||||
replacePhoto,
|
replacePhoto,
|
||||||
editCustomer,
|
editCustomer,
|
||||||
|
|
@ -99,6 +100,7 @@ const CustomerData = ({
|
||||||
const customInfoRequests = sortByName(
|
const customInfoRequests = sortByName(
|
||||||
R.path(['customInfoRequests'])(customer) ?? []
|
R.path(['customInfoRequests'])(customer) ?? []
|
||||||
)
|
)
|
||||||
|
const externalCompliance = []
|
||||||
|
|
||||||
const phone = R.path(['phone'])(customer)
|
const phone = R.path(['phone'])(customer)
|
||||||
const email = R.path(['email'])(customer)
|
const email = R.path(['email'])(customer)
|
||||||
|
|
@ -399,6 +401,60 @@ const CustomerData = ({
|
||||||
})
|
})
|
||||||
}, R.keys(smsData) ?? [])
|
}, R.keys(smsData) ?? [])
|
||||||
|
|
||||||
|
const externalComplianceProvider =
|
||||||
|
R.path([`externalCompliance`, `provider`])(customer) ?? undefined
|
||||||
|
|
||||||
|
const externalComplianceData = {
|
||||||
|
sumsub: {
|
||||||
|
getApplicantInfo: data => {
|
||||||
|
return R.path(['fixedInfo'])(data) ?? {}
|
||||||
|
},
|
||||||
|
getVerificationState: data => {
|
||||||
|
const reviewStatus = R.path(['review', 'reviewStatus'])(data)
|
||||||
|
const reviewResult = R.path(['review', 'reviewResult', 'reviewAnswer'])(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
const state =
|
||||||
|
reviewStatus === 'completed'
|
||||||
|
? reviewResult === 'GREEN'
|
||||||
|
? OVERRIDE_AUTHORIZED
|
||||||
|
: OVERRIDE_REJECTED
|
||||||
|
: OVERRIDE_PENDING
|
||||||
|
|
||||||
|
const comment = R.path(['review', 'reviewResult', 'clientComment'])(
|
||||||
|
data
|
||||||
|
)
|
||||||
|
|
||||||
|
const labels = R.path(['review', 'reviewResult', 'rejectLabels'])(data)
|
||||||
|
|
||||||
|
return { state, comment, labels }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const externalComplianceValues = R.path(['externalCompliance'])(customer)
|
||||||
|
|
||||||
|
if (
|
||||||
|
!R.isNil(externalComplianceValues) &&
|
||||||
|
!R.isEmpty(externalComplianceValues)
|
||||||
|
) {
|
||||||
|
externalCompliance.push({
|
||||||
|
fields: R.map(it => ({ name: it[0], label: it[0], value: it[1] }))(
|
||||||
|
R.toPairs(
|
||||||
|
externalComplianceData[externalComplianceProvider]?.getApplicantInfo(
|
||||||
|
externalComplianceValues
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
titleIcon: <CardIcon className={classes.cardIcon} />,
|
||||||
|
state: externalComplianceData[
|
||||||
|
externalComplianceProvider
|
||||||
|
]?.getVerificationState(externalComplianceValues),
|
||||||
|
title: 'External Info'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const editableCard = (
|
const editableCard = (
|
||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
|
|
@ -440,6 +496,21 @@ const CustomerData = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nonEditableCard = (
|
||||||
|
{ title, state, titleIcon, fields, hasImage },
|
||||||
|
idx
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
<NonEditableCard
|
||||||
|
title={title}
|
||||||
|
key={idx}
|
||||||
|
state={state}
|
||||||
|
titleIcon={titleIcon}
|
||||||
|
hasImage={hasImage}
|
||||||
|
fields={fields}></NonEditableCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const visibleCards = getVisibleCards(cards)
|
const visibleCards = getVisibleCards(cards)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -514,6 +585,25 @@ const CustomerData = ({
|
||||||
</Grid>
|
</Grid>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!R.isEmpty(externalCompliance) && (
|
||||||
|
<div className={classes.wrapper}>
|
||||||
|
<span className={classes.separator}>
|
||||||
|
External compliance information
|
||||||
|
</span>
|
||||||
|
<Grid container>
|
||||||
|
<Grid container direction="column" item xs={6}>
|
||||||
|
{externalCompliance.map((elem, idx) => {
|
||||||
|
return isEven(idx) ? nonEditableCard(elem, idx) : null
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
<Grid container direction="column" item xs={6}>
|
||||||
|
{externalCompliance.map((elem, idx) => {
|
||||||
|
return !isEven(idx) ? nonEditableCard(elem, idx) : null
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{retrieveAdditionalDataDialog}
|
{retrieveAdditionalDataDialog}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ const GET_CUSTOMER = gql`
|
||||||
isTestCustomer
|
isTestCustomer
|
||||||
subscriberInfo
|
subscriberInfo
|
||||||
phoneOverride
|
phoneOverride
|
||||||
|
externalCompliance
|
||||||
customFields {
|
customFields {
|
||||||
id
|
id
|
||||||
label
|
label
|
||||||
|
|
@ -153,6 +154,7 @@ const SET_CUSTOMER = gql`
|
||||||
lastTxClass
|
lastTxClass
|
||||||
subscriberInfo
|
subscriberInfo
|
||||||
phoneOverride
|
phoneOverride
|
||||||
|
externalCompliance
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { useState, React } from 'react'
|
||||||
import ErrorMessage from 'src/components/ErrorMessage'
|
import ErrorMessage from 'src/components/ErrorMessage'
|
||||||
import PromptWhenDirty from 'src/components/PromptWhenDirty'
|
import PromptWhenDirty from 'src/components/PromptWhenDirty'
|
||||||
import { MainStatus } from 'src/components/Status'
|
import { MainStatus } from 'src/components/Status'
|
||||||
// import { HoverableTooltip } from 'src/components/Tooltip'
|
import { HoverableTooltip } from 'src/components/Tooltip'
|
||||||
import { ActionButton } from 'src/components/buttons'
|
import { ActionButton } from 'src/components/buttons'
|
||||||
import { Label1, P, H3 } from 'src/components/typography'
|
import { Label1, P, H3 } from 'src/components/typography'
|
||||||
import {
|
import {
|
||||||
|
|
@ -402,4 +402,85 @@ const EditableCard = ({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default EditableCard
|
const NonEditableCard = ({
|
||||||
|
fields,
|
||||||
|
hasImage,
|
||||||
|
state: _state,
|
||||||
|
title,
|
||||||
|
titleIcon
|
||||||
|
}) => {
|
||||||
|
const classes = useStyles()
|
||||||
|
|
||||||
|
const { state, comment, labels } = _state
|
||||||
|
|
||||||
|
const label1ClassNames = {
|
||||||
|
[classes.label1]: true,
|
||||||
|
[classes.label1Pending]: state === OVERRIDE_PENDING,
|
||||||
|
[classes.label1Rejected]: state === OVERRIDE_REJECTED,
|
||||||
|
[classes.label1Accepted]: state === OVERRIDE_AUTHORIZED
|
||||||
|
}
|
||||||
|
const authorized =
|
||||||
|
state === OVERRIDE_PENDING
|
||||||
|
? { label: 'Pending', type: 'neutral' }
|
||||||
|
: state === OVERRIDE_REJECTED
|
||||||
|
? { label: 'Rejected', type: 'error' }
|
||||||
|
: { label: 'Accepted', type: 'success' }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Card className={classes.card}>
|
||||||
|
<CardContent>
|
||||||
|
<div className={classes.headerWrapper}>
|
||||||
|
<div className={classes.cardHeader}>
|
||||||
|
{titleIcon}
|
||||||
|
<H3 className={classes.cardTitle}>{title}</H3>
|
||||||
|
{
|
||||||
|
// TODO: Enable for next release
|
||||||
|
/* <HoverableTooltip width={304}></HoverableTooltip> */
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
{state && (
|
||||||
|
<div className={classnames(label1ClassNames)}>
|
||||||
|
<MainStatus statuses={[authorized]} />
|
||||||
|
{comment && labels && (
|
||||||
|
<HoverableTooltip width={304}>
|
||||||
|
<P>Comments about this decision:</P>
|
||||||
|
{R.map(
|
||||||
|
it => (
|
||||||
|
<P noMargin>{it}</P>
|
||||||
|
),
|
||||||
|
R.split('\n', comment)
|
||||||
|
)}
|
||||||
|
<P>Relevant labels: {R.join(',', labels)}</P>
|
||||||
|
</HoverableTooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className={classes.row}>
|
||||||
|
<Grid container>
|
||||||
|
<Grid container direction="column" item xs={6}>
|
||||||
|
{!hasImage &&
|
||||||
|
fields?.map((field, idx) => {
|
||||||
|
return idx >= 0 && idx < 4 ? (
|
||||||
|
<ReadOnlyField field={field} value={field.value} />
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
<Grid container direction="column" item xs={6}>
|
||||||
|
{!hasImage &&
|
||||||
|
fields?.map((field, idx) => {
|
||||||
|
return idx >= 4 ? (
|
||||||
|
<ReadOnlyField field={field} value={field.value} />
|
||||||
|
) : null
|
||||||
|
})}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { EditableCard, NonEditableCard }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import Wizard from '../Wizard'
|
||||||
|
|
||||||
import CustomerDetails from './CustomerDetails'
|
import CustomerDetails from './CustomerDetails'
|
||||||
import CustomerSidebar from './CustomerSidebar'
|
import CustomerSidebar from './CustomerSidebar'
|
||||||
import EditableCard from './EditableCard'
|
import { EditableCard, NonEditableCard } from './EditableCard'
|
||||||
import Field from './Field'
|
import Field from './Field'
|
||||||
import IdDataCard from './IdDataCard'
|
import IdDataCard from './IdDataCard'
|
||||||
import PhotosCarousel from './PhotosCarousel'
|
import PhotosCarousel from './PhotosCarousel'
|
||||||
|
|
@ -17,6 +17,7 @@ export {
|
||||||
CustomerSidebar,
|
CustomerSidebar,
|
||||||
Field,
|
Field,
|
||||||
EditableCard,
|
EditableCard,
|
||||||
|
NonEditableCard,
|
||||||
Wizard,
|
Wizard,
|
||||||
Upload
|
Upload
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
/* eslint-disable no-unused-vars */
|
|
||||||
import { useQuery } from '@apollo/react-hooks'
|
import { useQuery } from '@apollo/react-hooks'
|
||||||
import { makeStyles } from '@material-ui/core'
|
import { makeStyles } from '@material-ui/core'
|
||||||
import Grid from '@material-ui/core/Grid'
|
import Grid from '@material-ui/core/Grid'
|
||||||
|
|
@ -6,7 +5,7 @@ import BigNumber from 'bignumber.js'
|
||||||
import classnames from 'classnames'
|
import classnames from 'classnames'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useState } from 'react'
|
import React from 'react'
|
||||||
|
|
||||||
import { Label2 } from 'src/components/typography'
|
import { Label2 } from 'src/components/typography'
|
||||||
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
||||||
|
|
@ -38,7 +37,7 @@ const Footer = () => {
|
||||||
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
|
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const config = R.path(['config'])(data) ?? {}
|
const config = R.path(['config'])(data) ?? {}
|
||||||
const canExpand = R.keys(withCommissions).length > 4
|
// const canExpand = R.keys(withCommissions).length > 4
|
||||||
|
|
||||||
const wallets = fromNamespace('wallets')(config)
|
const wallets = fromNamespace('wallets')(config)
|
||||||
const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? []
|
const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? []
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import itbit from './itbit'
|
||||||
import kraken from './kraken'
|
import kraken from './kraken'
|
||||||
import mailgun from './mailgun'
|
import mailgun from './mailgun'
|
||||||
import scorechain from './scorechain'
|
import scorechain from './scorechain'
|
||||||
|
import sumsub from './sumsub'
|
||||||
import telnyx from './telnyx'
|
import telnyx from './telnyx'
|
||||||
import trongrid from './trongrid'
|
import trongrid from './trongrid'
|
||||||
import twilio from './twilio'
|
import twilio from './twilio'
|
||||||
|
|
@ -33,5 +34,6 @@ export default {
|
||||||
[scorechain.code]: scorechain,
|
[scorechain.code]: scorechain,
|
||||||
[trongrid.code]: trongrid,
|
[trongrid.code]: trongrid,
|
||||||
[binance.code]: binance,
|
[binance.code]: binance,
|
||||||
[bitfinex.code]: bitfinex
|
[bitfinex.code]: bitfinex,
|
||||||
|
[sumsub.code]: sumsub
|
||||||
}
|
}
|
||||||
|
|
|
||||||
84
new-lamassu-admin/src/pages/Services/schemas/sumsub.js
Normal file
84
new-lamassu-admin/src/pages/Services/schemas/sumsub.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import React, { useState } from 'react'
|
||||||
|
import * as Yup from 'yup'
|
||||||
|
|
||||||
|
import { Button } from 'src/components/buttons'
|
||||||
|
import { Checkbox } from 'src/components/inputs'
|
||||||
|
import { SecretInput, TextInput } from 'src/components/inputs/formik'
|
||||||
|
import { P } from 'src/components/typography'
|
||||||
|
|
||||||
|
import { secretTest } from './helper'
|
||||||
|
|
||||||
|
const SumsubSplash = ({ classes, onContinue }) => {
|
||||||
|
const [canContinue, setCanContinue] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={classes.form}>
|
||||||
|
<P>
|
||||||
|
Before linking the Sumsub 3rd party service to the Lamassu Admin, make
|
||||||
|
sure you have configured the required parameters in your personal Sumsub
|
||||||
|
Dashboard.
|
||||||
|
</P>
|
||||||
|
<P>
|
||||||
|
These parameters include the Sumsub Global Settings, Applicant Levels,
|
||||||
|
Twilio and Webhooks.
|
||||||
|
</P>
|
||||||
|
<Checkbox
|
||||||
|
value={canContinue}
|
||||||
|
onChange={() => setCanContinue(!canContinue)}
|
||||||
|
settings={{
|
||||||
|
enabled: true,
|
||||||
|
label: 'I have completed the steps needed to configure Sumsub',
|
||||||
|
rightSideLabel: true
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className={classes.footer}>
|
||||||
|
<div className={classes.buttonWrapper}>
|
||||||
|
<Button disabled={!canContinue} onClick={onContinue}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = {
|
||||||
|
code: 'sumsub',
|
||||||
|
name: 'Sumsub',
|
||||||
|
category: 'Compliance',
|
||||||
|
allowMultiInstances: false,
|
||||||
|
SplashScreenComponent: SumsubSplash,
|
||||||
|
elements: [
|
||||||
|
{
|
||||||
|
code: 'apiToken',
|
||||||
|
display: 'API Token',
|
||||||
|
component: SecretInput
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'secretKey',
|
||||||
|
display: 'Secret Key',
|
||||||
|
component: SecretInput
|
||||||
|
},
|
||||||
|
{
|
||||||
|
code: 'applicantLevel',
|
||||||
|
display: 'Applicant Level',
|
||||||
|
component: TextInput,
|
||||||
|
face: true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
getValidationSchema: account => {
|
||||||
|
return Yup.object().shape({
|
||||||
|
apiToken: Yup.string('The API token must be a string')
|
||||||
|
.max(100, 'The API token is too long')
|
||||||
|
.test(secretTest(account?.apiToken, 'API token')),
|
||||||
|
secretKey: Yup.string('The secret key must be a string')
|
||||||
|
.max(100, 'The secret key is too long')
|
||||||
|
.test(secretTest(account?.secretKey, 'secret key')),
|
||||||
|
applicantLevel: Yup.string('The applicant level must be a string')
|
||||||
|
.max(100, 'The applicant level is too long')
|
||||||
|
.required('The applicant level is required')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default schema
|
||||||
|
|
@ -29,7 +29,8 @@ const TriggerView = ({
|
||||||
toggleWizard,
|
toggleWizard,
|
||||||
addNewTriger,
|
addNewTriger,
|
||||||
customInfoRequests,
|
customInfoRequests,
|
||||||
emailAuth
|
emailAuth,
|
||||||
|
additionalInfo
|
||||||
}) => {
|
}) => {
|
||||||
const currency = R.path(['fiatCurrency'])(
|
const currency = R.path(['fiatCurrency'])(
|
||||||
fromNamespace(namespaces.LOCALE)(config)
|
fromNamespace(namespaces.LOCALE)(config)
|
||||||
|
|
@ -69,7 +70,12 @@ const TriggerView = ({
|
||||||
error={error?.message}
|
error={error?.message}
|
||||||
save={save}
|
save={save}
|
||||||
validationSchema={Schema}
|
validationSchema={Schema}
|
||||||
elements={getElements(currency, classes, customInfoRequests)}
|
elements={getElements(
|
||||||
|
currency,
|
||||||
|
classes,
|
||||||
|
customInfoRequests,
|
||||||
|
additionalInfo
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
{showWizard && (
|
{showWizard && (
|
||||||
<Wizard
|
<Wizard
|
||||||
|
|
@ -79,6 +85,8 @@ const TriggerView = ({
|
||||||
onClose={() => toggleWizard(true)}
|
onClose={() => toggleWizard(true)}
|
||||||
customInfoRequests={customInfoRequests}
|
customInfoRequests={customInfoRequests}
|
||||||
emailAuth={emailAuth}
|
emailAuth={emailAuth}
|
||||||
|
triggers={triggers}
|
||||||
|
additionalInfo={additionalInfo}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{R.isEmpty(triggers) && (
|
{R.isEmpty(triggers) && (
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import classnames from 'classnames'
|
||||||
import gql from 'graphql-tag'
|
import gql from 'graphql-tag'
|
||||||
import * as R from 'ramda'
|
import * as R from 'ramda'
|
||||||
import React, { useState } from 'react'
|
import React, { useState } from 'react'
|
||||||
|
import { getAccountInstance } from 'src/utils/accounts'
|
||||||
|
|
||||||
import Modal from 'src/components/Modal'
|
import Modal from 'src/components/Modal'
|
||||||
import { HoverableTooltip } from 'src/components/Tooltip'
|
import { HoverableTooltip } from 'src/components/Tooltip'
|
||||||
|
|
@ -18,6 +19,7 @@ import { ReactComponent as CustomInfoIcon } from 'src/styling/icons/circle butto
|
||||||
import { ReactComponent as ReverseSettingsIcon } from 'src/styling/icons/circle buttons/settings/white.svg'
|
import { ReactComponent as ReverseSettingsIcon } from 'src/styling/icons/circle buttons/settings/white.svg'
|
||||||
import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg'
|
import { ReactComponent as SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg'
|
||||||
import { fromNamespace, toNamespace } from 'src/utils/config'
|
import { fromNamespace, toNamespace } from 'src/utils/config'
|
||||||
|
import { COMPLIANCE_SERVICES } from 'src/utils/constants'
|
||||||
|
|
||||||
import CustomInfoRequests from './CustomInfoRequests'
|
import CustomInfoRequests from './CustomInfoRequests'
|
||||||
import TriggerView from './TriggerView'
|
import TriggerView from './TriggerView'
|
||||||
|
|
@ -132,32 +134,67 @@ const Triggers = () => {
|
||||||
else toggleWizard('newTrigger')()
|
else toggleWizard('newTrigger')()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accounts = data?.accounts ?? {}
|
||||||
|
const isAnyExternalValidationAccountEnabled = () => {
|
||||||
|
try {
|
||||||
|
return R.any(
|
||||||
|
it => it === true,
|
||||||
|
R.map(
|
||||||
|
ite => getAccountInstance(accounts[ite], ite)?.enabled,
|
||||||
|
COMPLIANCE_SERVICES
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} catch (e) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const buttons = []
|
||||||
|
const externalValidationLevels = !R.isEmpty(accounts)
|
||||||
|
? R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
const instances = accounts[value]?.instances ?? {}
|
||||||
|
return {
|
||||||
|
...acc,
|
||||||
|
[value]: R.map(
|
||||||
|
it => ({ value: it, display: it }),
|
||||||
|
R.uniq(R.map(ite => ite.applicantLevel, instances) ?? [])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
COMPLIANCE_SERVICES
|
||||||
|
)
|
||||||
|
: []
|
||||||
|
|
||||||
|
!isAnyExternalValidationAccountEnabled() &&
|
||||||
|
buttons.push({
|
||||||
|
text: 'Advanced settings',
|
||||||
|
icon: SettingsIcon,
|
||||||
|
inverseIcon: ReverseSettingsIcon,
|
||||||
|
forceDisable: !(subMenu === 'advancedSettings'),
|
||||||
|
toggle: show => {
|
||||||
|
refetch()
|
||||||
|
setSubMenu(show ? 'advancedSettings' : false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
buttons.push({
|
||||||
|
text: 'Custom info requests',
|
||||||
|
icon: CustomInfoIcon,
|
||||||
|
inverseIcon: ReverseCustomInfoIcon,
|
||||||
|
forceDisable: !(subMenu === 'customInfoRequests'),
|
||||||
|
toggle: show => {
|
||||||
|
refetch()
|
||||||
|
setSubMenu(show ? 'customInfoRequests' : false)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TitleSection
|
<TitleSection
|
||||||
title="Compliance Triggers"
|
title="Compliance triggers"
|
||||||
buttons={[
|
buttons={buttons}
|
||||||
{
|
|
||||||
text: 'Advanced settings',
|
|
||||||
icon: SettingsIcon,
|
|
||||||
inverseIcon: ReverseSettingsIcon,
|
|
||||||
forceDisable: !(subMenu === 'advancedSettings'),
|
|
||||||
toggle: show => {
|
|
||||||
refetch()
|
|
||||||
setSubMenu(show ? 'advancedSettings' : false)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: 'Custom info requests',
|
|
||||||
icon: CustomInfoIcon,
|
|
||||||
inverseIcon: ReverseCustomInfoIcon,
|
|
||||||
forceDisable: !(subMenu === 'customInfoRequests'),
|
|
||||||
toggle: show => {
|
|
||||||
refetch()
|
|
||||||
setSubMenu(show ? 'customInfoRequests' : false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]}
|
|
||||||
className={classnames(titleSectionWidth)}>
|
className={classnames(titleSectionWidth)}>
|
||||||
{!subMenu && (
|
{!subMenu && (
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
|
|
@ -219,8 +256,11 @@ const Triggers = () => {
|
||||||
config={data?.config ?? {}}
|
config={data?.config ?? {}}
|
||||||
toggleWizard={toggleWizard('newTrigger')}
|
toggleWizard={toggleWizard('newTrigger')}
|
||||||
addNewTriger={addNewTriger}
|
addNewTriger={addNewTriger}
|
||||||
customInfoRequests={enabledCustomInfoRequests}
|
|
||||||
emailAuth={emailAuth}
|
emailAuth={emailAuth}
|
||||||
|
additionalInfo={{
|
||||||
|
customInfoRequests: enabledCustomInfoRequests,
|
||||||
|
externalValidationLevels: externalValidationLevels
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!loading && subMenu === 'advancedSettings' && (
|
{!loading && subMenu === 'advancedSettings' && (
|
||||||
|
|
|
||||||
|
|
@ -48,14 +48,27 @@ const styles = {
|
||||||
|
|
||||||
const useStyles = makeStyles(styles)
|
const useStyles = makeStyles(styles)
|
||||||
|
|
||||||
const getStep = (step, currency, customInfoRequests, emailAuth) => {
|
const getStep = (
|
||||||
|
{ step, config },
|
||||||
|
currency,
|
||||||
|
customInfoRequests,
|
||||||
|
emailAuth,
|
||||||
|
triggers,
|
||||||
|
additionalInfo
|
||||||
|
) => {
|
||||||
switch (step) {
|
switch (step) {
|
||||||
// case 1:
|
// case 1:
|
||||||
// return txDirection
|
// return txDirection
|
||||||
case 1:
|
case 1:
|
||||||
return type(currency)
|
return type(currency)
|
||||||
case 2:
|
case 2:
|
||||||
return requirements(customInfoRequests, emailAuth)
|
return requirements(
|
||||||
|
customInfoRequests,
|
||||||
|
emailAuth,
|
||||||
|
config,
|
||||||
|
triggers,
|
||||||
|
additionalInfo
|
||||||
|
)
|
||||||
default:
|
default:
|
||||||
return Fragment
|
return Fragment
|
||||||
}
|
}
|
||||||
|
|
@ -166,6 +179,8 @@ const getRequirementText = (config, classes) => {
|
||||||
return <>blocked</>
|
return <>blocked</>
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return <>asked to fulfill a custom requirement</>
|
return <>asked to fulfill a custom requirement</>
|
||||||
|
case 'external':
|
||||||
|
return <>redirected to an external verification process</>
|
||||||
default:
|
default:
|
||||||
return orUnderline(null, classes)
|
return orUnderline(null, classes)
|
||||||
}
|
}
|
||||||
|
|
@ -210,7 +225,9 @@ const Wizard = ({
|
||||||
error,
|
error,
|
||||||
currency,
|
currency,
|
||||||
customInfoRequests,
|
customInfoRequests,
|
||||||
emailAuth
|
emailAuth,
|
||||||
|
triggers,
|
||||||
|
additionalInfo
|
||||||
}) => {
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
|
|
@ -220,7 +237,14 @@ const Wizard = ({
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLastStep = step === LAST_STEP
|
const isLastStep = step === LAST_STEP
|
||||||
const stepOptions = getStep(step, currency, customInfoRequests, emailAuth)
|
const stepOptions = getStep(
|
||||||
|
{ step, config },
|
||||||
|
currency,
|
||||||
|
customInfoRequests,
|
||||||
|
emailAuth,
|
||||||
|
triggers,
|
||||||
|
additionalInfo
|
||||||
|
)
|
||||||
|
|
||||||
const onContinue = async it => {
|
const onContinue = async it => {
|
||||||
const newConfig = R.merge(config, stepOptions.schema.cast(it))
|
const newConfig = R.merge(config, stepOptions.schema.cast(it))
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik'
|
||||||
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
|
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
|
||||||
import { errorColor } from 'src/styling/variables'
|
import { errorColor } from 'src/styling/variables'
|
||||||
import { transformNumber } from 'src/utils/number'
|
import { transformNumber } from 'src/utils/number'
|
||||||
|
import { onlyFirstToUpper } from 'src/utils/string'
|
||||||
|
|
||||||
// import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
// import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
|
||||||
// import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
|
// import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
|
||||||
|
|
@ -82,6 +83,14 @@ const useStyles = makeStyles({
|
||||||
dropdownField: {
|
dropdownField: {
|
||||||
marginTop: 16,
|
marginTop: 16,
|
||||||
minWidth: 155
|
minWidth: 155
|
||||||
|
},
|
||||||
|
externalFields: {
|
||||||
|
'& > *': {
|
||||||
|
marginRight: 15
|
||||||
|
},
|
||||||
|
'& > *:last-child': {
|
||||||
|
marginRight: 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -488,6 +497,20 @@ const requirementSchema = Yup.object()
|
||||||
otherwise: Yup.string()
|
otherwise: Yup.string()
|
||||||
.nullable()
|
.nullable()
|
||||||
.transform(() => '')
|
.transform(() => '')
|
||||||
|
}),
|
||||||
|
externalService: Yup.string().when('requirement', {
|
||||||
|
is: value => value === 'external',
|
||||||
|
then: Yup.string(),
|
||||||
|
otherwise: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.transform(() => '')
|
||||||
|
}),
|
||||||
|
externalServiceApplicantLevel: Yup.string().when('requirement', {
|
||||||
|
is: value => value === 'external',
|
||||||
|
then: Yup.string(),
|
||||||
|
otherwise: Yup.string()
|
||||||
|
.nullable()
|
||||||
|
.transform(() => '')
|
||||||
})
|
})
|
||||||
}).required()
|
}).required()
|
||||||
})
|
})
|
||||||
|
|
@ -502,6 +525,11 @@ const requirementSchema = Yup.object()
|
||||||
return requirement.requirement === type
|
return requirement.requirement === type
|
||||||
? !R.isNil(requirement.customInfoRequestId)
|
? !R.isNil(requirement.customInfoRequestId)
|
||||||
: true
|
: true
|
||||||
|
case 'external':
|
||||||
|
return requirement.requirement === type
|
||||||
|
? !R.isNil(requirement.externalService) &&
|
||||||
|
!R.isNil(requirement.externalServiceApplicantLevel)
|
||||||
|
: true
|
||||||
default:
|
default:
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
@ -518,6 +546,12 @@ const requirementSchema = Yup.object()
|
||||||
path: 'requirement',
|
path: 'requirement',
|
||||||
message: 'You must select an item'
|
message: 'You must select an item'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
if (requirement && !requirementValidator(requirement, 'external'))
|
||||||
|
return context.createError({
|
||||||
|
path: 'requirement',
|
||||||
|
message: 'You must select an item'
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const requirementOptions = [
|
const requirementOptions = [
|
||||||
|
|
@ -545,7 +579,24 @@ const hasCustomRequirementError = (errors, touched, values) =>
|
||||||
(!values.requirement?.customInfoRequestId ||
|
(!values.requirement?.customInfoRequestId ||
|
||||||
!R.isNil(values.requirement?.customInfoRequestId))
|
!R.isNil(values.requirement?.customInfoRequestId))
|
||||||
|
|
||||||
const Requirement = ({ customInfoRequests, emailAuth }) => {
|
const hasExternalRequirementError = (errors, touched, values) =>
|
||||||
|
!!errors.requirement &&
|
||||||
|
!!touched.requirement?.externalService &&
|
||||||
|
!!touched.requirement?.externalServiceApplicantLevel &&
|
||||||
|
(!values.requirement?.externalService ||
|
||||||
|
!R.isNil(values.requirement?.externalService)) &&
|
||||||
|
(!values.requirement?.externalServiceApplicantLevel ||
|
||||||
|
!R.isNil(values.requirement?.externalServiceApplicantLevel))
|
||||||
|
|
||||||
|
const Requirement = ({
|
||||||
|
config = {},
|
||||||
|
triggers,
|
||||||
|
additionalInfo: {
|
||||||
|
emailAuth,
|
||||||
|
customInfoRequests = [],
|
||||||
|
externalValidationLevels = {}
|
||||||
|
}
|
||||||
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const {
|
const {
|
||||||
touched,
|
touched,
|
||||||
|
|
@ -557,29 +608,74 @@ const Requirement = ({ customInfoRequests, emailAuth }) => {
|
||||||
|
|
||||||
const isSuspend = values?.requirement?.requirement === 'suspend'
|
const isSuspend = values?.requirement?.requirement === 'suspend'
|
||||||
const isCustom = values?.requirement?.requirement === 'custom'
|
const isCustom = values?.requirement?.requirement === 'custom'
|
||||||
|
const isExternal = values?.requirement?.requirement === 'external'
|
||||||
|
|
||||||
|
const customRequirementsInUse = R.reduce(
|
||||||
|
(acc, value) => {
|
||||||
|
if (value.requirement.requirement === 'custom')
|
||||||
|
acc.push({
|
||||||
|
triggerType: value.triggerType,
|
||||||
|
id: value.requirement.customInfoRequestId
|
||||||
|
})
|
||||||
|
return acc
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
triggers
|
||||||
|
)
|
||||||
|
|
||||||
|
const availableCustomRequirements = R.filter(
|
||||||
|
it =>
|
||||||
|
!R.includes(
|
||||||
|
{ triggerType: config.triggerType, id: it.id },
|
||||||
|
customRequirementsInUse
|
||||||
|
),
|
||||||
|
customInfoRequests
|
||||||
|
)
|
||||||
|
|
||||||
const makeCustomReqOptions = () =>
|
const makeCustomReqOptions = () =>
|
||||||
customInfoRequests.map(it => ({
|
availableCustomRequirements.map(it => ({
|
||||||
value: it.id,
|
value: it.id,
|
||||||
display: it.customRequest.name
|
display: it.customRequest.name
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const enableCustomRequirement = customInfoRequests?.length > 0
|
const enableCustomRequirement = !R.isEmpty(availableCustomRequirements)
|
||||||
|
const enableExternalRequirement = !R.any(
|
||||||
|
// TODO: right now this condition is directly related with sumsub. On adding external validation, this needs to be generalized
|
||||||
|
ite => ite.requirement === 'external' && ite.externalService === 'sumsub',
|
||||||
|
R.map(it => ({
|
||||||
|
requirement: it.requirement.requirement,
|
||||||
|
externalService: it.requirement.externalService
|
||||||
|
}))(triggers)
|
||||||
|
)
|
||||||
|
|
||||||
const customInfoOption = {
|
const customInfoOption = {
|
||||||
display: 'Custom information requirement',
|
display: 'Custom information requirement',
|
||||||
code: 'custom'
|
code: 'custom'
|
||||||
}
|
}
|
||||||
|
const externalOption = { display: 'External verification', code: 'external' }
|
||||||
|
|
||||||
const itemToRemove = emailAuth ? 'sms' : 'email'
|
const itemToRemove = emailAuth ? 'sms' : 'email'
|
||||||
const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove)
|
const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove)
|
||||||
const options = enableCustomRequirement
|
const options = R.clone(reqOptions)
|
||||||
? [...reqOptions, customInfoOption]
|
|
||||||
: [...reqOptions]
|
enableCustomRequirement && options.push(customInfoOption)
|
||||||
|
enableExternalRequirement && options.push(externalOption)
|
||||||
|
|
||||||
const titleClass = {
|
const titleClass = {
|
||||||
[classes.error]:
|
[classes.error]:
|
||||||
(!!errors.requirement && !isSuspend && !isCustom) ||
|
(!!errors.requirement && !isSuspend && !isCustom) ||
|
||||||
(isSuspend && hasRequirementError(errors, touched, values)) ||
|
(isSuspend && hasRequirementError(errors, touched, values)) ||
|
||||||
(isCustom && hasCustomRequirementError(errors, touched, values))
|
(isCustom && hasCustomRequirementError(errors, touched, values)) ||
|
||||||
|
(isExternal && hasExternalRequirementError(errors, touched, values))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const externalServices = [
|
||||||
|
{
|
||||||
|
value: 'sumsub',
|
||||||
|
display: 'Sumsub'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Box display="flex" alignItems="center">
|
<Box display="flex" alignItems="center">
|
||||||
|
|
@ -620,22 +716,49 @@ const Requirement = ({ customInfoRequests, emailAuth }) => {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{isExternal && (
|
||||||
|
<div className={classes.externalFields}>
|
||||||
|
<Field
|
||||||
|
className={classes.dropdownField}
|
||||||
|
component={Dropdown}
|
||||||
|
label="Service"
|
||||||
|
name="requirement.externalService"
|
||||||
|
options={externalServices}
|
||||||
|
/>
|
||||||
|
{!R.isNil(
|
||||||
|
externalValidationLevels[values.requirement.externalService]
|
||||||
|
) && (
|
||||||
|
<Field
|
||||||
|
className={classes.dropdownField}
|
||||||
|
component={Dropdown}
|
||||||
|
label="Applicant level"
|
||||||
|
name="requirement.externalServiceApplicantLevel"
|
||||||
|
options={
|
||||||
|
externalValidationLevels[values.requirement.externalService]
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const requirements = (customInfoRequests, emailAuth) => ({
|
const requirements = (config, triggers, additionalInfo) => ({
|
||||||
schema: requirementSchema,
|
schema: requirementSchema,
|
||||||
options: requirementOptions,
|
options: requirementOptions,
|
||||||
Component: Requirement,
|
Component: Requirement,
|
||||||
props: { customInfoRequests, emailAuth },
|
props: { config, triggers, additionalInfo },
|
||||||
hasRequirementError: hasRequirementError,
|
hasRequirementError: hasRequirementError,
|
||||||
hasCustomRequirementError: hasCustomRequirementError,
|
hasCustomRequirementError: hasCustomRequirementError,
|
||||||
|
hasExternalRequirementError: hasExternalRequirementError,
|
||||||
initialValues: {
|
initialValues: {
|
||||||
requirement: {
|
requirement: {
|
||||||
requirement: '',
|
requirement: '',
|
||||||
suspensionDays: '',
|
suspensionDays: '',
|
||||||
customInfoRequestId: ''
|
customInfoRequestId: '',
|
||||||
|
externalService: '',
|
||||||
|
externalServiceApplicantLevel: ''
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -665,7 +788,9 @@ const customReqIdMatches = customReqId => it => {
|
||||||
return it.id === customReqId
|
return it.id === customReqId
|
||||||
}
|
}
|
||||||
|
|
||||||
const RequirementInput = ({ customInfoRequests }) => {
|
const RequirementInput = ({
|
||||||
|
additionalInfo: { customInfoRequests = [], externalValidationLevels = {} }
|
||||||
|
}) => {
|
||||||
const { values } = useFormikContext()
|
const { values } = useFormikContext()
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
|
|
||||||
|
|
@ -700,7 +825,8 @@ const RequirementView = ({
|
||||||
requirement,
|
requirement,
|
||||||
suspensionDays,
|
suspensionDays,
|
||||||
customInfoRequestId,
|
customInfoRequestId,
|
||||||
customInfoRequests
|
externalService,
|
||||||
|
additionalInfo: { customInfoRequests = [], externalValidationLevels = {} }
|
||||||
}) => {
|
}) => {
|
||||||
const classes = useStyles()
|
const classes = useStyles()
|
||||||
const display =
|
const display =
|
||||||
|
|
@ -708,6 +834,8 @@ const RequirementView = ({
|
||||||
? R.path(['customRequest', 'name'])(
|
? R.path(['customRequest', 'name'])(
|
||||||
R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests)
|
R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests)
|
||||||
) ?? ''
|
) ?? ''
|
||||||
|
: requirement === 'external'
|
||||||
|
? `External validation (${onlyFirstToUpper(externalService)})`
|
||||||
: getView(requirementOptions, 'display')(requirement)
|
: getView(requirementOptions, 'display')(requirement)
|
||||||
const isSuspend = requirement === 'suspend'
|
const isSuspend = requirement === 'suspend'
|
||||||
return (
|
return (
|
||||||
|
|
@ -821,7 +949,7 @@ const ThresholdView = ({ config, currency }) => {
|
||||||
return <DisplayThreshold config={config} currency={currency} />
|
return <DisplayThreshold config={config} currency={currency} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const getElements = (currency, classes, customInfoRequests) => [
|
const getElements = (currency, classes, additionalInfo) => [
|
||||||
{
|
{
|
||||||
name: 'triggerType',
|
name: 'triggerType',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
|
|
@ -840,17 +968,15 @@ const getElements = (currency, classes, customInfoRequests) => [
|
||||||
{
|
{
|
||||||
name: 'requirement',
|
name: 'requirement',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
width: 230,
|
width: 260,
|
||||||
bypassField: true,
|
bypassField: true,
|
||||||
input: () => <RequirementInput customInfoRequests={customInfoRequests} />,
|
input: () => <RequirementInput additionalInfo={additionalInfo} />,
|
||||||
view: it => (
|
view: it => <RequirementView {...it} additionalInfo={additionalInfo} />
|
||||||
<RequirementView {...it} customInfoRequests={customInfoRequests} />
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'threshold',
|
name: 'threshold',
|
||||||
size: 'sm',
|
size: 'sm',
|
||||||
width: 284,
|
width: 254,
|
||||||
textAlign: 'right',
|
textAlign: 'right',
|
||||||
input: () => <ThresholdInput currency={currency} />,
|
input: () => <ThresholdInput currency={currency} />,
|
||||||
view: (it, config) => <ThresholdView config={config} currency={currency} />
|
view: (it, config) => <ThresholdView config={config} currency={currency} />
|
||||||
|
|
@ -885,12 +1011,16 @@ const fromServer = (triggers, customInfoRequests) => {
|
||||||
threshold,
|
threshold,
|
||||||
thresholdDays,
|
thresholdDays,
|
||||||
customInfoRequestId,
|
customInfoRequestId,
|
||||||
|
externalService,
|
||||||
|
externalServiceApplicantLevel,
|
||||||
...rest
|
...rest
|
||||||
}) => ({
|
}) => ({
|
||||||
requirement: {
|
requirement: {
|
||||||
requirement,
|
requirement,
|
||||||
suspensionDays,
|
suspensionDays,
|
||||||
customInfoRequestId
|
customInfoRequestId,
|
||||||
|
externalService,
|
||||||
|
externalServiceApplicantLevel
|
||||||
},
|
},
|
||||||
threshold: {
|
threshold: {
|
||||||
threshold,
|
threshold,
|
||||||
|
|
@ -908,6 +1038,8 @@ const toServer = triggers =>
|
||||||
threshold: threshold.threshold,
|
threshold: threshold.threshold,
|
||||||
thresholdDays: threshold.thresholdDays,
|
thresholdDays: threshold.thresholdDays,
|
||||||
customInfoRequestId: requirement.customInfoRequestId,
|
customInfoRequestId: requirement.customInfoRequestId,
|
||||||
|
externalService: requirement.externalService,
|
||||||
|
externalServiceApplicantLevel: requirement.externalServiceApplicantLevel,
|
||||||
...rest
|
...rest
|
||||||
}))(triggers)
|
}))(triggers)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import Login from 'src/pages/Authentication/Login'
|
||||||
import Register from 'src/pages/Authentication/Register'
|
import Register from 'src/pages/Authentication/Register'
|
||||||
import Reset2FA from 'src/pages/Authentication/Reset2FA'
|
import Reset2FA from 'src/pages/Authentication/Reset2FA'
|
||||||
import ResetPassword from 'src/pages/Authentication/ResetPassword'
|
import ResetPassword from 'src/pages/Authentication/ResetPassword'
|
||||||
|
import Sumsub from 'src/pages/Compliance/Sumsub'
|
||||||
import Dashboard from 'src/pages/Dashboard'
|
import Dashboard from 'src/pages/Dashboard'
|
||||||
import Machines from 'src/pages/Machines'
|
import Machines from 'src/pages/Machines'
|
||||||
import Wizard from 'src/pages/Wizard'
|
import Wizard from 'src/pages/Wizard'
|
||||||
|
|
@ -91,7 +92,8 @@ const Routes = () => {
|
||||||
'/login',
|
'/login',
|
||||||
'/register',
|
'/register',
|
||||||
'/resetpassword',
|
'/resetpassword',
|
||||||
'/reset2fa'
|
'/reset2fa',
|
||||||
|
'/sumsub'
|
||||||
]
|
]
|
||||||
|
|
||||||
if (!wizardTested && !R.contains(location.pathname)(dontTriggerPages)) {
|
if (!wizardTested && !R.contains(location.pathname)(dontTriggerPages)) {
|
||||||
|
|
@ -142,6 +144,7 @@ const Routes = () => {
|
||||||
</PrivateRoute>
|
</PrivateRoute>
|
||||||
<PrivateRoute path="/machines" component={Machines} />
|
<PrivateRoute path="/machines" component={Machines} />
|
||||||
<PrivateRoute path="/wizard" component={Wizard} />
|
<PrivateRoute path="/wizard" component={Wizard} />
|
||||||
|
<PublicRoute path="/sumsub" component={Sumsub} />
|
||||||
<PublicRoute path="/register" component={Register} />
|
<PublicRoute path="/register" component={Register} />
|
||||||
{/* <PublicRoute path="/configmigration" component={ConfigMigration} /> */}
|
{/* <PublicRoute path="/configmigration" component={ConfigMigration} /> */}
|
||||||
<PublicRoute path="/login" restricted component={Login} />
|
<PublicRoute path="/login" restricted component={Login} />
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ const IP_CHECK_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[
|
||||||
|
|
||||||
const SWEEPABLE_CRYPTOS = ['ETH']
|
const SWEEPABLE_CRYPTOS = ['ETH']
|
||||||
|
|
||||||
|
const COMPLIANCE_SERVICES = ['sumsub']
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CURRENCY_MAX,
|
CURRENCY_MAX,
|
||||||
MIN_NUMBER_OF_CASSETTES,
|
MIN_NUMBER_OF_CASSETTES,
|
||||||
|
|
@ -18,5 +20,6 @@ export {
|
||||||
MANUAL,
|
MANUAL,
|
||||||
WALLET_SCORING_DEFAULT_THRESHOLD,
|
WALLET_SCORING_DEFAULT_THRESHOLD,
|
||||||
IP_CHECK_REGEX,
|
IP_CHECK_REGEX,
|
||||||
SWEEPABLE_CRYPTOS
|
SWEEPABLE_CRYPTOS,
|
||||||
|
COMPLIANCE_SERVICES
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"@haensl/subset-sum": "^3.0.5",
|
"@haensl/subset-sum": "^3.0.5",
|
||||||
"@lamassu/coins": "v1.4.10",
|
"@lamassu/coins": "v1.4.10",
|
||||||
"@simplewebauthn/server": "^3.0.0",
|
"@simplewebauthn/server": "^3.0.0",
|
||||||
|
"@sumsub/websdk-react": "^1.3.6",
|
||||||
"@vonage/auth": "1.5.0",
|
"@vonage/auth": "1.5.0",
|
||||||
"@vonage/sms": "1.7.0",
|
"@vonage/sms": "1.7.0",
|
||||||
"@vonage/server-client": "1.7.0",
|
"@vonage/server-client": "1.7.0",
|
||||||
|
|
@ -47,6 +48,8 @@
|
||||||
"ethereumjs-wallet": "^0.6.3",
|
"ethereumjs-wallet": "^0.6.3",
|
||||||
"express": "4.17.1",
|
"express": "4.17.1",
|
||||||
"express-session": "^1.17.1",
|
"express-session": "^1.17.1",
|
||||||
|
"express-ws": "^3.0.0",
|
||||||
|
"form-data": "^4.0.0",
|
||||||
"futoin-hkdf": "^1.0.2",
|
"futoin-hkdf": "^1.0.2",
|
||||||
"got": "^7.1.0",
|
"got": "^7.1.0",
|
||||||
"graphql": "^15.5.0",
|
"graphql": "^15.5.0",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue