Merge branch 'dev' into update_install-nix

This commit is contained in:
André Sá 2021-10-05 21:47:16 +01:00
commit 3817ee63fe
77 changed files with 1163 additions and 817 deletions

View file

@ -33,7 +33,7 @@ npm install
## Generate certificates ## Generate certificates
``` ```
bash bin/cert-gen.sh bash tools/cert-gen.sh
``` ```
Notes: Notes:

View file

@ -51,7 +51,7 @@ npm install
## Generate certificates ## Generate certificates
``` ```
bash bin/cert-gen.sh bash tools/cert-gen.sh
``` ```
Notes: Notes:

View file

@ -26,5 +26,7 @@ keypool=10000
prune=4000 prune=4000
daemon=0 daemon=0
addresstype=p2sh-segwit addresstype=p2sh-segwit
walletrbf=1` walletrbf=1
bind=0.0.0.0:8332
rpcport=8333`
} }

View file

@ -26,6 +26,5 @@ keypool=10000
prune=4000 prune=4000
daemon=0 daemon=0
bind=0.0.0.0:8334 bind=0.0.0.0:8334
rpcport=8335 rpcport=8335`
`
} }

View file

@ -25,24 +25,24 @@ const BINARIES = {
dir: 'bitcoin-0.20.1/bin' dir: 'bitcoin-0.20.1/bin'
}, },
ETH: { ETH: {
url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.9.25-e7872729.tar.gz', url: 'https://gethstore.blob.core.windows.net/builds/geth-linux-amd64-1.10.8-26675454.tar.gz',
dir: 'geth-linux-amd64-1.9.25-e7872729' dir: 'geth-linux-amd64-1.10.8-26675454'
}, },
ZEC: { ZEC: {
url: 'https://z.cash/downloads/zcash-4.3.0-linux64-debian-stretch.tar.gz', url: 'https://z.cash/downloads/zcash-4.4.1-linux64-debian-stretch.tar.gz',
dir: 'zcash-4.3.0/bin' dir: 'zcash-4.4.1/bin'
}, },
DASH: { DASH: {
url: 'https://github.com/dashpay/dash/releases/download/v0.16.1.1/dashcore-0.16.1.1-x86_64-linux-gnu.tar.gz', url: 'https://github.com/dashpay/dash/releases/download/v0.17.0.3/dashcore-0.17.0.3-x86_64-linux-gnu.tar.gz',
dir: 'dashcore-0.16.1/bin' dir: 'dashcore-0.17.0/bin'
}, },
LTC: { LTC: {
url: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz', url: 'https://download.litecoin.org/litecoin-0.18.1/linux/litecoin-0.18.1-x86_64-linux-gnu.tar.gz',
dir: 'litecoin-0.18.1/bin' dir: 'litecoin-0.18.1/bin'
}, },
BCH: { BCH: {
url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v22.2.0/bitcoin-cash-node-22.2.0-x86_64-linux-gnu.tar.gz', url: 'https://github.com/bitcoin-cash-node/bitcoin-cash-node/releases/download/v23.1.0/bitcoin-cash-node-23.1.0-x86_64-linux-gnu.tar.gz',
dir: 'bitcoin-cash-node-22.2.0/bin', dir: 'bitcoin-cash-node-23.1.0/bin',
files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']] files: [['bitcoind', 'bitcoincashd'], ['bitcoin-cli', 'bitcoincash-cli']]
} }
} }

View file

@ -21,9 +21,9 @@ function buildConfig () {
rpcpassword=${common.randomPass()} rpcpassword=${common.randomPass()}
dbcache=500 dbcache=500
keypool=10000 keypool=10000
litemode=1 disablegovernance=1
prune=4000 prune=4000
txindex=0 txindex=0
enableprivatesend=1 enablecoinjoin=1
privatesendautostart=1` coinjoinautostart=1`
} }

View file

@ -7,6 +7,6 @@ module.exports = {setup}
function setup (dataDir) { function setup (dataDir) {
const coinRec = coinUtils.getCryptoCurrency('ETH') const coinRec = coinUtils.getCryptoCurrency('ETH')
common.firewall([coinRec.defaultPort]) common.firewall([coinRec.defaultPort])
const cmd = `/usr/local/bin/${coinRec.daemon} --datadir "${dataDir}" --syncmode="fast" --cache 2048 --maxpeers 40 --rpc` const cmd = `/usr/local/bin/${coinRec.daemon} --datadir "${dataDir}" --syncmode="light" --cache 2048 --maxpeers 40 --rpc`
common.writeSupervisorConfig(coinRec, cmd) common.writeSupervisorConfig(coinRec, cmd)
} }

View file

@ -19,11 +19,11 @@ const logger = common.logger
const PLUGINS = { const PLUGINS = {
BTC: require('./bitcoin.js'), BTC: require('./bitcoin.js'),
LTC: require('./litecoin.js'), BCH: require('./bitcoincash.js'),
ETH: require('./ethereum.js'),
DASH: require('./dash.js'), DASH: require('./dash.js'),
ZEC: require('./zcash.js'), ETH: require('./ethereum.js'),
BCH: require('./bitcoincash.js') LTC: require('./litecoin.js'),
ZEC: require('./zcash.js')
} }
module.exports = {run} module.exports = {run}
@ -57,7 +57,8 @@ function processCryptos (codes) {
const selectedCryptos = _.map(code => _.find(['code', code], cryptos), codes) const selectedCryptos = _.map(code => _.find(['code', code], cryptos), codes)
_.forEach(setupCrypto, selectedCryptos) _.forEach(setupCrypto, selectedCryptos)
common.es('sudo service supervisor restart') common.es('sudo supervisorctl reread')
common.es('sudo supervisorctl update')
const blockchainDir = options.blockchainDir const blockchainDir = options.blockchainDir
const backupDir = path.resolve(os.homedir(), 'backups') const backupDir = path.resolve(os.homedir(), 'backups')
@ -104,9 +105,7 @@ function run () {
name: c.display, name: c.display,
value: c.code, value: c.code,
checked, checked,
disabled: c.cryptoCode === 'ETH' disabled: checked && 'Installed'
? 'Use admin\'s Infura plugin'
: checked && 'Installed'
} }
}, cryptos) }, cryptos)

View file

@ -19,7 +19,8 @@ function atomic (tx, pi, fromClient) {
const isolationLevel = pgp.txMode.isolationLevel const isolationLevel = pgp.txMode.isolationLevel
const mode = new TransactionMode({ tiLevel: isolationLevel.serializable }) const mode = new TransactionMode({ tiLevel: isolationLevel.serializable })
function transaction (t) { function transaction (t) {
const sql = 'select * from cash_out_txs where id=$1' const sql = 'SELECT * FROM cash_out_txs WHERE id=$1 FOR UPDATE'
return t.oneOrNone(sql, [tx.id]) return t.oneOrNone(sql, [tx.id])
.then(toObj) .then(toObj)
.then(oldTx => { .then(oldTx => {
@ -72,7 +73,7 @@ function preProcess (t, oldTx, newTx, pi) {
} }
const hasError = !oldTx.error && newTx.error const hasError = !oldTx.error && newTx.error
const hasDispenseOccurred = !dispenseOccurred(oldTx.bills) && dispenseOccurred(newTx.bills) const hasDispenseOccurred = !oldTx.dispenseConfirmed && dispenseOccurred(newTx.bills)
if (hasError || hasDispenseOccurred) { if (hasError || hasDispenseOccurred) {
return cashOutActions.logDispense(t, updatedTx) return cashOutActions.logDispense(t, updatedTx)

View file

@ -81,6 +81,11 @@ function migrateCommissions (config) {
} }
const { global, scoped } = getConfigFields(_.keys(codes), config) const { global, scoped } = getConfigFields(_.keys(codes), config)
const defaultCashOutCommissions = { code: 'cashOutCommission', value: 0, scope: global[0].scope }
const isCashOutDisabled =
_.isEmpty(_.filter(commissionElement => commissionElement.code === 'cashOutCommission', global))
const globalWithDefaults =
isCashOutDisabled ? _.concat(global, defaultCashOutCommissions) : global
const machineAndCryptoScoped = scoped.filter( const machineAndCryptoScoped = scoped.filter(
f => f.scope.machine !== GLOBAL_SCOPE.machine && f.scope.crypto.length === 1 f => f.scope.machine !== GLOBAL_SCOPE.machine && f.scope.crypto.length === 1
@ -112,7 +117,7 @@ function migrateCommissions (config) {
const allCommissionsOverrides = withCryptoScoped.concat(filteredMachineScoped) const allCommissionsOverrides = withCryptoScoped.concat(filteredMachineScoped)
return { return {
..._.fromPairs(global.map(f => [`commissions_${codes[f.code]}`, f.value])), ..._.fromPairs(globalWithDefaults.map(f => [`commissions_${codes[f.code]}`, f.value])),
...(allCommissionsOverrides.length > 0 && { ...(allCommissionsOverrides.length > 0 && {
commissions_overrides: allCommissionsOverrides.map(s => ({ commissions_overrides: allCommissionsOverrides.map(s => ({
..._.fromPairs(s.values.map(f => [codes[f.code], f.value])), ..._.fromPairs(s.values.map(f => [codes[f.code], f.value])),

View file

@ -21,6 +21,10 @@ const NUM_RESULTS = 1000
const idPhotoCardBasedir = _.get('idPhotoCardDir', options) const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
const frontCameraBaseDir = _.get('frontCameraDir', options) const frontCameraBaseDir = _.get('frontCameraDir', options)
const operatorDataDir = _.get('operatorDataDir', options) const operatorDataDir = _.get('operatorDataDir', options)
const sms = require('./sms')
const settingsLoader = require('./new-settings-loader')
const TX_PASSTHROUGH_ERROR_CODES = ['operatorCancel']
/** /**
* Add new customer * Add new customer
@ -116,12 +120,20 @@ async function updateCustomer (id, data, userToken) {
const enhancedUpdateData = enhanceAtFields(enhanceOverrideFields(formattedData, userToken)) const enhancedUpdateData = enhanceAtFields(enhanceOverrideFields(formattedData, userToken))
const updateData = updateOverride(enhancedUpdateData) const updateData = updateOverride(enhancedUpdateData)
if (!_.isEmpty(updateData)) {
const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') + const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') +
' where id=$1' ' where id=$1'
invalidateCustomerNotifications(id, formattedData)
await db.none(sql, [id]) await db.none(sql, [id])
}
if (data.subscriberInfo) {
Promise.all([getCustomerById(id), settingsLoader.loadLatest()])
.then(([customer, config]) => sms.getLookup(config, customer.phone))
.then(res => updateSubscriberData(id, res, userToken))
.catch(console.error)
}
invalidateCustomerNotifications(id, formattedData)
return getCustomerById(id) return getCustomerById(id)
} }
@ -132,6 +144,11 @@ const invalidateCustomerNotifications = (id, data) => {
return notifierQueries.invalidateNotification(detailB, 'compliance') return notifierQueries.invalidateNotification(detailB, 'compliance')
} }
const updateSubscriberData = (customerId, data, userToken) => {
const sql = `UPDATE customers SET subscriber_info=$1, subscriber_info_at=now(), subscriber_info_by=$2 WHERE id=$3`
return db.none(sql, [data, userToken, customerId])
}
/** /**
* Get customer by id * Get customer by id
* *
@ -264,16 +281,19 @@ function getComplianceTypes () {
function updateOverride (fields) { function updateOverride (fields) {
const updateableFields = [ const updateableFields = [
'id_card_data', 'id_card_data',
'id_card_photo', 'id_card_photo_path',
'front_camera', 'front_camera_path',
'authorized', 'authorized',
'us_ssn' 'us_ssn'
] ]
const updatedFields = _.intersection(updateableFields, _.keys(fields)) const removePathSuffix = _.map(_.replace('_path', ''))
const atFields = _.fromPairs(_.map(f => [`${f}_override`, 'automatic'], updatedFields)) const getPairs = _.map(f => [`${f}_override`, 'automatic'])
return _.merge(fields, atFields) const updatedFields = _.intersection(updateableFields, _.keys(fields))
const overrideFields = _.compose(_.fromPairs, getPairs, removePathSuffix)(updatedFields)
return _.merge(fields, overrideFields)
} }
function enhanceAtFields (fields) { function enhanceAtFields (fields) {
@ -450,7 +470,10 @@ function batch () {
* *
* @returns {array} Array of customers with it's transactions aggregations * @returns {array} Array of customers with it's transactions aggregations
*/ */
function getCustomersList (phone = null, name = null, address = null, id = null) { function getCustomersList (phone = null, name = null, address = null, id = null) {
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
const sql = `SELECT id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override, 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, 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, id_card_photo_path, id_card_photo_override, us_ssn, us_ssn_override, sanctions, sanctions_at,
@ -465,21 +488,22 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
c.id_card_photo_path, c.id_card_photo_override, c.us_ssn, c.us_ssn_override, c.sanctions, 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, c.sanctions_at, c.sanctions_override, t.tx_class, t.fiat, t.fiat_code, t.created,
row_number() OVER (partition by c.id order by t.created desc) AS rn, row_number() OVER (partition by c.id order by t.created desc) AS rn,
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
sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs, sum(CASE WHEN t.id IS NOT NULL THEN 1 ELSE 0 END) OVER (partition by c.id) AS total_txs,
coalesce(sum(t.fiat) OVER (partition by c.id), 0) AS total_spent coalesce(sum(t.fiat) OVER (partition by c.id), 0) AS total_spent
FROM customers c LEFT OUTER JOIN ( FROM customers c LEFT OUTER JOIN (
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id 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 UNION
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id 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 FROM cash_out_txs WHERE confirmed_at IS NOT NULL) t ON c.id = t.customer_id
WHERE c.id != $1 WHERE c.id != $2
) AS cl WHERE rn = 1 ) AS cl WHERE rn = 1
AND ($3 IS NULL OR phone = $3) AND ($4 IS NULL OR phone = $4)
AND ($4 IS NULL OR concat(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') = $4 OR id_card_data::json->>'firstName' = $4 OR id_card_data::json->>'lastName' = $4) AND ($5 IS NULL OR concat(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') = $5 OR id_card_data::json->>'firstName' = $5 OR id_card_data::json->>'lastName' = $5)
AND ($5 IS NULL OR id_card_data::json->>'address' = $5) AND ($6 IS NULL OR id_card_data::json->>'address' = $6)
AND ($6 IS NULL OR id_card_data::json->>'documentNumber' = $6) AND ($7 IS NULL OR id_card_data::json->>'documentNumber' = $7)
limit $2` limit $3`
return db.any(sql, [ anonymous.uuid, NUM_RESULTS, phone, name, address, id ]) return db.any(sql, [ passableErrorCdoes, anonymous.uuid, NUM_RESULTS, phone, name, address, id ])
.then(customers => Promise.all(_.map(customer => { .then(customers => Promise.all(_.map(customer => {
return populateOverrideUsernames(customer) return populateOverrideUsernames(customer)
.then(camelize) .then(camelize)
@ -494,11 +518,12 @@ function getCustomersList (phone = null, name = null, address = null, id = null)
* @returns {array} Array of customers with it's transactions aggregations * @returns {array} Array of customers with it's transactions aggregations
*/ */
function getCustomerById (id) { function getCustomerById (id) {
const passableErrorCodes = _.map(Pgp.as.text, TX_PASSTHROUGH_ERROR_CODES).join(',')
const sql = `select id, authorized_override, days_suspended, is_suspended, front_camera_path, front_camera_override, 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, 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, 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, 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 fiat_code as last_tx_fiat_code, tx_class as last_tx_class, subscriber_info
from ( from (
select c.id, c.authorized_override, select c.id, c.authorized_override,
greatest(0, date_part('day', c.suspended_until - now())) as days_suspended, greatest(0, date_part('day', c.suspended_until - now())) as days_suspended,
@ -506,18 +531,18 @@ function getCustomerById (id) {
c.front_camera_path, c.front_camera_override, 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.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.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, c.sanctions_at, c.sanctions_override, c.subscriber_info, t.tx_class, t.fiat, t.fiat_code, t.created,
row_number() over (partition by c.id order by t.created desc) as rn, 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 t.id is not null then 1 else 0 end) over (partition by c.id) as total_txs,
sum(t.fiat) over (partition by c.id) as total_spent 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
from customers c left outer join ( from customers c left outer join (
select 'cashIn' as tx_class, id, fiat, fiat_code, created, customer_id 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 union
select 'cashOut' as tx_class, id, fiat, fiat_code, created, customer_id 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 from cash_out_txs where confirmed_at is not null) t on c.id = t.customer_id
where c.id = $1 where c.id = $2
) as cl where rn = 1` ) as cl where rn = 1`
return db.oneOrNone(sql, [id]) return db.oneOrNone(sql, [passableErrorCodes, id])
.then(populateOverrideUsernames) .then(populateOverrideUsernames)
.then(camelize) .then(camelize)
} }

View file

@ -20,7 +20,8 @@ const stripDefaultDbFuncs = dbCtx => {
manyOrNone: dbCtx.$manyOrNone, manyOrNone: dbCtx.$manyOrNone,
tx: dbCtx.$tx, tx: dbCtx.$tx,
task: dbCtx.$task, task: dbCtx.$task,
batch: dbCtx.batch batch: dbCtx.batch,
multi: dbCtx.$multi
} }
} }
@ -67,6 +68,7 @@ const pgp = Pgp({
obj.$one = (query, variables) => obj.__taskEx(t => t.one(query, variables)) obj.$one = (query, variables) => obj.__taskEx(t => t.one(query, variables))
obj.$none = (query, variables) => obj.__taskEx(t => t.none(query, variables)) obj.$none = (query, variables) => obj.__taskEx(t => t.none(query, variables))
obj.$any = (query, variables) => obj.__taskEx(t => t.any(query, variables)) obj.$any = (query, variables) => obj.__taskEx(t => t.any(query, variables))
obj.$multi = (query, variables) => obj.__taskEx(t => t.multi(query, variables))
// when opts is not defined "cb" occupies the "opts" spot of the arguments // when opts is not defined "cb" occupies the "opts" spot of the arguments
obj.$tx = (opts, cb) => typeof opts === 'function' ? _tx(obj, {}, opts) : _tx(obj, opts, cb) obj.$tx = (opts, cb) => typeof opts === 'function' ? _tx(obj, {}, opts) : _tx(obj, opts, cb)
obj.$task = (opts, cb) => typeof opts === 'function' ? _task(obj, {}, opts) : _task(obj, opts, cb) obj.$task = (opts, cb) => typeof opts === 'function' ? _task(obj, {}, opts) : _task(obj, opts, cb)

View file

@ -60,10 +60,11 @@ function update (deviceId, logLines) {
} }
function clearOldLogs () { function clearOldLogs () {
const sql = `delete from logs const sqls = `delete from logs
where timestamp < now() - interval '3 days'` where timestamp < now() - interval '3 days';
delete from server_logs
return db.none(sql) where timestamp < now() - interval '3 days';`
return db.multi(sqls)
} }
function getUnlimitedMachineLogs (deviceId, until = new Date().toISOString()) { function getUnlimitedMachineLogs (deviceId, until = new Date().toISOString()) {

View file

@ -12,6 +12,10 @@ const settingsLoader = require('./new-settings-loader')
const notifierUtils = require('./notifier/utils') const notifierUtils = require('./notifier/utils')
const notifierQueries = require('./notifier/queries') const notifierQueries = require('./notifier/queries')
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
const stuckStatus = { label: 'Stuck', type: 'error' }
function getMachines () { function getMachines () {
return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created') return db.any('SELECT * FROM devices WHERE display=TRUE ORDER BY created')
.then(rr => rr.map(r => ({ .then(rr => rr.map(r => ({
@ -36,11 +40,32 @@ function getConfig (defaultConfig) {
return settingsLoader.loadLatest().config return settingsLoader.loadLatest().config
} }
function getMachineNames (config) { const getStatus = (ping, stuck) => {
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' } if (ping && ping.age) return unresponsiveStatus
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
const stuckStatus = { label: 'Stuck', type: 'error' }
if (stuck && stuck.age) return stuckStatus
return fullyFunctionalStatus
}
function addName (pings, events, config) {
return machine => {
const cashOutConfig = configManager.getCashOut(machine.deviceId, config)
const cashOut = !!cashOutConfig.active
const statuses = [
getStatus(
_.first(pings[machine.deviceId]),
_.first(checkStuckScreen(events, machine.name))
)
]
return _.assign(machine, { cashOut, statuses })
}
}
function getMachineNames (config) {
return Promise.all([getMachines(), getConfig(config), getNetworkHeartbeat(), getNetworkPerformance()]) return Promise.all([getMachines(), getConfig(config), getNetworkHeartbeat(), getNetworkPerformance()])
.then(([rawMachines, config, heartbeat, performance]) => Promise.all( .then(([rawMachines, config, heartbeat, performance]) => Promise.all(
[rawMachines, checkPings(rawMachines), dbm.machineEvents(), config, heartbeat, performance] [rawMachines, checkPings(rawMachines), dbm.machineEvents(), config, heartbeat, performance]
@ -91,9 +116,27 @@ function getMachineName (machineId) {
.then(it => it.name) .then(it => it.name)
} }
function getMachine (machineId) { function getMachine (machineId, config) {
const sql = 'SELECT * FROM devices WHERE device_id=$1' const sql = 'SELECT * FROM devices WHERE device_id=$1'
return db.oneOrNone(sql, [machineId]).then(res => _.mapKeys(_.camelCase)(res)) const queryMachine = db.oneOrNone(sql, [machineId]).then(r => ({
deviceId: r.device_id,
cashbox: r.cashbox,
cassette1: r.cassette1,
cassette2: r.cassette2,
version: r.version,
model: r.model,
pairedAt: new Date(r.created),
lastPing: new Date(r.last_online),
name: r.name,
paired: r.paired
}))
return Promise.all([queryMachine, dbm.machineEvents(), config])
.then(([machine, events, config]) => {
const pings = checkPings([machine])
return [machine].map(addName(pings, events, config))[0]
})
} }
function renameMachine (rec) { function renameMachine (rec) {

View file

@ -25,7 +25,7 @@ const ALL_ACCOUNTS = [
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] }, { code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS }, { code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH, USDT] }, { code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'geth', display: 'geth (DEPRECATED)', class: WALLET, cryptos: [ETH, USDT], deprecated: true }, { code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] }, { code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] }, { code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] }, { code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },

View file

@ -31,8 +31,8 @@ const resolvers = {
Query: { Query: {
transactions: (...[, { from, until, limit, offset, deviceId, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status }]) => 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), 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 }]) => transactionsCsv: (...[, { from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, timezone, simplified }]) =>
transactions.batch(from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status) transactions.batch(from, until, limit, offset, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status, simplified)
.then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']), { fields: txLogFields })), .then(data => parseAsync(logDateFormat(timezone, data, ['created', 'sendTime']), { fields: txLogFields })),
transactionCsv: (...[, { id, txClass, timezone }]) => transactionCsv: (...[, { id, txClass, timezone }]) =>
transactions.getTx(id, txClass).then(data => transactions.getTx(id, txClass).then(data =>

View file

@ -28,6 +28,7 @@ const typeDef = gql`
lastTxFiatCode: String lastTxFiatCode: String
lastTxClass: String lastTxClass: String
transactions: [Transaction] transactions: [Transaction]
subscriberInfo: JSONObject
} }
input CustomerInput { input CustomerInput {
@ -53,6 +54,7 @@ const typeDef = gql`
lastTxFiatCode: String lastTxFiatCode: String
lastTxClass: String lastTxClass: String
suspendedUntil: Date suspendedUntil: Date
subscriberInfo: Boolean
} }
type Query { type Query {

View file

@ -52,7 +52,7 @@ const typeDef = gql`
type Query { 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 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): String @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
transactionCsv(id: ID, txClass: String, timezone: String): String @auth transactionCsv(id: ID, txClass: String, timezone: String): String @auth
txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth txAssociatedDataCsv(id: ID, txClass: String, timezone: String): String @auth
transactionFilters: [Filter] @auth transactionFilters: [Filter] @auth

View file

@ -36,7 +36,8 @@ function batch (
fiatCode = null, fiatCode = null,
cryptoCode = null, cryptoCode = null,
toAddress = null, toAddress = null,
status = null status = null,
simplified = false
) { ) {
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames) const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
@ -99,6 +100,55 @@ function batch (
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status]) db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id, txClass, machineName, customerName, fiatCode, cryptoCode, toAddress, status])
]) ])
.then(packager) .then(packager)
.then(res => {
if (simplified) return simplifiedBatch(res)
else return res
})
}
function simplifiedBatch (data) {
const fields = ['txClass', 'id', 'created', 'machineName',
'cryptoCode', 'fiat', 'fiatCode', 'phone', 'toAddress',
'txHash', 'dispense', 'error', 'status', 'fiatProfit', 'cryptoAmount']
const addSimplifiedFields = _.map(it => ({
...it,
status: getStatus(it),
fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it)
}))
return _.compose(_.map(_.pick(fields)), addSimplifiedFields)(data)
}
const getCryptoAmount = it => coinUtils.toUnit(BN(it.cryptoAtoms), it.cryptoCode).toString()
const getProfit = it => {
const getCommissionFee = it => BN(it.commissionPercentage).mul(BN(it.fiat))
if (!it.cashInFee) return getCommissionFee(it)
return getCommissionFee(it).add(BN(it.cashInFee))
}
const getCashOutStatus = it => {
if (it.hasError) return 'Error'
if (it.dispense) return 'Success'
if (it.expired) return 'Expired'
return 'Pending'
}
const getCashInStatus = it => {
if (it.operatorCompleted) return 'Cancelled'
if (it.hasError) return 'Error'
if (it.sendConfirmed) return 'Sent'
if (it.expired) return 'Expired'
return 'Pending'
}
const getStatus = it => {
if (it.txClass === 'cashOut') {
return getCashOutStatus(it)
}
return getCashInStatus(it)
} }
function getCustomerTransactionsBatch (ids) { function getCustomerTransactionsBatch (ids) {

View file

@ -105,6 +105,25 @@ const getGlobalNotifications = config => getNotifications(null, null, config)
const getTriggers = _.get('triggers') const getTriggers = _.get('triggers')
const getTriggersAutomation = config => {
const defaultAutomation = _.get('triggersConfig_automation')(config)
const requirements = {
sanctions: defaultAutomation,
idCardPhoto: defaultAutomation,
idCardData: defaultAutomation,
facephoto: defaultAutomation,
usSsn: defaultAutomation
}
const overrides = _.get('triggersConfig_overrides')(config)
const requirementsOverrides = _.reduce((acc, override) => {
return _.assign(acc, { [override.requirement]: override.automation })
}, {}, overrides)
return _.assign(requirements, requirementsOverrides)
}
const splitGetFirst = _.compose(_.head, _.split('_')) const splitGetFirst = _.compose(_.head, _.split('_'))
const getCryptosFromWalletNamespace = config => { const getCryptosFromWalletNamespace = config => {
@ -128,6 +147,7 @@ module.exports = {
getTermsConditions, getTermsConditions,
getAllCryptoCurrencies, getAllCryptoCurrencies,
getTriggers, getTriggers,
getTriggersAutomation,
getCashOut, getCashOut,
getCryptosFromWalletNamespace getCryptosFromWalletNamespace
} }

View file

@ -340,7 +340,7 @@ function plugins (settings, deviceId) {
const rate = rawRate.div(cashInCommission) const rate = rawRate.div(cashInCommission)
const lowBalanceMargin = new BN(1.03) const lowBalanceMargin = new BN(1.05)
const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode) const cryptoRec = coinUtils.getCryptoCurrency(cryptoCode)
const unitScale = cryptoRec.unitScale const unitScale = cryptoRec.unitScale

View file

@ -1,8 +1,19 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
exports.NAME = 'MockSMS' const NAME = 'MockSMS'
exports.sendMessage = function sendMessage (account, rec) { function getLookup (account, number) {
console.log('Looking up number: %j', number)
return new Promise((resolve, reject) => {
if (_.endsWith('666', number)) {
reject (new Error(`${exports.NAME} mocked error!`))
} else {
setTimeout(resolve, 1)
}
})
}
function sendMessage (account, rec) {
console.log('Sending SMS: %j', rec) console.log('Sending SMS: %j', rec)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (_.endsWith('666', _.getOr(false, 'sms.toNumber', rec))) { if (_.endsWith('666', _.getOr(false, 'sms.toNumber', rec))) {
@ -12,3 +23,9 @@ exports.sendMessage = function sendMessage (account, rec) {
} }
}) })
} }
module.exports = {
NAME,
sendMessage,
getLookup
}

View file

@ -37,7 +37,27 @@ function sendMessage (account, rec) {
}) })
} }
function getLookup (account, number) {
return Promise.resolve()
.then(() => {
const client = twilio(account.accountSid, account.authToken)
return client.lookups.v1.phoneNumbers(number)
.fetch({ addOns: ['lamassu_ekata'] })
})
.then(info => info.addOns.results['lamassu_ekata'])
.catch(err => {
if (_.includes(err.code, BAD_NUMBER_CODES)) {
const badNumberError = new Error(err.message)
badNumberError.name = 'BadNumberError'
throw badNumberError
}
throw new Error(`Twilio error: ${err.message}`)
})
}
module.exports = { module.exports = {
NAME, NAME,
sendMessage sendMessage,
getLookup
} }

View file

@ -78,7 +78,7 @@ function balance (account, cryptoCode, settings, operatorId) {
const pendingBalance = (address, cryptoCode) => { const pendingBalance = (address, cryptoCode) => {
const promises = [_balance(true, address, cryptoCode), _balance(false, address, cryptoCode)] const promises = [_balance(true, address, cryptoCode), _balance(false, address, cryptoCode)]
return Promise.all(promises).then(([pending, confirmed]) => pending - confirmed) return Promise.all(promises).then(([pending, confirmed]) => pending.minus(confirmed))
} }
const confirmedBalance = (address, cryptoCode) => _balance(false, address, cryptoCode) const confirmedBalance = (address, cryptoCode) => _balance(false, address, cryptoCode)
@ -126,6 +126,7 @@ function generateTx (_toAddress, wallet, amount, includesFee, cryptoCode) {
const contract = web3.eth.contract(ABI.ERC20).at(coins.utils.getErc20Token(cryptoCode).contractAddress) const contract = web3.eth.contract(ABI.ERC20).at(coins.utils.getErc20Token(cryptoCode).contractAddress)
const rawTx = { const rawTx = {
chainId: 1,
nonce: txCount, nonce: txCount,
gasPrice: hex(gasPrice), gasPrice: hex(gasPrice),
gasLimit: gas, gasLimit: gas,

View file

@ -40,6 +40,7 @@ function poll (req, res, next) {
const hasLightning = checkHasLightning(settings) const hasLightning = checkHasLightning(settings)
const triggers = configManager.getTriggers(settings.config) const triggers = configManager.getTriggers(settings.config)
const triggersAutomation = configManager.getTriggersAutomation(settings.config)
const operatorInfo = configManager.getOperatorInfo(settings.config) const operatorInfo = configManager.getOperatorInfo(settings.config)
const machineInfo = { deviceId: req.deviceId, deviceName: req.deviceName } const machineInfo = { deviceId: req.deviceId, deviceName: req.deviceName }
@ -82,7 +83,8 @@ function poll (req, res, next) {
receipt, receipt,
operatorInfo, operatorInfo,
machineInfo, machineInfo,
triggers triggers,
triggersAutomation
} }
// BACKWARDS_COMPATIBILITY 7.5 // BACKWARDS_COMPATIBILITY 7.5

View file

@ -1,15 +1,28 @@
const ph = require('./plugin-helper') const ph = require('./plugin-helper')
const argv = require('minimist')(process.argv.slice(2)) const argv = require('minimist')(process.argv.slice(2))
function sendMessage (settings, rec) { function getPlugin (settings) {
return Promise.resolve()
.then(() => {
const pluginCode = argv.mockSms ? 'mock-sms' : 'twilio' const pluginCode = argv.mockSms ? 'mock-sms' : 'twilio'
const plugin = ph.load(ph.SMS, pluginCode) const plugin = ph.load(ph.SMS, pluginCode)
const account = settings.accounts[pluginCode] const account = settings.accounts[pluginCode]
return { plugin, account }
}
function sendMessage (settings, rec) {
return Promise.resolve()
.then(() => {
const { plugin, account } = getPlugin(settings)
return plugin.sendMessage(account, rec) return plugin.sendMessage(account, rec)
}) })
} }
module.exports = {sendMessage} function getLookup (settings, number) {
return Promise.resolve()
.then(() => {
const { plugin, account } = getPlugin(settings)
return plugin.getLookup(account, number)
})
}
module.exports = { sendMessage, getLookup }

View file

@ -3,6 +3,9 @@ const db = require('./db')
const BN = require('./bn') const BN = require('./bn')
const CashInTx = require('./cash-in/cash-in-tx') const CashInTx = require('./cash-in/cash-in-tx')
const CashOutTx = require('./cash-out/cash-out-tx') const CashOutTx = require('./cash-out/cash-out-tx')
const T = require('./time')
const REDEEMABLE_AGE = T.day
function process (tx, pi) { function process (tx, pi) {
const mtx = massage(tx, pi) const mtx = massage(tx, pi)
@ -64,23 +67,27 @@ function cancel (txId) {
}) })
} }
function customerHistory (customerId, thresholdDays) { function customerHistory (customerId, thresholdDays) {
const sql = ` SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction const sql = `SELECT * FROM (
SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction,
((NOT txIn.send_confirmed) AND (txIn.created <= now() - interval $3)) AS expired
FROM cash_in_txs txIn FROM cash_in_txs txIn
WHERE txIn.customer_id = $1 WHERE txIn.customer_id = $1
AND txIn.created > now() - interval $2 AND txIn.created > now() - interval $2
AND fiat > 0 AND fiat > 0
UNION UNION
SELECT txOut.id, txOut.created, txOut.fiat, 'cashOut' AS direction SELECT txOut.id, txOut.created, txOut.fiat, 'cashOut' AS direction,
(extract(epoch FROM (now() - greatest(txOut.created, txOut.confirmed_at))) * 1000) >= $4 AS expired
FROM cash_out_txs txOut FROM cash_out_txs txOut
WHERE txOut.customer_id = $1 WHERE txOut.customer_id = $1
AND txOut.created > now() - interval $2 AND txOut.created > now() - interval $2
AND (timedout = true OR error_code != 'operatorCancel') AND error_code IS DISTINCT FROM 'operatorCancel'
AND fiat > 0 AND fiat > 0
ORDER BY created;` ) ch WHERE NOT ch.expired ORDER BY ch.created`
const days = _.isNil(thresholdDays) ? 0 : thresholdDays const days = _.isNil(thresholdDays) ? 0 : thresholdDays
return db.any(sql, [customerId, `${days} days`]) return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE])
} }
module.exports = { post, cancel, customerHistory } module.exports = { post, cancel, customerHistory }

View file

@ -1,9 +1,12 @@
const db = require('./db') const db = require('./db')
const settingsLoader = require('../lib/admin/settings-loader')
const machineLoader = require('../lib/machine-loader') const machineLoader = require('../lib/machine-loader')
const { saveConfig, saveAccounts } = require('../lib/new-settings-loader') const { saveConfig, saveAccounts, loadLatest } = require('../lib/new-settings-loader')
const { migrate } = require('../lib/config-migration') const { migrate } = require('../lib/config-migration')
const _ = require('lodash/fp')
const OLD_SETTINGS_LOADER_SCHEMA_VERSION = 1
module.exports.up = function (next) { module.exports.up = function (next) {
function migrateConfig (settings) { function migrateConfig (settings) {
const newSettings = migrate(settings.config, settings.accounts) const newSettings = migrate(settings.config, settings.accounts)
@ -14,22 +17,34 @@ module.exports.up = function (next) {
.then(() => next()) .then(() => next())
} }
settingsLoader.loadLatest(false) loadLatest(OLD_SETTINGS_LOADER_SCHEMA_VERSION)
.then(async settings => ({ .then(async settings => {
if (_.isEmpty(settings.config)) {
return {
settings,
machines: []
}
}
return {
settings, settings,
machines: await machineLoader.getMachineNames(settings.config) machines: await machineLoader.getMachineNames(settings.config)
})) }
})
.then(({ settings, machines }) => { .then(({ settings, machines }) => {
if (_.isEmpty(settings.config)) {
return next()
}
const sql = machines const sql = machines
? machines.map(m => `update devices set name = '${m.name}' where device_id = '${m.deviceId}'`) ? machines.map(m => `update devices set name = '${m.name}' where device_id = '${m.deviceId}'`)
: [] : []
return db.multi(sql, () => migrateConfig(settings)) return db.multi(sql, () => migrateConfig(settings))
}) })
.catch(err => { .catch(err => {
if (err.message = 'lamassu-server is not configured') if (err.message === 'lamassu-server is not configured') {
next() return next()
}
console.log(err.message) console.log(err.message)
return next(err)
}) })
} }

View file

@ -0,0 +1,13 @@
const db = require('./db')
exports.up = function (next) {
const sql = [
`ALTER TYPE compliance_type ADD VALUE 'us_ssn'`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -0,0 +1,15 @@
var db = require('./db')
exports.up = function (next) {
var sql = [
`ALTER TABLE customers ADD COLUMN subscriber_info JSON`,
`ALTER TABLE customers ADD COLUMN subscriber_info_at TIMESTAMPTZ`,
`ALTER TABLE customers ADD COLUMN subscriber_info_by UUID REFERENCES users(id)`
]
db.multi(sql, next)
}
exports.down = function (next) {
next()
}

View file

@ -14909,6 +14909,11 @@
"integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=",
"dev": true "dev": true
}, },
"is-finite": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz",
"integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w=="
},
"is-fullwidth-code-point": { "is-fullwidth-code-point": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz",
@ -17152,7 +17157,7 @@
"version": "git+https://github.com/lamassu/lamassu-coins.git#f80395e4bab0fccc860de166c97e981ca3ae66a6", "version": "git+https://github.com/lamassu/lamassu-coins.git#f80395e4bab0fccc860de166c97e981ca3ae66a6",
"from": "git+https://github.com/lamassu/lamassu-coins.git", "from": "git+https://github.com/lamassu/lamassu-coins.git",
"requires": { "requires": {
"bech32": "^1.1.3", "bech32": "2.0.0",
"bignumber.js": "^9.0.0", "bignumber.js": "^9.0.0",
"bitcoinjs-lib": "4.0.3", "bitcoinjs-lib": "4.0.3",
"bs58check": "^2.0.2", "bs58check": "^2.0.2",
@ -17160,6 +17165,13 @@
"crypto-js": "^3.1.9-1", "crypto-js": "^3.1.9-1",
"ethereumjs-icap": "^0.3.1", "ethereumjs-icap": "^0.3.1",
"lodash": "^4.17.10" "lodash": "^4.17.10"
},
"dependencies": {
"bech32": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/bech32/-/bech32-2.0.0.tgz",
"integrity": "sha512-LcknSilhIGatDAsY1ak2I8VtGaHNhgMSYVxFrGLXv+xLHytaKZKcaUJJUE7qmBr7h33o5YQwP55pMI0xmkpJwg=="
}
} }
}, },
"language-subtag-registry": { "language-subtag-registry": {
@ -19150,6 +19162,11 @@
"json-parse-better-errors": "^1.0.1" "json-parse-better-errors": "^1.0.1"
} }
}, },
"parse-ms": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-1.0.1.tgz",
"integrity": "sha1-VjRtR0nXjyNDDKDHE4UK75GqNh0="
},
"parse5": { "parse5": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-5.1.1.tgz",
@ -19431,6 +19448,11 @@
"semver-compare": "^1.0.0" "semver-compare": "^1.0.0"
} }
}, },
"plur": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/plur/-/plur-1.0.0.tgz",
"integrity": "sha1-24XGgU9eXlo7Se/CjWBP7GKXUVY="
},
"pnp-webpack-plugin": { "pnp-webpack-plugin": {
"version": "1.6.4", "version": "1.6.4",
"resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz", "resolved": "https://registry.npmjs.org/pnp-webpack-plugin/-/pnp-webpack-plugin-1.6.4.tgz",
@ -20672,6 +20694,16 @@
"integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=",
"dev": true "dev": true
}, },
"pretty-ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-2.1.0.tgz",
"integrity": "sha1-QlfCVt8/sLRR1q/6qwIYhBJpgdw=",
"requires": {
"is-finite": "^1.0.1",
"parse-ms": "^1.0.0",
"plur": "^1.0.0"
}
},
"prismjs": { "prismjs": {
"version": "1.23.0", "version": "1.23.0",
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz", "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.23.0.tgz",

View file

@ -29,6 +29,7 @@
"libphonenumber-js": "^1.7.50", "libphonenumber-js": "^1.7.50",
"match-sorter": "^4.2.0", "match-sorter": "^4.2.0",
"moment": "2.24.0", "moment": "2.24.0",
"pretty-ms": "^2.1.0",
"qrcode.react": "0.9.3", "qrcode.react": "0.9.3",
"ramda": "^0.26.1", "ramda": "^0.26.1",
"react": "^16.12.0", "react": "^16.12.0",

View file

@ -129,6 +129,8 @@ const styles = {
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const ALL = 'all' const ALL = 'all'
const RANGE = 'range' const RANGE = 'range'
const ADVANCED = 'advanced'
const SIMPLIFIED = 'simplified'
const LogsDownloaderPopover = ({ const LogsDownloaderPopover = ({
name, name,
@ -136,9 +138,12 @@ const LogsDownloaderPopover = ({
args, args,
title, title,
getLogs, getLogs,
timezone timezone,
simplified
}) => { }) => {
const [selectedRadio, setSelectedRadio] = useState(ALL) const [selectedRadio, setSelectedRadio] = useState(ALL)
const [selectedAdvancedRadio, setSelectedAdvancedRadio] = useState(ADVANCED)
const [range, setRange] = useState({ from: null, until: null }) const [range, setRange] = useState({ from: null, until: null })
const [anchorEl, setAnchorEl] = useState(null) const [anchorEl, setAnchorEl] = useState(null)
const [fetchLogs] = useLazyQuery(query, { const [fetchLogs] = useLazyQuery(query, {
@ -158,6 +163,11 @@ const LogsDownloaderPopover = ({
if (selectedRadio === ALL) setRange({ from: null, until: null }) if (selectedRadio === ALL) setRange({ from: null, until: null })
} }
const handleAdvancedRadioButtons = evt => {
const selectedAdvancedRadio = R.path(['target', 'value'])(evt)
setSelectedAdvancedRadio(selectedAdvancedRadio)
}
const handleRangeChange = useCallback( const handleRangeChange = useCallback(
(from, until) => { (from, until) => {
setRange({ from, until }) setRange({ from, until })
@ -165,11 +175,12 @@ const LogsDownloaderPopover = ({
[setRange] [setRange]
) )
const downloadLogs = (range, args, fetchLogs) => { const downloadLogs = (range, args) => {
if (selectedRadio === ALL) { if (selectedRadio === ALL) {
fetchLogs({ fetchLogs({
variables: { variables: {
...args ...args,
simplified: selectedAdvancedRadio === SIMPLIFIED
} }
}) })
} }
@ -183,7 +194,8 @@ const LogsDownloaderPopover = ({
variables: { variables: {
...args, ...args,
from: range.from, from: range.from,
until: range.until until: range.until,
simplified: selectedAdvancedRadio === SIMPLIFIED
} }
}) })
} }
@ -221,6 +233,11 @@ const LogsDownloaderPopover = ({
{ display: 'Date range', code: RANGE } { display: 'Date range', code: RANGE }
] ]
const advancedRadioButtonOptions = [
{ display: 'Advanced logs', code: ADVANCED },
{ display: 'Simplified logs', code: SIMPLIFIED }
]
const open = Boolean(anchorEl) const open = Boolean(anchorEl)
const id = open ? 'date-range-popover' : undefined const id = open ? 'date-range-popover' : undefined
@ -265,10 +282,20 @@ const LogsDownloaderPopover = ({
/> />
</div> </div>
)} )}
{simplified && (
<div className={classes.radioButtonsContainer}>
<RadioGroup
name="simplified-tx-logs"
value={selectedAdvancedRadio}
options={advancedRadioButtonOptions}
ariaLabel="simplified-tx-logs"
onChange={handleAdvancedRadioButtons}
className={classes.radioButtons}
/>
</div>
)}
<div className={classes.download}> <div className={classes.download}>
<Link <Link color="primary" onClick={() => downloadLogs(range, args)}>
color="primary"
onClick={() => downloadLogs(range, args, fetchLogs)}>
Download Download
</Link> </Link>
</div> </div>

View file

@ -1,5 +1,6 @@
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import classnames from 'classnames' import classnames from 'classnames'
import moment from 'moment'
import React, { useState, useEffect } from 'react' import React, { useState, useEffect } from 'react'
import Calendar from './Calendar' import Calendar from './Calendar'
@ -37,7 +38,7 @@ const DateRangePicker = ({ minDate, maxDate, className, onRangeChange }) => {
} }
if (from && !to && day.isSameOrAfter(from, 'day')) { if (from && !to && day.isSameOrAfter(from, 'day')) {
setTo(day) setTo(moment(day.toDate().setHours(23, 59, 59, 999)))
return return
} }

View file

@ -0,0 +1,219 @@
import { useMutation, useLazyQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag'
import React, { memo, useState } from 'react'
import { ConfirmDialog } from 'src/components/ConfirmDialog'
import ActionButton from 'src/components/buttons/ActionButton'
import { ReactComponent as EditReversedIcon } from 'src/styling/icons/button/edit/white.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/button/edit/zodiac.svg'
import { ReactComponent as RebootReversedIcon } from 'src/styling/icons/button/reboot/white.svg'
import { ReactComponent as RebootIcon } from 'src/styling/icons/button/reboot/zodiac.svg'
import { ReactComponent as ShutdownReversedIcon } from 'src/styling/icons/button/shut down/white.svg'
import { ReactComponent as ShutdownIcon } from 'src/styling/icons/button/shut down/zodiac.svg'
import { ReactComponent as UnpairReversedIcon } from 'src/styling/icons/button/unpair/white.svg'
import { ReactComponent as UnpairIcon } from 'src/styling/icons/button/unpair/zodiac.svg'
import { machineActionsStyles } from './MachineActions.styles'
const useStyles = makeStyles(machineActionsStyles)
const MACHINE_ACTION = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$newName: String
) {
machineAction(deviceId: $deviceId, action: $action, newName: $newName) {
deviceId
}
}
`
const MACHINE = gql`
query getMachine($deviceId: ID!) {
machine(deviceId: $deviceId) {
latestEvent {
note
}
}
}
`
const isStaticState = machineState => {
if (!machineState) {
return true
}
const staticStates = [
'chooseCoin',
'idle',
'pendingIdle',
'dualIdle',
'networkDown',
'unpaired',
'maintenance',
'virgin',
'wifiList'
]
return staticStates.includes(machineState)
}
const getState = machineEventsLazy =>
JSON.parse(machineEventsLazy.machine.latestEvent?.note ?? '{"state": null}')
.state
const Label = ({ children }) => {
const classes = useStyles()
return <div className={classes.label}>{children}</div>
}
const MachineActions = memo(({ machine, onActionSuccess }) => {
const [action, setAction] = useState({ command: null })
const [errorMessage, setErrorMessage] = useState(null)
const classes = useStyles()
const warningMessage = (
<span className={classes.warning}>
A user may be in the middle of a transaction and they could lose their
funds if you continue.
</span>
)
const [fetchMachineEvents, { loading: loadingEvents }] = useLazyQuery(
MACHINE,
{
variables: {
deviceId: machine.deviceId
},
onCompleted: machineEventsLazy => {
const message = !isStaticState(getState(machineEventsLazy))
? warningMessage
: null
setAction(action => ({ ...action, message }))
}
}
)
const [machineAction, { loading }] = useMutation(MACHINE_ACTION, {
onError: ({ message }) => {
const errorMessage = message ?? 'An error ocurred'
setErrorMessage(errorMessage)
},
onCompleted: () => {
onActionSuccess && onActionSuccess()
setAction({ command: null })
}
})
const confirmDialogOpen = Boolean(action.command)
const disabled = !!(action?.command === 'restartServices' && loadingEvents)
return (
<div>
<Label>Actions</Label>
<div className={classes.stack}>
<ActionButton
color="primary"
className={classes.mr}
Icon={EditIcon}
InverseIcon={EditReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'rename',
display: 'Rename',
confirmationMessage: 'Write the new name for this machine'
})
}>
Rename
</ActionButton>
<ActionButton
color="primary"
className={classes.mr}
Icon={UnpairIcon}
InverseIcon={UnpairReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'unpair',
display: 'Unpair'
})
}>
Unpair
</ActionButton>
<ActionButton
color="primary"
className={classes.mr}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'reboot',
display: 'Reboot'
})
}>
Reboot
</ActionButton>
<ActionButton
color="primary"
className={classes.mr}
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'shutdown',
display: 'Shutdown',
message:
'In order to bring it back online, the machine will need to be visited and its power reset.'
})
}>
Shutdown
</ActionButton>
<ActionButton
color="primary"
className={classes.inlineChip}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
fetchMachineEvents()
setAction({
command: 'restartServices',
display: 'Restart services for'
})
}}>
Restart Services
</ActionButton>
</div>
<ConfirmDialog
disabled={disabled}
open={confirmDialogOpen}
title={`${action?.display} this machine?`}
errorMessage={errorMessage}
toBeConfirmed={machine.name}
message={action?.message}
confirmationMessage={action?.confirmationMessage}
saveButtonAlwaysEnabled={action?.command === 'rename'}
onConfirmed={value => {
setErrorMessage(null)
machineAction({
variables: {
deviceId: machine.deviceId,
action: `${action?.command}`,
...(action?.command === 'rename' && { newName: value })
}
})
}}
onDissmised={() => {
setAction({ command: null })
setErrorMessage(null)
}}
/>
</div>
)
})
export default MachineActions

View file

@ -0,0 +1,30 @@
import typographyStyles from 'src/components/typography/styles'
import { offColor, spacer, errorColor } from 'src/styling/variables'
const { label1 } = typographyStyles
const machineActionsStyles = {
label: {
extend: label1,
color: offColor,
marginBottom: 4
},
inlineChip: {
marginInlineEnd: '0.25em'
},
stack: {
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
justifyContent: 'start'
},
mr: {
marginRight: spacer,
marginBottom: spacer
},
warning: {
color: errorColor
}
}
export { machineActionsStyles }

View file

@ -49,7 +49,7 @@ const Commissions = ({ name: SCREEN_KEY }) => {
const classes = useStyles() const classes = useStyles()
const [showMachines, setShowMachines] = useState(false) const [showMachines, setShowMachines] = useState(false)
const [error, setError] = useState(null) const [error, setError] = useState(null)
const { data } = useQuery(GET_DATA) const { data, loading } = useQuery(GET_DATA)
const [saveConfig] = useMutation(SAVE_CONFIG, { const [saveConfig] = useMutation(SAVE_CONFIG, {
refetchQueries: () => ['getData'], refetchQueries: () => ['getData'],
onError: error => setError(error) onError: error => setError(error)
@ -118,7 +118,7 @@ const Commissions = ({ name: SCREEN_KEY }) => {
iconClassName={classes.listViewButton} iconClassName={classes.listViewButton}
/> />
{!showMachines && ( {!showMachines && !loading && (
<CommissionsDetails <CommissionsDetails
config={config} config={config}
locale={localeConfig} locale={localeConfig}
@ -130,7 +130,7 @@ const Commissions = ({ name: SCREEN_KEY }) => {
classes={classes} classes={classes}
/> />
)} )}
{showMachines && ( {showMachines && !loading && (
<CommissionsList <CommissionsList
config={config} config={config}
localeConfig={localeConfig} localeConfig={localeConfig}

View file

@ -98,7 +98,7 @@ const CommissionsList = memo(
const [coinFilter, setCoinFilter] = useState(SHOW_ALL) const [coinFilter, setCoinFilter] = useState(SHOW_ALL)
const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0]) const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0])
const coins = R.prop('cryptoCurrencies', localeConfig) const coins = R.prop('cryptoCurrencies', localeConfig) ?? []
const getMachineCoins = deviceId => { const getMachineCoins = deviceId => {
const override = R.prop('overrides', localeConfig)?.find( const override = R.prop('overrides', localeConfig)?.find(

View file

@ -395,7 +395,7 @@ const createCommissions = (cryptoCode, deviceId, isDefault, config) => {
} }
const getCommissions = (cryptoCode, deviceId, config) => { const getCommissions = (cryptoCode, deviceId, config) => {
const overrides = R.prop('overrides', config) const overrides = R.prop('overrides', config) ?? []
if (!overrides && R.isEmpty(overrides)) { if (!overrides && R.isEmpty(overrides)) {
return createCommissions(cryptoCode, deviceId, true, config) return createCommissions(cryptoCode, deviceId, true, config)

View file

@ -97,6 +97,7 @@ const SET_CUSTOMER = gql`
lastTxFiat lastTxFiat
lastTxFiatCode lastTxFiatCode
lastTxClass lastTxClass
subscriberInfo
} }
} }
` `
@ -202,6 +203,24 @@ const CustomerProfile = memo(() => {
}> }>
{`${blocked ? 'Authorize' : 'Block'} customer`} {`${blocked ? 'Authorize' : 'Block'} customer`}
</ActionButton> </ActionButton>
<ActionButton
color="primary"
Icon={blocked ? AuthorizeIcon : BlockIcon}
InverseIcon={
blocked ? AuthorizeReversedIcon : BlockReversedIcon
}
onClick={() =>
setCustomer({
variables: {
customerId,
customerInput: {
subscriberInfo: true
}
}
})
}>
{`Retrieve information`}
</ActionButton>
</div> </div>
</div> </div>
)} )}

View file

@ -46,7 +46,7 @@ const IdDataCard = memo(({ customerData, updateCustomer }) => {
}, },
{ {
header: 'Gender', header: 'Gender',
display: R.path(['gender'])(idData), display: R.path(['gender'])(idData) ?? R.path(['sex'])(idData),
size: 80 size: 80
}, },
{ {

View file

@ -31,27 +31,29 @@ BigNumber.config({ ROUNDING_MODE: BigNumber.ROUND_HALF_UP })
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const Footer = () => { const Footer = () => {
const { data, loading } = useQuery(GET_DATA) const { data } = useQuery(GET_DATA)
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const [delayedExpand, setDelayedExpand] = useState(null) const [delayedExpand, setDelayedExpand] = useState(null)
const withCommissions = R.path(['cryptoRates', 'withCommissions'])(data) ?? {}
const classes = useStyles({ const classes = useStyles({
bigFooter: R.keys(data?.cryptoRates?.withCommissions).length > 8, bigFooter: R.keys(withCommissions).length > 8,
expanded expanded
}) })
const config = R.path(['config'])(data) ?? {}
const canExpand = R.keys(withCommissions).length > 4
const canExpand = R.keys(data?.cryptoRates.withCommissions ?? []).length > 4 const wallets = fromNamespace('wallets')(config)
const cryptoCurrencies = R.path(['cryptoCurrencies'])(data) ?? []
const wallets = fromNamespace('wallets')(data?.config) const accountsConfig = R.path(['accountsConfig'])(data) ?? []
const localeFiatCurrency = R.path(['locale_fiatCurrency'])(config) ?? ''
const renderFooterItem = key => { const renderFooterItem = key => {
const idx = R.findIndex(R.propEq('code', key))(data.cryptoCurrencies) const idx = R.findIndex(R.propEq('code', key))(cryptoCurrencies)
const tickerCode = wallets[`${key}_ticker`] const tickerCode = wallets[`${key}_ticker`]
const tickerIdx = R.findIndex(R.propEq('code', tickerCode))( const tickerIdx = R.findIndex(R.propEq('code', tickerCode))(accountsConfig)
data.accountsConfig
)
const tickerName = data.accountsConfig[tickerIdx].display const tickerName = tickerIdx > -1 ? accountsConfig[tickerIdx].display : ''
const cashInNoCommission = parseFloat( const cashInNoCommission = parseFloat(
R.path(['cryptoRates', 'withoutCommissions', key, 'cashIn'])(data) R.path(['cryptoRates', 'withoutCommissions', key, 'cashIn'])(data)
@ -74,12 +76,10 @@ const Footer = () => {
) )
).toFormat(2) ).toFormat(2)
const localeFiatCurrency = data.config.locale_fiatCurrency
return ( return (
<Grid key={key} item xs={3}> <Grid key={key} item xs={3}>
<Label2 className={classes.label}> <Label2 className={classes.label}>
{data.cryptoCurrencies[idx].display} {cryptoCurrencies[idx].display}
</Label2> </Label2>
<div className={classes.headerLabels}> <div className={classes.headerLabels}>
<div className={classes.headerLabel}> <div className={classes.headerLabel}>
@ -116,15 +116,11 @@ const Footer = () => {
onMouseEnter={handleMouseEnter} onMouseEnter={handleMouseEnter}
/> />
<div className={classes.content}> <div className={classes.content}>
{!loading && data && (
<Grid container spacing={1}> <Grid container spacing={1}>
<Grid container className={classes.footerContainer}> <Grid container className={classes.footerContainer}>
{R.keys(data.cryptoRates.withCommissions).map(key => {R.keys(withCommissions).map(key => renderFooterItem(key))}
renderFooterItem(key)
)}
</Grid> </Grid>
</Grid> </Grid>
)}
</div> </div>
<div className={classes.footer} /> <div className={classes.footer} />
</> </>

View file

@ -13,6 +13,7 @@ import { Status } from 'src/components/Status'
import { Label2, TL2 } from 'src/components/typography' import { Label2, TL2 } from 'src/components/typography'
// import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg' // import { ReactComponent as TxInIcon } from 'src/styling/icons/direction/cash-in.svg'
import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg' import { ReactComponent as TxOutIcon } from 'src/styling/icons/direction/cash-out.svg'
import { ReactComponent as MachineLinkIcon } from 'src/styling/icons/month arrows/right.svg'
import styles from './MachinesTable.styles' import styles from './MachinesTable.styles'
@ -99,10 +100,19 @@ const MachinesTable = ({ machines, numToRender }) => {
return ( return (
<TableRow <TableRow
onClick={() => redirect(machine)} onClick={() => redirect(machine)}
className={classnames(classes.row, classes.clickableRow)} className={classnames(classes.row)}
key={machine.deviceId + idx}> key={machine.deviceId + idx}>
<StyledCell align="left"> <StyledCell
align="left"
className={classes.machineNameWrapper}>
<TL2>{machine.name}</TL2> <TL2>{machine.name}</TL2>
<MachineLinkIcon
className={classnames(
classes.machineRedirectIcon,
classes.clickableRow
)}
onClick={() => redirect(machine)}
/>
</StyledCell> </StyledCell>
<StyledCell> <StyledCell>
<Status status={machine.statuses[0]} /> <Status status={machine.statuses[0]} />

View file

@ -87,6 +87,14 @@ const styles = {
marginBottom: 0, marginBottom: 0,
padding: 0, padding: 0,
textAlign: 'center' textAlign: 'center'
},
machineNameWrapper: {
display: 'flex',
flexDirection: 'row',
alignItems: 'center'
},
machineRedirectIcon: {
marginLeft: 10
} }
} }

View file

@ -73,7 +73,7 @@ const CashCassettes = ({ machine, config, refetchData }) => {
name: 'cashbox', name: 'cashbox',
header: 'Cashbox', header: 'Cashbox',
width: 240, width: 240,
stripe: true, stripe: false,
view: value => ( view: value => (
<CashIn currency={{ code: fiatCurrency }} notes={value} total={0} /> <CashIn currency={{ code: fiatCurrency }} notes={value} total={0} />
), ),
@ -90,7 +90,7 @@ const CashCassettes = ({ machine, config, refetchData }) => {
view: (value, { deviceId }) => ( view: (value, { deviceId }) => (
<CashOut <CashOut
className={classes.cashbox} className={classes.cashbox}
denomination={getCashoutSettings(deviceId)?.bottom} denomination={getCashoutSettings(deviceId)?.top}
currency={{ code: fiatCurrency }} currency={{ code: fiatCurrency }}
notes={value} notes={value}
/> />
@ -109,7 +109,7 @@ const CashCassettes = ({ machine, config, refetchData }) => {
return ( return (
<CashOut <CashOut
className={classes.cashbox} className={classes.cashbox}
denomination={getCashoutSettings(deviceId)?.top} denomination={getCashoutSettings(deviceId)?.bottom}
currency={{ code: fiatCurrency }} currency={{ code: fiatCurrency }}
notes={value} notes={value}
/> />
@ -145,7 +145,6 @@ const CashCassettes = ({ machine, config, refetchData }) => {
disableRowEdit={isCashOutDisabled} disableRowEdit={isCashOutDisabled}
name="cashboxes" name="cashboxes"
elements={elements} elements={elements}
enableEdit
data={[machine] || []} data={[machine] || []}
save={onSave} save={onSave}
validationSchema={ValidationSchema} validationSchema={ValidationSchema}

View file

@ -1,36 +1,15 @@
import { useMutation } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import gql from 'graphql-tag'
import moment from 'moment' import moment from 'moment'
import React, { useState } from 'react' import React from 'react'
import { ConfirmDialog } from 'src/components/ConfirmDialog'
import { Status } from 'src/components/Status' import { Status } from 'src/components/Status'
import ActionButton from 'src/components/buttons/ActionButton' import MachineActions from 'src/components/machineActions/MachineActions'
import { H3, Label3, P } from 'src/components/typography' import { H3, Label3, P } from 'src/components/typography'
import { ReactComponent as RebootReversedIcon } from 'src/styling/icons/button/reboot/white.svg'
import { ReactComponent as RebootIcon } from 'src/styling/icons/button/reboot/zodiac.svg'
import { ReactComponent as ShutdownReversedIcon } from 'src/styling/icons/button/shut down/white.svg'
import { ReactComponent as ShutdownIcon } from 'src/styling/icons/button/shut down/zodiac.svg'
import { ReactComponent as UnpairReversedIcon } from 'src/styling/icons/button/unpair/white.svg'
import { ReactComponent as UnpairIcon } from 'src/styling/icons/button/unpair/zodiac.svg'
import styles from '../Machines.styles' import styles from '../Machines.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const MACHINE_ACTION = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$newName: String
) {
machineAction(deviceId: $deviceId, action: $action, newName: $newName) {
deviceId
}
}
`
const makeLastPing = lastPing => { const makeLastPing = lastPing => {
if (!lastPing) return null if (!lastPing) return null
const now = moment() const now = moment()
@ -51,25 +30,8 @@ const makeLastPing = lastPing => {
} }
const Overview = ({ data, onActionSuccess }) => { const Overview = ({ data, onActionSuccess }) => {
const [action, setAction] = useState('')
const [confirmActionDialogOpen, setConfirmActionDialogOpen] = useState(false)
const [errorMessage, setErrorMessage] = useState(null)
const classes = useStyles() const classes = useStyles()
const [machineAction] = useMutation(MACHINE_ACTION, {
onError: ({ message }) => {
const errorMessage = message ?? 'An error ocurred'
setErrorMessage(errorMessage)
},
onCompleted: () => {
onActionSuccess && onActionSuccess()
setConfirmActionDialogOpen(false)
}
})
const confirmActionDialog = action =>
setAction(action) || setConfirmActionDialogOpen(true)
return ( return (
<> <>
<div className={classes.row}> <div className={classes.row}>
@ -101,78 +63,10 @@ const Overview = ({ data, onActionSuccess }) => {
</div> </div>
</div> </div>
<div className={classes.row}> <div className={classes.row}>
<div className={classes.rowItem}> <MachineActions
<Label3 className={classes.label3}>Latency</Label3> machine={data}
<P> onActionSuccess={onActionSuccess}></MachineActions>
{data.responseTime
? new BigNumber(data.responseTime).toFixed(3).toString() + ' ms'
: 'unavailable'}
</P>
</div> </div>
</div>
<div className={classes.row}>
<div className={classes.rowItem}>
<Label3 className={classes.label3}>Packet Loss</Label3>
<P>
{data.packetLoss
? new BigNumber(data.packetLoss).toFixed(3).toString() + ' %'
: 'unavailable'}
</P>
</div>
</div>
<div className={classes.row}>
<div className={classes.rowItem}>
{' '}
<Label3 className={classes.label3}>Actions</Label3>
{data.name && (
<div className={classes.actionButtonsContainer}>
<ActionButton
color="primary"
className={classes.actionButton}
Icon={UnpairIcon}
InverseIcon={UnpairReversedIcon}
onClick={() => confirmActionDialog('Unpair')}>
Unpair
</ActionButton>
<ActionButton
color="primary"
className={classes.actionButton}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
onClick={() => confirmActionDialog('Reboot')}>
Reboot
</ActionButton>
<ActionButton
className={classes.actionButton}
color="primary"
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
onClick={() => confirmActionDialog('Shutdown')}>
Shutdown
</ActionButton>
</div>
)}
</div>
</div>
<ConfirmDialog
open={confirmActionDialogOpen}
title={`${action} this machine?`}
errorMessage={errorMessage}
toBeConfirmed={data.name}
onConfirmed={() => {
setErrorMessage(null)
machineAction({
variables: {
deviceId: data.deviceId,
action: `${action}`.toLowerCase()
}
})
}}
onDissmised={() => {
setConfirmActionDialogOpen(false)
setErrorMessage(null)
}}
/>
</> </>
) )
} }

View file

@ -104,7 +104,7 @@ const DataTable = ({
useEffect(() => setExpanded(initialExpanded), [initialExpanded]) useEffect(() => setExpanded(initialExpanded), [initialExpanded])
const coreWidth = R.compose(R.sum, R.map(R.prop('width')))(elements) const coreWidth = R.compose(R.sum, R.map(R.prop('width')))(elements)
const expWidth = 1000 - coreWidth const expWidth = 850 - coreWidth
const width = coreWidth + (expandable ? expWidth : 0) const width = coreWidth + (expandable ? expWidth : 0)
const classes = useStyles({ width }) const classes = useStyles({ width })
@ -166,7 +166,7 @@ const DataTable = ({
{() => ( {() => (
<List <List
// this has to be in a style because of how the component works // this has to be in a style because of how the component works
style={{ overflow: 'inherit', outline: 'none' }} style={{ overflowX: 'inherit', outline: 'none' }}
{...props} {...props}
height={data.length * 62 + extraHeight} height={data.length * 62 + extraHeight}
width={width} width={width}

View file

@ -50,6 +50,9 @@ const GET_TRANSACTIONS = gql`
customerIdCardPhotoPath customerIdCardPhotoPath
customerFrontCameraPath customerFrontCameraPath
customerPhone customerPhone
discount
customerId
isAnonymous
} }
} }
` `
@ -95,13 +98,13 @@ const Transactions = ({ id }) => {
const elements = [ const elements = [
{ {
header: '', header: '',
width: 62, width: 0,
size: 'sm', size: 'sm',
view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />) view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />)
}, },
{ {
header: 'Customer', header: 'Customer',
width: 162, width: 122,
size: 'sm', size: 'sm',
view: getCustomerDisplayName view: getCustomerDisplayName
}, },
@ -128,20 +131,20 @@ const Transactions = ({ id }) => {
className: classes.overflowTd, className: classes.overflowTd,
size: 'sm', size: 'sm',
textAlign: 'left', textAlign: 'left',
width: 170 width: 140
}, },
{ {
header: 'Date (UTC)', header: 'Date (UTC)',
view: it => moment.utc(it.created).format('YYYY-MM-DD'), view: it => moment.utc(it.created).format('YYYY-MM-DD'),
textAlign: 'left', textAlign: 'left',
size: 'sm', size: 'sm',
width: 150 width: 140
}, },
{ {
header: 'Status', header: 'Status',
view: it => getStatus(it), view: it => getStatus(it),
size: 'sm', size: 'sm',
width: 80 width: 20
} }
] ]
@ -162,8 +165,7 @@ const Transactions = ({ id }) => {
loading={loading || id === null} loading={loading || id === null}
emptyText="No transactions so far" emptyText="No transactions so far"
elements={elements} elements={elements}
// need to splice because back end query could return double NUM_LOG_RESULTS because it doesnt merge the txIn and the txOut results before applying the limit data={R.path(['transactions'])(txResponse)}
data={R.path(['transactions'])(txResponse)} // .splice(0,NUM_LOG_RESULTS)}
Details={DetailsRow} Details={DetailsRow}
expandable expandable
/> />

View file

@ -6,7 +6,7 @@ import NavigateNextIcon from '@material-ui/icons/NavigateNext'
import classnames from 'classnames' import classnames from 'classnames'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useState, useEffect } from 'react' import React from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import { TL1, TL2, Label3 } from 'src/components/typography' import { TL1, TL2, Label3 } from 'src/components/typography'
@ -19,11 +19,9 @@ import Transactions from './MachineComponents/Transactions'
import styles from './Machines.styles' import styles from './Machines.styles'
const useStyles = makeStyles(styles) const useStyles = makeStyles(styles)
const getMachineInfo = R.compose(R.find, R.propEq('name'))
const GET_INFO = gql` const GET_INFO = gql`
query getInfo { query getMachine($deviceId: ID!) {
machines { machine(deviceId: $deviceId) {
name name
deviceId deviceId
paired paired
@ -41,33 +39,32 @@ const GET_INFO = gql`
downloadSpeed downloadSpeed
responseTime responseTime
packetLoss packetLoss
latestEvent {
note
}
} }
config config
} }
` `
const getMachines = R.path(['machines']) const getMachineID = path => path.slice(path.lastIndexOf('/') + 1)
const Machines = () => { const Machines = () => {
const { data, refetch, loading } = useQuery(GET_INFO)
const location = useLocation() const location = useLocation()
const [selectedMachine, setSelectedMachine] = useState('') const { data, refetch } = useQuery(GET_INFO, {
variables: {
deviceId: getMachineID(location.pathname)
}
})
const classes = useStyles() const classes = useStyles()
const machines = getMachines(data) ?? []
const machineInfo = getMachineInfo(selectedMachine)(machines) ?? {}
const timezone = R.path(['config', 'locale_timezone'], data) ?? {} const timezone = R.path(['config', 'locale_timezone'], data) ?? {}
// pre-selects first machine from the list, if there is a machine configured. const machine = R.path(['machine'])(data) ?? {}
useEffect(() => { const config = R.path(['config'])(data) ?? {}
if (!loading && data && data.machines) {
if (location.state && location.state.selectedMachine) { const machineName = R.path(['name'])(machine) ?? null
setSelectedMachine(location.state.selectedMachine) const machineID = R.path(['deviceId'])(machine) ?? null
} else {
setSelectedMachine(R.path(['machines', 0, 'name'])(data) ?? '')
}
}
}, [loading, data, location.state])
return ( return (
<Grid container className={classes.grid}> <Grid container className={classes.grid}>
@ -81,10 +78,10 @@ const Machines = () => {
</Label3> </Label3>
</Link> </Link>
<TL2 noMargin className={classes.subtitle}> <TL2 noMargin className={classes.subtitle}>
{selectedMachine} {machineName}
</TL2> </TL2>
</Breadcrumbs> </Breadcrumbs>
<Overview data={machineInfo} onActionSuccess={refetch} /> <Overview data={machine} onActionSuccess={refetch} />
</div> </div>
</Grid> </Grid>
<Grid item xs={12}> <Grid item xs={12}>
@ -102,26 +99,23 @@ const Machines = () => {
<div <div
className={classnames(classes.detailItem, classes.detailsMargin)}> className={classnames(classes.detailItem, classes.detailsMargin)}>
<TL1 className={classes.subtitle}>{'Details'}</TL1> <TL1 className={classes.subtitle}>{'Details'}</TL1>
<Details data={machineInfo} timezone={timezone} /> <Details data={machine} timezone={timezone} />
</div> </div>
<div className={classes.detailItem}> <div className={classes.detailItem}>
<TL1 className={classes.subtitle}>{'Cash cassettes'}</TL1> <TL1 className={classes.subtitle}>{'Cash cassettes'}</TL1>
<Cassettes <Cassettes
refetchData={refetch} refetchData={refetch}
machine={machineInfo} machine={machine}
config={data?.config ?? false} config={config ?? false}
/> />
</div> </div>
<div className={classes.transactionsItem}> <div className={classes.transactionsItem}>
<TL1 className={classes.subtitle}>{'Latest transactions'}</TL1> <TL1 className={classes.subtitle}>{'Latest transactions'}</TL1>
<Transactions id={machineInfo?.deviceId ?? null} /> <Transactions id={machineID} />
</div> </div>
<div className={classes.detailItem}> <div className={classes.detailItem}>
<TL1 className={classes.subtitle}>{'Commissions'}</TL1> <TL1 className={classes.subtitle}>{'Commissions'}</TL1>
<Commissions <Commissions name={'commissions'} id={machineID} />
name={'commissions'}
id={machineInfo?.deviceId ?? null}
/>
</div> </div>
</div> </div>
</Grid> </Grid>

View file

@ -1,10 +1,4 @@
import { import { spacer, comet } from 'src/styling/variables'
spacer,
fontPrimary,
primaryColor,
white,
comet
} from 'src/styling/variables'
const styles = { const styles = {
grid: { grid: {
@ -18,16 +12,6 @@ const styles = {
marginLeft: spacer * 6, marginLeft: spacer * 6,
maxWidth: 900 maxWidth: 900
}, },
footer: {
margin: [['auto', 0, spacer * 3, 'auto']]
},
modalTitle: {
lineHeight: '120%',
color: primaryColor,
fontSize: 14,
fontFamily: fontPrimary,
fontWeight: 900
},
subtitle: { subtitle: {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -39,15 +23,6 @@ const styles = {
color: comet, color: comet,
marginTop: 0 marginTop: 0
}, },
white: {
color: white
},
deleteButton: {
paddingLeft: 13
},
addressRow: {
marginLeft: 8
},
row: { row: {
display: 'flex', display: 'flex',
flexDirection: 'row', flexDirection: 'row',
@ -60,16 +35,10 @@ const styles = {
detailItem: { detailItem: {
marginBottom: spacer * 4 marginBottom: spacer * 4
}, },
transactionsItem: {
marginBottom: -spacer * 4
},
actionButtonsContainer: { actionButtonsContainer: {
display: 'flex', display: 'flex',
flexDirection: 'row' flexDirection: 'row'
}, },
actionButton: {
marginRight: 8
},
breadcrumbsContainer: { breadcrumbsContainer: {
marginTop: 32 marginTop: 32
}, },

View file

@ -25,10 +25,15 @@ const CashCassettesFooter = ({
const classes = useStyles() const classes = useStyles()
const cashout = config && fromNamespace('cashOut')(config) const cashout = config && fromNamespace('cashOut')(config)
const getCashoutSettings = id => fromNamespace(id)(cashout) const getCashoutSettings = id => fromNamespace(id)(cashout)
const reducerFn = (acc, { cassette1, cassette2, id }) => [ const reducerFn = (acc, { cassette1, cassette2, id }) => {
(acc[0] += cassette1 * getCashoutSettings(id).top), const topDenomination = getCashoutSettings(id).top ?? 0
(acc[1] += cassette2 * getCashoutSettings(id).bottom) const bottomDenomination = getCashoutSettings(id).bottom ?? 0
return [
(acc[0] += cassette1 * topDenomination),
(acc[1] += cassette2 * bottomDenomination)
] ]
}
const totalInCassettes = R.sum(R.reduce(reducerFn, [0, 0], machines)) const totalInCassettes = R.sum(R.reduce(reducerFn, [0, 0], machines))
/* const totalInCashBox = R.sum( /* const totalInCashBox = R.sum(

View file

@ -1,48 +1,16 @@
import { useMutation, useLazyQuery } from '@apollo/react-hooks'
import { Grid /*, Divider */ } from '@material-ui/core' import { Grid /*, Divider */ } from '@material-ui/core'
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import gql from 'graphql-tag' import BigNumber from 'bignumber.js'
import React, { useState } from 'react' import React from 'react'
import { ConfirmDialog } from 'src/components/ConfirmDialog'
// import { Status } from 'src/components/Status' // import { Status } from 'src/components/Status'
import ActionButton from 'src/components/buttons/ActionButton'
import { ReactComponent as EditReversedIcon } from 'src/styling/icons/button/edit/white.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/button/edit/zodiac.svg'
// import { ReactComponent as LinkIcon } from 'src/styling/icons/button/link/zodiac.svg' // import { ReactComponent as LinkIcon } from 'src/styling/icons/button/link/zodiac.svg'
import { ReactComponent as RebootReversedIcon } from 'src/styling/icons/button/reboot/white.svg' import MachineActions from 'src/components/machineActions/MachineActions'
import { ReactComponent as RebootIcon } from 'src/styling/icons/button/reboot/zodiac.svg'
import { ReactComponent as ShutdownReversedIcon } from 'src/styling/icons/button/shut down/white.svg'
import { ReactComponent as ShutdownIcon } from 'src/styling/icons/button/shut down/zodiac.svg'
import { ReactComponent as UnpairReversedIcon } from 'src/styling/icons/button/unpair/white.svg'
import { ReactComponent as UnpairIcon } from 'src/styling/icons/button/unpair/zodiac.svg'
import { modelPrettifier } from 'src/utils/machine' import { modelPrettifier } from 'src/utils/machine'
import { formatDate } from 'src/utils/timezones' import { formatDate } from 'src/utils/timezones'
import { labelStyles, machineDetailsStyles } from './MachineDetailsCard.styles' import { labelStyles, machineDetailsStyles } from './MachineDetailsCard.styles'
const MACHINE_ACTION = gql`
mutation MachineAction(
$deviceId: ID!
$action: MachineAction!
$newName: String
) {
machineAction(deviceId: $deviceId, action: $action, newName: $newName) {
deviceId
}
}
`
const MACHINE = gql`
query getMachine($deviceId: ID!) {
machine(deviceId: $deviceId) {
latestEvent {
note
}
}
}
`
// const supportArtices = [ // const supportArtices = [
// { // {
// // Default article for non-maped statuses // // Default article for non-maped statuses
@ -54,24 +22,6 @@ const MACHINE = gql`
// // TODO add Stuck and Fully Functional statuses articles for the new-admins // // TODO add Stuck and Fully Functional statuses articles for the new-admins
// ] // ]
const isStaticState = machineState => {
if (!machineState) {
return true
}
const staticStates = [
'chooseCoin',
'idle',
'pendingIdle',
'dualIdle',
'networkDown',
'unpaired',
'maintenance',
'virgin',
'wifiList'
]
return staticStates.includes(machineState)
}
// const article = ({ code: status }) => // const article = ({ code: status }) =>
// supportArtices.find(({ code: article }) => article === status) // supportArtices.find(({ code: article }) => article === status)
@ -97,51 +47,9 @@ const Item = ({ children, ...props }) => (
</Grid> </Grid>
) )
const getState = machineEventsLazy =>
JSON.parse(machineEventsLazy.machine.latestEvent?.note ?? '{"state": null}')
.state
const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => { const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => {
const [action, setAction] = useState({ command: null })
const [errorMessage, setErrorMessage] = useState(null)
const classes = useMDStyles() const classes = useMDStyles()
const warningMessage = (
<span className={classes.warning}>
A user may be in the middle of a transaction and they could lose their
funds if you continue.
</span>
)
const [fetchMachineEvents, { loading: loadingEvents }] = useLazyQuery(
MACHINE,
{
variables: {
deviceId: machine.deviceId
},
onCompleted: machineEventsLazy => {
const message = !isStaticState(getState(machineEventsLazy))
? warningMessage
: null
setAction(action => ({ ...action, message }))
}
}
)
const [machineAction, { loading }] = useMutation(MACHINE_ACTION, {
onError: ({ message }) => {
const errorMessage = message ?? 'An error ocurred'
setErrorMessage(errorMessage)
},
onCompleted: () => {
onActionSuccess && onActionSuccess()
setAction({ command: null })
}
})
const confirmDialogOpen = Boolean(action.command)
const disabled = !!(action?.command === 'restartServices' && loadingEvents)
return ( return (
<Container className={classes.wrapper}> <Container className={classes.wrapper}>
{/* <Item xs={5}> {/* <Item xs={5}>
@ -181,30 +89,6 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => {
flexItem flexItem
className={classes.separator} className={classes.separator}
/> */} /> */}
<ConfirmDialog
disabled={disabled}
open={confirmDialogOpen}
title={`${action?.display} this machine?`}
errorMessage={errorMessage}
toBeConfirmed={machine.name}
message={action?.message}
confirmationMessage={action?.confirmationMessage}
saveButtonAlwaysEnabled={action?.command === 'rename'}
onConfirmed={value => {
setErrorMessage(null)
machineAction({
variables: {
deviceId: machine.deviceId,
action: `${action?.command}`,
...(action?.command === 'rename' && { newName: value })
}
})
}}
onDissmised={() => {
setAction({ command: null })
setErrorMessage(null)
}}
/>
<Item xs> <Item xs>
<Container className={classes.row}> <Container className={classes.row}>
<Item xs={2}> <Item xs={2}>
@ -219,166 +103,38 @@ const MachineDetailsRow = ({ it: machine, onActionSuccess, timezone }) => {
</span> </span>
</Item> </Item>
<Item xs={6}> <Item xs={6}>
<Label>Actions</Label> <MachineActions
<div className={classes.stack}> machine={machine}
<ActionButton onActionSuccess={onActionSuccess}></MachineActions>
className={classes.mr} </Item>
disabled={loading} <Item xs={2}>
color="primary" <Label>Network speed</Label>
Icon={EditIcon} <span>
InverseIcon={EditReversedIcon} {machine.downloadSpeed
onClick={() => ? new BigNumber(machine.downloadSpeed).toFixed(4).toString() +
setAction({ ' MB/s'
command: 'rename', : 'unavailable'}
display: 'Rename', </span>
confirmationMessage: 'Write the new name for this machine' </Item>
}) <Item xs={2}>
}> <Label>Latency</Label>
Rename <span>
</ActionButton> {machine.responseTime
<ActionButton ? new BigNumber(machine.responseTime).toFixed(3).toString() +
color="primary" ' ms'
className={classes.mr} : 'unavailable'}
Icon={UnpairIcon} </span>
InverseIcon={UnpairReversedIcon} </Item>
disabled={loading} <Item xs={2}>
onClick={() => <Label>Packet Loss</Label>
setAction({ <span>
command: 'unpair', {machine.packetLoss
display: 'Unpair' ? new BigNumber(machine.packetLoss).toFixed(3).toString() +
}) ' %'
}> : 'unavailable'}
Unpair </span>
</ActionButton>
<ActionButton
color="primary"
className={classes.mr}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'reboot',
display: 'Reboot'
})
}>
Reboot
</ActionButton>
<ActionButton
className={classes.mr}
disabled={loading}
color="primary"
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
onClick={() =>
setAction({
command: 'shutdown',
display: 'Shutdown',
message:
'In order to bring it back online, the machine will need to be visited and its power reset.'
})
}>
Shutdown
</ActionButton>
<ActionButton
color="primary"
className={classes.inlineChip}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
fetchMachineEvents()
setAction({
command: 'restartServices',
display: 'Restart services for'
})
}}>
Restart Services
</ActionButton>
</div>
</Item> </Item>
</Container> </Container>
{/* <Container>
<Item>
<Label>Actions</Label>
<div className={classes.stack}>
<ActionButton
className={classes.mr}
disabled={loading}
color="primary"
Icon={EditIcon}
InverseIcon={EditReversedIcon}
onClick={() =>
setAction({
command: 'rename',
display: 'Rename',
confirmationMessage: 'Write the new name for this machine'
})
}>
Rename
</ActionButton>
<ActionButton
color="primary"
className={classes.mr}
Icon={UnpairIcon}
InverseIcon={UnpairReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'unpair',
display: 'Unpair'
})
}>
Unpair
</ActionButton>
<ActionButton
color="primary"
className={classes.mr}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() =>
setAction({
command: 'reboot',
display: 'Reboot'
})
}>
Reboot
</ActionButton>
<ActionButton
className={classes.mr}
disabled={loading}
color="primary"
Icon={ShutdownIcon}
InverseIcon={ShutdownReversedIcon}
onClick={() =>
setAction({
command: 'shutdown',
display: 'Shutdown',
message:
'In order to bring it back online, the machine will need to be visited and its power reset.'
})
}>
Shutdown
</ActionButton>
<ActionButton
color="primary"
className={classes.inlineChip}
Icon={RebootIcon}
InverseIcon={RebootReversedIcon}
disabled={loading}
onClick={() => {
fetchMachineEvents()
setAction({
command: 'restartServices',
display: 'Restart services for'
})
}}>
Restart Services
</ActionButton>
</div>
</Item>
</Container> */}
</Item> </Item>
</Container> </Container>
) )

View file

@ -4,29 +4,10 @@ import {
detailsRowStyles, detailsRowStyles,
labelStyles labelStyles
} from 'src/pages/Transactions/Transactions.styles' } from 'src/pages/Transactions/Transactions.styles'
import { import { spacer, comet, primaryColor, fontSize4 } from 'src/styling/variables'
spacer,
comet,
primaryColor,
fontSize4,
errorColor
} from 'src/styling/variables'
const machineDetailsStyles = { const machineDetailsStyles = {
...detailsRowStyles, ...detailsRowStyles,
colDivider: {
width: 1,
margin: [[spacer * 2, spacer * 4]],
backgroundColor: comet,
border: 'none'
},
inlineChip: {
marginInlineEnd: '0.25em'
},
stack: {
display: 'flex',
flexDirection: 'row'
},
wrapper: { wrapper: {
display: 'flex', display: 'flex',
// marginTop: 24, // marginTop: 24,
@ -53,12 +34,6 @@ const machineDetailsStyles = {
color: primaryColor, color: primaryColor,
textDecoration: 'none' textDecoration: 'none'
}, },
divider: {
margin: '0 1rem'
},
mr: {
marginRight: spacer
},
separator: { separator: {
width: 1, width: 1,
height: 170, height: 170,
@ -66,9 +41,6 @@ const machineDetailsStyles = {
marginRight: 60, marginRight: 60,
marginLeft: 'auto', marginLeft: 'auto',
background: fade(comet, 0.5) background: fade(comet, 0.5)
},
warning: {
color: errorColor
} }
} }

View file

@ -1,6 +1,5 @@
import { useQuery } from '@apollo/react-hooks' import { useQuery } from '@apollo/react-hooks'
import { makeStyles } from '@material-ui/core' import { makeStyles } from '@material-ui/core'
import BigNumber from 'bignumber.js'
import gql from 'graphql-tag' import gql from 'graphql-tag'
import moment from 'moment' import moment from 'moment'
import * as R from 'ramda' import * as R from 'ramda'
@ -62,7 +61,7 @@ const MachineStatus = () => {
const elements = [ const elements = [
{ {
header: 'Machine Name', header: 'Machine Name',
width: 150, width: 250,
size: 'sm', size: 'sm',
textAlign: 'left', textAlign: 'left',
view: m => ( view: m => (
@ -80,48 +79,18 @@ const MachineStatus = () => {
}, },
{ {
header: 'Status', header: 'Status',
width: 150, width: 350,
size: 'sm', size: 'sm',
textAlign: 'left', textAlign: 'left',
view: m => <MainStatus statuses={m.statuses} /> view: m => <MainStatus statuses={m.statuses} />
}, },
{ {
header: 'Last ping', header: 'Last ping',
width: 175, width: 200,
size: 'sm', size: 'sm',
textAlign: 'left', textAlign: 'left',
view: m => (m.lastPing ? moment(m.lastPing).fromNow() : 'unknown') view: m => (m.lastPing ? moment(m.lastPing).fromNow() : 'unknown')
}, },
{
header: 'Network speed',
width: 150,
size: 'sm',
textAlign: 'left',
view: m =>
m.downloadSpeed
? new BigNumber(m.downloadSpeed).toFixed(4).toString() + ' MB/s'
: 'unavailable'
},
{
header: 'Latency',
width: 150,
size: 'sm',
textAlign: 'left',
view: m =>
m.responseTime
? new BigNumber(m.responseTime).toFixed(3).toString() + ' ms'
: 'unavailable'
},
{
header: 'Packet Loss',
width: 125,
size: 'sm',
textAlign: 'left',
view: m =>
m.packetLoss
? new BigNumber(m.packetLoss).toFixed(3).toString() + ' %'
: 'unavailable'
},
{ {
header: 'Software Version', header: 'Software Version',
width: 200, width: 200,

View file

@ -285,7 +285,7 @@ const DetailsRow = ({ it: tx, timezone }) => {
) : ( ) : (
errorElements errorElements
)} )}
{tx.txClass === 'cashOut' && getStatus(tx) !== 'Cancelled' && ( {tx.txClass === 'cashOut' && getStatus(tx) === 'Pending' && (
<ActionButton <ActionButton
color="primary" color="primary"
Icon={CancelIcon} Icon={CancelIcon}
@ -338,4 +338,8 @@ const DetailsRow = ({ it: tx, timezone }) => {
) )
} }
export default memo(DetailsRow) export default memo(
DetailsRow,
(prev, next) =>
prev.it.id === next.it.id && prev.it.hasError === next.it.hasError
)

View file

@ -33,12 +33,14 @@ const GET_DATA = gql`
const GET_TRANSACTIONS_CSV = gql` const GET_TRANSACTIONS_CSV = gql`
query transactions( query transactions(
$simplified: Boolean
$limit: Int $limit: Int
$from: Date $from: Date
$until: Date $until: Date
$timezone: String $timezone: String
) { ) {
transactionsCsv( transactionsCsv(
simplified: $simplified
limit: $limit limit: $limit
from: $from from: $from
until: $until until: $until
@ -194,13 +196,13 @@ const Transactions = () => {
}, },
{ {
header: 'Crypto', header: 'Crypto',
width: 144, width: 150,
textAlign: 'right', textAlign: 'right',
size: 'sm', size: 'sm',
view: it => view: it =>
`${coinUtils `${coinUtils.toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode)} ${
.toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode) it.cryptoCode
.toFormat(5)} ${it.cryptoCode}` }`
}, },
{ {
header: 'Address', header: 'Address',
@ -277,9 +279,8 @@ const Transactions = () => {
title="Download logs" title="Download logs"
name="transactions" name="transactions"
query={GET_TRANSACTIONS_CSV} query={GET_TRANSACTIONS_CSV}
args={{ timezone }}
getLogs={logs => R.path(['transactionsCsv'])(logs)} getLogs={logs => R.path(['transactionsCsv'])(logs)}
timezone={timezone} simplified
/> />
</div> </div>
)} )}

View file

@ -9,6 +9,7 @@ import Stepper from 'src/components/Stepper'
import { Button } from 'src/components/buttons' import { Button } from 'src/components/buttons'
import { H5, Info3 } from 'src/components/typography' import { H5, Info3 } from 'src/components/typography'
import { comet } from 'src/styling/variables' import { comet } from 'src/styling/variables'
import { singularOrPlural } from 'src/utils/string'
import { type, requirements } from './helper' import { type, requirements } from './helper'
@ -105,21 +106,29 @@ const getTypeText = (config, currency, classes) => {
<> <>
makes {orUnderline(config.threshold.threshold, classes)} {currency}{' '} makes {orUnderline(config.threshold.threshold, classes)} {currency}{' '}
worth of transactions within{' '} worth of transactions within{' '}
{orUnderline(config.threshold.thresholdDays, classes)} days {orUnderline(config.threshold.thresholdDays, classes)}{' '}
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
</> </>
) )
case 'txVelocity': case 'txVelocity':
return ( return (
<> <>
makes {orUnderline(config.threshold.threshold, classes)} transactions makes {orUnderline(config.threshold.threshold, classes)}{' '}
in {orUnderline(config.threshold.thresholdDays, classes)} days {singularOrPlural(
config.threshold.threshold,
'transaction',
'transactions'
)}{' '}
in {orUnderline(config.threshold.thresholdDays, classes)}{' '}
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
</> </>
) )
case 'consecutiveDays': case 'consecutiveDays':
return ( return (
<> <>
at least one transaction every day for{' '} at least one transaction every day for{' '}
{orUnderline(config.threshold.thresholdDays, classes)} days {orUnderline(config.threshold.thresholdDays, classes)}{' '}
{singularOrPlural(config.threshold.thresholdDays, 'day', 'days')}
</> </>
) )
default: default:
@ -147,7 +156,8 @@ const getRequirementText = (config, classes) => {
return ( return (
<> <>
suspended for{' '} suspended for{' '}
{orUnderline(config.requirement.suspensionDays, classes)} days {orUnderline(config.requirement.suspensionDays, classes)}{' '}
{singularOrPlural(config.requirement.suspensionDays, 'day', 'days')}
</> </>
) )
case 'block': case 'block':
@ -212,6 +222,41 @@ const Wizard = ({ onClose, save, error, currency }) => {
}) })
} }
const createErrorMessage = (errors, touched, values) => {
const triggerType = values?.triggerType
const containsType = R.contains(triggerType)
const isSuspend = values?.requirement?.requirement === 'suspend'
const hasRequirementError =
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const hasAmountError =
!!errors.threshold &&
!!touched.threshold?.threshold &&
!containsType(['consecutiveDays']) &&
(!values.threshold?.threshold || values.threshold?.threshold < 0)
const hasDaysError =
!!errors.threshold &&
!!touched.threshold?.thresholdDays &&
!containsType(['txAmount']) &&
(!values.threshold?.thresholdDays || values.threshold?.thresholdDays < 0)
if (containsType(['txAmount', 'txVolume', 'txVelocity']) && hasAmountError)
return errors.threshold
if (
containsType(['txVolume', 'txVelocity', 'consecutiveDays']) &&
hasDaysError
)
return errors.threshold
if (isSuspend && hasRequirementError) return errors.requirement
}
return ( return (
<> <>
<Modal <Modal
@ -236,21 +281,28 @@ const Wizard = ({ onClose, save, error, currency }) => {
/> />
<Formik <Formik
validateOnBlur={false} validateOnBlur={false}
validateOnChange={false} validateOnChange={true}
enableReinitialize enableReinitialize
onSubmit={onContinue} onSubmit={onContinue}
initialValues={stepOptions.initialValues} initialValues={stepOptions.initialValues}
validationSchema={stepOptions.schema}> validationSchema={stepOptions.schema}>
{({ errors, touched, values }) => (
<Form className={classes.form}> <Form className={classes.form}>
<GetValues setValues={setLiveValues} /> <GetValues setValues={setLiveValues} />
<stepOptions.Component {...stepOptions.props} /> <stepOptions.Component {...stepOptions.props} />
<div className={classes.submit}> <div className={classes.submit}>
{error && <ErrorMessage>Failed to save</ErrorMessage>} {error && <ErrorMessage>Failed to save</ErrorMessage>}
{createErrorMessage(errors, touched, values) && (
<ErrorMessage>
{createErrorMessage(errors, touched, values)}
</ErrorMessage>
)}
<Button className={classes.button} type="submit"> <Button className={classes.button} type="submit">
{isLastStep ? 'Finish' : 'Next'} {isLastStep ? 'Finish' : 'Next'}
</Button> </Button>
</div> </div>
</Form> </Form>
)}
</Formik> </Formik>
</Modal> </Modal>
</> </>

View file

@ -9,7 +9,7 @@ import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
import { import {
defaultSchema, defaultSchema,
overridesSchema, getOverridesSchema,
defaults, defaults,
overridesDefaults, overridesDefaults,
getDefaultSettings, getDefaultSettings,
@ -95,7 +95,7 @@ const AdvancedTriggersSettings = memo(() => {
enableCreate enableCreate
initialValues={overridesDefaults} initialValues={overridesDefaults}
save={saveOverrides} save={saveOverrides}
validationSchema={overridesSchema} validationSchema={getOverridesSchema(requirementsOverrides)}
data={requirementsOverrides} data={requirementsOverrides}
elements={getOverrides()} elements={getOverrides()}
setEditing={onEditingOverrides} setEditing={onEditingOverrides}

View file

@ -1,7 +1,23 @@
import * as R from 'ramda'
import * as Yup from 'yup' import * as Yup from 'yup'
import Autocomplete from 'src/components/inputs/formik/Autocomplete.js' import Autocomplete from 'src/components/inputs/formik/Autocomplete.js'
import { getView, requirementOptions } from 'src/pages/Triggers/helper' import { getView } from 'src/pages/Triggers/helper'
const advancedRequirementOptions = [
{ display: 'Sanctions', code: 'sanctions' },
{ display: 'ID card image', code: 'idCardPhoto' },
{ display: 'ID data', code: 'idCardData' },
{ display: 'Customer camera', code: 'facephoto' },
{ display: 'US SSN', code: 'usSsn' }
]
const displayRequirement = code => {
return R.prop(
'display',
R.find(R.propEq('code', code))(advancedRequirementOptions)
)
}
const defaultSchema = Yup.object().shape({ const defaultSchema = Yup.object().shape({
expirationTime: Yup.string() expirationTime: Yup.string()
@ -13,10 +29,24 @@ const defaultSchema = Yup.object().shape({
.required() .required()
}) })
const overridesSchema = Yup.object().shape({ const getOverridesSchema = values => {
return Yup.object().shape({
id: Yup.string() id: Yup.string()
.label('Requirement') .label('Requirement')
.required(), .required()
.test({
test() {
const { requirement } = this.parent
if (R.find(R.propEq('requirement', requirement))(values)) {
return this.createError({
message: `Requirement ${displayRequirement(
requirement
)} already overriden`
})
}
return true
}
}),
expirationTime: Yup.string() expirationTime: Yup.string()
.label('Expiration time') .label('Expiration time')
.required(), .required(),
@ -25,6 +55,7 @@ const overridesSchema = Yup.object().shape({
.matches(/(Manual|Automatic)/) .matches(/(Manual|Automatic)/)
.required() .required()
}) })
}
const getDefaultSettings = () => { const getDefaultSettings = () => {
return [ return [
@ -60,10 +91,10 @@ const getOverrides = () => {
header: 'Requirement', header: 'Requirement',
width: 196, width: 196,
size: 'sm', size: 'sm',
view: getView(requirementOptions, 'display'), view: getView(advancedRequirementOptions, 'display'),
input: Autocomplete, input: Autocomplete,
inputProps: { inputProps: {
options: requirementOptions, options: advancedRequirementOptions,
labelProp: 'display', labelProp: 'display',
valueProp: 'code' valueProp: 'code'
} }
@ -108,7 +139,7 @@ const overridesDefaults = {
export { export {
defaultSchema, defaultSchema,
overridesSchema, getOverridesSchema,
defaults, defaults,
overridesDefaults, overridesDefaults,
getDefaultSettings, getDefaultSettings,

View file

@ -5,11 +5,7 @@ import * as R from 'ramda'
import React, { memo } from 'react' import React, { memo } from 'react'
import * as Yup from 'yup' import * as Yup from 'yup'
import { import { NumberInput, RadioGroup } from 'src/components/inputs/formik'
NumberInput,
TextInput,
RadioGroup
} from 'src/components/inputs/formik'
import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography' import { H4, Label2, Label1, Info1, Info2 } from 'src/components/typography'
import { errorColor } from 'src/styling/variables' import { errorColor } from 'src/styling/variables'
import { transformNumber } from 'src/utils/number' import { transformNumber } from 'src/utils/number'
@ -100,16 +96,9 @@ const threshold = Yup.object().shape({
const requirement = Yup.object().shape({ const requirement = Yup.object().shape({
requirement: Yup.string().required(), requirement: Yup.string().required(),
suspensionDays: Yup.number().when('requirement', { suspensionDays: Yup.number()
is: 'suspend', .transform(transformNumber)
then: Yup.number()
.required()
.min(1)
.label('Invalid value'),
otherwise: Yup.number()
.nullable() .nullable()
.transform(() => null)
})
}) })
const Schema = Yup.object() const Schema = Yup.object()
@ -119,11 +108,28 @@ const Schema = Yup.object()
threshold threshold
// direction // direction
}) })
.test( .test(({ threshold, triggerType }, context) => {
'are-fields-set', const errorMessages = {
'Invalid values', txAmount: threshold => 'Amount must be greater than or equal to 0',
({ threshold, triggerType }, context) => { txVolume: threshold => {
const validator = { const thresholdMessage = 'Volume must be greater than or equal to 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (threshold.threshold < 0) message.push(thresholdMessage)
if (threshold.thresholdDays <= 0) message.push(thresholdDaysMessage)
return message.join(', ')
},
txVelocity: threshold => {
const thresholdMessage = 'Transactions must be greater than 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (threshold.threshold <= 0) message.push(thresholdMessage)
if (threshold.thresholdDays <= 0) message.push(thresholdDaysMessage)
return message.join(', ')
},
consecutiveDays: threshold => 'Days must be greater than 0'
}
const thresholdValidator = {
txAmount: threshold => threshold.threshold >= 0, txAmount: threshold => threshold.threshold >= 0,
txVolume: threshold => txVolume: threshold =>
threshold.threshold >= 0 && threshold.thresholdDays > 0, threshold.threshold >= 0 && threshold.thresholdDays > 0,
@ -131,12 +137,27 @@ const Schema = Yup.object()
threshold.threshold > 0 && threshold.thresholdDays > 0, threshold.threshold > 0 && threshold.thresholdDays > 0,
consecutiveDays: threshold => threshold.thresholdDays > 0 consecutiveDays: threshold => threshold.thresholdDays > 0
} }
return (
(triggerType && validator?.[triggerType](threshold)) || if (triggerType && thresholdValidator[triggerType](threshold)) return
context.createError({ path: 'threshold' })
) return context.createError({
} path: 'threshold',
) message: errorMessages[triggerType](threshold)
})
})
.test(({ requirement }, context) => {
const requirementValidator = requirement =>
requirement.requirement === 'suspend'
? requirement.suspensionDays > 0
: true
if (requirement && requirementValidator(requirement)) return
return context.createError({
path: 'requirement',
message: 'Suspension days must be greater than 0'
})
})
// Direction V2 only // Direction V2 only
// const directionSchema = Yup.object().shape({ direction }) // const directionSchema = Yup.object().shape({ direction })
@ -237,11 +258,32 @@ const typeSchema = Yup.object()
.nullable() .nullable()
}) })
}) })
.test( .test(({ threshold, triggerType }, context) => {
'are-fields-set', const errorMessages = {
'All fields must be set.', txAmount: threshold => 'Amount must be greater than or equal to 0',
({ triggerType, threshold }, context) => { txVolume: threshold => {
const validator = { const thresholdMessage = 'Volume must be greater than or equal to 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (!threshold.threshold || threshold.threshold < 0)
message.push(thresholdMessage)
if (!threshold.thresholdDays || threshold.thresholdDays <= 0)
message.push(thresholdDaysMessage)
return message.join(', ')
},
txVelocity: threshold => {
const thresholdMessage = 'Transactions must be greater than 0'
const thresholdDaysMessage = 'Days must be greater than 0'
const message = []
if (!threshold.threshold || threshold.threshold <= 0)
message.push(thresholdMessage)
if (!threshold.thresholdDays || threshold.thresholdDays <= 0)
message.push(thresholdDaysMessage)
return message.join(', ')
},
consecutiveDays: threshold => 'Days must be greater than 0'
}
const thresholdValidator = {
txAmount: threshold => threshold.threshold >= 0, txAmount: threshold => threshold.threshold >= 0,
txVolume: threshold => txVolume: threshold =>
threshold.threshold >= 0 && threshold.thresholdDays > 0, threshold.threshold >= 0 && threshold.thresholdDays > 0,
@ -250,12 +292,13 @@ const typeSchema = Yup.object()
consecutiveDays: threshold => threshold.thresholdDays > 0 consecutiveDays: threshold => threshold.thresholdDays > 0
} }
return ( if (triggerType && thresholdValidator[triggerType](threshold)) return
(triggerType && validator?.[triggerType](threshold)) ||
context.createError({ path: 'threshold' }) return context.createError({
) path: 'threshold',
} message: errorMessages[triggerType](threshold)
) })
})
const typeOptions = [ const typeOptions = [
{ display: 'Transaction amount', code: 'txAmount' }, { display: 'Transaction amount', code: 'txAmount' },
@ -266,7 +309,13 @@ const typeOptions = [
const Type = ({ ...props }) => { const Type = ({ ...props }) => {
const classes = useStyles() const classes = useStyles()
const { errors, touched, values } = useFormikContext() const {
errors,
touched,
values,
setTouched,
handleChange
} = useFormikContext()
const typeClass = { const typeClass = {
[classes.error]: errors.triggerType && touched.triggerType [classes.error]: errors.triggerType && touched.triggerType
@ -278,11 +327,21 @@ const Type = ({ ...props }) => {
const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity']) const isThresholdDaysEnabled = containsType(['txVolume', 'txVelocity'])
const isConsecutiveDaysEnabled = containsType(['consecutiveDays']) const isConsecutiveDaysEnabled = containsType(['consecutiveDays'])
const hasAmountError =
!!errors.threshold &&
!!touched.threshold?.threshold &&
!isConsecutiveDaysEnabled &&
(!values.threshold?.threshold || values.threshold?.threshold < 0)
const hasDaysError =
!!errors.threshold &&
!!touched.threshold?.thresholdDays &&
!containsType(['txAmount']) &&
(!values.threshold?.thresholdDays || values.threshold?.thresholdDays < 0)
const triggerTypeError = !!(hasDaysError || hasAmountError)
const thresholdClass = { const thresholdClass = {
[classes.error]: [classes.error]: triggerTypeError
errors.threshold &&
((!containsType(['consecutiveDays']) && touched.threshold?.threshold) ||
(!containsType(['txAmount']) && touched.threshold?.thresholdDays))
} }
const isRadioGroupActive = () => { const isRadioGroupActive = () => {
@ -306,6 +365,13 @@ const Type = ({ ...props }) => {
labelClassName={classes.radioLabel} labelClassName={classes.radioLabel}
radioClassName={classes.radio} radioClassName={classes.radio}
className={classes.radioGroup} className={classes.radioGroup}
onChange={e => {
handleChange(e)
setTouched({
threshold: false,
thresholdDays: false
})
}}
/> />
<div className={classes.thresholdWrapper}> <div className={classes.thresholdWrapper}>
@ -322,6 +388,7 @@ const Type = ({ ...props }) => {
component={NumberInput} component={NumberInput}
size="lg" size="lg"
name="threshold.threshold" name="threshold.threshold"
error={hasAmountError}
/> />
<Info1 className={classnames(classes.description)}> <Info1 className={classnames(classes.description)}>
{props.currency} {props.currency}
@ -335,6 +402,7 @@ const Type = ({ ...props }) => {
component={NumberInput} component={NumberInput}
size="lg" size="lg"
name="threshold.threshold" name="threshold.threshold"
error={hasAmountError}
/> />
<Info1 className={classnames(classes.description)}> <Info1 className={classnames(classes.description)}>
transactions transactions
@ -356,6 +424,7 @@ const Type = ({ ...props }) => {
component={NumberInput} component={NumberInput}
size="lg" size="lg"
name="threshold.thresholdDays" name="threshold.thresholdDays"
error={hasDaysError}
/> />
<Info1 className={classnames(classes.description)}>days</Info1> <Info1 className={classnames(classes.description)}>days</Info1>
</> </>
@ -367,6 +436,7 @@ const Type = ({ ...props }) => {
component={NumberInput} component={NumberInput}
size="lg" size="lg"
name="threshold.thresholdDays" name="threshold.thresholdDays"
error={hasDaysError}
/> />
<Info1 className={classnames(classes.description)}> <Info1 className={classnames(classes.description)}>
consecutive days consecutive days
@ -390,20 +460,34 @@ const type = currency => ({
} }
}) })
const requirementSchema = Yup.object().shape({ const requirementSchema = Yup.object()
.shape({
requirement: Yup.object({ requirement: Yup.object({
requirement: Yup.string().required(), requirement: Yup.string().required(),
suspensionDays: Yup.number().when('requirement', { suspensionDays: Yup.number().when('requirement', {
is: value => value === 'suspend', is: value => value === 'suspend',
then: Yup.number() then: Yup.number()
.required() .nullable()
.min(1), .transform(transformNumber),
otherwise: Yup.number() otherwise: Yup.number()
.nullable() .nullable()
.transform(() => null) .transform(() => null)
}) })
}).required() }).required()
}) })
.test(({ requirement }, context) => {
const requirementValidator = requirement =>
requirement.requirement === 'suspend'
? requirement.suspensionDays > 0
: true
if (requirement && requirementValidator(requirement)) return
return context.createError({
path: 'requirement',
message: 'Suspension days must be greater than 0'
})
})
const requirementOptions = [ const requirementOptions = [
{ display: 'SMS verification', code: 'sms' }, { display: 'SMS verification', code: 'sms' },
@ -419,19 +503,27 @@ const requirementOptions = [
const Requirement = () => { const Requirement = () => {
const classes = useStyles() const classes = useStyles()
const { touched, errors, values } = useFormikContext() const {
touched,
errors,
values,
handleChange,
setTouched
} = useFormikContext()
const hasRequirementError =
!!errors.requirement &&
!!touched.requirement?.suspensionDays &&
(!values.requirement?.suspensionDays ||
values.requirement?.suspensionDays < 0)
const isSuspend = values?.requirement?.requirement === 'suspend'
const titleClass = { const titleClass = {
[classes.error]: [classes.error]:
!R.isEmpty(R.omit(['suspensionDays'], errors.requirement)) || (!!errors.requirement && !isSuspend) || (isSuspend && hasRequirementError)
(errors.requirement &&
touched.requirement &&
errors.requirement.suspensionDays &&
touched.requirement.suspensionDays)
} }
const isSuspend = values?.requirement?.requirement === 'suspend'
return ( return (
<> <>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
@ -444,6 +536,12 @@ const Requirement = () => {
labelClassName={classes.specialLabel} labelClassName={classes.specialLabel}
radioClassName={classes.radio} radioClassName={classes.radio}
className={classnames(classes.radioGroup, classes.specialGrid)} className={classnames(classes.radioGroup, classes.specialGrid)}
onChange={e => {
handleChange(e)
setTouched({
suspensionDays: false
})
}}
/> />
{isSuspend && ( {isSuspend && (
@ -453,6 +551,7 @@ const Requirement = () => {
label="Days" label="Days"
size="lg" size="lg"
name="requirement.suspensionDays" name="requirement.suspensionDays"
error={hasRequirementError}
/> />
)} )}
</> </>
@ -504,7 +603,7 @@ const RequirementInput = () => {
bold bold
className={classes.suspensionDays} className={classes.suspensionDays}
name="requirement.suspensionDays" name="requirement.suspensionDays"
component={TextInput} component={NumberInput}
textAlign="center" textAlign="center"
/> />
)} )}

View file

@ -104,11 +104,12 @@ const getElements = (cryptoCurrencies, accounts, onChange, wizard = false) => {
}, },
{ {
name: 'zeroConf', name: 'zeroConf',
header: 'Confidence Checking',
size: 'sm', size: 'sm',
stripe: true, stripe: true,
view: getDisplayName('zeroConf'), view: getDisplayName('zeroConf'),
input: Autocomplete, input: Autocomplete,
width: 190 - widthAdjust, width: 220 - widthAdjust,
inputProps: { inputProps: {
options: getOptions('zeroConf'), options: getOptions('zeroConf'),
valueProp: 'code', valueProp: 'code',

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="64" version="1.1"> <svg xmlns="http://www.w3.org/2000/svg" width="104" height="64" version="1.1">
<path fill="#F7931A" d="m0,0l29.7,0a39,39,0,0,0,0,64l-29.7,0zm52,0a32,32,0,0,0,0,64a32,32,0,0,0,0,-64m52,0l-29.7,0a39,39,0,0,1,0,64l29.7,0z"/> <path fill="#0AC18E" d="m0,0l29.7,0a39,39,0,0,0,0,64l-29.7,0zm52,0a32,32,0,0,0,0,64a32,32,0,0,0,0,-64m52,0l-29.7,0a39,39,0,0,1,0,64l29.7,0z"/>
<path fill="#FFF" transform="rotate(-28 52 32)" d="m66.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/> <path fill="#FFF" transform="rotate(-28 52 32)" d="m66.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

@ -26,4 +26,7 @@ const startCase = R.compose(
splitOnUpper splitOnUpper
) )
export { startCase, onlyFirstToUpper, formatLong } const singularOrPlural = (amount, singularStr, pluralStr) =>
parseInt(amount) === 1 ? singularStr : pluralStr
export { startCase, onlyFirstToUpper, formatLong, singularOrPlural }

4
package-lock.json generated
View file

@ -1,6 +1,6 @@
{ {
"name": "lamassu-server", "name": "lamassu-server",
"version": "7.5.0-beta.3", "version": "7.5.3",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {
@ -14372,7 +14372,7 @@
"version": "git+https://github.com/lamassu/lamassu-coins.git#de843fb210ad8adfa29a0441796125fcb0ab3b67", "version": "git+https://github.com/lamassu/lamassu-coins.git#de843fb210ad8adfa29a0441796125fcb0ab3b67",
"from": "git+https://github.com/lamassu/lamassu-coins.git", "from": "git+https://github.com/lamassu/lamassu-coins.git",
"requires": { "requires": {
"bech32": "^1.1.3", "bech32": "2.0.0",
"bignumber.js": "^9.0.0", "bignumber.js": "^9.0.0",
"bitcoinjs-lib": "4.0.3", "bitcoinjs-lib": "4.0.3",
"bs58check": "^2.0.2", "bs58check": "^2.0.2",

View file

@ -2,7 +2,7 @@
"name": "lamassu-server", "name": "lamassu-server",
"description": "bitcoin atm client server protocol module", "description": "bitcoin atm client server protocol module",
"keywords": [], "keywords": [],
"version": "7.5.0-beta.3", "version": "7.5.3",
"license": "Unlicense", "license": "Unlicense",
"author": "Lamassu (https://lamassu.is)", "author": "Lamassu (https://lamassu.is)",
"dependencies": { "dependencies": {

View file

@ -1,13 +1,13 @@
{ {
"files": { "files": {
"main.js": "/static/js/main.b833e621.chunk.js", "main.js": "/static/js/main.fc66d358.chunk.js",
"main.js.map": "/static/js/main.b833e621.chunk.js.map", "main.js.map": "/static/js/main.fc66d358.chunk.js.map",
"runtime-main.js": "/static/js/runtime-main.ee1cbb9c.js", "runtime-main.js": "/static/js/runtime-main.ee1cbb9c.js",
"runtime-main.js.map": "/static/js/runtime-main.ee1cbb9c.js.map", "runtime-main.js.map": "/static/js/runtime-main.ee1cbb9c.js.map",
"static/js/2.346a7f7f.chunk.js": "/static/js/2.346a7f7f.chunk.js", "static/js/2.ee7f7ea2.chunk.js": "/static/js/2.ee7f7ea2.chunk.js",
"static/js/2.346a7f7f.chunk.js.map": "/static/js/2.346a7f7f.chunk.js.map", "static/js/2.ee7f7ea2.chunk.js.map": "/static/js/2.ee7f7ea2.chunk.js.map",
"index.html": "/index.html", "index.html": "/index.html",
"static/js/2.346a7f7f.chunk.js.LICENSE.txt": "/static/js/2.346a7f7f.chunk.js.LICENSE.txt", "static/js/2.ee7f7ea2.chunk.js.LICENSE.txt": "/static/js/2.ee7f7ea2.chunk.js.LICENSE.txt",
"static/media/cash-in.c06970a7.svg": "/static/media/cash-in.c06970a7.svg", "static/media/cash-in.c06970a7.svg": "/static/media/cash-in.c06970a7.svg",
"static/media/cash-out.f029ae96.svg": "/static/media/cash-out.f029ae96.svg", "static/media/cash-out.f029ae96.svg": "/static/media/cash-out.f029ae96.svg",
"static/media/cashbox-empty.828bd3b9.svg": "/static/media/cashbox-empty.828bd3b9.svg", "static/media/cashbox-empty.828bd3b9.svg": "/static/media/cashbox-empty.828bd3b9.svg",
@ -34,7 +34,7 @@
"static/media/false.7f926859.svg": "/static/media/false.7f926859.svg", "static/media/false.7f926859.svg": "/static/media/false.7f926859.svg",
"static/media/full.67b8cd67.svg": "/static/media/full.67b8cd67.svg", "static/media/full.67b8cd67.svg": "/static/media/full.67b8cd67.svg",
"static/media/icon-bitcoin-colour.bd8da481.svg": "/static/media/icon-bitcoin-colour.bd8da481.svg", "static/media/icon-bitcoin-colour.bd8da481.svg": "/static/media/icon-bitcoin-colour.bd8da481.svg",
"static/media/icon-bitcoincash-colour.3b27f3ed.svg": "/static/media/icon-bitcoincash-colour.3b27f3ed.svg", "static/media/icon-bitcoincash-colour.ed917caa.svg": "/static/media/icon-bitcoincash-colour.ed917caa.svg",
"static/media/icon-dash-colour.e01c021b.svg": "/static/media/icon-dash-colour.e01c021b.svg", "static/media/icon-dash-colour.e01c021b.svg": "/static/media/icon-dash-colour.e01c021b.svg",
"static/media/icon-ethereum-colour.761723a2.svg": "/static/media/icon-ethereum-colour.761723a2.svg", "static/media/icon-ethereum-colour.761723a2.svg": "/static/media/icon-ethereum-colour.761723a2.svg",
"static/media/icon-litecoin-colour.bd861b5e.svg": "/static/media/icon-litecoin-colour.bd861b5e.svg", "static/media/icon-litecoin-colour.bd861b5e.svg": "/static/media/icon-litecoin-colour.bd861b5e.svg",
@ -95,7 +95,7 @@
}, },
"entrypoints": [ "entrypoints": [
"static/js/runtime-main.ee1cbb9c.js", "static/js/runtime-main.ee1cbb9c.js",
"static/js/2.346a7f7f.chunk.js", "static/js/2.ee7f7ea2.chunk.js",
"static/js/main.b833e621.chunk.js" "static/js/main.fc66d358.chunk.js"
] ]
} }

View file

@ -1 +1 @@
<!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/><meta name="robots" content="noindex"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Lamassu Admin</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="root"></div><script>!function(e){function r(r){for(var n,a,l=r[0],i=r[1],f=r[2],c=0,s=[];c<l.length;c++)a=l[c],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,f||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var i=t[l];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/";var l=this["webpackJsonplamassu-admin"]=this["webpackJsonplamassu-admin"]||[],i=l.push.bind(l);l.push=r,l=l.slice();for(var f=0;f<l.length;f++)r(l[f]);var p=i;t()}([])</script><script src="/static/js/2.346a7f7f.chunk.js"></script><script src="/static/js/main.b833e621.chunk.js"></script></body></html> <!doctype html><html lang="en"><head><meta charset="utf-8"/><link rel="shortcut icon" href="/favicon.ico"/><meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no"/><meta name="robots" content="noindex"/><meta name="theme-color" content="#000000"/><link rel="manifest" href="/manifest.json"/><title>Lamassu Admin</title></head><body><noscript>You need to enable JavaScript to run this app.</noscript><div id="root" class="root"></div><script>!function(e){function r(r){for(var n,a,l=r[0],i=r[1],f=r[2],c=0,s=[];c<l.length;c++)a=l[c],Object.prototype.hasOwnProperty.call(o,a)&&o[a]&&s.push(o[a][0]),o[a]=0;for(n in i)Object.prototype.hasOwnProperty.call(i,n)&&(e[n]=i[n]);for(p&&p(r);s.length;)s.shift()();return u.push.apply(u,f||[]),t()}function t(){for(var e,r=0;r<u.length;r++){for(var t=u[r],n=!0,l=1;l<t.length;l++){var i=t[l];0!==o[i]&&(n=!1)}n&&(u.splice(r--,1),e=a(a.s=t[0]))}return e}var n={},o={1:0},u=[];function a(r){if(n[r])return n[r].exports;var t=n[r]={i:r,l:!1,exports:{}};return e[r].call(t.exports,t,t.exports,a),t.l=!0,t.exports}a.m=e,a.c=n,a.d=function(e,r,t){a.o(e,r)||Object.defineProperty(e,r,{enumerable:!0,get:t})},a.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},a.t=function(e,r){if(1&r&&(e=a(e)),8&r)return e;if(4&r&&"object"==typeof e&&e&&e.__esModule)return e;var t=Object.create(null);if(a.r(t),Object.defineProperty(t,"default",{enumerable:!0,value:e}),2&r&&"string"!=typeof e)for(var n in e)a.d(t,n,function(r){return e[r]}.bind(null,n));return t},a.n=function(e){var r=e&&e.__esModule?function(){return e.default}:function(){return e};return a.d(r,"a",r),r},a.o=function(e,r){return Object.prototype.hasOwnProperty.call(e,r)},a.p="/";var l=this["webpackJsonplamassu-admin"]=this["webpackJsonplamassu-admin"]||[],i=l.push.bind(l);l.push=r,l=l.slice();for(var f=0;f<l.length;f++)r(l[f]);var p=i;t()}([])</script><script src="/static/js/2.ee7f7ea2.chunk.js"></script><script src="/static/js/main.fc66d358.chunk.js"></script></body></html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,4 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="104" height="64" version="1.1"> <svg xmlns="http://www.w3.org/2000/svg" width="104" height="64" version="1.1">
<path fill="#F7931A" d="m0,0l29.7,0a39,39,0,0,0,0,64l-29.7,0zm52,0a32,32,0,0,0,0,64a32,32,0,0,0,0,-64m52,0l-29.7,0a39,39,0,0,1,0,64l29.7,0z"/> <path fill="#0AC18E" d="m0,0l29.7,0a39,39,0,0,0,0,64l-29.7,0zm52,0a32,32,0,0,0,0,64a32,32,0,0,0,0,-64m52,0l-29.7,0a39,39,0,0,1,0,64l29.7,0z"/>
<path fill="#FFF" transform="rotate(-28 52 32)" d="m66.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/> <path fill="#FFF" transform="rotate(-28 52 32)" d="m66.103,27.444c0.637-4.258-2.605-6.547-7.038-8.074l1.438-5.768-3.511-0.875-1.4,5.616c-0.923-0.23-1.871-0.447-2.813-0.662l1.41-5.653-3.509-0.875-1.439,5.766c-0.764-0.174-1.514-0.346-2.242-0.527l0.004-0.018-4.842-1.209-0.934,3.75s2.605,0.597,2.55,0.634c1.422,0.355,1.679,1.296,1.636,2.042l-1.638,6.571c0.098,0.025,0.225,0.061,0.365,0.117-0.117-0.029-0.242-0.061-0.371-0.092l-2.296,9.205c-0.174,0.432-0.615,1.08-1.609,0.834,0.035,0.051-2.552-0.637-2.552-0.637l-1.743,4.019,4.569,1.139c0.85,0.213,1.683,0.436,2.503,0.646l-1.453,5.834,3.507,0.875,1.439-5.772c0.958,0.26,1.888,0.5,2.798,0.726l-1.434,5.745,3.511,0.875,1.453-5.823c5.987,1.133,10.489,0.676,12.384-4.739,1.527-4.36-0.076-6.875-3.226-8.515,2.294-0.529,4.022-2.038,4.483-5.155zm-8.022,11.249c-1.085,4.36-8.426,2.003-10.806,1.412l1.928-7.729c2.38,0.594,10.012,1.77,8.878,6.317zm1.086-11.312c-0.99,3.966-7.1,1.951-9.082,1.457l1.748-7.01c1.982,0.494,8.365,1.416,7.334,5.553z"/>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After