Merge branch 'dev' into fix/lam-235/lamassu-coins-install

This commit is contained in:
André Sá 2022-01-20 10:29:01 +00:00 committed by GitHub
commit 0e9f3e5863
66 changed files with 1544 additions and 638 deletions

View file

@ -2,6 +2,7 @@
const { asyncLocalStorage, defaultStore } = require('../lib/async-storage')
const userManagement = require('../lib/new-admin/graphql/modules/userManagement')
const authErrors = require('../lib/new-admin/graphql/errors/authentication')
const options = require('../lib/options')
const name = process.argv[2]
@ -14,29 +15,25 @@ if (!domain) {
}
if (!name || !role) {
console.log('Usage: lamassu-register <username> <role>')
console.log('Usage: lamassu-register <email> <role>')
console.log('<role> must be \'user\' or \'superuser\'')
process.exit(2)
}
const emailRegex = /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/
if (!emailRegex.test(name)) {
console.log('Usage: <name> should be in an email format')
console.log('Usage: <email> must be in an email format')
process.exit(2)
}
if (role !== 'user' && role !== 'superuser') {
console.log('Usage: <role> has two possible values: user | superuser')
console.log('Usage: <role> must be \'user\' or \'superuser\'')
process.exit(2)
}
asyncLocalStorage.run(defaultStore(), () => {
userManagement.createRegisterToken(name, role).then(token => {
if (!token) {
console.log(`A user named ${name} already exists!`)
process.exit(2)
}
if (domain === 'localhost') {
console.log(`https://${domain}:3001/register?t=${token.token}`)
} else {
@ -45,6 +42,12 @@ asyncLocalStorage.run(defaultStore(), () => {
process.exit(0)
}).catch(err => {
if (err instanceof authErrors.UserAlreadyExistsError){
console.log(`A user with email ${name} already exists!`)
process.exit(2)
}
console.log('Error: %s', err)
process.exit(3)
})

View file

@ -73,8 +73,9 @@ function unmergeCassettes(cassettes, output) {
}
function makeChangeDuo(cassettes, amount) {
const small = cassettes[0]
const large = cassettes[1]
// Initialize empty cassettes in case of undefined, due to same denomination across all cassettes results in a single merged cassette
const small = cassettes[0] ?? { denomination: 0, count: 0 }
const large = cassettes[1] ?? { denomination: 0, count: 0 }
const largeDenom = large.denomination
const smallDenom = small.denomination

View file

@ -23,18 +23,18 @@ module.exports = {
const BINARIES = {
BTC: {
defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.0/bitcoin-0.20.0-x86_64-linux-gnu.tar.gz',
defaultDir: 'bitcoin-0.20.0/bin',
defaultUrl: 'https://bitcoincore.org/bin/bitcoin-core-0.20.1/bitcoin-0.20.1-x86_64-linux-gnu.tar.gz',
defaultDir: 'bitcoin-0.20.1/bin',
url: 'https://bitcoincore.org/bin/bitcoin-core-22.0/bitcoin-22.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-22.0/bin'
},
ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.13-7a0c19f8.tar.gz',
dir: 'geth-linux-amd64-1.10.13-7a0c19f8'
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.15-8be800ff.tar.gz',
dir: 'geth-linux-amd64-1.10.15-8be800ff'
},
ZEC: {
url: 'https://z.cash/downloads/zcash-4.5.1-1-linux64-debian-stretch.tar.gz',
dir: 'zcash-4.5.1-1/bin'
url: 'https://z.cash/downloads/zcash-4.6.0-1-linux64-debian-stretch.tar.gz',
dir: 'zcash-4.6.0-1/bin'
},
DASH: {
url: 'https://github.com/dashpay/dash/releases/download/v0.17.0.3/dashcore-0.17.0.3-x86_64-linux-gnu.tar.gz',
@ -50,8 +50,8 @@ const BINARIES = {
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
},
XMR: {
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.2.0.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.17.2.0',
url: 'https://downloads.getmonero.org/cli/monero-linux-x64-v0.17.3.0.tar.bz2',
dir: 'monero-x86_64-linux-gnu-v0.17.3.0',
files: [['monerod', 'monerod'], ['monero-wallet-rpc', 'monero-wallet-rpc']]
}
}

View file

@ -117,8 +117,9 @@ function run () {
_.filter(c => c.type !== 'erc-20'),
_.map(c => {
const checked = isInstalledSoftware(c) && isInstalledVolume(c)
const name = c.code === 'ethereum' ? 'Ethereum and/or USDT' : c.display
return {
name: c.display,
name,
value: c.code,
checked,
disabled: checked && 'Installed'

View file

@ -631,8 +631,8 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override,
phone, sms_override, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes
sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
@ -640,13 +640,13 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
c.front_camera_path, c.front_camera_override,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes,
c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (partition by c.id order by t.created desc) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
coalesce(sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (partition by c.id), 0) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true UNION
FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id
LEFT OUTER JOIN (
@ -686,8 +686,8 @@ function getCustomerById (id) {
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_at, front_camera_override,
phone, sms_override, id_card_data_at, id_card_data, id_card_data_override, id_card_data_expiration,
id_card_photo_path, id_card_photo_at, id_card_photo_override, us_ssn_at, us_ssn, us_ssn_override, sanctions, sanctions_at,
sanctions_override, total_txs, total_spent, created AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes
sanctions_override, total_txs, total_spent, LEAST(created, last_transaction) AS last_active, fiat AS last_tx_fiat,
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, subscriber_info, custom_fields, notes, is_test_customer
FROM (
SELECT c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) AS days_suspended,
@ -695,13 +695,13 @@ function getCustomerById (id) {
c.front_camera_path, c.front_camera_override, c.front_camera_at,
c.phone, c.sms_override, c.id_card_data, c.id_card_data_at, c.id_card_data_override, c.id_card_data_expiration,
c.id_card_photo_path, c.id_card_photo_at, c.id_card_photo_override, c.us_ssn, c.us_ssn_at, c.us_ssn_override, c.sanctions,
c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created, cn.notes,
c.sanctions_at, c.sanctions_override, c.subscriber_info, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
row_number() OVER (PARTITION BY c.id ORDER BY t.created DESC) AS rn,
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (PARTITION BY c.id) AS total_txs,
sum(CASE WHEN error_code IS NULL OR error_code NOT IN ($1^) THEN t.fiat ELSE 0 END) OVER (PARTITION BY c.id) AS total_spent, ccf.custom_fields
FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_in_txs WHERE send_confirmed = true UNION
FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id
LEFT OUTER JOIN (
@ -993,6 +993,7 @@ function addCustomField (customerId, label, value) {
}
})
)
.then(res => !_.isNil(res))
}
function saveCustomField (customerId, fieldId, newValue) {
@ -1022,6 +1023,16 @@ function getCustomInfoRequestsData (customer) {
return db.any(sql, [customer.id]).then(res => _.set('custom_info_request_data', res, customer))
}
function enableTestCustomer (customerId) {
const sql = `UPDATE customers SET is_test_customer=true WHERE id=$1`
return db.none(sql, [customerId])
}
function disableTestCustomer (customerId) {
const sql = `UPDATE customers SET is_test_customer=false WHERE id=$1`
return db.none(sql, [customerId])
}
module.exports = {
add,
get,
@ -1040,5 +1051,7 @@ module.exports = {
edit,
deleteEditedData,
updateEditedPhoto,
updateTxCustomerPhoto
updateTxCustomerPhoto,
enableTestCustomer,
disableTestCustomer
}

View file

@ -12,14 +12,14 @@ const configManager = require('./new-config-manager')
const settingsLoader = require('./new-settings-loader')
const notifierUtils = require('./notifier/utils')
const notifierQueries = require('./notifier/queries')
const { ApolloError } = require('apollo-server-errors');
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
const stuckStatus = { label: 'Stuck', type: 'error' }
function getMachines () {
return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created')
.then(rr => rr.map(r => ({
function toMachineObject (r) {
return {
deviceId: r.device_id,
cashbox: r.cashbox,
cassette1: r.cassette1,
@ -32,10 +32,15 @@ function getMachines () {
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
paired: r.paired
// TODO: we shall start using this JSON field at some point
// location: r.location,
paired: r.paired
})))
}
}
function getMachines () {
return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created')
.then(rr => rr.map(toMachineObject))
}
function getConfig (defaultConfig) {
@ -100,21 +105,10 @@ function getMachineName (machineId) {
function getMachine (machineId, config) {
const sql = 'SELECT * FROM devices WHERE device_id=$1'
const queryMachine = db.oneOrNone(sql, [machineId]).then(r => ({
deviceId: r.device_id,
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
cassette3: r.cassette3,
cassette4: r.cassette4,
numberOfCassettes: r.number_of_cassettes,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
paired: r.paired
}))
const queryMachine = db.oneOrNone(sql, [machineId]).then(r => {
if (r === null) throw new ApolloError('Resource doesn\'t exist', 'NOT_FOUND')
else return toMachineObject(r)
})
return Promise.all([queryMachine, dbm.machineEvents(), config])
.then(([machine, events, config]) => {

View file

@ -240,7 +240,7 @@ const reset2FA = (token, userID, code, context) => {
}
const getToken = context => {
if (_.isNil(context.req.cookies.lid) || _.isNil(context.req.session.user.id))
if (_.isNil(context.req.cookies['lamassu_sid']) || _.isNil(context.req.session.user.id))
throw new authErrors.AuthenticationError('Authentication failed')
return context.req.session.user.id

View file

@ -1,3 +1,4 @@
const authentication = require('../modules/userManagement')
const queries = require('../../services/customInfoRequests')
const DataLoader = require('dataloader')
@ -21,7 +22,10 @@ const resolvers = {
insertCustomInfoRequest: (...[, { customRequest }]) => queries.addCustomInfoRequest(customRequest),
removeCustomInfoRequest: (...[, { id }]) => queries.removeCustomInfoRequest(id),
editCustomInfoRequest: (...[, { id, customRequest }]) => queries.editCustomInfoRequest(id, customRequest),
setAuthorizedCustomRequest: (...[, { customerId, infoRequestId, isAuthorized }]) => queries.setAuthorizedCustomRequest(customerId, infoRequestId, isAuthorized),
setAuthorizedCustomRequest: (...[, { customerId, infoRequestId, override }, context]) => {
const token = authentication.getToken(context)
return queries.setAuthorizedCustomRequest(customerId, infoRequestId, override, token)
},
setCustomerCustomInfoRequest: (...[, { customerId, infoRequestId, data }]) => queries.setCustomerData(customerId, infoRequestId, data)
}
}

View file

@ -21,7 +21,7 @@ const resolvers = {
return customers.updateCustomer(customerId, customerInput, token)
},
addCustomField: (...[, { customerId, label, value }]) => customers.addCustomField(customerId, label, value),
saveCustomField: (...[, { customerId, fieldId, newValue }]) => customers.saveCustomField(customerId, fieldId, newValue),
saveCustomField: (...[, { customerId, fieldId, value }]) => customers.saveCustomField(customerId, fieldId, value),
removeCustomField: (...[, [ { customerId, fieldId } ]]) => customers.removeCustomField(customerId, fieldId),
editCustomer: async (root, { customerId, customerEdit }, context) => {
const token = authentication.getToken(context)
@ -49,7 +49,12 @@ const resolvers = {
},
deleteCustomerNote: (...[, { noteId }]) => {
return customerNotes.deleteCustomerNote(noteId)
}
},
createCustomer: (...[, { phoneNumber }]) => customers.add({ phone: phoneNumber }),
enableTestCustomer: (...[, { customerId }]) =>
customers.enableTestCustomer(customerId),
disableTestCustomer: (...[, { customerId }]) =>
customers.disableTestCustomer(customerId)
}
}

View file

@ -30,10 +30,10 @@ const resolvers = {
isAnonymous: parent => (parent.customerId === anonymous.uuid)
},
Query: {
transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status }]) =>
transactions.batch(from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status),
transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, simplified }]) =>
transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, simplified)
transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers }]) =>
transactions.batch(from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers),
transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, excludeTestingCustomers, simplified }]) =>
transactions.batch(from, until, limit, offset, null, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, excludeTestingCustomers, simplified)
.then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']), { fields: txLogFields })),
transactionCsv: (...[, { id, txClass, timezone }]) =>
transactions.getTx(id, txClass).then(data =>

View file

@ -32,7 +32,9 @@ const typeDef = gql`
type CustomRequestData {
customerId: ID
infoRequestId: ID
approved: Boolean
override: String
overrideAt: Date
overrideBy: ID
customerData: JSON
customInfoRequest: CustomInfoRequest
}
@ -47,7 +49,7 @@ const typeDef = gql`
insertCustomInfoRequest(customRequest: CustomRequestInput!): CustomInfoRequest @auth
removeCustomInfoRequest(id: ID!): CustomInfoRequest @auth
editCustomInfoRequest(id: ID!, customRequest: CustomRequestInput!): CustomInfoRequest @auth
setAuthorizedCustomRequest(customerId: ID!, infoRequestId: ID!, isAuthorized: Boolean!): Boolean @auth
setAuthorizedCustomRequest(customerId: ID!, infoRequestId: ID!, override: String!): Boolean @auth
setCustomerCustomInfoRequest(customerId: ID!, infoRequestId: ID!, data: JSON!): Boolean @auth
}
`

View file

@ -1,12 +1,6 @@
const { gql } = require('apollo-server-express')
const typeDef = gql`
type CustomerCustomField {
id: ID
label: String
value: String
}
type Customer {
id: ID!
authorizedOverride: String
@ -42,6 +36,7 @@ const typeDef = gql`
customFields: [CustomerCustomField]
customInfoRequests: [CustomRequestData]
notes: [CustomerNote]
isTestCustomer: Boolean
}
input CustomerInput {
@ -86,6 +81,12 @@ const typeDef = gql`
content: String
}
type CustomerCustomField {
id: ID
label: String
value: String
}
type Query {
customers(phone: String, name: String, address: String, id: String): [Customer] @auth
customer(customerId: ID!): Customer @auth
@ -94,15 +95,18 @@ const typeDef = gql`
type Mutation {
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer @auth
addCustomField(customerId: ID!, label: String!, value: String!): CustomerCustomField @auth
saveCustomField(customerId: ID!, fieldId: ID!, value: String!): CustomerCustomField @auth
removeCustomField(customerId: ID!, fieldId: ID!): CustomerCustomField @auth
addCustomField(customerId: ID!, label: String!, value: String!): Boolean @auth
saveCustomField(customerId: ID!, fieldId: ID!, value: String!): Boolean @auth
removeCustomField(customerId: ID!, fieldId: ID!): Boolean @auth
editCustomer(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
deleteEditedData(customerId: ID!, customerEdit: CustomerEdit): Customer @auth
replacePhoto(customerId: ID!, photoType: String, newPhoto: UploadGQL): Customer @auth
createCustomerNote(customerId: ID!, title: String!, content: String!): Boolean @auth
editCustomerNote(noteId: ID!, newContent: String!): Boolean @auth
deleteCustomerNote(noteId: ID!): Boolean @auth
createCustomer(phoneNumber: String): Customer @auth
enableTestCustomer(customerId: ID!): Boolean @auth
disableTestCustomer(customerId: ID!): Boolean @auth
}
`

View file

@ -55,8 +55,8 @@ const typeDef = gql`
}
type Query {
transactions(from: Date, until: Date, limit: Int, offset: Int, deviceId: ID, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String): [Transaction] @auth
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, timezone: String, simplified: Boolean): String @auth
transactions(from: Date, until: Date, limit: Int, offset: Int, deviceId: ID, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, excludeTestingCustomers: Boolean): [Transaction] @auth
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int, txClass: String, machineName: String, customerName: String, fiatCode: String, cryptoCode: String, toAddress: String, status: String, timezone: String, excludeTestingCustomers: Boolean, simplified: Boolean): String @auth
transactionCsv(id: ID, txClass: String, timezone: String): String @auth
txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth
transactionFilters: [Filter] @auth

View file

@ -35,8 +35,10 @@ const getAllCustomInfoRequestsForCustomer = (customerId) => {
return db.any(sql, [customerId]).then(res => res.map(item => ({
customerId: item.customer_id,
infoRequestId: item.info_request_id,
approved: item.approved,
customerData: item.customer_data
customerData: item.customer_data,
override: item.override,
overrideAt: item.override_at,
overrideBy: item.override_by
})))
}
@ -46,8 +48,10 @@ const getCustomInfoRequestForCustomer = (customerId, infoRequestId) => {
return {
customerId: item.customer_id,
infoRequestId: item.info_request_id,
approved: item.approved,
customerData: item.customer_data
customerData: item.customer_data,
override: item.override,
overrideAt: item.override_at,
overrideBy: item.override_by
}
})
}
@ -61,8 +65,10 @@ const batchGetAllCustomInfoRequestsForCustomer = (customerIds) => {
return items.map(item => ({
customerId: item.customer_id,
infoRequestId: item.info_request_id,
approved: item.approved,
customerData: item.customer_data
customerData: item.customer_data,
override: item.override,
overrideAt: item.override_at,
overrideBy: item.override_by
}))
})
})
@ -93,9 +99,9 @@ const batchGetCustomInfoRequest = (infoRequestIds) => {
})
}
const setAuthorizedCustomRequest = (customerId, infoRequestId, isAuthorized) => {
const sql = `UPDATE customers_custom_info_requests SET approved = $1 WHERE customer_id = $2 AND info_request_id = $3`
return db.none(sql, [isAuthorized, customerId, infoRequestId]).then(() => true)
const setAuthorizedCustomRequest = (customerId, infoRequestId, override, token) => {
const sql = `UPDATE customers_custom_info_requests SET override = $1, override_by = $2, override_at = now() WHERE customer_id = $3 AND info_request_id = $4`
return db.none(sql, [override, token, customerId, infoRequestId]).then(() => true)
}
const setCustomerData = (customerId, infoRequestId, data) => {
@ -103,7 +109,7 @@ const setCustomerData = (customerId, infoRequestId, data) => {
INSERT INTO customers_custom_info_requests (customer_id, info_request_id, customer_data)
VALUES ($1, $2, $3)
ON CONFLICT (customer_id, info_request_id)
DO UPDATE SET customer_data = $3, approved = null`
DO UPDATE SET customer_data = $3`
return db.none(sql, [customerId, infoRequestId, data])
}

View file

@ -39,6 +39,7 @@ function batch (
cryptoCode = null,
toAddress = null,
status = null,
excludeTestingCustomers = false,
simplified = false
) {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
@ -67,6 +68,7 @@ function batch (
AND ($11 is null or txs.crypto_code = $11)
AND ($12 is null or txs.to_address = $12)
AND ($13 is null or txs.txStatus = $13)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0)
ORDER BY created DESC limit $4 offset $5`
@ -98,6 +100,7 @@ function batch (
AND ($11 is null or txs.crypto_code = $11)
AND ($12 is null or txs.to_address = $12)
AND ($13 is null or txs.txStatus = $13)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
AND (fiat > 0)
ORDER BY created DESC limit $4 offset $5`

View file

@ -1,4 +1,5 @@
const _ = require('lodash/fp')
const { getCustomInfoRequests } = require('./new-admin/services/customInfoRequests')
const namespaces = {
WALLETS: 'wallets',
@ -107,6 +108,8 @@ const getGlobalNotifications = config => getNotifications(null, null, config)
const getTriggers = _.get('triggers')
const getTriggersAutomation = config => {
return getCustomInfoRequests(true)
.then(infoRequests => {
const defaultAutomation = _.get('triggersConfig_automation')(config)
const requirements = {
sanctions: defaultAutomation,
@ -116,6 +119,10 @@ const getTriggersAutomation = config => {
usSsn: defaultAutomation
}
_.forEach(it => {
requirements[it.id] = defaultAutomation
}, infoRequests)
const overrides = _.get('triggersConfig_overrides')(config)
const requirementsOverrides = _.reduce((acc, override) => {
@ -123,6 +130,7 @@ const getTriggersAutomation = config => {
}, {}, overrides)
return _.assign(requirements, requirementsOverrides)
})
}
const splitGetFirst = _.compose(_.head, _.split('_'))

View file

@ -20,8 +20,13 @@ const machineLoader = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader')
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
function updateCustomerCustomInfoRequest (customerId, dataToSave, req, res) {
return customInfoRequestQueries.setCustomerData(customerId, dataToSave.info_request_id, dataToSave)
function updateCustomerCustomInfoRequest (customerId, patch, req, res) {
if (_.isNil(patch.data)) {
return customers.getById(customerId)
.then(customer => respond(req, res, { customer }))
}
return customInfoRequestQueries.setCustomerData(customerId, patch.infoRequestId, patch)
.then(() => customers.getById(customerId))
.then(customer => respond(req, res, { customer }))
}
@ -35,7 +40,7 @@ function updateCustomer (req, res, next) {
const compatTriggers = complianceTriggers.getBackwardsCompatibleTriggers(triggers)
if (patch.customRequestPatch) {
return updateCustomerCustomInfoRequest(id, patch.dataToSave, req, res).catch(next)
return updateCustomerCustomInfoRequest(id, patch.customRequestPatch, req, res).catch(next)
}
customers.getById(id)

View file

@ -41,8 +41,8 @@ const createTerms = terms => (terms.active && terms.text) ? ({
const buildTriggers = (allTriggers) => {
const normalTriggers = []
const customTriggers = _.filter(o => {
if (o.customInfoRequestId === '') normalTriggers.push(o)
return o.customInfoRequestId !== ''
if (_.isEmpty(o.customInfoRequestId) || _.isNil(o.customInfoRequestId)) normalTriggers.push(o)
return !_.isNil(o.customInfoRequestId) && !_.isEmpty(o.customInfoRequestId)
}, allTriggers)
return _.flow([_.map(_.get('customInfoRequestId')), customRequestQueries.batchGetCustomInfoRequest])(customTriggers)
@ -73,7 +73,7 @@ function poll (req, res, next) {
const pi = plugins(settings, deviceId)
const hasLightning = checkHasLightning(settings)
const triggersAutomation = configManager.getTriggersAutomation(settings.config)
const triggersAutomationPromise = configManager.getTriggersAutomation(settings.config)
const triggersPromise = buildTriggers(configManager.getTriggers(settings.config))
const operatorInfo = configManager.getOperatorInfo(settings.config)
@ -84,8 +84,8 @@ function poll (req, res, next) {
state.pids[operatorId] = { [deviceId]: { pid, ts: Date.now() } }
return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise])
.then(([results, triggers]) => {
return Promise.all([pi.pollQueries(serialNumber, deviceTime, req.query, machineVersion, machineModel), triggersPromise, triggersAutomationPromise])
.then(([results, triggers, triggersAutomation]) => {
const cassettes = results.cassettes
const reboot = pid && state.reboots?.[operatorId]?.[deviceId] === pid

View file

@ -0,0 +1,13 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE customers ADD COLUMN is_test_customer BOOLEAN NOT NULL DEFAULT false`,
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,16 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE customers_custom_info_requests DROP COLUMN approved`,
`ALTER TABLE customers_custom_info_requests ADD COLUMN override verification_type NOT NULL DEFAULT 'automatic'`,
`ALTER TABLE customers_custom_info_requests ADD COLUMN override_by UUID REFERENCES users(id)`,
`ALTER TABLE customers_custom_info_requests ADD COLUMN override_at TIMESTAMPTZ`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -13870,6 +13870,11 @@
"delegate": "^3.1.2"
}
},
"google-libphonenumber": {
"version": "3.2.22",
"resolved": "https://registry.npmjs.org/google-libphonenumber/-/google-libphonenumber-3.2.22.tgz",
"integrity": "sha512-lzEllxWc05n/HEv75SsDrA7zdEVvQzTZimItZm/TZ5XBs7cmx2NJmSlA5I0kZbdKNu8GFETBhSpo+SOhx0JslA=="
},
"graceful-fs": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.5.tgz",

View file

@ -26,6 +26,7 @@
"downshift": "3.3.4",
"file-saver": "2.0.2",
"formik": "2.2.0",
"google-libphonenumber": "^3.2.22",
"graphql": "^14.5.8",
"graphql-tag": "^2.10.3",
"jss-plugin-extend": "^10.0.0",

View file

@ -38,6 +38,12 @@ export const Carousel = memo(({ photosData, slidePhoto }) => {
opacity: 1
}
}}
// navButtonsWrapperProps={{
// style: {
// background: 'linear-gradient(to right, black 10%, transparent 80%)',
// opacity: '0.4'
// }
// }}
autoPlay={false}
indicators={false}
navButtonsAlwaysVisible={true}

View file

@ -181,7 +181,8 @@ const LogsDownloaderPopover = ({
fetchLogs({
variables: {
...args,
simplified: selectedAdvancedRadio === SIMPLIFIED
simplified: selectedAdvancedRadio === SIMPLIFIED,
excludeTestingCustomers: true
}
})
}
@ -196,7 +197,8 @@ const LogsDownloaderPopover = ({
...args,
from: range.from,
until: range.until,
simplified: selectedAdvancedRadio === SIMPLIFIED
simplified: selectedAdvancedRadio === SIMPLIFIED,
excludeTestingCustomers: true
}
})
}

View file

@ -64,6 +64,9 @@ const styles = {
position: 'relative',
marginBottom: spacer / 2,
paddingTop: spacer * 1.5,
'& > *:first-child': {
marginRight: 24
},
'& > *': {
marginRight: 10
},
@ -74,7 +77,8 @@ const styles = {
notificationContent: {
display: 'flex',
flexDirection: 'column',
justifyContent: 'center'
justifyContent: 'center',
width: 300
},
unread: {
backgroundColor: spring3
@ -89,8 +93,7 @@ const styles = {
flexGrow: 1
},
unreadIcon: {
marginLeft: spacer,
marginTop: 5,
marginTop: 2,
width: '12px',
height: '12px',
backgroundColor: secondaryColor,

View file

@ -13,12 +13,27 @@ import styles from './NotificationCenter.styles'
const useStyles = makeStyles(styles)
const types = {
transaction: { display: 'Transactions', icon: <Transaction /> },
highValueTransaction: { display: 'Transactions', icon: <Transaction /> },
fiatBalance: { display: 'Maintenance', icon: <Wrench /> },
cryptoBalance: { display: 'Maintenance', icon: <Wrench /> },
compliance: { display: 'Compliance', icon: <WarningIcon /> },
error: { display: 'Error', icon: <WarningIcon /> }
transaction: {
display: 'Transactions',
icon: <Transaction height={16} width={16} />
},
highValueTransaction: {
display: 'Transactions',
icon: <Transaction height={16} width={16} />
},
fiatBalance: {
display: 'Maintenance',
icon: <Wrench height={16} width={16} />
},
cryptoBalance: {
display: 'Maintenance',
icon: <Wrench height={16} width={16} />
},
compliance: {
display: 'Compliance',
icon: <WarningIcon height={16} width={16} />
},
error: { display: 'Error', icon: <WarningIcon height={16} width={16} /> }
}
const NotificationRow = ({
@ -35,7 +50,9 @@ const NotificationRow = ({
const classes = useStyles()
const typeDisplay = R.path([type, 'display'])(types) ?? null
const icon = R.path([type, 'icon'])(types) ?? <Wrench />
const icon = R.path([type, 'icon'])(types) ?? (
<Wrench height={16} width={16} />
)
const age = prettyMs(new Date().getTime() - new Date(created).getTime(), {
compact: true,
verbose: true
@ -57,7 +74,9 @@ const NotificationRow = ({
classes.notificationRow,
!read && valid ? classes.unread : ''
)}>
<div className={classes.notificationRowIcon}>{icon}</div>
<div className={classes.notificationRowIcon}>
<div>{icon}</div>
</div>
<div className={classes.notificationContent}>
<Label2 className={classes.notificationTitle}>
{notificationTitle}

View file

@ -132,7 +132,7 @@ const Header = memo(({ tree, user }) => {
return (
<NavLink
key={idx}
to={!R.isNil(it.children) ? it.children[0].route : it.route}
to={it.route || it.children[0].route}
isActive={match => {
if (!match) return false
setActive(it)

View file

@ -19,14 +19,14 @@ const TitleSection = ({
buttons = [],
children,
appendix,
appendixClassName
appendixRight
}) => {
const classes = useStyles()
return (
<div className={classnames(classes.titleWrapper, className)}>
<div className={classes.titleAndButtonsContainer}>
<Title>{title}</Title>
{appendix && <div className={appendixClassName}>{appendix}</div>}
{!!appendix && appendix}
{error && (
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
)}
@ -46,13 +46,14 @@ const TitleSection = ({
</>
)}
</div>
<Box display="flex" flexDirection="row">
<Box display="flex" flexDirection="row" alignItems="center">
{(labels ?? []).map(({ icon, label }, idx) => (
<Box key={idx} display="flex" alignItems="center">
<div className={classes.icon}>{icon}</div>
<Label1 className={classes.label}>{label}</Label1>
</Box>
))}
{appendixRight}
</Box>
{children}
</div>

View file

@ -2,6 +2,8 @@ import { useQuery } from '@apollo/react-hooks'
import { Box } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames'
import { endOfToday } from 'date-fns'
import { subDays } from 'date-fns/fp'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
@ -42,8 +44,16 @@ const TIME_OPTIONS = {
}
const GET_TRANSACTIONS = gql`
query transactions($limit: Int, $from: Date, $until: Date) {
transactions(limit: $limit, from: $from, until: $until) {
query transactions(
$from: Date
$until: Date
$excludeTestingCustomers: Boolean
) {
transactions(
from: $from
until: $until
excludeTestingCustomers: $excludeTestingCustomers
) {
id
txClass
txHash
@ -116,7 +126,13 @@ const OverviewEntry = ({ label, value, oldValue, currency }) => {
const Analytics = () => {
const classes = useStyles()
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS)
const { data: txResponse, loading: txLoading } = useQuery(GET_TRANSACTIONS, {
variables: {
from: subDays(65, endOfToday()),
until: endOfToday(),
excludeTestingCustomers: true
}
})
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const [representing, setRepresenting] = useState(REPRESENTING_OPTIONS[0])

View file

@ -46,23 +46,24 @@ const GET_USER_DATA = gql`
`
const validationSchema = Yup.object().shape({
client: Yup.string()
.required('Client field is required!')
.email('Username field should be in an email format!'),
email: Yup.string()
.label('Email')
.required()
.email(),
password: Yup.string().required('Password field is required'),
rememberMe: Yup.boolean()
})
const initialValues = {
client: '',
email: '',
password: '',
rememberMe: false
}
const getErrorMsg = (formikErrors, formikTouched, mutationError) => {
if (!formikErrors || !formikTouched) return null
if (mutationError) return 'Invalid login/password combination'
if (formikErrors.client && formikTouched.client) return formikErrors.client
if (mutationError) return 'Invalid email/password combination'
if (formikErrors.email && formikTouched.email) return formikErrors.email
if (formikErrors.password && formikTouched.password)
return formikErrors.password
return null
@ -142,13 +143,13 @@ const LoginState = ({ state, dispatch, strategy }) => {
validationSchema={validationSchema}
initialValues={initialValues}
onSubmit={values =>
submitLogin(values.client, values.password, values.rememberMe)
submitLogin(values.email, values.password, values.rememberMe)
}>
{({ errors, touched }) => (
<Form id="login-form">
<Field
name="client"
label="Client"
name="email"
label="Email"
size="lg"
component={TextInput}
fullWidth

View file

@ -210,6 +210,10 @@ const Register = () => {
{!loading && state.result === 'failure' && (
<>
<Label3>Link has expired</Label3>
<Label3>
To obtain a new link, run the command{' '}
<strong>lamassu-register</strong> in your servers terminal.
</Label3>
</>
)}
</div>

View file

@ -140,14 +140,13 @@ const Setup2FAState = ({ state, dispatch }) => {
<>
<div className={classes.infoWrapper}>
<Label3 className={classes.info2}>
We detected that this account does not have its two-factor
authentication enabled. In order to protect the resources in the
system, a two-factor authentication is enforced.
This account does not yet have two-factor authentication enabled. To
secure the admin, two-factor authentication is required.
</Label3>
<Label3 className={classes.info2}>
To finish this process, please scan the following QR code or insert
the secret further below on an authentication app of your choice,
such as Google Authenticator or Authy.
To complete the registration process, scan the following QR code or
insert the secret below on a 2FA app, such as Google Authenticator
or AndOTP.
</Label3>
</div>
<div className={classes.qrCodeWrapper}>

View file

@ -1,6 +1,6 @@
import Grid from '@material-ui/core/Grid'
import { makeStyles } from '@material-ui/core/styles'
import { parse, format, isValid } from 'date-fns/fp'
import { parse, format } from 'date-fns/fp'
import _ from 'lodash/fp'
import * as R from 'ramda'
import { useState, React } from 'react'
@ -26,6 +26,11 @@ import { URI } from 'src/utils/apollo'
import styles from './CustomerData.styles.js'
import { EditableCard } from './components'
import {
customerDataElements,
customerDataSchemas,
formatDates
} from './helper.js'
const useStyles = makeStyles(styles)
@ -63,7 +68,8 @@ const CustomerData = ({
editCustomer,
deleteEditedData,
updateCustomRequest,
authorizeCustomRequest
authorizeCustomRequest,
updateCustomEntry
}) => {
const classes = useStyles()
const [listView, setListView] = useState(false)
@ -84,8 +90,8 @@ const CustomerData = ({
R.compose(R.toLower, R.path(['customInfoRequest', 'customRequest', 'name']))
)
const customEntries = null // get customer custom entries
const customRequirements = [] // get customer custom requirements
const customFields = []
const customRequirements = []
const customInfoRequests = sortByName(
R.path(['customInfoRequests'])(customer) ?? []
)
@ -94,87 +100,8 @@ const CustomerData = ({
const getVisibleCards = _.filter(elem => elem.isAvailable)
const schemas = {
idScan: Yup.object().shape({
firstName: Yup.string().required(),
lastName: Yup.string().required(),
documentNumber: Yup.string().required(),
dateOfBirth: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required(),
gender: Yup.string().required(),
country: Yup.string().required(),
expirationDate: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required()
}),
usSsn: Yup.object().shape({
usSsn: Yup.string().required()
}),
idCardPhoto: Yup.object().shape({
idCardPhoto: Yup.mixed().required()
}),
frontCamera: Yup.object().shape({
frontCamera: Yup.mixed().required()
})
}
const idScanElements = [
{
name: 'firstName',
label: 'First name',
component: TextInput
},
{
name: 'documentNumber',
label: 'ID number',
component: TextInput
},
{
name: 'dateOfBirth',
label: 'Birthdate',
component: TextInput
},
{
name: 'gender',
label: 'Gender',
component: TextInput
},
{
name: 'lastName',
label: 'Last name',
component: TextInput
},
{
name: 'expirationDate',
label: 'Expiration Date',
component: TextInput
},
{
name: 'country',
label: 'Country',
component: TextInput
}
]
const usSsnElements = [
{
name: 'usSsn',
label: 'US SSN',
component: TextInput,
size: 190
}
]
const idCardPhotoElements = [{ name: 'idCardPhoto' }]
const frontCameraElements = [{ name: 'frontCamera' }]
const initialValues = {
idScan: {
idCardData: {
firstName: R.path(['firstName'])(idData) ?? '',
lastName: R.path(['lastName'])(idData) ?? '',
documentNumber: R.path(['documentNumber'])(idData) ?? '',
@ -202,19 +129,9 @@ const CustomerData = ({
}
}
const formatDates = values => {
_.map(
elem =>
(values[elem] = format('yyyyMMdd')(
parse(new Date(), 'yyyy-MM-dd', values[elem])
))
)(['dateOfBirth', 'expirationDate'])
return values
}
const cards = [
{
fields: idScanElements,
fields: customerDataElements.idCardData,
title: 'ID Scan',
titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['idCardDataOverride'])(customer),
@ -226,8 +143,8 @@ const CustomerData = ({
editCustomer({
idCardData: _.merge(idData, formatDates(values))
}),
validationSchema: schemas.idScan,
initialValues: initialValues.idScan,
validationSchema: customerDataSchemas.idCardData,
initialValues: initialValues.idCardData,
isAvailable: !_.isNil(idData)
},
{
@ -257,7 +174,7 @@ const CustomerData = ({
isAvailable: !_.isNil(sanctions)
},
{
fields: frontCameraElements,
fields: customerDataElements.frontCamera,
title: 'Front facing camera',
titleIcon: <EditIcon className={classes.editIcon} />,
state: R.path(['frontCameraOverride'])(customer),
@ -279,12 +196,12 @@ const CustomerData = ({
/>
) : null,
hasImage: true,
validationSchema: schemas.frontCamera,
validationSchema: customerDataSchemas.frontCamera,
initialValues: initialValues.frontCamera,
isAvailable: !_.isNil(customer.frontCameraPath)
},
{
fields: idCardPhotoElements,
fields: customerDataElements.idCardPhoto,
title: 'ID card image',
titleIcon: <EditIcon className={classes.editIcon} />,
state: R.path(['idCardPhotoOverride'])(customer),
@ -304,27 +221,26 @@ const CustomerData = ({
/>
) : null,
hasImage: true,
validationSchema: schemas.idCardPhoto,
validationSchema: customerDataSchemas.idCardPhoto,
initialValues: initialValues.idCardPhoto,
isAvailable: !_.isNil(customer.idCardPhotoPath)
},
{
fields: usSsnElements,
fields: customerDataElements.usSsn,
title: 'US SSN',
titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['usSsnOverride'])(customer),
authorize: () => updateCustomer({ usSsnOverride: OVERRIDE_AUTHORIZED }),
reject: () => updateCustomer({ usSsnOverride: OVERRIDE_REJECTED }),
save: values => editCustomer({ usSsn: values.usSsn }),
save: values => editCustomer(values),
deleteEditedData: () => deleteEditedData({ usSsn: null }),
validationSchema: schemas.usSsn,
validationSchema: customerDataSchemas.usSsn,
initialValues: initialValues.usSsn,
isAvailable: !_.isNil(customer.usSsn)
}
]
R.forEach(it => {
console.log('it', it)
customRequirements.push({
fields: [
{
@ -336,12 +252,13 @@ const CustomerData = ({
],
title: it.customInfoRequest.customRequest.name,
titleIcon: <CardIcon className={classes.cardIcon} />,
state: R.path(['override'])(it),
authorize: () =>
authorizeCustomRequest({
variables: {
customerId: it.customerId,
infoRequestId: it.customInfoRequest.id,
isAuthorized: true
override: OVERRIDE_AUTHORIZED
}
}),
reject: () =>
@ -349,7 +266,7 @@ const CustomerData = ({
variables: {
customerId: it.customerId,
infoRequestId: it.customInfoRequest.id,
isAuthorized: false
override: OVERRIDE_REJECTED
}
}),
save: values => {
@ -374,6 +291,34 @@ const CustomerData = ({
})
}, customInfoRequests)
R.forEach(it => {
customFields.push({
fields: [
{
name: it.label,
label: it.label,
value: it.value ?? '',
component: TextInput
}
],
title: it.label,
titleIcon: <EditIcon className={classes.editIcon} />,
save: values => {
updateCustomEntry({
fieldId: it.id,
value: values[it.label]
})
},
deleteEditedData: () => {},
validationSchema: Yup.object().shape({
[it.label]: Yup.string()
}),
initialValues: {
[it.label]: it.value ?? ''
}
})
}, R.path(['customFields'])(customer) ?? [])
const editableCard = (
{
title,
@ -415,6 +360,9 @@ const CustomerData = ({
<div>
<div className={classes.header}>
<H3 className={classes.title}>{'Customer data'}</H3>
{// TODO: Remove false condition for next release
false && (
<>
<FeatureButton
active={!listView}
className={classes.viewIcons}
@ -428,6 +376,8 @@ const CustomerData = ({
Icon={CustomerListViewIcon}
InverseIcon={CustomerListViewReversedIcon}
onClick={() => setListView(true)}></FeatureButton>
</>
)}
</div>
<div>
{!listView && customer && (
@ -444,9 +394,21 @@ const CustomerData = ({
</Grid>
</Grid>
)}
{customEntries && (
{!_.isEmpty(customFields) && (
<div className={classes.wrapper}>
<span className={classes.separator}>Custom data entry</span>
<Grid container>
<Grid container direction="column" item xs={6}>
{customFields.map((elem, idx) => {
return isEven(idx) ? editableCard(elem, idx) : null
})}
</Grid>
<Grid container direction="column" item xs={6}>
{customFields.map((elem, idx) => {
return !isEven(idx) ? editableCard(elem, idx) : null
})}
</Grid>
</Grid>
</div>
)}
{!R.isEmpty(customRequirements) && (

View file

@ -7,6 +7,7 @@ import React, { memo, useState } from 'react'
import { useHistory, useParams } from 'react-router-dom'
import { ActionButton } from 'src/components/buttons'
import { Switch } from 'src/components/inputs'
import { Label1, Label2 } from 'src/components/typography'
import {
OVERRIDE_AUTHORIZED,
@ -18,8 +19,9 @@ import { ReactComponent as BlockReversedIcon } from 'src/styling/icons/button/bl
import { ReactComponent as BlockIcon } from 'src/styling/icons/button/block/zodiac.svg'
import { ReactComponent as DataReversedIcon } from 'src/styling/icons/button/data/white.svg'
import { ReactComponent as DataIcon } from 'src/styling/icons/button/data/zodiac.svg'
import { ReactComponent as DiscountReversedIcon } from 'src/styling/icons/button/discount/white.svg'
import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zodiac.svg'
// TODO: Enable for next release
// import { ReactComponent as DiscountReversedIcon } from 'src/styling/icons/button/discount/white.svg'
// import { ReactComponent as Discount } from 'src/styling/icons/button/discount/zodiac.svg'
import { fromNamespace, namespaces } from 'src/utils/config'
import CustomerData from './CustomerData'
@ -66,6 +68,7 @@ const GET_CUSTOMER = gql`
lastTxClass
daysSuspended
isSuspended
isTestCustomer
customFields {
id
label
@ -95,7 +98,9 @@ const GET_CUSTOMER = gql`
}
customInfoRequests {
customerId
approved
override
overrideBy
overrideAt
customerData
customInfoRequest {
id
@ -180,12 +185,12 @@ const SET_AUTHORIZED_REQUEST = gql`
mutation setAuthorizedCustomRequest(
$customerId: ID!
$infoRequestId: ID!
$isAuthorized: Boolean!
$override: String!
) {
setAuthorizedCustomRequest(
customerId: $customerId
infoRequestId: $infoRequestId
isAuthorized: $isAuthorized
override: $override
)
}
`
@ -230,12 +235,45 @@ const EDIT_NOTE = gql`
}
`
const ENABLE_TEST_CUSTOMER = gql`
mutation enableTestCustomer($customerId: ID!) {
enableTestCustomer(customerId: $customerId)
}
`
const DISABLE_TEST_CUSTOMER = gql`
mutation disableTestCustomer($customerId: ID!) {
disableTestCustomer(customerId: $customerId)
}
`
const GET_DATA = gql`
query getData {
config
}
`
const SET_CUSTOM_ENTRY = gql`
mutation addCustomField($customerId: ID!, $label: String!, $value: String!) {
addCustomField(customerId: $customerId, label: $label, value: $value)
}
`
const EDIT_CUSTOM_ENTRY = gql`
mutation saveCustomField($customerId: ID!, $fieldId: ID!, $value: String!) {
saveCustomField(customerId: $customerId, fieldId: $fieldId, value: $value)
}
`
const GET_ACTIVE_CUSTOM_REQUESTS = gql`
query customInfoRequests($onlyEnabled: Boolean) {
customInfoRequests(onlyEnabled: $onlyEnabled) {
id
customRequest
}
}
`
const CustomerProfile = memo(() => {
const history = useHistory()
@ -255,6 +293,20 @@ const CustomerProfile = memo(() => {
const { data: configResponse, loading: configLoading } = useQuery(GET_DATA)
const { data: activeCustomRequests } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, {
variables: {
onlyEnabled: true
}
})
const [setCustomEntry] = useMutation(SET_CUSTOM_ENTRY, {
onCompleted: () => getCustomer()
})
const [editCustomEntry] = useMutation(EDIT_CUSTOM_ENTRY, {
onCompleted: () => getCustomer()
})
const [replaceCustomerPhoto] = useMutation(REPLACE_CUSTOMER_PHOTO, {
onCompleted: () => getCustomer()
})
@ -294,6 +346,37 @@ const CustomerProfile = memo(() => {
onCompleted: () => getCustomer()
})
const saveCustomEntry = it => {
setCustomEntry({
variables: {
customerId,
label: it.title,
value: it.data
}
})
setWizard(null)
}
const updateCustomEntry = it => {
editCustomEntry({
variables: {
customerId,
fieldId: it.fieldId,
value: it.value
}
})
}
const [enableTestCustomer] = useMutation(ENABLE_TEST_CUSTOMER, {
variables: { customerId },
onCompleted: () => getCustomer()
})
const [disableTestCustomer] = useMutation(DISABLE_TEST_CUSTOMER, {
variables: { customerId },
onCompleted: () => getCustomer()
})
const updateCustomer = it =>
setCustomer({
variables: {
@ -302,7 +385,7 @@ const CustomerProfile = memo(() => {
}
})
const replacePhoto = it =>
const replacePhoto = it => {
replaceCustomerPhoto({
variables: {
customerId,
@ -310,14 +393,18 @@ const CustomerProfile = memo(() => {
photoType: it.photoType
}
})
setWizard(null)
}
const editCustomer = it =>
const editCustomer = it => {
editCustomerData({
variables: {
customerId,
customerEdit: it
}
})
setWizard(null)
}
const deleteEditedData = it =>
deleteCustomerEditedData({
@ -385,6 +472,12 @@ const CustomerProfile = memo(() => {
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const customInfoRequirementOptions =
activeCustomRequests?.customInfoRequests?.map(it => ({
value: it.id,
display: it.customRequest.name
})) ?? []
const classes = useStyles()
return (
@ -411,13 +504,12 @@ const CustomerProfile = memo(() => {
<div className={classes.panels}>
<div className={classes.leftSidePanel}>
{!loading && !customerData.isAnonymous && (
<div>
<div>
<>
<CustomerSidebar
isSelected={code => code === clickedItem}
onClick={onClickSidebarItem}
/>
</div>
<div>
<Label1 className={classes.actionLabel}>Actions</Label1>
<div className={classes.actionBar}>
<ActionButton
@ -428,14 +520,14 @@ const CustomerProfile = memo(() => {
onClick={() => setWizard(true)}>
{`Manual data entry`}
</ActionButton>
<ActionButton
{/* <ActionButton
className={classes.actionButton}
color="primary"
Icon={Discount}
InverseIcon={DiscountReversedIcon}
onClick={() => {}}>
{`Add individual discount`}
</ActionButton>
</ActionButton> */}
{isSuspended && (
<ActionButton
className={classes.actionButton}
@ -487,6 +579,26 @@ const CustomerProfile = memo(() => {
</ActionButton>
</div>
</div>
<div>
<Label1 className={classes.actionLabel}>
{`Special user status`}
</Label1>
<div className={classes.actionBar}>
<div className={classes.userStatusAction}>
<Switch
checked={!!R.path(['isTestCustomer'])(customerData)}
value={!!R.path(['isTestCustomer'])(customerData)}
onChange={() =>
R.path(['isTestCustomer'])(customerData)
? disableTestCustomer()
: enableTestCustomer()
}
/>
{`Test user`}
</div>
</div>
</div>
</>
)}
</div>
<div className={classes.rightSidePanel}>
@ -522,7 +634,8 @@ const CustomerProfile = memo(() => {
editCustomer={editCustomer}
deleteEditedData={deleteEditedData}
updateCustomRequest={setCustomerCustomInfoRequest}
authorizeCustomRequest={authorizeCustomRequest}></CustomerData>
authorizeCustomRequest={authorizeCustomRequest}
updateCustomEntry={updateCustomEntry}></CustomerData>
</div>
)}
{isNotes && (
@ -544,8 +657,11 @@ const CustomerProfile = memo(() => {
{wizard && (
<Wizard
error={error?.message}
save={() => {}}
save={saveCustomEntry}
addPhoto={replacePhoto}
addCustomerData={editCustomer}
onClose={() => setWizard(null)}
customInfoRequirementOptions={customInfoRequirementOptions}
/>
)}
</div>

View file

@ -1,4 +1,4 @@
import { comet } from 'src/styling/variables'
import { comet, subheaderColor } from 'src/styling/variables'
export default {
labelLink: {
@ -34,6 +34,23 @@ export default {
width: 1100
},
leftSidePanel: {
width: 300
width: 300,
'& > *': {
marginBottom: 25
},
'& > *:last-child': {
marginBottom: 0
},
'& > *:first-child': {
marginBottom: 50
}
},
userStatusAction: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
backgroundColor: subheaderColor,
borderRadius: 8,
padding: [[0, 5]]
}
}

View file

@ -1,5 +1,5 @@
import { useQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles'
import { useQuery, useMutation } from '@apollo/react-hooks'
import { Box, makeStyles } from '@material-ui/core'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { useState } from 'react'
@ -7,6 +7,7 @@ import { useHistory } from 'react-router-dom'
import SearchBox from 'src/components/SearchBox'
import SearchFilter from 'src/components/SearchFilter'
import { Link } from 'src/components/buttons'
import TitleSection from 'src/components/layout/TitleSection'
import baseStyles from 'src/pages/Logs.styles'
import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
@ -14,6 +15,7 @@ import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-ou
import { fromNamespace, namespaces } from 'src/utils/config'
import CustomersList from './CustomersList'
import CreateCustomerModal from './components/CreateCustomerModal'
const GET_CUSTOMER_FILTERS = gql`
query filters {
@ -43,14 +45,35 @@ const GET_CUSTOMERS = gql`
lastTxFiatCode
lastTxClass
authorizedOverride
frontCameraPath
frontCameraOverride
idCardPhotoPath
idCardPhotoOverride
idCardData
idCardDataOverride
usSsn
usSsnOverride
sanctions
sanctionsOverride
daysSuspended
isSuspended
}
}
`
const CREATE_CUSTOMER = gql`
mutation createCustomer($phoneNumber: String) {
createCustomer(phoneNumber: $phoneNumber) {
phone
}
}
`
const useBaseStyles = makeStyles(baseStyles)
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const Customers = () => {
const baseStyles = useBaseStyles()
const history = useHistory()
@ -61,6 +84,7 @@ const Customers = () => {
const [filteredCustomers, setFilteredCustomers] = useState([])
const [variables, setVariables] = useState({})
const [filters, setFilters] = useState([])
const [showCreationModal, setShowCreationModal] = useState(false)
const {
data: customersResponse,
@ -75,19 +99,25 @@ const Customers = () => {
GET_CUSTOMER_FILTERS
)
const [createNewCustomer] = useMutation(CREATE_CUSTOMER, {
onCompleted: () => setShowCreationModal(false),
refetchQueries: () => [
{
query: GET_CUSTOMERS,
variables
}
]
})
const configData = R.path(['config'])(customersResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const customersData = R.sortWith([R.descend(R.prop('lastActive'))])(
filteredCustomers ?? []
)
const triggers = configData && fromNamespace(namespaces.TRIGGERS, configData)
const customersData = R.sortWith([
R.descend(it => new Date(R.prop('lastActive', it) ?? '0'))
])(filteredCustomers ?? [])
const onFilterChange = filters => {
const filtersObject = R.compose(
R.mergeAll,
R.map(f => ({
[f.type]: f.value
}))
)(filters)
const filtersObject = getFiltersObj(filters)
setFilters(filters)
@ -101,10 +131,38 @@ const Customers = () => {
refetch && refetch()
}
const onFilterDelete = filter =>
setFilters(
R.filter(f => !R.whereEq(R.pick(['type', 'value'], f), filter))(filters)
)
const onFilterDelete = filter => {
const newFilters = R.filter(
f => !R.whereEq(R.pick(['type', 'value'], f), filter)
)(filters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
phone: filtersObject.phone,
name: filtersObject.name,
address: filtersObject.address,
id: filtersObject.id
})
refetch && refetch()
}
const filterOptions = R.path(['customerFilters'])(filtersResponse)
@ -113,7 +171,7 @@ const Customers = () => {
<TitleSection
title="Customers"
appendix={
<div>
<div className={baseStyles.buttonsWrapper}>
<SearchBox
loading={loadingFilters}
filters={filters}
@ -123,7 +181,13 @@ const Customers = () => {
/>
</div>
}
appendixClassName={baseStyles.buttonsWrapper}
appendixRight={
<Box display="flex">
<Link color="primary" onClick={() => setShowCreationModal(true)}>
Add new user
</Link>
</Box>
}
labels={[
{ label: 'Cash-in', icon: <TxInIcon /> },
{ label: 'Cash-out', icon: <TxOutIcon /> }
@ -131,9 +195,10 @@ const Customers = () => {
/>
{filters.length > 0 && (
<SearchFilter
entries={customersData.length}
filters={filters}
onFilterDelete={onFilterDelete}
setFilters={setFilters}
deleteAllFilters={deleteAllFilters}
/>
)}
<CustomersList
@ -141,6 +206,13 @@ const Customers = () => {
locale={locale}
onClick={handleCustomerClicked}
loading={customerLoading}
triggers={triggers}
/>
<CreateCustomerModal
showModal={showCreationModal}
handleClose={() => setShowCreationModal(false)}
locale={locale}
onSubmit={createNewCustomer}
/>
</>
)

View file

@ -13,42 +13,42 @@ import { getAuthorizedStatus, getFormattedPhone, getName } from './helper'
const useStyles = makeStyles(styles)
const CustomersList = ({ data, locale, onClick, loading }) => {
const CustomersList = ({ data, locale, onClick, loading, triggers }) => {
const classes = useStyles()
const elements = [
{
header: 'Phone',
width: 175,
width: 199,
view: it => getFormattedPhone(it.phone, locale.country)
},
{
header: 'Name',
width: 247,
width: 241,
view: getName
},
{
header: 'Total TXs',
width: 130,
width: 126,
textAlign: 'right',
view: it => `${Number.parseInt(it.totalTxs)}`
},
{
header: 'Total spent',
width: 155,
width: 152,
textAlign: 'right',
view: it =>
`${Number.parseFloat(it.totalSpent)} ${it.lastTxFiatCode ?? ''}`
},
{
header: 'Last active',
width: 137,
width: 133,
view: it =>
(it.lastActive && format('yyyy-MM-dd', new Date(it.lastActive))) ?? ''
},
{
header: 'Last transaction',
width: 165,
width: 161,
textAlign: 'right',
view: it => {
const hasLastTx = !R.isNil(it.lastTxFiatCode)
@ -66,7 +66,7 @@ const CustomersList = ({ data, locale, onClick, loading }) => {
{
header: 'Status',
width: 191,
view: it => <MainStatus statuses={[getAuthorizedStatus(it)]} />
view: it => <MainStatus statuses={[getAuthorizedStatus(it, triggers)]} />
}
]

View file

@ -9,7 +9,14 @@ import Stepper from 'src/components/Stepper'
import { Button } from 'src/components/buttons'
import { comet } from 'src/styling/variables'
import { entryType, customElements } from './helper'
import {
entryType,
customElements,
requirementElements,
formatDates,
REQUIREMENT,
ID_CARD_DATA
} from './helper'
const LAST_STEP = 2
@ -41,23 +48,40 @@ const styles = {
margin: [[0, 4, 0, 2]],
borderBottom: `1px solid ${comet}`,
display: 'inline-block'
},
dropdownField: {
marginTop: 16,
minWidth: 155
}
}
const useStyles = makeStyles(styles)
const getStep = (step, selectedValues) => {
const elements =
selectedValues?.entryType === REQUIREMENT &&
!R.isNil(selectedValues?.requirement)
? requirementElements[selectedValues?.requirement]
: customElements[selectedValues?.dataType]
switch (step) {
case 1:
return entryType
case 2:
return customElements[selectedValues?.dataType]
return elements
default:
return Fragment
}
}
const Wizard = ({ onClose, save, error }) => {
const Wizard = ({
onClose,
save,
error,
customInfoRequirementOptions,
addCustomerData,
addPhoto
}) => {
const classes = useStyles()
const [selectedValues, setSelectedValues] = useState(null)
@ -66,6 +90,10 @@ const Wizard = ({ onClose, save, error }) => {
step: 1
})
const isIdCardData = values => values?.requirement === ID_CARD_DATA
const formatCustomerData = (it, newConfig) =>
isIdCardData(newConfig) ? { [newConfig.requirement]: formatDates(it) } : it
const isLastStep = step === LAST_STEP
const stepOptions = getStep(step, selectedValues)
@ -74,7 +102,23 @@ const Wizard = ({ onClose, save, error }) => {
setSelectedValues(newConfig)
if (isLastStep) {
switch (stepOptions.saveType) {
case 'customerData':
return addCustomerData(formatCustomerData(it, newConfig))
case 'customerDataUpload':
return addPhoto({
newPhoto: R.head(R.values(it)),
photoType: R.head(R.keys(it))
})
case 'customEntry':
return save(newConfig)
case 'customInfoRequirement':
return
// case 'customerEntryUpload':
// break
default:
break
}
}
setState({
@ -106,6 +150,7 @@ const Wizard = ({ onClose, save, error }) => {
<Form className={classes.form}>
<stepOptions.Component
selectedValues={selectedValues}
customInfoRequirementOptions={customInfoRequirementOptions}
{...stepOptions.props}
/>
<div className={classes.submit}>

View file

@ -0,0 +1,139 @@
import { makeStyles } from '@material-ui/core/styles'
import { Field, Form, Formik } from 'formik'
import { PhoneNumberFormat, PhoneNumberUtil } from 'google-libphonenumber'
import * as R from 'ramda'
import React from 'react'
import * as Yup from 'yup'
import ErrorMessage from 'src/components/ErrorMessage'
import Modal from 'src/components/Modal'
import { Button } from 'src/components/buttons'
import { TextInput } from 'src/components/inputs/formik'
import { H1 } from 'src/components/typography'
import { spacer, primaryColor, fontPrimary } from 'src/styling/variables'
const styles = {
modalTitle: {
marginTop: -5,
color: primaryColor,
fontFamily: fontPrimary
},
footer: {
display: 'flex',
flexDirection: 'row',
margin: [['auto', 0, spacer * 3, 0]]
},
form: {
display: 'flex',
flexDirection: 'column',
height: '100%'
},
submit: {
margin: [['auto', 0, 0, 'auto']]
}
}
const pnUtilInstance = PhoneNumberUtil.getInstance()
const getValidationSchema = countryCodes =>
Yup.object().shape({
phoneNumber: Yup.string()
.required('A phone number is required')
.test('is-valid-number', 'That is not a valid phone number', value => {
try {
const validMap = R.map(it => {
const number = pnUtilInstance.parseAndKeepRawInput(value, it)
return pnUtilInstance.isValidNumber(number)
}, countryCodes)
return R.any(it => it === true, validMap)
} catch (e) {}
})
.trim()
})
const formatPhoneNumber = (countryCodes, numberStr) => {
const matchedCountry = R.find(it => {
const number = pnUtilInstance.parseAndKeepRawInput(numberStr, it)
return pnUtilInstance.isValidNumber(number)
}, countryCodes)
const matchedNumber = pnUtilInstance.parseAndKeepRawInput(
numberStr,
matchedCountry
)
return pnUtilInstance.format(matchedNumber, PhoneNumberFormat.E164)
}
const initialValues = {
phoneNumber: ''
}
const useStyles = makeStyles(styles)
const getErrorMsg = (formikErrors, formikTouched) => {
if (!formikErrors || !formikTouched) return null
if (formikErrors.phoneNumber && formikTouched.phoneNumber)
return formikErrors.phoneNumber
return null
}
const CreateCustomerModal = ({ showModal, handleClose, onSubmit, locale }) => {
const classes = useStyles()
const possibleCountries = R.append(
locale?.country,
R.map(it => it.country, locale?.overrides ?? [])
)
return (
<Modal
closeOnBackdropClick={true}
width={600}
height={300}
handleClose={handleClose}
open={showModal}>
<Formik
validationSchema={getValidationSchema(possibleCountries)}
initialValues={initialValues}
validateOnChange={false}
onSubmit={values => {
onSubmit({
variables: {
phoneNumber: formatPhoneNumber(
possibleCountries,
values.phoneNumber
)
}
})
}}>
{({ errors, touched }) => (
<Form id="customer-registration-form" className={classes.form}>
<H1 className={classes.modalTitle}>Create new customer</H1>
<Field
component={TextInput}
name="phoneNumber"
width={338}
autoFocus
label="Phone number"
/>
<div className={classes.footer}>
{getErrorMsg(errors, touched) && (
<ErrorMessage>{getErrorMsg(errors, touched)}</ErrorMessage>
)}
<Button
type="submit"
form="customer-registration-form"
className={classes.submit}>
Finish
</Button>
</div>
</Form>
)}
</Formik>
</Modal>
)
}
export default CreateCustomerModal

View file

@ -53,12 +53,12 @@ const SET_AUTHORIZED_REQUEST = gql`
mutation setAuthorizedCustomRequest(
$customerId: ID!
$infoRequestId: ID!
$isAuthorized: Boolean!
$override: String!
) {
setAuthorizedCustomRequest(
customerId: $customerId
infoRequestId: $infoRequestId
isAuthorized: $isAuthorized
override: $override
)
}
`

View file

@ -11,8 +11,7 @@ export default {
backgroundColor: sidebarColor,
width: 219,
flexDirection: 'column',
borderRadius: 5,
marginBottom: 50
borderRadius: 5
},
link: {
alignItems: 'center',

View file

@ -150,7 +150,7 @@ const EditableCard = ({
<H3 className={classes.cardTitle}>{title}</H3>
<Tooltip width={304}></Tooltip>
</div>
{state && (
{state && authorize && (
<div className={classnames(label1ClassNames)}>
<MainStatus statuses={[authorized]} />
</div>
@ -207,6 +207,8 @@ const EditableCard = ({
<div className={classes.edit}>
{!editing && (
<div className={classes.editButton}>
{// TODO: Remove false condition for next release
false && (
<div className={classes.deleteButton}>
<ActionButton
color="primary"
@ -217,7 +219,7 @@ const EditableCard = ({
{`Delete`}
</ActionButton>
</div>
)}
<ActionButton
color="primary"
Icon={EditIcon}
@ -279,7 +281,7 @@ const EditableCard = ({
Cancel
</ActionButton>
</div>
{authorized.label !== 'Accepted' && (
{authorize && authorized.label !== 'Accepted' && (
<div className={classes.button}>
<ActionButton
color="spring"
@ -291,7 +293,7 @@ const EditableCard = ({
</ActionButton>
</div>
)}
{authorized.label !== 'Rejected' && (
{authorize && authorized.label !== 'Rejected' && (
<ActionButton
color="tomato"
type="button"

View file

@ -12,7 +12,6 @@ import { offColor, subheaderColor } from 'src/styling/variables'
const useStyles = makeStyles({
box: {
boxSizing: 'border-box',
marginTop: 40,
width: 450,
height: 120,
borderStyle: 'dashed',
@ -32,6 +31,7 @@ const useStyles = makeStyles({
display: 'flex'
},
board: {
marginTop: 40,
width: 450,
height: 120
},
@ -48,12 +48,15 @@ const Upload = ({ type }) => {
const { setFieldValue } = useFormikContext()
const IMAGE = 'image'
const isImage = type === IMAGE
const ID_CARD_PHOTO = 'idCardPhoto'
const FRONT_CAMERA = 'frontCamera'
const isImage =
type === IMAGE || type === FRONT_CAMERA || type === ID_CARD_PHOTO
const onDrop = useCallback(
acceptedData => {
// TODO: attach the uploaded data to the form as well
setFieldValue(type, R.head(acceptedData).name)
setFieldValue(type, R.head(acceptedData))
setData({
preview: isImage
@ -84,12 +87,12 @@ const Upload = ({ type }) => {
</div>
</div>
)}
{!R.isEmpty(data) && type === IMAGE && (
{!R.isEmpty(data) && isImage && (
<div key={data.name}>
<img src={data.preview} className={classes.box} alt=""></img>
</div>
)}
{!R.isEmpty(data) && type !== IMAGE && (
{!R.isEmpty(data) && !isImage && (
<div className={classes.box}>
<H3 className={classes.uploadContent}>{data.preview}</H3>
</div>

View file

@ -1,13 +1,19 @@
import { makeStyles, Box } from '@material-ui/core'
import classnames from 'classnames'
import { parse, isValid, format } from 'date-fns/fp'
import { Field, useFormikContext } from 'formik'
import { parsePhoneNumberFromString } from 'libphonenumber-js'
import * as R from 'ramda'
import * as Yup from 'yup'
import { RadioGroup, TextInput } from 'src/components/inputs/formik'
import {
RadioGroup,
TextInput,
Autocomplete
} from 'src/components/inputs/formik'
import { H4 } from 'src/components/typography'
import { errorColor } from 'src/styling/variables'
import { MANUAL } from 'src/utils/constants'
import { Upload } from './components'
@ -34,19 +40,63 @@ const useStyles = makeStyles({
specialGrid: {
display: 'grid',
gridTemplateColumns: [[182, 162, 141]]
},
picker: {
width: 150
},
field: {
'& > *:last-child': {
marginBottom: 24
}
}
})
const CUSTOMER_BLOCKED = 'blocked'
const CUSTOM = 'custom'
const REQUIREMENT = 'requirement'
const ID_CARD_DATA = 'idCardData'
const getAuthorizedStatus = it =>
it.authorizedOverride === CUSTOMER_BLOCKED
? { label: 'Blocked', type: 'error' }
: it.isSuspended
? it.daysSuspended > 0
const getAuthorizedStatus = (it, triggers) => {
const fields = [
'frontCameraPath',
'idCardData',
'idCardPhotoPath',
'usSsn',
'sanctions'
]
const isManualField = fieldName => {
const manualOverrides = R.filter(
ite => R.equals(R.toLower(ite.automation), MANUAL),
triggers?.overrides ?? []
)
return (
!!R.find(ite => R.equals(ite.requirement, fieldName), manualOverrides) ||
R.equals(triggers.automation, MANUAL)
)
}
const pendingFieldStatus = R.map(
ite =>
!R.isNil(it[`${ite}`])
? isManualField(ite)
? R.equals(it[`${ite}Override`], 'automatic')
: false
: false,
fields
)
if (it.authorizedOverride === CUSTOMER_BLOCKED)
return { label: 'Blocked', type: 'error' }
if (it.isSuspended)
return it.daysSuspended > 0
? { label: `${it.daysSuspended} day suspension`, type: 'warning' }
: { label: `< 1 day suspension`, type: 'warning' }
: { label: 'Authorized', type: 'success' }
if (R.any(ite => ite === true, pendingFieldStatus))
return { label: 'Pending', type: 'warning' }
return { label: 'Authorized', type: 'success' }
}
const getFormattedPhone = (phone, country) => {
const phoneNumber =
@ -63,34 +113,46 @@ const getName = it => {
) ?? ''}`.trim()
}
// Manual Entry Wizard
const entryOptions = [
{ display: 'Custom entry', code: 'custom' },
{ display: 'Populate existing requirement', code: 'requirement' }
]
const dataOptions = [
{ display: 'Text', code: 'text' },
{ display: 'File', code: 'file' },
{ display: 'Image', code: 'image' }
{ display: 'Text', code: 'text' }
// TODO: Requires backend modifications to support File and Image
// { display: 'File', code: 'file' },
// { display: 'Image', code: 'image' }
]
const requirementOptions = [
{ display: 'Birthdate', code: 'birthdate' },
{ display: 'ID card image', code: 'idCardPhoto' },
{ display: 'ID data', code: 'idCardData' },
{ display: 'Customer camera', code: 'facephoto' },
{ display: 'US SSN', code: 'usSsn' }
{ display: 'US SSN', code: 'usSsn' },
{ display: 'Customer camera', code: 'frontCamera' }
]
const customTextOptions = [
{ display: 'Data entry title', code: 'title' },
{ display: 'Data entry', code: 'data' }
{ label: 'Data entry title', name: 'title' },
{ label: 'Data entry', name: 'data' }
]
const customUploadOptions = [{ display: 'Data entry title', code: 'title' }]
const customUploadOptions = [{ label: 'Data entry title', name: 'title' }]
const entryTypeSchema = Yup.object().shape({
entryType: Yup.string().required()
const entryTypeSchema = Yup.lazy(values => {
if (values.entryType === 'custom') {
return Yup.object().shape({
entryType: Yup.string().required(),
dataType: Yup.string().required()
})
} else if (values.entryType === 'requirement') {
return Yup.object().shape({
entryType: Yup.string().required(),
requirement: Yup.string().required()
})
}
})
const customFileSchema = Yup.object().shape({
@ -108,13 +170,18 @@ const customTextSchema = Yup.object().shape({
data: Yup.string().required()
})
const EntryType = () => {
const updateRequirementOptions = it => [
{
display: 'Custom information requirement',
code: 'custom'
},
...it
]
const EntryType = ({ customInfoRequirementOptions }) => {
const classes = useStyles()
const { values } = useFormikContext()
const CUSTOM = 'custom'
const REQUIREMENT = 'requirement'
const displayCustomOptions = values.entryType === CUSTOM
const displayRequirementOptions = values.entryType === REQUIREMENT
@ -154,7 +221,13 @@ const EntryType = () => {
<Field
component={RadioGroup}
name="requirement"
options={requirementOptions}
options={
requirementOptions
// TODO: Enable once custom info requirement manual entry is finished
// !R.isEmpty(customInfoRequirementOptions)
// ? updateRequirementOptions(requirementOptions)
// : requirementOptions
}
labelClassName={classes.label}
radioClassName={classes.radio}
className={classnames(classes.radioGroup, classes.specialGrid)}
@ -165,18 +238,73 @@ const EntryType = () => {
)
}
const CustomData = ({ selectedValues }) => {
const ManualDataEntry = ({ selectedValues, customInfoRequirementOptions }) => {
const classes = useStyles()
const typeOfEntrySelected = selectedValues?.entryType
const dataTypeSelected = selectedValues?.dataType
const upload = dataTypeSelected === 'file' || dataTypeSelected === 'image'
const requirementSelected = selectedValues?.requirement
const displayRequirements = typeOfEntrySelected === 'requirement'
const isCustomInfoRequirement = requirementSelected === CUSTOM
const updatedRequirementOptions = !R.isEmpty(customInfoRequirementOptions)
? updateRequirementOptions(requirementOptions)
: requirementOptions
const requirementName = displayRequirements
? R.find(R.propEq('code', requirementSelected))(updatedRequirementOptions)
.display
: ''
const title = displayRequirements
? `Requirement ${requirementName}`
: `Custom ${dataTypeSelected} entry`
const elements = displayRequirements
? requirementElements[requirementSelected]
: customElements[dataTypeSelected]
const upload = displayRequirements
? requirementSelected === 'idCardPhoto' ||
requirementSelected === 'frontCamera'
: dataTypeSelected === 'file' || dataTypeSelected === 'image'
return (
<>
<Box display="flex" alignItems="center">
<H4>{`Custom ${dataTypeSelected} entry`}</H4>
<H4>{title}</H4>
</Box>
{customElements[dataTypeSelected].options.map(({ display, code }) => (
<Field name={code} label={display} component={TextInput} width={390} />
{isCustomInfoRequirement && (
<Autocomplete
fullWidth
label={`Available requests`}
className={classes.picker}
getOptionSelected={R.eqProps('code')}
labelProp={'display'}
options={customInfoRequirementOptions}
onChange={(evt, it) => {}}
/>
)}
<div className={classes.field}>
{!upload &&
!isCustomInfoRequirement &&
elements.options.map(({ label, name }) => (
<Field
name={name}
label={label}
component={TextInput}
width={390}
/>
))}
{upload && <Upload type={dataTypeSelected}></Upload>}
</div>
{upload && (
<Upload
type={
displayRequirements ? requirementSelected : dataTypeSelected
}></Upload>
)}
</>
)
}
@ -185,20 +313,23 @@ const customElements = {
text: {
schema: customTextSchema,
options: customTextOptions,
Component: CustomData,
initialValues: { data: '', title: '' }
Component: ManualDataEntry,
initialValues: { data: '', title: '' },
saveType: 'customEntry'
},
file: {
schema: customFileSchema,
options: customUploadOptions,
Component: CustomData,
initialValues: { file: '', title: '' }
Component: ManualDataEntry,
initialValues: { file: null, title: '' },
saveType: 'customEntryUpload'
},
image: {
schema: customImageSchema,
options: customUploadOptions,
Component: CustomData,
initialValues: { image: '', title: '' }
Component: ManualDataEntry,
initialValues: { image: null, title: '' },
saveType: 'customEntryUpload'
}
}
@ -209,6 +340,142 @@ const entryType = {
initialValues: { entryType: '' }
}
// Customer data
const customerDataElements = {
idCardData: [
{
name: 'firstName',
label: 'First name',
component: TextInput
},
{
name: 'documentNumber',
label: 'ID number',
component: TextInput
},
{
name: 'dateOfBirth',
label: 'Birthdate',
component: TextInput
},
{
name: 'gender',
label: 'Gender',
component: TextInput
},
{
name: 'lastName',
label: 'Last name',
component: TextInput
},
{
name: 'expirationDate',
label: 'Expiration Date',
component: TextInput
},
{
name: 'country',
label: 'Country',
component: TextInput
}
],
usSsn: [
{
name: 'usSsn',
label: 'US SSN',
component: TextInput,
size: 190
}
],
idCardPhoto: [{ name: 'idCardPhoto' }],
frontCamera: [{ name: 'frontCamera' }]
}
const customerDataSchemas = {
idCardData: Yup.object().shape({
firstName: Yup.string().required(),
lastName: Yup.string().required(),
documentNumber: Yup.string().required(),
dateOfBirth: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required(),
gender: Yup.string().required(),
country: Yup.string().required(),
expirationDate: Yup.string()
.test({
test: val => isValid(parse(new Date(), 'yyyy-MM-dd', val))
})
.required()
}),
usSsn: Yup.object().shape({
usSsn: Yup.string().required()
}),
idCardPhoto: Yup.object().shape({
idCardPhoto: Yup.mixed().required()
}),
frontCamera: Yup.object().shape({
frontCamera: Yup.mixed().required()
})
}
const requirementElements = {
idCardData: {
schema: customerDataSchemas.idCardData,
options: customerDataElements.idCardData,
Component: ManualDataEntry,
initialValues: {
firstName: '',
lastName: '',
documentNumber: '',
dateOfBirth: '',
gender: '',
country: '',
expirationDate: ''
},
saveType: 'customerData'
},
usSsn: {
schema: customerDataSchemas.usSsn,
options: customerDataElements.usSsn,
Component: ManualDataEntry,
initialValues: { usSsn: '' },
saveType: 'customerData'
},
idCardPhoto: {
schema: customerDataSchemas.idCardPhoto,
options: customerDataElements.idCardPhoto,
Component: ManualDataEntry,
initialValues: { idCardPhoto: null },
saveType: 'customerDataUpload'
},
frontCamera: {
schema: customerDataSchemas.frontCamera,
options: customerDataElements.frontCamera,
Component: ManualDataEntry,
initialValues: { frontCamera: null },
saveType: 'customerDataUpload'
},
custom: {
// schema: customerDataSchemas.customInfoRequirement,
Component: ManualDataEntry,
initialValues: { customInfoRequirement: null },
saveType: 'customInfoRequirement'
}
}
const formatDates = values => {
R.map(
elem =>
(values[elem] = format('yyyyMMdd')(
parse(new Date(), 'yyyy-MM-dd', values[elem])
))
)(['dateOfBirth', 'expirationDate'])
return values
}
const mapKeys = pair => {
const [key, value] = pair
if (key === 'txCustomerPhotoPath' || key === 'frontCameraPath') {
@ -245,5 +512,12 @@ export {
getName,
entryType,
customElements,
formatPhotosData
requirementElements,
formatPhotosData,
customerDataElements,
customerDataSchemas,
formatDates,
REQUIREMENT,
CUSTOM,
ID_CARD_DATA
}

View file

@ -62,7 +62,7 @@ const Graph = ({ data, timeFrame, timezone }) => {
[]
)
const filterDay = useMemo(
const filterDay = useCallback(
x => (timeFrame === 'day' ? x.getUTCHours() === 0 : x.getUTCDate() === 1),
[timeFrame]
)

View file

@ -52,8 +52,8 @@ const ranges = {
}
const GET_DATA = gql`
query getData {
transactions {
query getData($excludeTestingCustomers: Boolean) {
transactions(excludeTestingCustomers: $excludeTestingCustomers) {
fiatCode
fiat
cashInFee
@ -78,7 +78,9 @@ const reducer = (acc, it) =>
const SystemPerformance = () => {
const classes = useStyles()
const [selectedRange, setSelectedRange] = useState('Day')
const { data, loading } = useQuery(GET_DATA)
const { data, loading } = useQuery(GET_DATA, {
variables: { excludeTestingCustomers: true }
})
const fiatLocale = fromNamespace('locale')(data?.config).fiatCurrency
const timezone = fromNamespace('locale')(data?.config).timezone

View file

@ -128,9 +128,8 @@ const MachinesTable = ({ machines = [], numToRender }) => {
onClick={() => redirect(machine)}
className={classnames(classes.row)}
key={machine.deviceId + idx}>
<StyledCell
align="left"
className={classes.machineNameWrapper}>
<StyledCell align="left">
<div className={classes.machineNameWrapper}>
<TL2>{machine.name}</TL2>
<MachineLinkIcon
className={classnames(
@ -139,6 +138,7 @@ const MachinesTable = ({ machines = [], numToRender }) => {
)}
onClick={() => redirect(machine)}
/>
</div>
</StyledCell>
<StyledCell>
<Status status={machine.statuses[0]} />

View file

@ -145,7 +145,7 @@ const Transactions = ({ id }) => {
width: 140
},
{
header: 'Date (UTC)',
header: 'Date',
view: it => formatDate(it.created, timezone, 'yyyy-MM-dd'),
textAlign: 'left',
size: 'sm',

View file

@ -6,8 +6,8 @@ import NavigateNextIcon from '@material-ui/icons/NavigateNext'
import classnames from 'classnames'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React from 'react'
import { Link, useLocation } from 'react-router-dom'
import React, { useState } from 'react'
import { Link, useLocation, useHistory } from 'react-router-dom'
import { TL1, TL2, Label3 } from 'src/components/typography'
@ -58,18 +58,42 @@ const GET_INFO = gql`
const getMachineID = path => path.slice(path.lastIndexOf('/') + 1)
const Machines = () => {
const MachineRoute = () => {
const location = useLocation()
const { data, loading, refetch } = useQuery(GET_INFO, {
const history = useHistory()
const id = getMachineID(location.pathname)
const [loading, setLoading] = useState(true)
const { data, refetch } = useQuery(GET_INFO, {
onCompleted: data => {
if (data.machine === null)
return history.push('/maintenance/machine-status')
setLoading(false)
},
variables: {
deviceId: getMachineID(location.pathname),
deviceId: id
},
billFilters: {
deviceId: getMachineID(location.pathname),
deviceId: id,
batch: 'none'
}
}
})
const reload = () => {
return history.push(location.pathname)
}
return (
!loading && (
<Machines data={data} refetch={refetch} reload={reload}></Machines>
)
)
}
const Machines = ({ data, refetch, reload }) => {
const classes = useStyles()
const timezone = R.path(['config', 'locale_timezone'], data) ?? {}
@ -82,7 +106,6 @@ const Machines = () => {
const machineID = R.path(['deviceId'])(machine) ?? null
return (
!loading && (
<Grid container className={classes.grid}>
<Grid item xs={3}>
<Grid item xs={12}>
@ -97,7 +120,7 @@ const Machines = () => {
{machineName}
</TL2>
</Breadcrumbs>
<Overview data={machine} onActionSuccess={refetch} />
<Overview data={machine} onActionSuccess={reload} />
</div>
</Grid>
</Grid>
@ -129,7 +152,6 @@ const Machines = () => {
</Grid>
</Grid>
)
)
}
export default Machines
export default MachineRoute

View file

@ -28,6 +28,30 @@ import Wizard from './Wizard/Wizard'
const useStyles = makeStyles(styles)
const widthsByNumberOfCassettes = {
2: {
machine: 250,
cashbox: 260,
cassette: 300,
cassetteGraph: 80,
editWidth: 90
},
3: {
machine: 220,
cashbox: 215,
cassette: 225,
cassetteGraph: 60,
editWidth: 90
},
4: {
machine: 190,
cashbox: 180,
cassette: 185,
cassetteGraph: 50,
editWidth: 90
}
}
const ValidationSchema = Yup.object().shape({
name: Yup.string().required(),
cashbox: Yup.number()
@ -201,14 +225,14 @@ const CashCassettes = () => {
{
name: 'name',
header: 'Machine',
width: 184,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.machine,
view: name => <>{name}</>,
input: ({ field: { value: name } }) => <>{name}</>
},
{
name: 'cashbox',
header: 'Cash box',
width: maxNumberOfCassettes > 2 ? 140 : 280,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cashbox,
view: (value, { id }) => (
<CashIn
currency={{ code: fiatCurrency }}
@ -229,7 +253,7 @@ const CashCassettes = () => {
elements.push({
name: `cassette${it}`,
header: `Cassette ${it}`,
width: (maxNumberOfCassettes > 2 ? 560 : 650) / maxNumberOfCassettes,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassette,
stripe: true,
doubleHeader: 'Cash-out',
view: (value, { id }) => (
@ -238,7 +262,9 @@ const CashCassettes = () => {
denomination={getCashoutSettings(id)?.[`cassette${it}`]}
currency={{ code: fiatCurrency }}
notes={value}
width={50}
width={
widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph
}
threshold={
fillingPercentageSettings[`fillingPercentageCassette${it}`]
}
@ -248,7 +274,7 @@ const CashCassettes = () => {
input: CashCassetteInput,
inputProps: {
decimalPlaces: 0,
width: 50,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.cassetteGraph,
inputClassName: classes.cashbox
}
})
@ -260,7 +286,8 @@ const CashCassettes = () => {
elements.push({
name: 'edit',
header: 'Edit',
width: 87,
width: widthsByNumberOfCassettes[maxNumberOfCassettes]?.editWidth,
textAlign: 'center',
view: (value, { id }) => {
return (
<IconButton
@ -279,12 +306,14 @@ const CashCassettes = () => {
<>
<TitleSection
title="Cash Boxes & Cassettes"
button={{
buttons={[
{
text: 'Cash box history',
icon: HistoryIcon,
inverseIcon: ReverseHistoryIcon,
toggle: setShowHistory
}}
}
]}
iconClassName={classes.listViewButton}
className={classes.tableWidth}>
{!showHistory && (

View file

@ -28,7 +28,6 @@ const GET_BATCHES = gql`
fiat
deviceId
created
cashbox
}
}
}

View file

@ -274,6 +274,7 @@ const WizardStep = ({
placeholder={originalCassetteCount.toString()}
name={cassetteField}
className={classes.cashboxBills}
autoFocus
/>
<P>
{cassetteDenomination} {fiatCurrency} bills loaded

View file

@ -79,7 +79,7 @@ const SessionManagement = () => {
}
},
{
header: 'Expiration date (UTC)',
header: 'Expiration date',
width: 290,
textAlign: 'right',
size: 'sm',

View file

@ -1,7 +1,7 @@
import { useLazyQuery, useMutation } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core'
import BigNumber from 'bignumber.js'
import { add, differenceInYears, format, sub } from 'date-fns/fp'
import { add, differenceInYears, format, sub, parse } from 'date-fns/fp'
import FileSaver from 'file-saver'
import gql from 'graphql-tag'
import JSZip from 'jszip'
@ -116,17 +116,27 @@ const DetailsRow = ({ it: tx, timezone }) => {
const exchangeRate = BigNumber(fiat / crypto).toFormat(2)
const displayExRate = `1 ${tx.cryptoCode} = ${exchangeRate} ${tx.fiatCode}`
const parseDateString = parse(new Date(), 'yyyyMMdd')
const customer = tx.customerIdCardData && {
name: `${onlyFirstToUpper(
tx.customerIdCardData.firstName
)} ${onlyFirstToUpper(tx.customerIdCardData.lastName)}`,
age: differenceInYears(tx.customerIdCardData.dateOfBirth, new Date()),
age:
(tx.customerIdCardData.dateOfBirth &&
differenceInYears(
parseDateString(tx.customerIdCardData.dateOfBirth),
new Date()
)) ??
'',
country: tx.customerIdCardData.country,
idCardNumber: tx.customerIdCardData.documentNumber,
idCardExpirationDate: format(
'dd-MM-yyyy',
tx.customerIdCardData.expirationDate
)
idCardExpirationDate:
(tx.customerIdCardData.expirationDate &&
format('yyyy-MM-dd')(
parseDateString(tx.customerIdCardData.expirationDate)
)) ??
''
}
const from = sub({ minutes: MINUTES_OFFSET }, tx.created)

View file

@ -40,6 +40,7 @@ const GET_TRANSACTIONS_CSV = gql`
$from: Date
$until: Date
$timezone: String
$excludeTestingCustomers: Boolean
) {
transactionsCsv(
simplified: $simplified
@ -47,6 +48,7 @@ const GET_TRANSACTIONS_CSV = gql`
from: $from
until: $until
timezone: $timezone
excludeTestingCustomers: $excludeTestingCustomers
)
}
`
@ -222,7 +224,7 @@ const Transactions = () => {
width: 140
},
{
header: 'Date (UTC)',
header: 'Date',
view: it =>
timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm:ss'),
textAlign: 'right',

View file

@ -75,6 +75,7 @@ const TriggerView = ({
error={error?.message}
save={add}
onClose={toggleWizard}
customInfoRequests={customInfoRequests}
/>
)}
{R.isEmpty(triggers) && (

View file

@ -48,8 +48,10 @@ const GET_CUSTOM_REQUESTS = gql`
const Triggers = () => {
const classes = useStyles()
const [wizardType, setWizard] = useState(false)
const { data, loading } = useQuery(GET_CONFIG)
const { data: customInfoReqData } = useQuery(GET_CUSTOM_REQUESTS)
const { data, loading: configLoading } = useQuery(GET_CONFIG)
const { data: customInfoReqData, loading: customInfoLoading } = useQuery(
GET_CUSTOM_REQUESTS
)
const [error, setError] = useState(null)
const [subMenu, setSubMenu] = useState(false)
@ -94,6 +96,8 @@ const Triggers = () => {
return setWizard(wizardName)
}
const loading = configLoading || customInfoLoading
return (
<>
<TitleSection
@ -178,7 +182,7 @@ const Triggers = () => {
showWizard={wizardType === 'newTrigger'}
config={data?.config ?? {}}
toggleWizard={toggleWizard('newTrigger')}
customInfoRequests={customInfoRequests}
customInfoRequests={enabledCustomInfoRequests}
/>
)}
{!loading && subMenu === 'advancedSettings' && (

View file

@ -48,14 +48,14 @@ const styles = {
const useStyles = makeStyles(styles)
const getStep = (step, currency) => {
const getStep = (step, currency, customInfoRequests) => {
switch (step) {
// case 1:
// return txDirection
case 1:
return type(currency)
case 2:
return requirements
return requirements(customInfoRequests)
default:
return Fragment
}
@ -202,7 +202,7 @@ const GetValues = ({ setValues }) => {
return null
}
const Wizard = ({ onClose, save, error, currency }) => {
const Wizard = ({ onClose, save, error, currency, customInfoRequests }) => {
const classes = useStyles()
const [liveValues, setLiveValues] = useState({})
@ -211,7 +211,7 @@ const Wizard = ({ onClose, save, error, currency }) => {
})
const isLastStep = step === LAST_STEP
const stepOptions = getStep(step, currency)
const stepOptions = getStep(step, currency, customInfoRequests)
const onContinue = async it => {
const newConfig = R.merge(config, stepOptions.schema.cast(it))
@ -230,12 +230,18 @@ const Wizard = ({ onClose, save, error, currency }) => {
const triggerType = values?.triggerType
const containsType = R.contains(triggerType)
const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
const hasRequirementError =
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const hasRequirementError = requirements().hasRequirementError(
errors,
touched,
values
)
const hasCustomRequirementError = requirements().hasCustomRequirementError(
errors,
touched,
values
)
const hasAmountError =
!!errors.threshold &&
@ -258,7 +264,11 @@ const Wizard = ({ onClose, save, error, currency }) => {
)
return errors.threshold
if (isSuspend && hasRequirementError) return errors.requirement
if (
(isSuspend && hasRequirementError) ||
(isCustom && hasCustomRequirementError)
)
return errors.requirement
}
return (

View file

@ -28,13 +28,34 @@ const GET_INFO = gql`
}
`
const GET_CUSTOM_REQUESTS = gql`
query customInfoRequests {
customInfoRequests {
id
customRequest
enabled
}
}
`
const AdvancedTriggersSettings = memo(() => {
const SCREEN_KEY = namespaces.TRIGGERS
const [error, setError] = useState(null)
const [isEditingDefault, setEditingDefault] = useState(false)
const [isEditingOverrides, setEditingOverrides] = useState(false)
const { data } = useQuery(GET_INFO)
const { data, loading: configLoading } = useQuery(GET_INFO)
const { data: customInfoReqData, loading: customInfoLoading } = useQuery(
GET_CUSTOM_REQUESTS
)
const customInfoRequests =
R.path(['customInfoRequests'])(customInfoReqData) ?? []
const enabledCustomInfoRequests = R.filter(R.propEq('enabled', true))(
customInfoRequests
)
const loading = configLoading || customInfoLoading
const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData'],
@ -67,6 +88,7 @@ const AdvancedTriggersSettings = memo(() => {
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
return (
!loading && (
<>
<Section>
<EditableTable
@ -95,15 +117,19 @@ const AdvancedTriggersSettings = memo(() => {
enableCreate
initialValues={overridesDefaults}
save={saveOverrides}
validationSchema={getOverridesSchema(requirementsOverrides)}
validationSchema={getOverridesSchema(
requirementsOverrides,
enabledCustomInfoRequests
)}
data={requirementsOverrides}
elements={getOverrides()}
elements={getOverrides(enabledCustomInfoRequests)}
setEditing={onEditingOverrides}
forceDisable={isEditingDefault}
/>
</Section>
</>
)
)
})
export default AdvancedTriggersSettings

View file

@ -4,7 +4,8 @@ import * as Yup from 'yup'
import Autocomplete from 'src/components/inputs/formik/Autocomplete.js'
import { getView } from 'src/pages/Triggers/helper'
const advancedRequirementOptions = [
const buildAdvancedRequirementOptions = customInfoRequests => {
const base = [
{ display: 'Sanctions', code: 'sanctions' },
{ display: 'ID card image', code: 'idCardPhoto' },
{ display: 'ID data', code: 'idCardData' },
@ -12,10 +13,20 @@ const advancedRequirementOptions = [
{ display: 'US SSN', code: 'usSsn' }
]
const displayRequirement = code => {
const custom = R.map(it => ({
display: it.customRequest.name,
code: it.id
}))(customInfoRequests)
return R.concat(base, custom)
}
const displayRequirement = (code, customInfoRequests) => {
return R.prop(
'display',
R.find(R.propEq('code', code))(advancedRequirementOptions)
R.find(R.propEq('code', code))(
buildAdvancedRequirementOptions(customInfoRequests)
)
)
}
@ -29,7 +40,7 @@ const defaultSchema = Yup.object().shape({
.required()
})
const getOverridesSchema = values => {
const getOverridesSchema = (values, customInfoRequests) => {
return Yup.object().shape({
id: Yup.string()
.label('Requirement')
@ -40,7 +51,8 @@ const getOverridesSchema = values => {
if (R.find(R.propEq('requirement', requirement))(values)) {
return this.createError({
message: `Requirement ${displayRequirement(
requirement
requirement,
customInfoRequests
)} already overriden`
})
}
@ -84,17 +96,20 @@ const getDefaultSettings = () => {
]
}
const getOverrides = () => {
const getOverrides = customInfoRequests => {
return [
{
name: 'requirement',
header: 'Requirement',
width: 196,
size: 'sm',
view: getView(advancedRequirementOptions, 'display'),
view: getView(
buildAdvancedRequirementOptions(customInfoRequests),
'display'
),
input: Autocomplete,
inputProps: {
options: advancedRequirementOptions,
options: buildAdvancedRequirementOptions(customInfoRequests),
labelProp: 'display',
valueProp: 'code'
}

View file

@ -1,8 +1,6 @@
import { useQuery } from '@apollo/react-hooks'
import { makeStyles, Box } from '@material-ui/core'
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import gql from 'graphql-tag'
import * as R from 'ramda'
import React, { memo } from 'react'
import * as Yup from 'yup'
@ -479,21 +477,43 @@ const requirementSchema = Yup.object()
otherwise: Yup.number()
.nullable()
.transform(() => null)
}),
customInfoRequestId: Yup.string().when('requirement', {
is: value => value === 'custom',
then: Yup.string(),
otherwise: Yup.string()
.nullable()
.transform(() => '')
})
}).required()
})
.test(({ requirement }, context) => {
const requirementValidator = requirement =>
requirement.requirement === 'suspend'
const requirementValidator = (requirement, type) => {
switch (type) {
case 'suspend':
return requirement.requirement === type
? requirement.suspensionDays > 0
: true
case 'custom':
return requirement.requirement === type
? !R.isNil(requirement.customInfoRequestId)
: true
default:
return true
}
}
if (requirement && requirementValidator(requirement)) return
if (requirement && !requirementValidator(requirement, 'suspend'))
return context.createError({
path: 'requirement',
message: 'Suspension days must be greater than 0'
})
if (requirement && !requirementValidator(requirement, 'custom'))
return context.createError({
path: 'requirement',
message: 'You must select an item'
})
})
const requirementOptions = [
@ -508,16 +528,19 @@ const requirementOptions = [
{ display: 'Block', code: 'block' }
]
const GET_ACTIVE_CUSTOM_REQUESTS = gql`
query customInfoRequests($onlyEnabled: Boolean) {
customInfoRequests(onlyEnabled: $onlyEnabled) {
id
customRequest
}
}
`
const hasRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const Requirement = () => {
const hasCustomRequirementError = (errors, touched, values) =>
!!errors.requirement &&
!!touched.requirement?.customInfoRequestId &&
(!values.requirement?.customInfoRequestId ||
!R.isNil(values.requirement?.customInfoRequestId))
const Requirement = ({ customInfoRequests }) => {
const classes = useStyles()
const {
touched,
@ -526,11 +549,6 @@ const Requirement = () => {
handleChange,
setTouched
} = useFormikContext()
const { data } = useQuery(GET_ACTIVE_CUSTOM_REQUESTS, {
variables: {
onlyEnabled: true
}
})
const isSuspend = values?.requirement?.requirement === 'suspend'
const isCustom = values?.requirement?.requirement === 'custom'
@ -540,24 +558,19 @@ const Requirement = () => {
display: it.customRequest.name
}))
const hasRequirementError =
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const customInfoRequests = R.path(['customInfoRequests'])(data) ?? []
const enableCustomRequirement = customInfoRequests.length > 0
const enableCustomRequirement = customInfoRequests?.length > 0
const customInfoOption = {
display: 'Custom information requirement',
code: 'custom'
}
const options = enableCustomRequirement
? [...requirementOptions, customInfoOption]
: [...requirementOptions, { ...customInfoOption, disabled: true }]
: [...requirementOptions]
const titleClass = {
[classes.error]:
(!!errors.requirement && !isSuspend) || (isSuspend && hasRequirementError)
(!!errors.requirement && !isSuspend && !isCustom) ||
(isSuspend && hasRequirementError(errors, touched, values)) ||
(isCustom && hasCustomRequirementError(errors, touched, values))
}
return (
@ -586,7 +599,7 @@ const Requirement = () => {
label="Days"
size="lg"
name="requirement.suspensionDays"
error={hasRequirementError}
error={hasRequirementError(errors, touched, values)}
/>
)}
{isCustom && (
@ -604,10 +617,13 @@ const Requirement = () => {
)
}
const requirements = {
const requirements = customInfoRequests => ({
schema: requirementSchema,
options: requirementOptions,
Component: Requirement,
props: { customInfoRequests },
hasRequirementError: hasRequirementError,
hasCustomRequirementError: hasCustomRequirementError,
initialValues: {
requirement: {
requirement: '',
@ -615,7 +631,7 @@ const requirements = {
customInfoRequestId: ''
}
}
}
})
const getView = (data, code, compare) => it => {
if (!data) return ''

View file

@ -140,12 +140,14 @@ const Wallet = ({ name: SCREEN_KEY }) => {
<div className={classes.header}>
<TitleSection
title="Wallet Settings"
button={{
buttons={[
{
text: 'Advanced settings',
icon: SettingsIcon,
inverseIcon: ReverseSettingsIcon,
toggle: setAdvancedSettings
}}
}
]}
/>
<Box alignItems="center" justifyContent="end">
<Label1 className={classes.feeDiscountLabel}>Fee discount</Label1>

View file

@ -130,7 +130,7 @@ const getLamassuRoutes = () => [
},
{
key: 'services',
label: '3rd party services',
label: '3rd Party Services',
route: '/settings/3rd-party-services',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Services

View file

@ -132,7 +132,7 @@ const getPazuzRoutes = () => [
},
{
key: 'services',
label: '3rd party services',
label: '3rd Party Services',
route: '/settings/3rd-party-services',
allowedRoles: [ROLES.USER, ROLES.SUPERUSER],
component: Services

View file

@ -28,7 +28,10 @@ export default {
pointerEvents: 'none'
},
html: {
height: fill
height: fill,
'@media screen and (max-height: 900px)': {
scrollbarGutter: 'stable'
}
},
body: {
width: mainWidth,

View file

@ -1,5 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="32px" height="32px" viewBox="0 0 24 24" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<svg width="32px" height="32px" viewBox="0 0 18 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="↳-notification-center" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd" stroke-linecap="round" stroke-linejoin="round">
<g id="notification-center_v01a#2-(open)" transform="translate(-1023.000000, -459.000000)" stroke="#1B2559">
<g id="Group-5" transform="translate(1000.000000, 0.000000)">

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After