diff --git a/lib/compliance-external.js b/lib/compliance-external.js
new file mode 100644
index 00000000..9974df29
--- /dev/null
+++ b/lib/compliance-external.js
@@ -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
+}
diff --git a/lib/customers.js b/lib/customers.js
index 62ef15a3..f605c269 100644
--- a/lib/customers.js
+++ b/lib/customers.js
@@ -17,6 +17,7 @@ const NUM_RESULTS = 1000
const sms = require('./sms')
const settingsLoader = require('./new-settings-loader')
const logger = require('./logger')
+const externalCompliance = require('./compliance-external')
const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel', 'scoreThresholdReached', 'walletScoringError']
@@ -243,7 +244,7 @@ function deleteEditedData (id, data) {
'id_card_data',
'id_card_photo',
'us_ssn',
- 'subcriber_info',
+ 'subscriber_info',
'name'
]
const filteredData = _.pick(defaults, _.mapKeys(_.snakeCase, data))
@@ -322,6 +323,7 @@ function getById (id) {
return db.oneOrNone(sql, [id])
.then(assignCustomerData)
.then(getCustomInfoRequestsData)
+ .then(getExternalCustomerData)
.then(camelize)
}
@@ -589,6 +591,7 @@ function getCustomerById (id) {
.then(getCustomInfoRequestsData)
.then(camelizeDeep)
.then(formatSubscriberInfo)
+ .then(getExternalCustomerData)
}
function assignCustomerData (customer) {
@@ -927,6 +930,15 @@ function updateLastAuthAttempt (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 = {
add,
addWithEmail,
diff --git a/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js b/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js
new file mode 100644
index 00000000..76b7a57a
--- /dev/null
+++ b/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js
@@ -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
diff --git a/lib/new-admin/graphql/resolvers/index.js b/lib/new-admin/graphql/resolvers/index.js
index 81e79614..f10e10cc 100644
--- a/lib/new-admin/graphql/resolvers/index.js
+++ b/lib/new-admin/graphql/resolvers/index.js
@@ -7,6 +7,7 @@ const config = require('./config.resolver')
const currency = require('./currency.resolver')
const customer = require('./customer.resolver')
const customInfoRequests = require('./customInfoRequests.resolver')
+const externalCompliance = require('./externalCompliance.resolver')
const funding = require('./funding.resolver')
const log = require('./log.resolver')
const loyalty = require('./loyalty.resolver')
@@ -30,6 +31,7 @@ const resolvers = [
currency,
customer,
customInfoRequests,
+ externalCompliance,
funding,
log,
loyalty,
diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js
index d238fda6..0259d6eb 100644
--- a/lib/new-admin/graphql/types/customer.type.js
+++ b/lib/new-admin/graphql/types/customer.type.js
@@ -40,6 +40,7 @@ const typeDef = gql`
customInfoRequests: [CustomRequestData]
notes: [CustomerNote]
isTestCustomer: Boolean
+ externalCompliance: JSONObject
}
input CustomerInput {
diff --git a/lib/new-admin/graphql/types/externalCompliance.type.js b/lib/new-admin/graphql/types/externalCompliance.type.js
new file mode 100644
index 00000000..c8dbc02c
--- /dev/null
+++ b/lib/new-admin/graphql/types/externalCompliance.type.js
@@ -0,0 +1,9 @@
+const { gql } = require('apollo-server-express')
+
+const typeDef = gql`
+ type Query {
+ getApplicantAccessToken(customerId: ID, triggerId: ID): String
+ }
+`
+
+module.exports = typeDef
diff --git a/lib/new-admin/graphql/types/index.js b/lib/new-admin/graphql/types/index.js
index a1886a28..11bfee20 100644
--- a/lib/new-admin/graphql/types/index.js
+++ b/lib/new-admin/graphql/types/index.js
@@ -7,6 +7,7 @@ const config = require('./config.type')
const currency = require('./currency.type')
const customer = require('./customer.type')
const customInfoRequests = require('./customInfoRequests.type')
+const externalCompliance = require('./externalCompliance.type')
const funding = require('./funding.type')
const log = require('./log.type')
const loyalty = require('./loyalty.type')
@@ -30,6 +31,7 @@ const types = [
currency,
customer,
customInfoRequests,
+ externalCompliance,
funding,
log,
loyalty,
diff --git a/lib/new-settings-loader.js b/lib/new-settings-loader.js
index 40880527..757a93a6 100644
--- a/lib/new-settings-loader.js
+++ b/lib/new-settings-loader.js
@@ -28,7 +28,9 @@ const SECRET_FIELDS = [
'vonage.apiSecret',
'galoy.walletId',
'galoy.apiSecret',
- 'bitfinex.secret'
+ 'bitfinex.secret',
+ 'sumsub.apiToken',
+ 'sumsub.privateKey'
]
/*
diff --git a/lib/plugin-helper.js b/lib/plugin-helper.js
index 6af93c74..de189979 100644
--- a/lib/plugin-helper.js
+++ b/lib/plugin-helper.js
@@ -11,7 +11,8 @@ const pluginCodes = {
LAYER2: 'layer2',
SMS: 'sms',
EMAIL: 'email',
- ZERO_CONF: 'zero-conf'
+ ZERO_CONF: 'zero-conf',
+ COMPLIANCE: 'compliance'
}
module.exports = _.assign({load}, pluginCodes)
diff --git a/lib/plugins/compliance/sumsub/request.js b/lib/plugins/compliance/sumsub/request.js
new file mode 100644
index 00000000..588acc23
--- /dev/null
+++ b/lib/plugins/compliance/sumsub/request.js
@@ -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
diff --git a/lib/plugins/compliance/sumsub/sumsub.js b/lib/plugins/compliance/sumsub/sumsub.js
new file mode 100644
index 00000000..7637d170
--- /dev/null
+++ b/lib/plugins/compliance/sumsub/sumsub.js
@@ -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
+}
diff --git a/lib/plugins/compliance/sumsub/utils.js b/lib/plugins/compliance/sumsub/utils.js
new file mode 100644
index 00000000..b81bfe48
--- /dev/null
+++ b/lib/plugins/compliance/sumsub/utils.js
@@ -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
+}
diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js
index d0e11431..ebf99334 100644
--- a/lib/routes/customerRoutes.js
+++ b/lib/routes/customerRoutes.js
@@ -18,13 +18,14 @@ const notifier = require('../notifier')
const respond = require('../respond')
const { getTx } = require('../new-admin/services/transactions.js')
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 T = require('../time')
const plugins = require('../plugins')
const Tx = require('../tx')
const loyalty = require('../loyalty')
const logger = require('../logger')
+const externalCompliance = require('../compliance-external')
function updateCustomerCustomInfoRequest (customerId, patch) {
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) {
const triggers = configManager.getTriggers(config)
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
@@ -311,6 +329,7 @@ router.patch('/:id/suspend', triggerSuspend)
router.patch('/:id/photos/idcarddata', updateIdCardData)
router.patch('/:id/:txId/photos/customerphoto', updateTxCustomerPhoto)
router.post('/:id/smsreceipt', sendSmsReceipt)
+router.get('/external', getExternalComplianceLink)
router.post('/phone_code', getOrAddCustomerPhone)
router.post('/email_code', getOrAddCustomerEmail)
diff --git a/migrations/1667945906700-integrate-sumsub.js b/migrations/1667945906700-integrate-sumsub.js
new file mode 100644
index 00000000..0080faa6
--- /dev/null
+++ b/migrations/1667945906700-integrate-sumsub.js
@@ -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()
+}
diff --git a/new-lamassu-admin/src/components/buttons/DeleteButton.js b/new-lamassu-admin/src/components/buttons/DeleteButton.js
new file mode 100644
index 00000000..c1c31082
--- /dev/null
+++ b/new-lamassu-admin/src/components/buttons/DeleteButton.js
@@ -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 (
+
+ )
+})
+
+export default SimpleButton
diff --git a/new-lamassu-admin/src/components/buttons/index.js b/new-lamassu-admin/src/components/buttons/index.js
index 42530ee3..e0e34f7d 100644
--- a/new-lamassu-admin/src/components/buttons/index.js
+++ b/new-lamassu-admin/src/components/buttons/index.js
@@ -1,6 +1,7 @@
import ActionButton from './ActionButton'
import AddButton from './AddButton'
import Button from './Button'
+import DeleteButton from './DeleteButton'
import FeatureButton from './FeatureButton'
import IDButton from './IDButton'
import IconButton from './IconButton'
@@ -19,5 +20,6 @@ export {
IDButton,
AddButton,
SupportLinkButton,
- SubpageButton
+ SubpageButton,
+ DeleteButton
}
diff --git a/new-lamassu-admin/src/pages/Compliance/Sumsub.js b/new-lamassu-admin/src/pages/Compliance/Sumsub.js
new file mode 100644
index 00000000..2e38cd66
--- /dev/null
+++ b/new-lamassu-admin/src/pages/Compliance/Sumsub.js
@@ -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 (
+
Comments about this decision:
+ {R.map( + it => ( +{it}
+ ), + R.split('\n', comment) + )} +Relevant labels: {R.join(',', labels)}
++ Before linking the Sumsub 3rd party service to the Lamassu Admin, make + sure you have configured the required parameters in your personal Sumsub + Dashboard. +
++ These parameters include the Sumsub Global Settings, Applicant Levels, + Twilio and Webhooks. +
+