From 6ba06320671eff4983c76d0cb84f673bff6d26c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Salgado?= Date: Thu, 3 Nov 2022 18:53:08 +0000 Subject: [PATCH 1/7] 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 --- lib/compliance-external.js | 55 +++ lib/customers.js | 14 +- .../resolvers/externalCompliance.resolver.js | 11 + lib/new-admin/graphql/resolvers/index.js | 2 + lib/new-admin/graphql/types/customer.type.js | 1 + .../graphql/types/externalCompliance.type.js | 9 + lib/new-admin/graphql/types/index.js | 2 + lib/new-settings-loader.js | 4 +- lib/plugin-helper.js | 3 +- lib/plugins/compliance/sumsub/request.js | 41 ++ lib/plugins/compliance/sumsub/sumsub.js | 461 ++++++++++++++++++ lib/plugins/compliance/sumsub/utils.js | 455 +++++++++++++++++ lib/routes/customerRoutes.js | 21 +- migrations/1667945906700-integrate-sumsub.js | 13 + .../src/components/buttons/DeleteButton.js | 53 ++ .../src/components/buttons/index.js | 4 +- .../src/pages/Compliance/Sumsub.js | 52 ++ .../src/pages/Customers/CustomerData.js | 96 +++- .../src/pages/Customers/CustomerProfile.js | 2 + .../Customers/components/EditableCard.js | 85 +++- .../src/pages/Customers/components/index.js | 3 +- .../src/pages/Dashboard/Footer/Footer.js | 5 +- .../src/pages/Services/schemas/index.js | 4 +- .../src/pages/Services/schemas/sumsub.js | 84 ++++ .../src/pages/Triggers/TriggerView.js | 12 +- .../src/pages/Triggers/Triggers.js | 88 +++- .../src/pages/Triggers/Wizard.js | 32 +- .../src/pages/Triggers/helper.js | 172 ++++++- new-lamassu-admin/src/routing/routes.js | 5 +- new-lamassu-admin/src/utils/constants.js | 5 +- package.json | 3 + 31 files changed, 1730 insertions(+), 67 deletions(-) create mode 100644 lib/compliance-external.js create mode 100644 lib/new-admin/graphql/resolvers/externalCompliance.resolver.js create mode 100644 lib/new-admin/graphql/types/externalCompliance.type.js create mode 100644 lib/plugins/compliance/sumsub/request.js create mode 100644 lib/plugins/compliance/sumsub/sumsub.js create mode 100644 lib/plugins/compliance/sumsub/utils.js create mode 100644 migrations/1667945906700-integrate-sumsub.js create mode 100644 new-lamassu-admin/src/components/buttons/DeleteButton.js create mode 100644 new-lamassu-admin/src/pages/Compliance/Sumsub.js create mode 100644 new-lamassu-admin/src/pages/Services/schemas/sumsub.js 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 ( + + ) +} + +export default Sumsub diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index a2aa9a81..cc159380 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -11,7 +11,8 @@ import { TextInput } from 'src/components/inputs/formik' import { H3, Info3 } from 'src/components/typography' import { OVERRIDE_AUTHORIZED, - OVERRIDE_REJECTED + OVERRIDE_REJECTED, + OVERRIDE_PENDING } from 'src/pages/Customers/components/propertyCard' import { ReactComponent as CardIcon } from 'src/styling/icons/ID/card/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 styles from './CustomerData.styles.js' -import { EditableCard } from './components' +import { EditableCard, NonEditableCard } from './components' import { customerDataElements, customerDataSchemas, @@ -64,7 +65,7 @@ const Photo = ({ show, src }) => { const CustomerData = ({ locale, - customer, + customer = {}, updateCustomer, replacePhoto, editCustomer, @@ -99,6 +100,7 @@ const CustomerData = ({ const customInfoRequests = sortByName( R.path(['customInfoRequests'])(customer) ?? [] ) + const externalCompliance = [] const phone = R.path(['phone'])(customer) const email = R.path(['email'])(customer) @@ -399,6 +401,60 @@ const CustomerData = ({ }) }, 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: , + state: externalComplianceData[ + externalComplianceProvider + ]?.getVerificationState(externalComplianceValues), + title: 'External Info' + }) + } + const editableCard = ( { title, @@ -440,6 +496,21 @@ const CustomerData = ({ ) } + const nonEditableCard = ( + { title, state, titleIcon, fields, hasImage }, + idx + ) => { + return ( + + ) + } + const visibleCards = getVisibleCards(cards) return ( @@ -514,6 +585,25 @@ const CustomerData = ({ )} + {!R.isEmpty(externalCompliance) && ( +
+ + External compliance information + + + + {externalCompliance.map((elem, idx) => { + return isEven(idx) ? nonEditableCard(elem, idx) : null + })} + + + {externalCompliance.map((elem, idx) => { + return !isEven(idx) ? nonEditableCard(elem, idx) : null + })} + + +
+ )} {retrieveAdditionalDataDialog} diff --git a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js index 5e95d4fb..cc8c54bf 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerProfile.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerProfile.js @@ -82,6 +82,7 @@ const GET_CUSTOMER = gql` isTestCustomer subscriberInfo phoneOverride + externalCompliance customFields { id label @@ -153,6 +154,7 @@ const SET_CUSTOMER = gql` lastTxClass subscriberInfo phoneOverride + externalCompliance } } ` diff --git a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js index 287fc52a..6783b648 100644 --- a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js +++ b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js @@ -8,7 +8,7 @@ import { useState, React } from 'react' import ErrorMessage from 'src/components/ErrorMessage' import PromptWhenDirty from 'src/components/PromptWhenDirty' 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 { Label1, P, H3 } from 'src/components/typography' 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 ( +
+ + +
+
+ {titleIcon} +

{title}

+ { + // TODO: Enable for next release + /* */ + } +
+ {state && ( +
+ + {comment && labels && ( + +

Comments about this decision:

+ {R.map( + it => ( +

{it}

+ ), + R.split('\n', comment) + )} +

Relevant labels: {R.join(',', labels)}

+
+ )} +
+ )} +
+
+ + + {!hasImage && + fields?.map((field, idx) => { + return idx >= 0 && idx < 4 ? ( + + ) : null + })} + + + {!hasImage && + fields?.map((field, idx) => { + return idx >= 4 ? ( + + ) : null + })} + + +
+
+
+
+ ) +} + +export { EditableCard, NonEditableCard } diff --git a/new-lamassu-admin/src/pages/Customers/components/index.js b/new-lamassu-admin/src/pages/Customers/components/index.js index 7e3c3e19..bc009220 100644 --- a/new-lamassu-admin/src/pages/Customers/components/index.js +++ b/new-lamassu-admin/src/pages/Customers/components/index.js @@ -2,7 +2,7 @@ import Wizard from '../Wizard' import CustomerDetails from './CustomerDetails' import CustomerSidebar from './CustomerSidebar' -import EditableCard from './EditableCard' +import { EditableCard, NonEditableCard } from './EditableCard' import Field from './Field' import IdDataCard from './IdDataCard' import PhotosCarousel from './PhotosCarousel' @@ -17,6 +17,7 @@ export { CustomerSidebar, Field, EditableCard, + NonEditableCard, Wizard, Upload } diff --git a/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js b/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js index 5ff9bdd3..eefaab13 100644 --- a/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js +++ b/new-lamassu-admin/src/pages/Dashboard/Footer/Footer.js @@ -1,4 +1,3 @@ -/* eslint-disable no-unused-vars */ import { useQuery } from '@apollo/react-hooks' import { makeStyles } from '@material-ui/core' import Grid from '@material-ui/core/Grid' @@ -6,7 +5,7 @@ import BigNumber from 'bignumber.js' import classnames from 'classnames' import gql from 'graphql-tag' import * as R from 'ramda' -import React, { useState } from 'react' +import React from 'react' import { Label2 } from 'src/components/typography' 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 classes = useStyles() 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 cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? [] diff --git a/new-lamassu-admin/src/pages/Services/schemas/index.js b/new-lamassu-admin/src/pages/Services/schemas/index.js index fdcf2097..3b226ed6 100644 --- a/new-lamassu-admin/src/pages/Services/schemas/index.js +++ b/new-lamassu-admin/src/pages/Services/schemas/index.js @@ -11,6 +11,7 @@ import itbit from './itbit' import kraken from './kraken' import mailgun from './mailgun' import scorechain from './scorechain' +import sumsub from './sumsub' import telnyx from './telnyx' import trongrid from './trongrid' import twilio from './twilio' @@ -33,5 +34,6 @@ export default { [scorechain.code]: scorechain, [trongrid.code]: trongrid, [binance.code]: binance, - [bitfinex.code]: bitfinex + [bitfinex.code]: bitfinex, + [sumsub.code]: sumsub } diff --git a/new-lamassu-admin/src/pages/Services/schemas/sumsub.js b/new-lamassu-admin/src/pages/Services/schemas/sumsub.js new file mode 100644 index 00000000..20eb6bc8 --- /dev/null +++ b/new-lamassu-admin/src/pages/Services/schemas/sumsub.js @@ -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 ( +
+

+ 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. +

+ setCanContinue(!canContinue)} + settings={{ + enabled: true, + label: 'I have completed the steps needed to configure Sumsub', + rightSideLabel: true + }} + /> +
+
+ +
+
+
+ ) +} + +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 diff --git a/new-lamassu-admin/src/pages/Triggers/TriggerView.js b/new-lamassu-admin/src/pages/Triggers/TriggerView.js index 6957fbfc..5c049f36 100644 --- a/new-lamassu-admin/src/pages/Triggers/TriggerView.js +++ b/new-lamassu-admin/src/pages/Triggers/TriggerView.js @@ -29,7 +29,8 @@ const TriggerView = ({ toggleWizard, addNewTriger, customInfoRequests, - emailAuth + emailAuth, + additionalInfo }) => { const currency = R.path(['fiatCurrency'])( fromNamespace(namespaces.LOCALE)(config) @@ -69,7 +70,12 @@ const TriggerView = ({ error={error?.message} save={save} validationSchema={Schema} - elements={getElements(currency, classes, customInfoRequests)} + elements={getElements( + currency, + classes, + customInfoRequests, + additionalInfo + )} /> {showWizard && ( toggleWizard(true)} customInfoRequests={customInfoRequests} emailAuth={emailAuth} + triggers={triggers} + additionalInfo={additionalInfo} /> )} {R.isEmpty(triggers) && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Triggers.js b/new-lamassu-admin/src/pages/Triggers/Triggers.js index 9560cda4..ade21956 100644 --- a/new-lamassu-admin/src/pages/Triggers/Triggers.js +++ b/new-lamassu-admin/src/pages/Triggers/Triggers.js @@ -4,6 +4,7 @@ import classnames from 'classnames' import gql from 'graphql-tag' import * as R from 'ramda' import React, { useState } from 'react' +import { getAccountInstance } from 'src/utils/accounts' import Modal from 'src/components/Modal' 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 SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg' import { fromNamespace, toNamespace } from 'src/utils/config' +import { COMPLIANCE_SERVICES } from 'src/utils/constants' import CustomInfoRequests from './CustomInfoRequests' import TriggerView from './TriggerView' @@ -132,32 +134,67 @@ const Triggers = () => { 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 ( <> { - refetch() - setSubMenu(show ? 'advancedSettings' : false) - } - }, - { - text: 'Custom info requests', - icon: CustomInfoIcon, - inverseIcon: ReverseCustomInfoIcon, - forceDisable: !(subMenu === 'customInfoRequests'), - toggle: show => { - refetch() - setSubMenu(show ? 'customInfoRequests' : false) - } - } - ]} + title="Compliance triggers" + buttons={buttons} className={classnames(titleSectionWidth)}> {!subMenu && ( @@ -219,8 +256,11 @@ const Triggers = () => { config={data?.config ?? {}} toggleWizard={toggleWizard('newTrigger')} addNewTriger={addNewTriger} - customInfoRequests={enabledCustomInfoRequests} emailAuth={emailAuth} + additionalInfo={{ + customInfoRequests: enabledCustomInfoRequests, + externalValidationLevels: externalValidationLevels + }} /> )} {!loading && subMenu === 'advancedSettings' && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Wizard.js b/new-lamassu-admin/src/pages/Triggers/Wizard.js index 0e699f7b..0d966d6c 100644 --- a/new-lamassu-admin/src/pages/Triggers/Wizard.js +++ b/new-lamassu-admin/src/pages/Triggers/Wizard.js @@ -48,14 +48,27 @@ const styles = { const useStyles = makeStyles(styles) -const getStep = (step, currency, customInfoRequests, emailAuth) => { +const getStep = ( + { step, config }, + currency, + customInfoRequests, + emailAuth, + triggers, + additionalInfo +) => { switch (step) { // case 1: // return txDirection case 1: return type(currency) case 2: - return requirements(customInfoRequests, emailAuth) + return requirements( + customInfoRequests, + emailAuth, + config, + triggers, + additionalInfo + ) default: return Fragment } @@ -166,6 +179,8 @@ const getRequirementText = (config, classes) => { return <>blocked case 'custom': return <>asked to fulfill a custom requirement + case 'external': + return <>redirected to an external verification process default: return orUnderline(null, classes) } @@ -210,7 +225,9 @@ const Wizard = ({ error, currency, customInfoRequests, - emailAuth + emailAuth, + triggers, + additionalInfo }) => { const classes = useStyles() @@ -220,7 +237,14 @@ const Wizard = ({ }) 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 newConfig = R.merge(config, stepOptions.schema.cast(it)) diff --git a/new-lamassu-admin/src/pages/Triggers/helper.js b/new-lamassu-admin/src/pages/Triggers/helper.js index c91ea450..9e03abb7 100644 --- a/new-lamassu-admin/src/pages/Triggers/helper.js +++ b/new-lamassu-admin/src/pages/Triggers/helper.js @@ -9,6 +9,7 @@ import { NumberInput, RadioGroup, Dropdown } from 'src/components/inputs/formik' import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography' import { errorColor } from 'src/styling/variables' 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 TxOutIcon } from 'src/styling/icons/direction/cash-out.svg' @@ -82,6 +83,14 @@ const useStyles = makeStyles({ dropdownField: { marginTop: 16, minWidth: 155 + }, + externalFields: { + '& > *': { + marginRight: 15 + }, + '& > *:last-child': { + marginRight: 0 + } } }) @@ -488,6 +497,20 @@ const requirementSchema = Yup.object() otherwise: Yup.string() .nullable() .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() }) @@ -502,6 +525,11 @@ const requirementSchema = Yup.object() return requirement.requirement === type ? !R.isNil(requirement.customInfoRequestId) : true + case 'external': + return requirement.requirement === type + ? !R.isNil(requirement.externalService) && + !R.isNil(requirement.externalServiceApplicantLevel) + : true default: return true } @@ -518,6 +546,12 @@ const requirementSchema = Yup.object() path: 'requirement', message: 'You must select an item' }) + + if (requirement && !requirementValidator(requirement, 'external')) + return context.createError({ + path: 'requirement', + message: 'You must select an item' + }) }) const requirementOptions = [ @@ -545,7 +579,24 @@ const hasCustomRequirementError = (errors, touched, values) => (!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 { touched, @@ -557,29 +608,74 @@ const Requirement = ({ customInfoRequests, emailAuth }) => { const isSuspend = values?.requirement?.requirement === 'suspend' 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 = () => - customInfoRequests.map(it => ({ + availableCustomRequirements.map(it => ({ value: it.id, 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 = { display: 'Custom information requirement', code: 'custom' } + const externalOption = { display: 'External verification', code: 'external' } + const itemToRemove = emailAuth ? 'sms' : 'email' const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove) - const options = enableCustomRequirement - ? [...reqOptions, customInfoOption] - : [...reqOptions] + const options = R.clone(reqOptions) + + enableCustomRequirement && options.push(customInfoOption) + enableExternalRequirement && options.push(externalOption) + const titleClass = { [classes.error]: (!!errors.requirement && !isSuspend && !isCustom) || (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 ( <> @@ -620,22 +716,49 @@ const Requirement = ({ customInfoRequests, emailAuth }) => { /> )} + {isExternal && ( +
+ + {!R.isNil( + externalValidationLevels[values.requirement.externalService] + ) && ( + + )} +
+ )} ) } -const requirements = (customInfoRequests, emailAuth) => ({ +const requirements = (config, triggers, additionalInfo) => ({ schema: requirementSchema, options: requirementOptions, Component: Requirement, - props: { customInfoRequests, emailAuth }, + props: { config, triggers, additionalInfo }, hasRequirementError: hasRequirementError, hasCustomRequirementError: hasCustomRequirementError, + hasExternalRequirementError: hasExternalRequirementError, initialValues: { requirement: { requirement: '', suspensionDays: '', - customInfoRequestId: '' + customInfoRequestId: '', + externalService: '', + externalServiceApplicantLevel: '' } } }) @@ -665,7 +788,9 @@ const customReqIdMatches = customReqId => it => { return it.id === customReqId } -const RequirementInput = ({ customInfoRequests }) => { +const RequirementInput = ({ + additionalInfo: { customInfoRequests = [], externalValidationLevels = {} } +}) => { const { values } = useFormikContext() const classes = useStyles() @@ -700,7 +825,8 @@ const RequirementView = ({ requirement, suspensionDays, customInfoRequestId, - customInfoRequests + externalService, + additionalInfo: { customInfoRequests = [], externalValidationLevels = {} } }) => { const classes = useStyles() const display = @@ -708,6 +834,8 @@ const RequirementView = ({ ? R.path(['customRequest', 'name'])( R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests) ) ?? '' + : requirement === 'external' + ? `External validation (${onlyFirstToUpper(externalService)})` : getView(requirementOptions, 'display')(requirement) const isSuspend = requirement === 'suspend' return ( @@ -821,7 +949,7 @@ const ThresholdView = ({ config, currency }) => { return } -const getElements = (currency, classes, customInfoRequests) => [ +const getElements = (currency, classes, additionalInfo) => [ { name: 'triggerType', size: 'sm', @@ -840,17 +968,15 @@ const getElements = (currency, classes, customInfoRequests) => [ { name: 'requirement', size: 'sm', - width: 230, + width: 260, bypassField: true, - input: () => , - view: it => ( - - ) + input: () => , + view: it => }, { name: 'threshold', size: 'sm', - width: 284, + width: 254, textAlign: 'right', input: () => , view: (it, config) => @@ -885,12 +1011,16 @@ const fromServer = (triggers, customInfoRequests) => { threshold, thresholdDays, customInfoRequestId, + externalService, + externalServiceApplicantLevel, ...rest }) => ({ requirement: { requirement, suspensionDays, - customInfoRequestId + customInfoRequestId, + externalService, + externalServiceApplicantLevel }, threshold: { threshold, @@ -908,6 +1038,8 @@ const toServer = triggers => threshold: threshold.threshold, thresholdDays: threshold.thresholdDays, customInfoRequestId: requirement.customInfoRequestId, + externalService: requirement.externalService, + externalServiceApplicantLevel: requirement.externalServiceApplicantLevel, ...rest }))(triggers) diff --git a/new-lamassu-admin/src/routing/routes.js b/new-lamassu-admin/src/routing/routes.js index 18876686..bf6fdc4c 100644 --- a/new-lamassu-admin/src/routing/routes.js +++ b/new-lamassu-admin/src/routing/routes.js @@ -16,6 +16,7 @@ import Login from 'src/pages/Authentication/Login' import Register from 'src/pages/Authentication/Register' import Reset2FA from 'src/pages/Authentication/Reset2FA' import ResetPassword from 'src/pages/Authentication/ResetPassword' +import Sumsub from 'src/pages/Compliance/Sumsub' import Dashboard from 'src/pages/Dashboard' import Machines from 'src/pages/Machines' import Wizard from 'src/pages/Wizard' @@ -91,7 +92,8 @@ const Routes = () => { '/login', '/register', '/resetpassword', - '/reset2fa' + '/reset2fa', + '/sumsub' ] if (!wizardTested && !R.contains(location.pathname)(dontTriggerPages)) { @@ -142,6 +144,7 @@ const Routes = () => { + {/* */} diff --git a/new-lamassu-admin/src/utils/constants.js b/new-lamassu-admin/src/utils/constants.js index 9d829d3c..43f4d4f2 100644 --- a/new-lamassu-admin/src/utils/constants.js +++ b/new-lamassu-admin/src/utils/constants.js @@ -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 COMPLIANCE_SERVICES = ['sumsub'] + export { CURRENCY_MAX, MIN_NUMBER_OF_CASSETTES, @@ -18,5 +20,6 @@ export { MANUAL, WALLET_SCORING_DEFAULT_THRESHOLD, IP_CHECK_REGEX, - SWEEPABLE_CRYPTOS + SWEEPABLE_CRYPTOS, + COMPLIANCE_SERVICES } diff --git a/package.json b/package.json index ae41ad28..b9f3a450 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@haensl/subset-sum": "^3.0.5", "@lamassu/coins": "v1.4.10", "@simplewebauthn/server": "^3.0.0", + "@sumsub/websdk-react": "^1.3.6", "@vonage/auth": "1.5.0", "@vonage/sms": "1.7.0", "@vonage/server-client": "1.7.0", @@ -47,6 +48,8 @@ "ethereumjs-wallet": "^0.6.3", "express": "4.17.1", "express-session": "^1.17.1", + "express-ws": "^3.0.0", + "form-data": "^4.0.0", "futoin-hkdf": "^1.0.2", "got": "^7.1.0", "graphql": "^15.5.0", From b06927fd1c6426f6440bba9eb216f8e8c0263878 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9rgio=20Salgado?= Date: Tue, 11 Apr 2023 23:37:33 +0100 Subject: [PATCH 2/7] fix: change sumsub usage away from self-hosted solutions --- lib/compliance-external.js | 8 ++++---- lib/graphql/types.js | 1 + .../graphql/resolvers/externalCompliance.resolver.js | 4 ++-- lib/new-admin/graphql/types/externalCompliance.type.js | 2 +- lib/plugins/compliance/sumsub/sumsub.js | 7 ++++--- lib/routes/customerRoutes.js | 5 ++--- new-lamassu-admin/src/routing/routes.js | 5 +---- 7 files changed, 15 insertions(+), 17 deletions(-) diff --git a/lib/compliance-external.js b/lib/compliance-external.js index 9974df29..87436f8f 100644 --- a/lib/compliance-external.js +++ b/lib/compliance-external.js @@ -40,16 +40,16 @@ const getApplicant = (settings, customer) => { } } -const createApplicantAccessToken = (settings, customerId, triggerId) => { +const createApplicantExternalLink = (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) + return plugin.createApplicantExternalLink({ levelName: trigger.externalServiceApplicantLevel, userId: customerId }) + .then(r => r.data.url) } module.exports = { createApplicant, getApplicant, - createApplicantAccessToken + createApplicantExternalLink } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index 7977e522..8d086151 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -82,6 +82,7 @@ type CustomInput { constraintType: String! label1: String label2: String + label3: String choiceList: [String] } diff --git a/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js b/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js index 76b7a57a..c25afdc7 100644 --- a/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js +++ b/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js @@ -3,8 +3,8 @@ const { loadLatest } = require('../../../new-settings-loader') const resolvers = { Query: { - getApplicantAccessToken: (...[, { customerId, triggerId }]) => loadLatest() - .then(settings => externalCompliance.createApplicantAccessToken(settings, customerId, triggerId)) + getApplicantExternalLink: (...[, { customerId, triggerId }]) => loadLatest() + .then(settings => externalCompliance.createApplicantExternalLink(settings, customerId, triggerId)) } } diff --git a/lib/new-admin/graphql/types/externalCompliance.type.js b/lib/new-admin/graphql/types/externalCompliance.type.js index c8dbc02c..f05f3153 100644 --- a/lib/new-admin/graphql/types/externalCompliance.type.js +++ b/lib/new-admin/graphql/types/externalCompliance.type.js @@ -2,7 +2,7 @@ const { gql } = require('apollo-server-express') const typeDef = gql` type Query { - getApplicantAccessToken(customerId: ID, triggerId: ID): String + getApplicantExternalLink(customerId: ID, triggerId: ID): String } ` diff --git a/lib/plugins/compliance/sumsub/sumsub.js b/lib/plugins/compliance/sumsub/sumsub.js index 7637d170..0fdd9838 100644 --- a/lib/plugins/compliance/sumsub/sumsub.js +++ b/lib/plugins/compliance/sumsub/sumsub.js @@ -17,7 +17,7 @@ const getMissingRequiredFields = (fields, obj) => fields ) -const createApplicantAccessToken = opts => { +const createApplicantExternalLink = opts => { const REQUIRED_FIELDS = ['userId', 'levelName'] if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) { @@ -26,8 +26,9 @@ const createApplicantAccessToken = opts => { return request({ method: 'POST', - url: `/resources/accessTokens?userId=${opts.userId}&levelName=${opts.levelName}`, + url: `/resources/sdkIntegrations/levels/${opts.levelName}/websdkLink?ttlInSecs=${600}&externalUserId=${opts.userId}`, headers: { + 'Content-Type': 'application/json', 'Accept': 'application/json' } }) @@ -434,7 +435,7 @@ const getApiHealth = () => { module.exports = { CODE, - createApplicantAccessToken, + createApplicantExternalLink, createApplicant, getApplicant, addIdDocument, diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js index ebf99334..ea9ed016 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -244,9 +244,8 @@ function getExternalComplianceLink (req, res, next) { 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}` + return externalCompliance.createApplicantExternalLink(settings, customerId, trigger.id) + .then(url => { process.env.NODE_ENV === 'development' && console.log(url) return respond(req, res, { url: url }) }) diff --git a/new-lamassu-admin/src/routing/routes.js b/new-lamassu-admin/src/routing/routes.js index bf6fdc4c..18876686 100644 --- a/new-lamassu-admin/src/routing/routes.js +++ b/new-lamassu-admin/src/routing/routes.js @@ -16,7 +16,6 @@ import Login from 'src/pages/Authentication/Login' import Register from 'src/pages/Authentication/Register' import Reset2FA from 'src/pages/Authentication/Reset2FA' import ResetPassword from 'src/pages/Authentication/ResetPassword' -import Sumsub from 'src/pages/Compliance/Sumsub' import Dashboard from 'src/pages/Dashboard' import Machines from 'src/pages/Machines' import Wizard from 'src/pages/Wizard' @@ -92,8 +91,7 @@ const Routes = () => { '/login', '/register', '/resetpassword', - '/reset2fa', - '/sumsub' + '/reset2fa' ] if (!wizardTested && !R.contains(location.pathname)(dontTriggerPages)) { @@ -144,7 +142,6 @@ const Routes = () => { - {/* */} From 04eea85a0dd8890098bb9045f01167d1c6722592 Mon Sep 17 00:00:00 2001 From: Rafael Taranto Date: Mon, 17 Jun 2024 22:11:24 +0100 Subject: [PATCH 3/7] refactor: yagni and flow of external compliance --- lib/compliance-external.js | 103 ++-- lib/customers.js | 28 +- lib/graphql/types.js | 1 - lib/new-admin/config/accounts.js | 5 +- .../resolvers/externalCompliance.resolver.js | 11 - lib/new-admin/graphql/resolvers/index.js | 2 - .../graphql/types/externalCompliance.type.js | 9 - lib/new-admin/graphql/types/index.js | 2 - lib/plugins/compliance/consts.js | 6 + .../mock-compliance/mock-compliance.js | 14 + lib/plugins/compliance/sumsub/request.js | 43 +- lib/plugins/compliance/sumsub/sumsub.api.js | 99 ++++ lib/plugins/compliance/sumsub/sumsub.js | 477 ++---------------- lib/plugins/compliance/sumsub/utils.js | 455 ----------------- lib/routes/customerRoutes.js | 12 +- migrations/1667945906700-integrate-sumsub.js | 13 - migrations/1718464437502-integrate-sumsub.js | 21 + .../src/pages/Compliance/Sumsub.js | 52 -- .../src/pages/Customers/CustomerData.js | 79 +-- .../Customers/components/EditableCard.js | 84 +-- .../src/pages/Customers/components/index.js | 3 +- .../src/pages/Services/schemas/sumsub.js | 42 +- .../src/pages/Triggers/TriggerView.js | 11 +- .../src/pages/Triggers/Triggers.js | 86 +--- .../src/pages/Triggers/Wizard.js | 17 +- .../src/pages/Triggers/helper.js | 64 +-- new-lamassu-admin/src/utils/constants.js | 5 +- package-lock.json | 60 +-- package.json | 2 - 29 files changed, 389 insertions(+), 1417 deletions(-) delete mode 100644 lib/new-admin/graphql/resolvers/externalCompliance.resolver.js delete mode 100644 lib/new-admin/graphql/types/externalCompliance.type.js create mode 100644 lib/plugins/compliance/consts.js create mode 100644 lib/plugins/compliance/mock-compliance/mock-compliance.js create mode 100644 lib/plugins/compliance/sumsub/sumsub.api.js delete mode 100644 lib/plugins/compliance/sumsub/utils.js delete mode 100644 migrations/1667945906700-integrate-sumsub.js create mode 100644 migrations/1718464437502-integrate-sumsub.js delete mode 100644 new-lamassu-admin/src/pages/Compliance/Sumsub.js diff --git a/lib/compliance-external.js b/lib/compliance-external.js index 87436f8f..e8e817c9 100644 --- a/lib/compliance-external.js +++ b/lib/compliance-external.js @@ -1,55 +1,88 @@ const _ = require('lodash/fp') +const logger = require('./logger') 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) +const getPlugin = (settings, pluginCode) => { + const account = settings.accounts[pluginCode] + const plugin = ph.load(ph.COMPLIANCE, pluginCode) 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) => { +const getStatus = (settings, service, customerId) => { try { - const { plugin } = getPlugin(settings) - const { id } = customer - return plugin.getApplicant({ externalUserId: id }, false) - .then(res => ({ - provider: plugin.CODE, - ...res.data + const { plugin, account } = getPlugin(settings, service) + + return plugin.getApplicantStatus(account, customerId) + .then((status) => ({ + service, + status })) - .catch(() => ({})) - } catch (e) { - return {} + .catch((error) => { + logger.error(`Error getting applicant for service ${service}:`, error) + return { + service: service, + status: null, + } + }) + } catch (error) { + logger.error(`Error loading plugin for service ${service}:`, error) + return Promise.resolve({ + service: service, + status: null, + }) } + } -const createApplicantExternalLink = (settings, customerId, triggerId) => { +const getStatusMap = (settings, customerExternalCompliance) => { const triggers = configManager.getTriggers(settings.config) - const trigger = _.find(it => it.id === triggerId)(triggers) - const { plugin } = getPlugin(settings) - return plugin.createApplicantExternalLink({ levelName: trigger.externalServiceApplicantLevel, userId: customerId }) - .then(r => r.data.url) + const services = _.flow( + _.map('externalService'), + _.compact, + _.uniq + )(triggers) + + const applicantPromises = _.map(service => { + return getStatus(settings, service, customerExternalCompliance) + })(services) + + return Promise.all(applicantPromises) + .then((applicantResults) => { + return _.reduce((map, result) => { + map[result.service] = result.status + return map + }, {})(applicantResults) + }) +} + +const createApplicant = (settings, externalService, customerId) => { + const account = settings.accounts[externalService] + const { plugin } = getPlugin(settings, externalService) + + return plugin.createApplicant(account, customerId, account.applicantLevel) +} + +const createLink = (settings, externalService, customerId) => { + const account = settings.accounts[externalService] + const { plugin } = getPlugin(settings, externalService) + + return plugin.createLink(account, customerId, account.applicantLevel) +} + +const getApplicantByExternalId = (settings, externalService, customerId) => { + const account = settings.accounts[externalService] + const { plugin } = getPlugin(settings, externalService) + + return plugin.getApplicantByExternalId(account, customerId) } module.exports = { + getStatusMap, + getStatus, createApplicant, - getApplicant, - createApplicantExternalLink + getApplicantByExternalId, + createLink } diff --git a/lib/customers.js b/lib/customers.js index f605c269..164bf807 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -932,13 +932,30 @@ function updateLastAuthAttempt (customerId) { function getExternalCustomerData (customer) { return settingsLoader.loadLatest() - .then(settings => externalCompliance.getApplicant(settings, customer)) - .then(externalCompliance => { - customer.externalCompliance = externalCompliance - return customer + .then(settings => externalCompliance.getStatusMap(settings, customer.id)) + .then(statusMap => { + return updateExternalCompliance(customer.id, statusMap) + .then(() => customer.externalCompliance = statusMap) + .then(() => customer) }) } +function updateExternalCompliance(customerId, serviceMap) { + const sql = ` + UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() + WHERE customer_id=$2 AND service=$3 + ` + const pairs = _.toPairs(serviceMap) + const promises = _.map(([service, status]) => db.none(sql, [status.answer, customerId, service]))(pairs) + return Promise.all(promises) +} + +function addExternalCompliance(customerId, service, id) { + const sql = `INSERT INTO customer_external_compliance (customer_id, external_id, service) VALUES ($1, $2, $3)` + return db.none(sql, [customerId, id, service]) +} + + module.exports = { add, addWithEmail, @@ -962,5 +979,6 @@ module.exports = { updateTxCustomerPhoto, enableTestCustomer, disableTestCustomer, - updateLastAuthAttempt + updateLastAuthAttempt, + addExternalCompliance } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index 8d086151..7977e522 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -82,7 +82,6 @@ type CustomInput { constraintType: String! label1: String label2: String - label3: String choiceList: [String] } diff --git a/lib/new-admin/config/accounts.js b/lib/new-admin/config/accounts.js index 0041ba93..8bbb15e8 100644 --- a/lib/new-admin/config/accounts.js +++ b/lib/new-admin/config/accounts.js @@ -15,6 +15,7 @@ const ID_VERIFIER = 'idVerifier' const EMAIL = 'email' const ZERO_CONF = 'zeroConf' const WALLET_SCORING = 'wallet_scoring' +const COMPLIANCE = 'compliance' const ALL_ACCOUNTS = [ { code: 'bitfinex', display: 'Bitfinex', class: TICKER, cryptos: bitfinex.CRYPTO }, @@ -60,7 +61,9 @@ const ALL_ACCOUNTS = [ { code: 'blockcypher', display: 'Blockcypher', class: ZERO_CONF, cryptos: [BTC] }, { code: 'mock-zero-conf', display: 'Mock 0-conf', class: ZERO_CONF, cryptos: ALL_CRYPTOS, dev: true }, { code: 'scorechain', display: 'Scorechain', class: WALLET_SCORING, cryptos: [BTC, ETH, LTC, BCH, DASH, USDT, USDT_TRON, TRX] }, - { code: 'mock-scoring', display: 'Mock scoring', class: WALLET_SCORING, cryptos: ALL_CRYPTOS, dev: true } + { code: 'mock-scoring', display: 'Mock scoring', class: WALLET_SCORING, cryptos: ALL_CRYPTOS, dev: true }, + { code: 'sumsub', display: 'Sumsub', class: COMPLIANCE }, + { code: 'mock-compliance', display: 'Mock Compliance', class: COMPLIANCE, dev: true }, ] const devMode = require('minimist')(process.argv.slice(2)).dev diff --git a/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js b/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js deleted file mode 100644 index c25afdc7..00000000 --- a/lib/new-admin/graphql/resolvers/externalCompliance.resolver.js +++ /dev/null @@ -1,11 +0,0 @@ -const externalCompliance = require('../../../compliance-external') -const { loadLatest } = require('../../../new-settings-loader') - -const resolvers = { - Query: { - getApplicantExternalLink: (...[, { customerId, triggerId }]) => loadLatest() - .then(settings => externalCompliance.createApplicantExternalLink(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 f10e10cc..81e79614 100644 --- a/lib/new-admin/graphql/resolvers/index.js +++ b/lib/new-admin/graphql/resolvers/index.js @@ -7,7 +7,6 @@ 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') @@ -31,7 +30,6 @@ const resolvers = [ currency, customer, customInfoRequests, - externalCompliance, funding, log, loyalty, diff --git a/lib/new-admin/graphql/types/externalCompliance.type.js b/lib/new-admin/graphql/types/externalCompliance.type.js deleted file mode 100644 index f05f3153..00000000 --- a/lib/new-admin/graphql/types/externalCompliance.type.js +++ /dev/null @@ -1,9 +0,0 @@ -const { gql } = require('apollo-server-express') - -const typeDef = gql` - type Query { - getApplicantExternalLink(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 11bfee20..a1886a28 100644 --- a/lib/new-admin/graphql/types/index.js +++ b/lib/new-admin/graphql/types/index.js @@ -7,7 +7,6 @@ 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') @@ -31,7 +30,6 @@ const types = [ currency, customer, customInfoRequests, - externalCompliance, funding, log, loyalty, diff --git a/lib/plugins/compliance/consts.js b/lib/plugins/compliance/consts.js new file mode 100644 index 00000000..3700c1d3 --- /dev/null +++ b/lib/plugins/compliance/consts.js @@ -0,0 +1,6 @@ +module.exports = { + WAIT: 'WAIT', + RETRY: 'RETRY', + APPROVED: 'APPROVED', + REJECTED: 'REJECTED' +} \ No newline at end of file diff --git a/lib/plugins/compliance/mock-compliance/mock-compliance.js b/lib/plugins/compliance/mock-compliance/mock-compliance.js new file mode 100644 index 00000000..366b1861 --- /dev/null +++ b/lib/plugins/compliance/mock-compliance/mock-compliance.js @@ -0,0 +1,14 @@ +const CODE = 'mock-compliance' + +const createLink = (settings, userId, level) => { + return `this is a mock external link, ${userId}, ${level}` +} + +const getApplicantStatus = (settings, userId) => { +} + +module.exports = { + CODE, + createLink, + getApplicantStatus +} diff --git a/lib/plugins/compliance/sumsub/request.js b/lib/plugins/compliance/sumsub/request.js index 588acc23..f102f996 100644 --- a/lib/plugins/compliance/sumsub/request.js +++ b/lib/plugins/compliance/sumsub/request.js @@ -2,40 +2,33 @@ 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 getSigBuilder = (apiToken, secretKey) => config => { + const timestamp = Math.floor(Date.now() / 1000) + const signature = crypto.createHmac('sha256', secretKey) -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) - } + signature.update(`${timestamp}${_.toUpper(config.method)}${config.url}`) + if (config.data instanceof FormData) { + signature.update(config.data.getBuffer()) + } else if (config.data) { + signature.update(JSON.stringify(config.data)) + } - config.headers['X-App-Token'] = apiToken - config.headers['X-App-Access-Sig'] = signature.digest('hex') - config.headers['X-App-Access-Ts'] = timestamp + config.headers['X-App-Token'] = apiToken + config.headers['X-App-Access-Sig'] = signature.digest('hex') + config.headers['X-App-Access-Ts'] = timestamp - return config - }) + return config } -axiosInstance.interceptors.request.use(buildSignature, Promise.reject) - -const request = config => axiosInstance(config) +const request = ((account, config) => { + const instance = axios.create(axiosConfig) + instance.interceptors.request.use(getSigBuilder(account.apiToken, account.secretKey), Promise.reject) + return instance(config) +}) module.exports = request diff --git a/lib/plugins/compliance/sumsub/sumsub.api.js b/lib/plugins/compliance/sumsub/sumsub.api.js new file mode 100644 index 00000000..f611678f --- /dev/null +++ b/lib/plugins/compliance/sumsub/sumsub.api.js @@ -0,0 +1,99 @@ +const request = require('./request') + +const createApplicant = (account, userId, level) => { + if (!userId || !level) { + return Promise.reject(`Missing required fields: userId: ${userId}, level: ${level}`) + } + + const config = { + method: 'POST', + url: `/resources/applicants?levelName=${level}`, + data: { + externalUserId: userId, + sourceKey: 'lamassu' + }, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + + return request(account, config) +} + +const createLink = (account, userId, level) => { + if (!userId || !level) { + return Promise.reject(`Missing required fields: userId: ${userId}, level: ${level}`) + } + + const config = { + method: 'POST', + url: `/resources/sdkIntegrations/levels/${level}/websdkLink?ttlInSecs=${600}&externalUserId=${userId}`, + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + } + } + + return request(account, config) +} + +const getApplicantByExternalId = (account, id) => { + console.log('id', id) + if (!id) { + return Promise.reject('Missing required fields: id') + } + + const config = { + method: 'GET', + url: `/resources/applicants/-;externalUserId=${id}/one`, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + } + + return request(account, config) +} + +const getApplicantStatus = (account, id) => { + if (!id) { + return Promise.reject(`Missing required fields: id`) + } + + const config = { + method: 'GET', + url: `/resources/applicants/${id}/status`, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + } + + return request(account, config) +} + +const getApplicantById = (account, id) => { + if (!id) { + return Promise.reject(`Missing required fields: id`) + } + + const config = { + method: 'GET', + url: `/resources/applicants/${id}/one`, + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + } + } + + return request(account, config) +} + +module.exports = { + createLink, + createApplicant, + getApplicantByExternalId, + getApplicantById, + getApplicantStatus +} diff --git a/lib/plugins/compliance/sumsub/sumsub.js b/lib/plugins/compliance/sumsub/sumsub.js index 0fdd9838..8f036f97 100644 --- a/lib/plugins/compliance/sumsub/sumsub.js +++ b/lib/plugins/compliance/sumsub/sumsub.js @@ -1,462 +1,55 @@ const _ = require('lodash/fp') -const request = require('./request') +const sumsubApi = require('./sumsub.api') +const { WAIT, RETRY, APPROVED, REJECTED } = require('../consts') 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 createApplicantExternalLink = 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/sdkIntegrations/levels/${opts.levelName}/websdkLink?ttlInSecs=${600}&externalUserId=${opts.userId}`, - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - } - }) +const getApplicantByExternalId = (account, userId) => { + return sumsubApi.getApplicantByExternalId(account, userId) + .then(r => r.data) } -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 createApplicant = (account, userId, level) => { + return sumsubApi.createApplicant(account, userId, level) + .then(r => r.data) + .catch(err => { + if (err.response.status === 409) return getApplicantByExternalId(account, userId) + throw err + }) } -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 createLink = (account, userId, level) => { + return sumsubApi.createLink(account, userId, level) + .then(r => r.data.url) } -const getApplicant = (opts, knowsApplicantId = true) => { - const REQUIRED_FIELDS = knowsApplicantId - ? ['applicantId'] - : ['externalUserId'] +const getApplicantStatus = (account, userId) => { + return sumsubApi.getApplicantByExternalId(account, userId) + .then(r => { + const levelName = _.get('data.review.levelName', r) + const reviewStatus = _.get('data.review.reviewStatus', r) + const reviewAnswer = _.get('data.review.reviewResult.reviewAnswer', r) + const reviewRejectType = _.get('data.review.reviewResult.reviewRejectType', r) - if (_.isEmpty(opts) || !hasRequiredFields(REQUIRED_FIELDS, opts)) { - return Promise.reject(`Missing required fields: ${getMissingRequiredFields(REQUIRED_FIELDS, opts)}`) - } + console.log('levelName', levelName) + console.log('reviewStatus', reviewStatus) + console.log('reviewAnswer', reviewAnswer) + console.log('reviewRejectType', reviewRejectType) - 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' - } - }) -} + let answer = WAIT + if (reviewAnswer === 'GREEN') answer = APPROVED + if (reviewAnswer === 'RED' && reviewRejectType === 'RETRY') answer = RETRY + if (reviewAnswer === 'RED' && reviewRejectType === 'FINAL') answer = REJECTED -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` - }) + return { level: levelName, answer } + }) } module.exports = { CODE, - createApplicantExternalLink, createApplicant, - getApplicant, - addIdDocument, - changeApplicantFixedInfo, getApplicantStatus, - getApplicantIdDocsStatus, - getApplicantRejectReasons, - requestApplicantCheck, - requestApplicantCheckDiffVerificationType, - getDocumentImages, - blockApplicant, - generateShareToken, - importRawApplicant, - importApplicantFromPartnerService, - resetVerificationStep, - resetApplicant, - patchApplicantTopLevelInfo, - setApplicantRiskLevel, - addApplicantTags, - markImageAsInactive, - markImageAsActive, - getApiHealth, - changeRequiredLevel -} + getApplicantByExternalId, + createLink +} \ No newline at end of file diff --git a/lib/plugins/compliance/sumsub/utils.js b/lib/plugins/compliance/sumsub/utils.js deleted file mode 100644 index b81bfe48..00000000 --- a/lib/plugins/compliance/sumsub/utils.js +++ /dev/null @@ -1,455 +0,0 @@ -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 ea9ed016..75fe6641 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -18,7 +18,7 @@ const notifier = require('../notifier') const respond = require('../respond') const { getTx } = require('../new-admin/services/transactions.js') const machineLoader = require('../machine-loader') -const { loadLatest, loadLatestConfig } = require('../new-settings-loader') +const { loadLatestConfig } = require('../new-settings-loader') const customInfoRequestQueries = require('../new-admin/services/customInfoRequests') const T = require('../time') const plugins = require('../plugins') @@ -243,12 +243,12 @@ function getExternalComplianceLink (req, res, next) { const settings = req.settings const triggers = configManager.getTriggers(settings.config) const trigger = _.find(it => it.id === triggerId)(triggers) + const externalService = trigger.externalService - return externalCompliance.createApplicantExternalLink(settings, customerId, trigger.id) - .then(url => { - process.env.NODE_ENV === 'development' && console.log(url) - return respond(req, res, { url: url }) - }) + return externalCompliance.createApplicant(settings, externalService, customerId) + .then(applicant => customers.addExternalCompliance(customerId, externalService, applicant.id)) + .then(() => externalCompliance.createLink(settings, externalService, customerId)) + .then(url => respond(req, res, { url })) } function addOrUpdateCustomer (customerData, config, isEmailAuth) { diff --git a/migrations/1667945906700-integrate-sumsub.js b/migrations/1667945906700-integrate-sumsub.js deleted file mode 100644 index 0080faa6..00000000 --- a/migrations/1667945906700-integrate-sumsub.js +++ /dev/null @@ -1,13 +0,0 @@ -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/migrations/1718464437502-integrate-sumsub.js b/migrations/1718464437502-integrate-sumsub.js new file mode 100644 index 00000000..0e49de6e --- /dev/null +++ b/migrations/1718464437502-integrate-sumsub.js @@ -0,0 +1,21 @@ +const db = require('./db') + +exports.up = function (next) { + let sql = [ + `CREATE TYPE EXTERNAL_COMPLIANCE_STATUS AS ENUM('WAIT', 'APPROVED', 'REJECTED', 'RETRY')`, + `CREATE TABLE CUSTOMER_EXTERNAL_COMPLIANCE ( + customer_id UUID NOT NULL REFERENCES customers(id), + service TEXT NOT NULL, + external_id TEXT NOT NULL, + last_known_status EXTERNAL_COMPLIANCE_STATUS, + last_updated TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (customer_id, service) + )` + ] + + db.multi(sql, next) +} + +exports.down = function (next) { + next() +} diff --git a/new-lamassu-admin/src/pages/Compliance/Sumsub.js b/new-lamassu-admin/src/pages/Compliance/Sumsub.js deleted file mode 100644 index 2e38cd66..00000000 --- a/new-lamassu-admin/src/pages/Compliance/Sumsub.js +++ /dev/null @@ -1,52 +0,0 @@ -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 ( - - ) -} - -export default Sumsub diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index cc159380..8c6d5bc0 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -11,8 +11,7 @@ import { TextInput } from 'src/components/inputs/formik' import { H3, Info3 } from 'src/components/typography' import { OVERRIDE_AUTHORIZED, - OVERRIDE_REJECTED, - OVERRIDE_PENDING + OVERRIDE_REJECTED } from 'src/pages/Customers/components/propertyCard' import { ReactComponent as CardIcon } from 'src/styling/icons/ID/card/comet.svg' import { ReactComponent as PhoneIcon } from 'src/styling/icons/ID/phone/comet.svg' @@ -26,7 +25,7 @@ import { URI } from 'src/utils/apollo' import { onlyFirstToUpper } from 'src/utils/string' import styles from './CustomerData.styles.js' -import { EditableCard, NonEditableCard } from './components' +import { EditableCard } from './components' import { customerDataElements, customerDataSchemas, @@ -401,59 +400,22 @@ const CustomerData = ({ }) }, 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: , - state: externalComplianceData[ - externalComplianceProvider - ]?.getVerificationState(externalComplianceValues), - title: 'External Info' - }) - } + // TODO - add external compliance data + // R.forEach(outer => { + // initialValues. + // externalCompliance.push({ + // fields: [ + // { + // name: 'lastKnownStatus', + // label: 'Last Known Status', + // component: TextInput + // } + // ], + // titleIcon: , + // state: outer.state, + // title: 'External Info' + // }) + // })(R.keys(customer.externalCompliance)) const editableCard = ( { @@ -501,13 +463,14 @@ const CustomerData = ({ idx ) => { return ( - + fields={fields}> ) } diff --git a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js index 6783b648..1dc71165 100644 --- a/new-lamassu-admin/src/pages/Customers/components/EditableCard.js +++ b/new-lamassu-admin/src/pages/Customers/components/EditableCard.js @@ -8,7 +8,6 @@ import { useState, React } from 'react' import ErrorMessage from 'src/components/ErrorMessage' import PromptWhenDirty from 'src/components/PromptWhenDirty' import { MainStatus } from 'src/components/Status' -import { HoverableTooltip } from 'src/components/Tooltip' import { ActionButton } from 'src/components/buttons' import { Label1, P, H3 } from 'src/components/typography' import { @@ -402,85 +401,4 @@ const 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 ( -
- - -
-
- {titleIcon} -

{title}

- { - // TODO: Enable for next release - /* */ - } -
- {state && ( -
- - {comment && labels && ( - -

Comments about this decision:

- {R.map( - it => ( -

{it}

- ), - R.split('\n', comment) - )} -

Relevant labels: {R.join(',', labels)}

-
- )} -
- )} -
-
- - - {!hasImage && - fields?.map((field, idx) => { - return idx >= 0 && idx < 4 ? ( - - ) : null - })} - - - {!hasImage && - fields?.map((field, idx) => { - return idx >= 4 ? ( - - ) : null - })} - - -
-
-
-
- ) -} - -export { EditableCard, NonEditableCard } +export default EditableCard diff --git a/new-lamassu-admin/src/pages/Customers/components/index.js b/new-lamassu-admin/src/pages/Customers/components/index.js index bc009220..7e3c3e19 100644 --- a/new-lamassu-admin/src/pages/Customers/components/index.js +++ b/new-lamassu-admin/src/pages/Customers/components/index.js @@ -2,7 +2,7 @@ import Wizard from '../Wizard' import CustomerDetails from './CustomerDetails' import CustomerSidebar from './CustomerSidebar' -import { EditableCard, NonEditableCard } from './EditableCard' +import EditableCard from './EditableCard' import Field from './Field' import IdDataCard from './IdDataCard' import PhotosCarousel from './PhotosCarousel' @@ -17,7 +17,6 @@ export { CustomerSidebar, Field, EditableCard, - NonEditableCard, Wizard, Upload } diff --git a/new-lamassu-admin/src/pages/Services/schemas/sumsub.js b/new-lamassu-admin/src/pages/Services/schemas/sumsub.js index 20eb6bc8..ee156a9e 100644 --- a/new-lamassu-admin/src/pages/Services/schemas/sumsub.js +++ b/new-lamassu-admin/src/pages/Services/schemas/sumsub.js @@ -1,53 +1,13 @@ -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 ( -
-

- 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. -

- setCanContinue(!canContinue)} - settings={{ - enabled: true, - label: 'I have completed the steps needed to configure Sumsub', - rightSideLabel: true - }} - /> -
-
- -
-
-
- ) -} - const schema = { code: 'sumsub', name: 'Sumsub', - category: 'Compliance', - allowMultiInstances: false, - SplashScreenComponent: SumsubSplash, + title: 'Sumsub (Compliance)', elements: [ { code: 'apiToken', diff --git a/new-lamassu-admin/src/pages/Triggers/TriggerView.js b/new-lamassu-admin/src/pages/Triggers/TriggerView.js index 5c049f36..58688227 100644 --- a/new-lamassu-admin/src/pages/Triggers/TriggerView.js +++ b/new-lamassu-admin/src/pages/Triggers/TriggerView.js @@ -28,9 +28,8 @@ const TriggerView = ({ config, toggleWizard, addNewTriger, - customInfoRequests, emailAuth, - additionalInfo + customInfoRequests }) => { const currency = R.path(['fiatCurrency'])( fromNamespace(namespaces.LOCALE)(config) @@ -70,12 +69,7 @@ const TriggerView = ({ error={error?.message} save={save} validationSchema={Schema} - elements={getElements( - currency, - classes, - customInfoRequests, - additionalInfo - )} + elements={getElements(currency, classes, customInfoRequests)} /> {showWizard && ( )} {R.isEmpty(triggers) && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Triggers.js b/new-lamassu-admin/src/pages/Triggers/Triggers.js index ade21956..9d70edfe 100644 --- a/new-lamassu-admin/src/pages/Triggers/Triggers.js +++ b/new-lamassu-admin/src/pages/Triggers/Triggers.js @@ -4,7 +4,6 @@ import classnames from 'classnames' import gql from 'graphql-tag' import * as R from 'ramda' import React, { useState } from 'react' -import { getAccountInstance } from 'src/utils/accounts' import Modal from 'src/components/Modal' import { HoverableTooltip } from 'src/components/Tooltip' @@ -19,7 +18,6 @@ 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 SettingsIcon } from 'src/styling/icons/circle buttons/settings/zodiac.svg' import { fromNamespace, toNamespace } from 'src/utils/config' -import { COMPLIANCE_SERVICES } from 'src/utils/constants' import CustomInfoRequests from './CustomInfoRequests' import TriggerView from './TriggerView' @@ -134,67 +132,32 @@ const Triggers = () => { 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 ( <> { + 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)}> {!subMenu && ( @@ -257,10 +220,7 @@ const Triggers = () => { toggleWizard={toggleWizard('newTrigger')} addNewTriger={addNewTriger} emailAuth={emailAuth} - additionalInfo={{ - customInfoRequests: enabledCustomInfoRequests, - externalValidationLevels: externalValidationLevels - }} + customInfoRequests={enabledCustomInfoRequests} /> )} {!loading && subMenu === 'advancedSettings' && ( diff --git a/new-lamassu-admin/src/pages/Triggers/Wizard.js b/new-lamassu-admin/src/pages/Triggers/Wizard.js index 0d966d6c..1d83c3ea 100644 --- a/new-lamassu-admin/src/pages/Triggers/Wizard.js +++ b/new-lamassu-admin/src/pages/Triggers/Wizard.js @@ -53,8 +53,7 @@ const getStep = ( currency, customInfoRequests, emailAuth, - triggers, - additionalInfo + triggers ) => { switch (step) { // case 1: @@ -62,13 +61,7 @@ const getStep = ( case 1: return type(currency) case 2: - return requirements( - customInfoRequests, - emailAuth, - config, - triggers, - additionalInfo - ) + return requirements(config, triggers, customInfoRequests, emailAuth) default: return Fragment } @@ -226,8 +219,7 @@ const Wizard = ({ currency, customInfoRequests, emailAuth, - triggers, - additionalInfo + triggers }) => { const classes = useStyles() @@ -242,8 +234,7 @@ const Wizard = ({ currency, customInfoRequests, emailAuth, - triggers, - additionalInfo + triggers ) const onContinue = async it => { diff --git a/new-lamassu-admin/src/pages/Triggers/helper.js b/new-lamassu-admin/src/pages/Triggers/helper.js index 9e03abb7..5bd3d0f8 100644 --- a/new-lamassu-admin/src/pages/Triggers/helper.js +++ b/new-lamassu-admin/src/pages/Triggers/helper.js @@ -504,13 +504,6 @@ const requirementSchema = Yup.object() otherwise: Yup.string() .nullable() .transform(() => '') - }), - externalServiceApplicantLevel: Yup.string().when('requirement', { - is: value => value === 'external', - then: Yup.string(), - otherwise: Yup.string() - .nullable() - .transform(() => '') }) }).required() }) @@ -527,8 +520,7 @@ const requirementSchema = Yup.object() : true case 'external': return requirement.requirement === type - ? !R.isNil(requirement.externalService) && - !R.isNil(requirement.externalServiceApplicantLevel) + ? !R.isNil(requirement.externalService) : true default: return true @@ -582,20 +574,13 @@ const hasCustomRequirementError = (errors, touched, values) => 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)) + !values.requirement?.externalService const Requirement = ({ config = {}, triggers, - additionalInfo: { - emailAuth, - customInfoRequests = [], - externalValidationLevels = {} - } + emailAuth, + customInfoRequests = [] }) => { const classes = useStyles() const { @@ -725,30 +710,17 @@ const Requirement = ({ name="requirement.externalService" options={externalServices} /> - {!R.isNil( - externalValidationLevels[values.requirement.externalService] - ) && ( - - )} )} ) } -const requirements = (config, triggers, additionalInfo) => ({ +const requirements = (config, triggers, customInfoRequests, emailAuth) => ({ schema: requirementSchema, options: requirementOptions, Component: Requirement, - props: { config, triggers, additionalInfo }, + props: { config, triggers, customInfoRequests, emailAuth }, hasRequirementError: hasRequirementError, hasCustomRequirementError: hasCustomRequirementError, hasExternalRequirementError: hasExternalRequirementError, @@ -757,8 +729,7 @@ const requirements = (config, triggers, additionalInfo) => ({ requirement: '', suspensionDays: '', customInfoRequestId: '', - externalService: '', - externalServiceApplicantLevel: '' + externalService: '' } } }) @@ -788,9 +759,7 @@ const customReqIdMatches = customReqId => it => { return it.id === customReqId } -const RequirementInput = ({ - additionalInfo: { customInfoRequests = [], externalValidationLevels = {} } -}) => { +const RequirementInput = ({ customInfoRequests = [] }) => { const { values } = useFormikContext() const classes = useStyles() @@ -826,7 +795,7 @@ const RequirementView = ({ suspensionDays, customInfoRequestId, externalService, - additionalInfo: { customInfoRequests = [], externalValidationLevels = {} } + customInfoRequests = [] }) => { const classes = useStyles() const display = @@ -949,7 +918,7 @@ const ThresholdView = ({ config, currency }) => { return } -const getElements = (currency, classes, additionalInfo) => [ +const getElements = (currency, classes, customInfoRequests) => [ { name: 'triggerType', size: 'sm', @@ -970,8 +939,10 @@ const getElements = (currency, classes, additionalInfo) => [ size: 'sm', width: 260, bypassField: true, - input: () => , - view: it => + input: () => , + view: it => ( + + ) }, { name: 'threshold', @@ -1003,7 +974,7 @@ const sortBy = [ ) ] -const fromServer = (triggers, customInfoRequests) => { +const fromServer = triggers => { return R.map( ({ requirement, @@ -1012,15 +983,13 @@ const fromServer = (triggers, customInfoRequests) => { thresholdDays, customInfoRequestId, externalService, - externalServiceApplicantLevel, ...rest }) => ({ requirement: { requirement, suspensionDays, customInfoRequestId, - externalService, - externalServiceApplicantLevel + externalService }, threshold: { threshold, @@ -1039,7 +1008,6 @@ const toServer = triggers => thresholdDays: threshold.thresholdDays, customInfoRequestId: requirement.customInfoRequestId, externalService: requirement.externalService, - externalServiceApplicantLevel: requirement.externalServiceApplicantLevel, ...rest }))(triggers) diff --git a/new-lamassu-admin/src/utils/constants.js b/new-lamassu-admin/src/utils/constants.js index 43f4d4f2..9d829d3c 100644 --- a/new-lamassu-admin/src/utils/constants.js +++ b/new-lamassu-admin/src/utils/constants.js @@ -10,8 +10,6 @@ const IP_CHECK_REGEX = /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[ const SWEEPABLE_CRYPTOS = ['ETH'] -const COMPLIANCE_SERVICES = ['sumsub'] - export { CURRENCY_MAX, MIN_NUMBER_OF_CASSETTES, @@ -20,6 +18,5 @@ export { MANUAL, WALLET_SCORING_DEFAULT_THRESHOLD, IP_CHECK_REGEX, - SWEEPABLE_CRYPTOS, - COMPLIANCE_SERVICES + SWEEPABLE_CRYPTOS } diff --git a/package-lock.json b/package-lock.json index 67ebc670..e2d8f8f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1342,7 +1342,7 @@ "integrity": "sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==" }, "bitcoinjs-message": { - "version": "npm:@bitgo-forks/bitcoinjs-message@1.0.0-master.2", + "version": "npm:bitcoinjs-message@1.0.0-master.2", "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-message/-/bitcoinjs-message-1.0.0-master.2.tgz", "integrity": "sha512-XSDGM3rA75vcDxeKqHPexika/TgWUFWdfKTv1lV8TZTb5XFHHD6ARckLdMOBiCf29eZSzbJQvF/OIWqNqMl/2A==", "requires": { @@ -4714,36 +4714,6 @@ "wif": "^2.0.1" } }, - "bitcoinjs-message": { - "version": "npm:@bitgo-forks/bitcoinjs-message@1.0.0-master.2", - "resolved": "https://registry.npmjs.org/@bitgo-forks/bitcoinjs-message/-/bitcoinjs-message-1.0.0-master.2.tgz", - "integrity": "sha512-XSDGM3rA75vcDxeKqHPexika/TgWUFWdfKTv1lV8TZTb5XFHHD6ARckLdMOBiCf29eZSzbJQvF/OIWqNqMl/2A==", - "requires": { - "bech32": "^1.1.3", - "bs58check": "^2.1.2", - "buffer-equals": "^1.0.3", - "create-hash": "^1.1.2", - "secp256k1": "5.0.0", - "varuint-bitcoin": "^1.0.1" - }, - "dependencies": { - "bech32": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/bech32/-/bech32-1.1.4.tgz", - "integrity": "sha512-s0IrSOzLlbvX7yp4WBfPITzpAU8sqQcpsmwXDiKwrG4r491vwCO/XpejasRNl0piBMe/DvP4Tz0mIS/X1DPJBQ==" - }, - "secp256k1": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/secp256k1/-/secp256k1-5.0.0.tgz", - "integrity": "sha512-TKWX8xvoGHrxVdqbYeZM9w+izTF4b9z3NhSaDkdn81btvuh+ivbIMGT/zQvDtTFWhRlThpoz6LEYTr7n8A5GcA==", - "requires": { - "elliptic": "^6.5.4", - "node-addon-api": "^5.0.0", - "node-gyp-build": "^4.2.0" - } - } - } - }, "bitcore-lib": { "version": "8.25.47", "resolved": "https://registry.npmjs.org/bitcore-lib/-/bitcore-lib-8.25.47.tgz", @@ -8644,12 +8614,12 @@ "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==" }, "form-data": { - "version": "2.5.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", - "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", "requires": { "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", + "combined-stream": "^1.0.8", "mime-types": "^2.1.12" } }, @@ -12203,6 +12173,16 @@ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==" }, + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "get-uri": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-2.0.4.tgz", @@ -16423,6 +16403,16 @@ "readable-stream": "^2.3.5" }, "dependencies": { + "form-data": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz", + "integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==", + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + } + }, "readable-stream": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", diff --git a/package.json b/package.json index b9f3a450..2f432c93 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@haensl/subset-sum": "^3.0.5", "@lamassu/coins": "v1.4.10", "@simplewebauthn/server": "^3.0.0", - "@sumsub/websdk-react": "^1.3.6", "@vonage/auth": "1.5.0", "@vonage/sms": "1.7.0", "@vonage/server-client": "1.7.0", @@ -48,7 +47,6 @@ "ethereumjs-wallet": "^0.6.3", "express": "4.17.1", "express-session": "^1.17.1", - "express-ws": "^3.0.0", "form-data": "^4.0.0", "futoin-hkdf": "^1.0.2", "got": "^7.1.0", From 11e0a03df14cb3a6066cf3a69352c413253c8c79 Mon Sep 17 00:00:00 2001 From: Rafael Taranto Date: Tue, 18 Jun 2024 10:42:59 +0100 Subject: [PATCH 4/7] fix: admin ui --- lib/compliance-external.js | 2 +- lib/customers.js | 23 +++++++-- lib/new-admin/graphql/types/customer.type.js | 2 +- lib/plugins/compliance/sumsub/sumsub.api.js | 1 - lib/plugins/compliance/sumsub/sumsub.js | 11 ++--- .../src/pages/Customers/CustomerData.js | 48 ++++++++++++------- 6 files changed, 57 insertions(+), 30 deletions(-) diff --git a/lib/compliance-external.js b/lib/compliance-external.js index e8e817c9..26e6b60f 100644 --- a/lib/compliance-external.js +++ b/lib/compliance-external.js @@ -45,6 +45,7 @@ const getStatusMap = (settings, customerExternalCompliance) => { _.uniq )(triggers) + const meta = {} const applicantPromises = _.map(service => { return getStatus(settings, service, customerExternalCompliance) })(services) @@ -81,7 +82,6 @@ const getApplicantByExternalId = (settings, externalService, customerId) => { module.exports = { getStatusMap, - getStatus, createApplicant, getApplicantByExternalId, createLink diff --git a/lib/customers.js b/lib/customers.js index 164bf807..34ac8020 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -323,7 +323,7 @@ function getById (id) { return db.oneOrNone(sql, [id]) .then(assignCustomerData) .then(getCustomInfoRequestsData) - .then(getExternalCustomerData) + .then(getExternalComplianceMachine) .then(camelize) } @@ -344,7 +344,11 @@ function camelize (customer) { function camelizeDeep (customer) { return _.flow( camelize, - it => ({ ...it, notes: (it.notes ?? []).map(camelize) }) + it => ({ + ...it, + notes: (it.notes ?? []).map(camelize), + externalCompliance: (it.externalCompliance ?? []).map(camelize) + }) )(customer) } @@ -589,9 +593,9 @@ function getCustomerById (id) { return db.oneOrNone(sql, [passableErrorCodes, id]) .then(assignCustomerData) .then(getCustomInfoRequestsData) + .then(getExternalCompliance) .then(camelizeDeep) .then(formatSubscriberInfo) - .then(getExternalCustomerData) } function assignCustomerData (customer) { @@ -930,7 +934,7 @@ function updateLastAuthAttempt (customerId) { return db.none(sql, [customerId]) } -function getExternalCustomerData (customer) { +function getExternalComplianceMachine (customer) { return settingsLoader.loadLatest() .then(settings => externalCompliance.getStatusMap(settings, customer.id)) .then(statusMap => { @@ -950,6 +954,17 @@ function updateExternalCompliance(customerId, serviceMap) { return Promise.all(promises) } +function getExternalCompliance(customer) { + const sql = `SELECT external_id, service, last_known_status, last_updated + FROM customer_external_compliance where customer_id=$1` + return db.manyOrNone(sql, [customer.id]) + .then(compliance => { + console.log(compliance) + customer.externalCompliance = compliance + }) + .then(() => customer) +} + function addExternalCompliance(customerId, service, id) { const sql = `INSERT INTO customer_external_compliance (customer_id, external_id, service) VALUES ($1, $2, $3)` return db.none(sql, [customerId, id, service]) diff --git a/lib/new-admin/graphql/types/customer.type.js b/lib/new-admin/graphql/types/customer.type.js index 0259d6eb..fe140032 100644 --- a/lib/new-admin/graphql/types/customer.type.js +++ b/lib/new-admin/graphql/types/customer.type.js @@ -40,7 +40,7 @@ const typeDef = gql` customInfoRequests: [CustomRequestData] notes: [CustomerNote] isTestCustomer: Boolean - externalCompliance: JSONObject + externalCompliance: [JSONObject] } input CustomerInput { diff --git a/lib/plugins/compliance/sumsub/sumsub.api.js b/lib/plugins/compliance/sumsub/sumsub.api.js index f611678f..a7d7557a 100644 --- a/lib/plugins/compliance/sumsub/sumsub.api.js +++ b/lib/plugins/compliance/sumsub/sumsub.api.js @@ -39,7 +39,6 @@ const createLink = (account, userId, level) => { } const getApplicantByExternalId = (account, id) => { - console.log('id', id) if (!id) { return Promise.reject('Missing required fields: id') } diff --git a/lib/plugins/compliance/sumsub/sumsub.js b/lib/plugins/compliance/sumsub/sumsub.js index 8f036f97..a0a42e3b 100644 --- a/lib/plugins/compliance/sumsub/sumsub.js +++ b/lib/plugins/compliance/sumsub/sumsub.js @@ -31,18 +31,17 @@ const getApplicantStatus = (account, userId) => { const reviewStatus = _.get('data.review.reviewStatus', r) const reviewAnswer = _.get('data.review.reviewResult.reviewAnswer', r) const reviewRejectType = _.get('data.review.reviewResult.reviewRejectType', r) + const sumsubUserId = _.get('data.review.reviewResult.reviewRejectType', r) - console.log('levelName', levelName) - console.log('reviewStatus', reviewStatus) - console.log('reviewAnswer', reviewAnswer) - console.log('reviewRejectType', reviewRejectType) + // if last review was from a different level, return the current level and RETRY + if (levelName !== account.applicantLevel) return { thirdPartyId: sumsubUserId, level: account.applicantLevel, answer: RETRY } let answer = WAIT - if (reviewAnswer === 'GREEN') answer = APPROVED + if (reviewAnswer === 'GREEN' && reviewStatus === 'completed') answer = APPROVED if (reviewAnswer === 'RED' && reviewRejectType === 'RETRY') answer = RETRY if (reviewAnswer === 'RED' && reviewRejectType === 'FINAL') answer = REJECTED - return { level: levelName, answer } + return { thirdPartyId: sumsubUserId, level: levelName, answer } }) } diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index 8c6d5bc0..55d0f32d 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -400,22 +400,34 @@ const CustomerData = ({ }) }, R.keys(smsData) ?? []) - // TODO - add external compliance data - // R.forEach(outer => { - // initialValues. - // externalCompliance.push({ - // fields: [ - // { - // name: 'lastKnownStatus', - // label: 'Last Known Status', - // component: TextInput - // } - // ], - // titleIcon: , - // state: outer.state, - // title: 'External Info' - // }) - // })(R.keys(customer.externalCompliance)) + R.forEach(it => { + externalCompliance.push({ + fields: [ + { + name: 'externalId', + label: 'Third Party ID', + editable: false + }, + { + name: 'lastKnownStatus', + label: 'Last Known Status', + editable: false + }, + { + name: 'lastUpdated', + label: 'Last Updated', + editable: false + } + ], + titleIcon: , + title: `External Info [${it.service}]`, + initialValues: it ?? { + externalId: '', + lastKnownStatus: '', + lastUpdated: '' + } + }) + })(customer.externalCompliance ?? []) const editableCard = ( { @@ -459,7 +471,7 @@ const CustomerData = ({ } const nonEditableCard = ( - { title, state, titleIcon, fields, hasImage }, + { title, state, titleIcon, fields, hasImage, initialValues, children }, idx ) => { return ( @@ -467,6 +479,8 @@ const CustomerData = ({ title={title} key={idx} state={state} + children={children} + initialValues={initialValues} titleIcon={titleIcon} editable={false} hasImage={hasImage} From 423cfd4bbb3c489e4222c5fb335056bc51085d53 Mon Sep 17 00:00:00 2001 From: Rafael Taranto Date: Tue, 18 Jun 2024 12:41:53 +0100 Subject: [PATCH 5/7] fix: generic external auth on ui --- .../src/pages/Triggers/TriggerView.js | 2 + .../src/pages/Triggers/Triggers.js | 10 +++++ .../src/pages/Triggers/Wizard.js | 11 ++++- .../src/pages/Triggers/helper.js | 44 +++++++++---------- 4 files changed, 44 insertions(+), 23 deletions(-) diff --git a/new-lamassu-admin/src/pages/Triggers/TriggerView.js b/new-lamassu-admin/src/pages/Triggers/TriggerView.js index 58688227..5333184a 100644 --- a/new-lamassu-admin/src/pages/Triggers/TriggerView.js +++ b/new-lamassu-admin/src/pages/Triggers/TriggerView.js @@ -29,6 +29,7 @@ const TriggerView = ({ toggleWizard, addNewTriger, emailAuth, + complianceServices, customInfoRequests }) => { const currency = R.path(['fiatCurrency'])( @@ -78,6 +79,7 @@ const TriggerView = ({ save={add} onClose={() => toggleWizard(true)} customInfoRequests={customInfoRequests} + complianceServices={complianceServices} emailAuth={emailAuth} triggers={triggers} /> diff --git a/new-lamassu-admin/src/pages/Triggers/Triggers.js b/new-lamassu-admin/src/pages/Triggers/Triggers.js index 9d70edfe..2292de5a 100644 --- a/new-lamassu-admin/src/pages/Triggers/Triggers.js +++ b/new-lamassu-admin/src/pages/Triggers/Triggers.js @@ -42,6 +42,12 @@ const GET_CONFIG = gql` query getData { config accounts + accountsConfig { + code + display + class + cryptos + } } ` @@ -75,6 +81,9 @@ const Triggers = () => { const emailAuth = data?.config?.triggersConfig_customerAuthentication === 'EMAIL' + const complianceServices = R.filter(R.propEq('class', 'compliance'))( + data?.accountsConfig || [] + ) const triggers = fromServer(data?.config?.triggers ?? []) const complianceConfig = data?.config && fromNamespace('compliance')(data.config) @@ -220,6 +229,7 @@ const Triggers = () => { toggleWizard={toggleWizard('newTrigger')} addNewTriger={addNewTriger} emailAuth={emailAuth} + complianceServices={complianceServices} customInfoRequests={enabledCustomInfoRequests} /> )} diff --git a/new-lamassu-admin/src/pages/Triggers/Wizard.js b/new-lamassu-admin/src/pages/Triggers/Wizard.js index 1d83c3ea..8b978410 100644 --- a/new-lamassu-admin/src/pages/Triggers/Wizard.js +++ b/new-lamassu-admin/src/pages/Triggers/Wizard.js @@ -52,6 +52,7 @@ const getStep = ( { step, config }, currency, customInfoRequests, + complianceServices, emailAuth, triggers ) => { @@ -61,7 +62,13 @@ const getStep = ( case 1: return type(currency) case 2: - return requirements(config, triggers, customInfoRequests, emailAuth) + return requirements( + config, + triggers, + customInfoRequests, + complianceServices, + emailAuth + ) default: return Fragment } @@ -218,6 +225,7 @@ const Wizard = ({ error, currency, customInfoRequests, + complianceServices, emailAuth, triggers }) => { @@ -233,6 +241,7 @@ const Wizard = ({ { step, config }, currency, customInfoRequests, + complianceServices, emailAuth, triggers ) diff --git a/new-lamassu-admin/src/pages/Triggers/helper.js b/new-lamassu-admin/src/pages/Triggers/helper.js index 5bd3d0f8..e25a51eb 100644 --- a/new-lamassu-admin/src/pages/Triggers/helper.js +++ b/new-lamassu-admin/src/pages/Triggers/helper.js @@ -556,7 +556,8 @@ const requirementOptions = [ { display: 'US SSN', code: 'usSsn' }, // { display: 'Super user', code: 'superuser' }, { display: 'Suspend', code: 'suspend' }, - { display: 'Block', code: 'block' } + { display: 'Block', code: 'block' }, + { display: 'External Verification', code: 'external' } ] const hasRequirementError = (errors, touched, values) => @@ -580,6 +581,7 @@ const Requirement = ({ config = {}, triggers, emailAuth, + complianceServices, customInfoRequests = [] }) => { const classes = useStyles() @@ -624,27 +626,17 @@ const Requirement = ({ })) 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 = { display: 'Custom information requirement', code: 'custom' } - const externalOption = { display: 'External verification', code: 'external' } const itemToRemove = emailAuth ? 'sms' : 'email' const reqOptions = requirementOptions.filter(it => it.code !== itemToRemove) const options = R.clone(reqOptions) enableCustomRequirement && options.push(customInfoOption) - enableExternalRequirement && options.push(externalOption) const titleClass = { [classes.error]: @@ -654,13 +646,6 @@ const Requirement = ({ (isExternal && hasExternalRequirementError(errors, touched, values)) } - const externalServices = [ - { - value: 'sumsub', - display: 'Sumsub' - } - ] - return ( <> @@ -708,7 +693,10 @@ const Requirement = ({ component={Dropdown} label="Service" name="requirement.externalService" - options={externalServices} + options={complianceServices.map(it => ({ + value: it.code, + display: it.display + }))} /> )} @@ -716,11 +704,23 @@ const Requirement = ({ ) } -const requirements = (config, triggers, customInfoRequests, emailAuth) => ({ +const requirements = ( + config, + triggers, + customInfoRequests, + complianceServices, + emailAuth +) => ({ schema: requirementSchema, options: requirementOptions, Component: Requirement, - props: { config, triggers, customInfoRequests, emailAuth }, + props: { + config, + triggers, + customInfoRequests, + emailAuth, + complianceServices + }, hasRequirementError: hasRequirementError, hasCustomRequirementError: hasCustomRequirementError, hasExternalRequirementError: hasExternalRequirementError, @@ -804,7 +804,7 @@ const RequirementView = ({ R.find(customReqIdMatches(customInfoRequestId))(customInfoRequests) ) ?? '' : requirement === 'external' - ? `External validation (${onlyFirstToUpper(externalService)})` + ? `External Verification (${onlyFirstToUpper(externalService)})` : getView(requirementOptions, 'display')(requirement) const isSuspend = requirement === 'suspend' return ( From 8f8e95c292102ef79863edd4894960b74a514e43 Mon Sep 17 00:00:00 2001 From: Rafael Taranto Date: Tue, 18 Jun 2024 23:43:35 +0100 Subject: [PATCH 6/7] feat: notifications and flow fixes --- lib/compliance-external.js | 14 +---- lib/customers.js | 63 +++++++++++++++++-- lib/graphql/types.js | 1 + lib/plugins/compliance/consts.js | 2 +- .../mock-compliance/mock-compliance.js | 23 ++++++- lib/plugins/compliance/sumsub/sumsub.js | 10 ++- lib/poller.js | 8 +++ lib/routes/customerRoutes.js | 6 ++ migrations/1718464437502-integrate-sumsub.js | 2 +- 9 files changed, 103 insertions(+), 26 deletions(-) diff --git a/lib/compliance-external.js b/lib/compliance-external.js index 26e6b60f..466b77e9 100644 --- a/lib/compliance-external.js +++ b/lib/compliance-external.js @@ -21,7 +21,7 @@ const getStatus = (settings, service, customerId) => { status })) .catch((error) => { - logger.error(`Error getting applicant for service ${service}:`, error) + if (error.response.status !== 404) logger.error(`Error getting applicant for service ${service}:`, error.message) return { service: service, status: null, @@ -45,7 +45,6 @@ const getStatusMap = (settings, customerExternalCompliance) => { _.uniq )(triggers) - const meta = {} const applicantPromises = _.map(service => { return getStatus(settings, service, customerExternalCompliance) })(services) @@ -53,7 +52,7 @@ const getStatusMap = (settings, customerExternalCompliance) => { return Promise.all(applicantPromises) .then((applicantResults) => { return _.reduce((map, result) => { - map[result.service] = result.status + if (result.status) map[result.service] = result.status return map }, {})(applicantResults) }) @@ -73,16 +72,9 @@ const createLink = (settings, externalService, customerId) => { return plugin.createLink(account, customerId, account.applicantLevel) } -const getApplicantByExternalId = (settings, externalService, customerId) => { - const account = settings.accounts[externalService] - const { plugin } = getPlugin(settings, externalService) - - return plugin.getApplicantByExternalId(account, customerId) -} - module.exports = { getStatusMap, + getStatus, createApplicant, - getApplicantByExternalId, createLink } diff --git a/lib/customers.js b/lib/customers.js index 34ac8020..cb7c588f 100644 --- a/lib/customers.js +++ b/lib/customers.js @@ -19,6 +19,8 @@ const settingsLoader = require('./new-settings-loader') const logger = require('./logger') const externalCompliance = require('./compliance-external') +const { APPROVED, RETRY } = require('./plugins/compliance/consts') + const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel', 'scoreThresholdReached', 'walletScoringError'] const ID_PHOTO_CARD_DIR = process.env.ID_PHOTO_CARD_DIR @@ -938,13 +940,21 @@ function getExternalComplianceMachine (customer) { return settingsLoader.loadLatest() .then(settings => externalCompliance.getStatusMap(settings, customer.id)) .then(statusMap => { - return updateExternalCompliance(customer.id, statusMap) + return updateExternalComplianceByMap(customer.id, statusMap) .then(() => customer.externalCompliance = statusMap) .then(() => customer) }) } -function updateExternalCompliance(customerId, serviceMap) { +function updateExternalCompliance(customerId, service, status) { + const sql = ` + UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() + WHERE customer_id=$2 AND service=$3 + ` + return db.none(sql, [status, customerId, service]) +} + +function updateExternalComplianceByMap(customerId, serviceMap) { const sql = ` UPDATE customer_external_compliance SET last_known_status = $1, last_updated = now() WHERE customer_id=$2 AND service=$3 @@ -959,12 +969,56 @@ function getExternalCompliance(customer) { FROM customer_external_compliance where customer_id=$1` return db.manyOrNone(sql, [customer.id]) .then(compliance => { - console.log(compliance) customer.externalCompliance = compliance }) .then(() => customer) } +function getOpenExternalCompliance() { + const sql = `SELECT customer_id, service, last_known_status FROM customer_external_compliance where last_known_status in ('PENDING', 'RETRY') or last_known_status is null` + return db.manyOrNone(sql) +} + +function notifyRetryExternalCompliance(settings, customerId, service) { + const sql = 'SELECT phone FROM customers WHERE id=$1' + const promises = [db.one(sql, [customerId]), externalCompliance.createLink(settings, service, customerId)] + + return Promise.all(promises) + .then(([toNumber, link]) => { + const body = `Your external compliance verification has failed. Please try again. Link for retry: ${link}` + + return sms.sendMessage(settings, { toNumber, body }) + }) +} + +function notifyApprovedExternalCompliance(settings, customerId) { + const sql = 'SELECT phone FROM customers WHERE id=$1' + return db.one(sql, [customerId]) + .then((toNumber) => { + const body = 'Your external compliance verification has been approved.' + + return sms.sendMessage(settings, { toNumber, body }) + }) +} + +function checkExternalCompliance(settings) { + return getOpenExternalCompliance() + .then(externals => { + console.log(externals) + const promises = _.map(external => { + return externalCompliance.getStatus(settings, external.service, external.customer_id) + .then(status => { + console.log('status', status, external.customer_id, external.service) + if (status.status.answer === RETRY) notifyRetryExternalCompliance(settings, external.customer_id, status.service) + if (status.status.answer === APPROVED) notifyApprovedExternalCompliance(settings, external.customer_id) + + return updateExternalCompliance(external.customer_id, external.service, status.status.answer) + }) + }, externals) + return Promise.all(promises) + }) +} + function addExternalCompliance(customerId, service, id) { const sql = `INSERT INTO customer_external_compliance (customer_id, external_id, service) VALUES ($1, $2, $3)` return db.none(sql, [customerId, id, service]) @@ -995,5 +1049,6 @@ module.exports = { enableTestCustomer, disableTestCustomer, updateLastAuthAttempt, - addExternalCompliance + addExternalCompliance, + checkExternalCompliance } diff --git a/lib/graphql/types.js b/lib/graphql/types.js index 7977e522..26622c37 100644 --- a/lib/graphql/types.js +++ b/lib/graphql/types.js @@ -109,6 +109,7 @@ type Trigger { thresholdDays: Int customInfoRequestId: String customInfoRequest: CustomInfoRequest + externalService: String } type TermsDetails { diff --git a/lib/plugins/compliance/consts.js b/lib/plugins/compliance/consts.js index 3700c1d3..c6291189 100644 --- a/lib/plugins/compliance/consts.js +++ b/lib/plugins/compliance/consts.js @@ -1,5 +1,5 @@ module.exports = { - WAIT: 'WAIT', + PENDING: 'PENDING', RETRY: 'RETRY', APPROVED: 'APPROVED', REJECTED: 'REJECTED' diff --git a/lib/plugins/compliance/mock-compliance/mock-compliance.js b/lib/plugins/compliance/mock-compliance/mock-compliance.js index 366b1861..954b8072 100644 --- a/lib/plugins/compliance/mock-compliance/mock-compliance.js +++ b/lib/plugins/compliance/mock-compliance/mock-compliance.js @@ -1,14 +1,31 @@ +const uuid = require('uuid') + +const {APPROVED} = require('../consts') + const CODE = 'mock-compliance' const createLink = (settings, userId, level) => { return `this is a mock external link, ${userId}, ${level}` } -const getApplicantStatus = (settings, userId) => { +const getApplicantStatus = (account, userId) => { + return Promise.resolve({ + service: CODE, + status: { + level: account.applicantLevel, answer: APPROVED + } + }) +} + +const createApplicant = () => { + return Promise.resolve({ + id: uuid.v4() + }) } module.exports = { CODE, - createLink, - getApplicantStatus + createApplicant, + getApplicantStatus, + createLink } diff --git a/lib/plugins/compliance/sumsub/sumsub.js b/lib/plugins/compliance/sumsub/sumsub.js index a0a42e3b..80e2d2ea 100644 --- a/lib/plugins/compliance/sumsub/sumsub.js +++ b/lib/plugins/compliance/sumsub/sumsub.js @@ -1,7 +1,7 @@ const _ = require('lodash/fp') const sumsubApi = require('./sumsub.api') -const { WAIT, RETRY, APPROVED, REJECTED } = require('../consts') +const { PENDING, RETRY, APPROVED, REJECTED } = require('../consts') const CODE = 'sumsub' @@ -31,17 +31,16 @@ const getApplicantStatus = (account, userId) => { const reviewStatus = _.get('data.review.reviewStatus', r) const reviewAnswer = _.get('data.review.reviewResult.reviewAnswer', r) const reviewRejectType = _.get('data.review.reviewResult.reviewRejectType', r) - const sumsubUserId = _.get('data.review.reviewResult.reviewRejectType', r) // if last review was from a different level, return the current level and RETRY - if (levelName !== account.applicantLevel) return { thirdPartyId: sumsubUserId, level: account.applicantLevel, answer: RETRY } + if (levelName !== account.applicantLevel) return { level: account.applicantLevel, answer: RETRY } - let answer = WAIT + let answer = PENDING if (reviewAnswer === 'GREEN' && reviewStatus === 'completed') answer = APPROVED if (reviewAnswer === 'RED' && reviewRejectType === 'RETRY') answer = RETRY if (reviewAnswer === 'RED' && reviewRejectType === 'FINAL') answer = REJECTED - return { thirdPartyId: sumsubUserId, level: levelName, answer } + return { level: levelName, answer } }) } @@ -49,6 +48,5 @@ module.exports = { CODE, createApplicant, getApplicantStatus, - getApplicantByExternalId, createLink } \ No newline at end of file diff --git a/lib/poller.js b/lib/poller.js index ca002612..3c33c93b 100644 --- a/lib/poller.js +++ b/lib/poller.js @@ -6,6 +6,7 @@ const T = require('./time') const logger = require('./logger') const cashOutTx = require('./cash-out/cash-out-tx') const cashInTx = require('./cash-in/cash-in-tx') +const customers = require('./customers') const sanctionsUpdater = require('./ofac/update') const sanctions = require('./ofac/index') const coinAtmRadar = require('./coinatmradar/coinatmradar') @@ -31,6 +32,7 @@ const RADAR_UPDATE_INTERVAL = 5 * T.minutes const PRUNE_MACHINES_HEARTBEAT = 1 * T.day const TRANSACTION_BATCH_LIFECYCLE = 20 * T.minutes const TICKER_RATES_INTERVAL = 59 * T.seconds +const EXTERNAL_COMPLIANCE_INTERVAL = 1 * T.minutes const CHECK_NOTIFICATION_INTERVAL = 20 * T.seconds const PENDING_INTERVAL = 10 * T.seconds @@ -127,6 +129,10 @@ function updateCoinAtmRadar () { .then(rates => coinAtmRadar.update(rates, settings())) } +// function checkExternalCompliance (settings) { +// return customers.checkExternalCompliance(settings) +// } + function initializeEachSchema (schemas = ['public']) { // for each schema set "thread variables" and do polling return _.forEach(schema => { @@ -190,6 +196,7 @@ function doPolling (schema) { pi().sweepHd() notifier.checkNotification(pi()) updateCoinAtmRadar() + // checkExternalCompliance(settings()) addToQueue(pi().getRawRates, TICKER_RATES_INTERVAL, schema, QUEUE.FAST) addToQueue(pi().executeTrades, TRADE_INTERVAL, schema, QUEUE.FAST) @@ -206,6 +213,7 @@ function doPolling (schema) { addToQueue(updateAndLoadSanctions, SANCTIONS_UPDATE_INTERVAL, schema, QUEUE.SLOW) addToQueue(updateCoinAtmRadar, RADAR_UPDATE_INTERVAL, schema, QUEUE.SLOW) addToQueue(pi().pruneMachinesHeartbeat, PRUNE_MACHINES_HEARTBEAT, schema, QUEUE.SLOW, settings) + // addToQueue(checkExternalCompliance, EXTERNAL_COMPLIANCE_INTERVAL, schema, QUEUE.SLOW, settings) } function setup (schemasToAdd = [], schemasToRemove = []) { diff --git a/lib/routes/customerRoutes.js b/lib/routes/customerRoutes.js index 75fe6641..0ebb4128 100644 --- a/lib/routes/customerRoutes.js +++ b/lib/routes/customerRoutes.js @@ -238,6 +238,7 @@ function sendSmsReceipt (req, res, next) { function getExternalComplianceLink (req, res, next) { const customerId = req.query.customer const triggerId = req.query.trigger + const isRetry = req.query.isRetry if (_.isNil(customerId) || _.isNil(triggerId)) return next(httpError('Not Found', 404)) const settings = req.settings @@ -245,6 +246,11 @@ function getExternalComplianceLink (req, res, next) { const trigger = _.find(it => it.id === triggerId)(triggers) const externalService = trigger.externalService + if (isRetry) { + return externalCompliance.createLink(settings, externalService, customerId) + .then(url => respond(req, res, { url })) + } + return externalCompliance.createApplicant(settings, externalService, customerId) .then(applicant => customers.addExternalCompliance(customerId, externalService, applicant.id)) .then(() => externalCompliance.createLink(settings, externalService, customerId)) diff --git a/migrations/1718464437502-integrate-sumsub.js b/migrations/1718464437502-integrate-sumsub.js index 0e49de6e..e47348c3 100644 --- a/migrations/1718464437502-integrate-sumsub.js +++ b/migrations/1718464437502-integrate-sumsub.js @@ -2,7 +2,7 @@ const db = require('./db') exports.up = function (next) { let sql = [ - `CREATE TYPE EXTERNAL_COMPLIANCE_STATUS AS ENUM('WAIT', 'APPROVED', 'REJECTED', 'RETRY')`, + `CREATE TYPE EXTERNAL_COMPLIANCE_STATUS AS ENUM('PENDING', 'APPROVED', 'REJECTED', 'RETRY')`, `CREATE TABLE CUSTOMER_EXTERNAL_COMPLIANCE ( customer_id UUID NOT NULL REFERENCES customers(id), service TEXT NOT NULL, From ae0d90dd99cee80ae572455dc2a07a1afb662c93 Mon Sep 17 00:00:00 2001 From: Rafael Taranto Date: Tue, 16 Jul 2024 09:33:13 +0100 Subject: [PATCH 7/7] chore: use map instead of foreach --- .../src/pages/Customers/CustomerData.js | 53 +++++++++---------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/new-lamassu-admin/src/pages/Customers/CustomerData.js b/new-lamassu-admin/src/pages/Customers/CustomerData.js index 55d0f32d..049c0227 100644 --- a/new-lamassu-admin/src/pages/Customers/CustomerData.js +++ b/new-lamassu-admin/src/pages/Customers/CustomerData.js @@ -99,7 +99,6 @@ const CustomerData = ({ const customInfoRequests = sortByName( R.path(['customInfoRequests'])(customer) ?? [] ) - const externalCompliance = [] const phone = R.path(['phone'])(customer) const email = R.path(['email'])(customer) @@ -400,34 +399,32 @@ const CustomerData = ({ }) }, R.keys(smsData) ?? []) - R.forEach(it => { - externalCompliance.push({ - fields: [ - { - name: 'externalId', - label: 'Third Party ID', - editable: false - }, - { - name: 'lastKnownStatus', - label: 'Last Known Status', - editable: false - }, - { - name: 'lastUpdated', - label: 'Last Updated', - editable: false - } - ], - titleIcon: , - title: `External Info [${it.service}]`, - initialValues: it ?? { - externalId: '', - lastKnownStatus: '', - lastUpdated: '' + const externalCompliance = R.map(it => ({ + fields: [ + { + name: 'externalId', + label: 'Third Party ID', + editable: false + }, + { + name: 'lastKnownStatus', + label: 'Last Known Status', + editable: false + }, + { + name: 'lastUpdated', + label: 'Last Updated', + editable: false } - }) - })(customer.externalCompliance ?? []) + ], + titleIcon: , + title: `External Info [${it.service}]`, + initialValues: it ?? { + externalId: '', + lastKnownStatus: '', + lastUpdated: '' + } + }))(customer.externalCompliance ?? []) const editableCard = ( {