commit
cec919297e
232 changed files with 17611 additions and 16861 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -32,6 +32,8 @@ scratch/
|
|||
seeds/
|
||||
mnemonics/
|
||||
certs/
|
||||
test/stress/machines
|
||||
test/stress/config.json
|
||||
lamassu.json
|
||||
|
||||
terraform.*
|
||||
|
|
|
|||
|
|
@ -31,7 +31,6 @@ const server = require('./server')
|
|||
const transactions = require('./transactions')
|
||||
const customers = require('../customers')
|
||||
const logs = require('../logs')
|
||||
const supportLogs = require('../support_logs')
|
||||
const funding = require('./funding')
|
||||
const supportServer = require('./admin-support')
|
||||
|
||||
|
|
@ -208,29 +207,6 @@ app.get('/api/logs', (req, res, next) => {
|
|||
.catch(next)
|
||||
})
|
||||
|
||||
app.get('/api/support_logs', (req, res, next) => {
|
||||
return supportLogs.batch()
|
||||
.then(supportLogs => res.send({ supportLogs }))
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
app.get('/api/support_logs/logs', (req, res, next) => {
|
||||
return supportLogs.get(req.query.supportLogId)
|
||||
.then(log => (!_.isNil(log) && !_.isEmpty(log)) ? log : supportLogs.batch().then(_.first))
|
||||
.then(result => {
|
||||
const log = result || {}
|
||||
return logs.getMachineLogs(log.deviceId, log.timestamp)
|
||||
})
|
||||
.then(r => res.send(r))
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
app.post('/api/support_logs', (req, res, next) => {
|
||||
return supportLogs.insert(req.query.deviceId)
|
||||
.then(r => res.send(r))
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
app.patch('/api/customer/:id', (req, res, next) => {
|
||||
if (!req.params.id) return res.status(400).send({Error: 'Requires id'})
|
||||
const token = req.token || req.cookies.token
|
||||
|
|
@ -349,7 +325,7 @@ wss.on('connection', ws => {
|
|||
})
|
||||
|
||||
function run () {
|
||||
const serverPort = devMode ? 8070 : 443
|
||||
const serverPort = devMode ? 8072 : 443
|
||||
const supportPort = 8071
|
||||
|
||||
const serverLog = `lamassu-admin-server listening on port ${serverPort}`
|
||||
|
|
|
|||
|
|
@ -10,8 +10,6 @@ const _ = require('lodash/fp')
|
|||
const serveStatic = require('serve-static')
|
||||
const path = require('path')
|
||||
|
||||
const logs = require('../logs')
|
||||
const supportLogs = require('../support_logs')
|
||||
const options = require('../options')
|
||||
|
||||
app.use(morgan('dev'))
|
||||
|
|
@ -30,29 +28,6 @@ const certOptions = {
|
|||
rejectUnauthorized: true
|
||||
}
|
||||
|
||||
app.get('/api/support_logs', (req, res, next) => {
|
||||
return supportLogs.batch()
|
||||
.then(supportLogs => res.send({ supportLogs }))
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
app.get('/api/support_logs/logs', (req, res, next) => {
|
||||
return supportLogs.get(req.query.supportLogId)
|
||||
.then(log => (!_.isNil(log) && !_.isEmpty(log)) ? log : supportLogs.batch().then(_.first))
|
||||
.then(result => {
|
||||
const log = result || {}
|
||||
return logs.getUnlimitedMachineLogs(log.deviceId, log.timestamp)
|
||||
})
|
||||
.then(r => res.send(r))
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
app.post('/api/support_logs', (req, res, next) => {
|
||||
return supportLogs.insert(req.query.deviceId)
|
||||
.then(r => res.send(r))
|
||||
.catch(next)
|
||||
})
|
||||
|
||||
function run (port) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const webServer = https.createServer(certOptions, app)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const db = require('./db')
|
||||
const notifierQueries = require('./notifier/queries')
|
||||
|
||||
// Get all blacklist rows from the DB "blacklist" table that were manually inserted by the operator
|
||||
const getBlacklist = () => {
|
||||
|
|
@ -13,10 +14,9 @@ const getBlacklist = () => {
|
|||
|
||||
// Delete row from blacklist table by crypto code and address
|
||||
const deleteFromBlacklist = (cryptoCode, address) => {
|
||||
return db.none(
|
||||
`DELETE FROM blacklist WHERE created_by_operator = 't' AND crypto_code = $1 AND address = $2`,
|
||||
[cryptoCode, address]
|
||||
)
|
||||
const sql = `DELETE FROM blacklist WHERE crypto_code = $1 AND address = $2`
|
||||
notifierQueries.clearBlacklistNotification(cryptoCode, address)
|
||||
return db.none(sql, [cryptoCode, address])
|
||||
}
|
||||
|
||||
const insertIntoBlacklist = (cryptoCode, address) => {
|
||||
|
|
@ -27,12 +27,12 @@ const insertIntoBlacklist = (cryptoCode, address) => {
|
|||
)
|
||||
}
|
||||
|
||||
function blocked(address, cryptoCode) {
|
||||
function blocked (address, cryptoCode) {
|
||||
const sql = `select * from blacklist where address = $1 and crypto_code = $2`
|
||||
return db.any(sql, [address, cryptoCode])
|
||||
}
|
||||
|
||||
function addToUsedAddresses(address, cryptoCode) {
|
||||
function addToUsedAddresses (address, cryptoCode) {
|
||||
// ETH reuses addresses
|
||||
if (cryptoCode === 'ETH') return Promise.resolve()
|
||||
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ const BINARIES = {
|
|||
dir: 'geth-linux-amd64-1.9.25-e7872729'
|
||||
},
|
||||
ZEC: {
|
||||
url: 'https://download.z.cash/downloads/zcash-4.2.0-linux64-debian-stretch.tar.gz',
|
||||
dir: 'zcash-4.2.0/bin'
|
||||
url: 'https://z.cash/downloads/zcash-4.3.0-linux64-debian-stretch.tar.gz',
|
||||
dir: 'zcash-4.3.0/bin'
|
||||
},
|
||||
DASH: {
|
||||
url: 'https://github.com/dashpay/dash/releases/download/v0.16.1.1/dashcore-0.16.1.1-x86_64-linux-gnu.tar.gz',
|
||||
|
|
|
|||
|
|
@ -44,10 +44,16 @@ function insertNewBills (t, billRows, machineTx) {
|
|||
if (_.isEmpty(bills)) return Promise.resolve([])
|
||||
|
||||
const dbBills = _.map(cashInLow.massage, bills)
|
||||
const columns = _.keys(dbBills[0])
|
||||
const columns = ['id', 'fiat', 'fiat_code', 'crypto_code', 'cash_in_fee', 'cash_in_txs_id', 'device_time']
|
||||
const sql = pgp.helpers.insert(dbBills, columns, 'bills')
|
||||
const deviceID = machineTx.deviceId
|
||||
const sql2 = `update devices set cashbox = cashbox + $2
|
||||
where device_id = $1`
|
||||
|
||||
return t.none(sql)
|
||||
return t.none(sql2, [deviceID, dbBills.length])
|
||||
.then(() => {
|
||||
return t.none(sql)
|
||||
})
|
||||
.then(() => bills)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,13 @@ const E = require('../error')
|
|||
|
||||
const PENDING_INTERVAL_MS = 60 * T.minutes
|
||||
|
||||
const massage = _.flow(_.omit(['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']),
|
||||
const massageFields = ['direction', 'cryptoNetwork', 'bills', 'blacklisted', 'addressReuse']
|
||||
const massageUpdateFields = _.concat(massageFields, 'cryptoAtoms')
|
||||
|
||||
const massage = _.flow(_.omit(massageFields),
|
||||
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||
|
||||
const massageUpdates = _.flow(_.omit(massageUpdateFields),
|
||||
convertBigNumFields, _.mapKeys(_.snakeCase))
|
||||
|
||||
module.exports = {toObj, upsert, insert, update, massage, isClearToSend}
|
||||
|
|
@ -62,7 +68,7 @@ function insert (t, tx) {
|
|||
function update (t, tx, changes) {
|
||||
if (_.isEmpty(changes)) return Promise.resolve(tx)
|
||||
|
||||
const dbChanges = massage(changes)
|
||||
const dbChanges = isFinalTxStage(changes) ? massage(changes) : massageUpdates(changes)
|
||||
const sql = pgp.helpers.update(dbChanges, null, 'cash_in_txs') +
|
||||
pgp.as.format(' where id=$1', [tx.id]) + ' returning *'
|
||||
|
||||
|
|
@ -136,3 +142,7 @@ function isClearToSend (oldTx, newTx) {
|
|||
(!oldTx || (!oldTx.sendPending && !oldTx.sendConfirmed)) &&
|
||||
(newTx.created > now - PENDING_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function isFinalTxStage (txChanges) {
|
||||
return txChanges.send
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const plugins = require('../plugins')
|
|||
const logger = require('../logger')
|
||||
const settingsLoader = require('../new-settings-loader')
|
||||
const configManager = require('../new-config-manager')
|
||||
const notifier = require('../notifier')
|
||||
|
||||
const cashInAtomic = require('./cash-in-atomic')
|
||||
const cashInLow = require('./cash-in-low')
|
||||
|
|
@ -15,7 +16,7 @@ const cashInLow = require('./cash-in-low')
|
|||
const PENDING_INTERVAL = '60 minutes'
|
||||
const MAX_PENDING = 10
|
||||
|
||||
module.exports = {post, monitorPending, cancel, PENDING_INTERVAL}
|
||||
module.exports = { post, monitorPending, cancel, PENDING_INTERVAL }
|
||||
|
||||
function post (machineTx, pi) {
|
||||
return db.tx(cashInAtomic.atomic(machineTx, pi))
|
||||
|
|
@ -28,12 +29,13 @@ function post (machineTx, pi) {
|
|||
.then(([{ config }, blacklistItems]) => {
|
||||
const rejectAddressReuseActive = configManager.getCompliance(config).rejectAddressReuse
|
||||
|
||||
if (_.some(it => it.created_by_operator === true)(blacklistItems)) {
|
||||
if (_.some(it => it.created_by_operator)(blacklistItems)) {
|
||||
blacklisted = true
|
||||
} else if (_.some(it => it.created_by_operator === false)(blacklistItems) && rejectAddressReuseActive) {
|
||||
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, false)
|
||||
} else if (_.some(it => !it.created_by_operator)(blacklistItems) && rejectAddressReuseActive) {
|
||||
notifier.notifyIfActive('compliance', 'blacklistNotify', r.tx, true)
|
||||
addressReuse = true
|
||||
}
|
||||
|
||||
return postProcess(r, pi, blacklisted, addressReuse)
|
||||
})
|
||||
.then(changes => cashInLow.update(db, updatedTx, changes))
|
||||
|
|
@ -43,8 +45,8 @@ function post (machineTx, pi) {
|
|||
})
|
||||
}
|
||||
|
||||
function registerTrades (pi, newBills) {
|
||||
_.forEach(bill => pi.buy(bill), newBills)
|
||||
function registerTrades (pi, r) {
|
||||
_.forEach(bill => pi.buy(bill, r.tx), r.newBills)
|
||||
}
|
||||
|
||||
function logAction (rec, tx) {
|
||||
|
|
@ -63,7 +65,7 @@ function logAction (rec, tx) {
|
|||
}
|
||||
|
||||
function logActionById (action, _rec, txId) {
|
||||
const rec = _.assign(_rec, {action, tx_id: txId})
|
||||
const rec = _.assign(_rec, { action, tx_id: txId })
|
||||
const sql = pgp.helpers.insert(rec, null, 'cash_in_actions')
|
||||
|
||||
return db.none(sql)
|
||||
|
|
@ -92,7 +94,7 @@ function postProcess (r, pi, isBlacklisted, addressReuse) {
|
|||
})
|
||||
}
|
||||
|
||||
registerTrades(pi, r.newBills)
|
||||
registerTrades(pi, r)
|
||||
|
||||
if (!cashInLow.isClearToSend(r.dbTx, r.tx)) return Promise.resolve({})
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ module.exports = {
|
|||
|
||||
const STALE_INCOMING_TX_AGE = T.day
|
||||
const STALE_LIVE_INCOMING_TX_AGE = 10 * T.minutes
|
||||
const STALE_LIVE_INCOMING_TX_AGE_FILTER = 5 * T.minutes
|
||||
const MAX_NOTIFY_AGE = T.day
|
||||
const MIN_NOTIFY_AGE = 5 * T.minutes
|
||||
const INSUFFICIENT_FUNDS_CODE = 570
|
||||
|
|
@ -95,16 +96,21 @@ function postProcess (txVector, justAuthorized, pi) {
|
|||
return Promise.resolve({})
|
||||
}
|
||||
|
||||
function fetchOpenTxs (statuses, fromAge, toAge) {
|
||||
function fetchOpenTxs (statuses, fromAge, toAge, applyFilter, coinFilter) {
|
||||
const notClause = applyFilter ? '' : 'not'
|
||||
const sql = `select *
|
||||
from cash_out_txs
|
||||
where ((extract(epoch from (now() - created))) * 1000)>$1
|
||||
and ((extract(epoch from (now() - created))) * 1000)<$2
|
||||
and status in ($3^)`
|
||||
${_.isEmpty(coinFilter)
|
||||
? ``
|
||||
: `and crypto_code ${notClause} in ($3^)`}
|
||||
and status in ($4^)`
|
||||
|
||||
const coinClause = _.map(pgp.as.text, coinFilter).join(',')
|
||||
const statusClause = _.map(pgp.as.text, statuses).join(',')
|
||||
|
||||
return db.any(sql, [fromAge, toAge, statusClause])
|
||||
return db.any(sql, [fromAge, toAge, coinClause, statusClause])
|
||||
.then(rows => rows.map(toObj))
|
||||
}
|
||||
|
||||
|
|
@ -116,20 +122,22 @@ function processTxStatus (tx, settings) {
|
|||
.then(_tx => selfPost(_tx, pi))
|
||||
}
|
||||
|
||||
function monitorLiveIncoming (settings) {
|
||||
function monitorLiveIncoming (settings, applyFilter, coinFilter) {
|
||||
const statuses = ['notSeen', 'published', 'insufficientFunds']
|
||||
const toAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE
|
||||
|
||||
return monitorIncoming(settings, statuses, 0, STALE_LIVE_INCOMING_TX_AGE)
|
||||
return monitorIncoming(settings, statuses, 0, toAge, applyFilter, coinFilter)
|
||||
}
|
||||
|
||||
function monitorStaleIncoming (settings) {
|
||||
function monitorStaleIncoming (settings, applyFilter, coinFilter) {
|
||||
const statuses = ['notSeen', 'published', 'authorized', 'instant', 'rejected', 'insufficientFunds']
|
||||
const fromAge = applyFilter ? STALE_LIVE_INCOMING_TX_AGE_FILTER : STALE_LIVE_INCOMING_TX_AGE
|
||||
|
||||
return monitorIncoming(settings, statuses, STALE_LIVE_INCOMING_TX_AGE, STALE_INCOMING_TX_AGE)
|
||||
return monitorIncoming(settings, statuses, fromAge, STALE_INCOMING_TX_AGE, applyFilter, coinFilter)
|
||||
}
|
||||
|
||||
function monitorIncoming (settings, statuses, fromAge, toAge) {
|
||||
return fetchOpenTxs(statuses, fromAge, toAge)
|
||||
function monitorIncoming (settings, statuses, fromAge, toAge, applyFilter, coinFilter) {
|
||||
return fetchOpenTxs(statuses, fromAge, toAge, applyFilter, coinFilter)
|
||||
.then(txs => pEachSeries(txs, tx => processTxStatus(tx, settings)))
|
||||
.catch(err => {
|
||||
if (err.code === dbErrorCodes.SERIALIZATION_FAILURE) {
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ const CRYPTO_CURRENCIES = [
|
|||
configFile: 'bitcoin.conf',
|
||||
daemon: 'bitcoind',
|
||||
defaultPort: 8332,
|
||||
unitScale: 8
|
||||
unitScale: 8,
|
||||
displayScale: 5
|
||||
},
|
||||
{
|
||||
cryptoCode: 'ETH',
|
||||
|
|
@ -21,7 +22,8 @@ const CRYPTO_CURRENCIES = [
|
|||
configFile: 'geth.conf',
|
||||
daemon: 'geth',
|
||||
defaultPort: 8545,
|
||||
unitScale: 18
|
||||
unitScale: 18,
|
||||
displayScale: 15
|
||||
},
|
||||
{
|
||||
cryptoCode: 'LTC',
|
||||
|
|
@ -30,7 +32,8 @@ const CRYPTO_CURRENCIES = [
|
|||
configFile: 'litecoin.conf',
|
||||
daemon: 'litecoind',
|
||||
defaultPort: 9332,
|
||||
unitScale: 8
|
||||
unitScale: 8,
|
||||
displayScale: 5
|
||||
},
|
||||
{
|
||||
cryptoCode: 'DASH',
|
||||
|
|
@ -39,7 +42,8 @@ const CRYPTO_CURRENCIES = [
|
|||
configFile: 'dash.conf',
|
||||
daemon: 'dashd',
|
||||
defaultPort: 9998,
|
||||
unitScale: 8
|
||||
unitScale: 8,
|
||||
displayScale: 5
|
||||
},
|
||||
{
|
||||
cryptoCode: 'ZEC',
|
||||
|
|
@ -48,7 +52,8 @@ const CRYPTO_CURRENCIES = [
|
|||
configFile: 'zcash.conf',
|
||||
daemon: 'zcashd',
|
||||
defaultPort: 8232,
|
||||
unitScale: 8
|
||||
unitScale: 8,
|
||||
displayScale: 5
|
||||
},
|
||||
{
|
||||
cryptoCode: 'BCH',
|
||||
|
|
@ -57,7 +62,8 @@ const CRYPTO_CURRENCIES = [
|
|||
configFile: 'bitcoincash.conf',
|
||||
daemon: 'bitcoincashd',
|
||||
defaultPort: 8335,
|
||||
unitScale: 8
|
||||
unitScale: 8,
|
||||
displayScale: 5
|
||||
}
|
||||
]
|
||||
|
||||
|
|
|
|||
41
lib/commission-math.js
Normal file
41
lib/commission-math.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
const BN = require('./bn')
|
||||
const configManager = require('./new-config-manager')
|
||||
const coinUtils = require('./coin-utils')
|
||||
|
||||
function truncateCrypto (cryptoAtoms, cryptoCode) {
|
||||
const DECIMAL_PLACES = 3
|
||||
if (cryptoAtoms.eq(0)) return cryptoAtoms
|
||||
|
||||
const scale = coinUtils.getCryptoCurrency(cryptoCode).displayScale
|
||||
const scaleFactor = BN(10).pow(scale)
|
||||
|
||||
return BN(cryptoAtoms).truncated().div(scaleFactor)
|
||||
.round(DECIMAL_PLACES).times(scaleFactor)
|
||||
}
|
||||
|
||||
function fiatToCrypto (tx, rec, deviceId, config) {
|
||||
const usableFiat = rec.fiat - rec.cashInFee
|
||||
|
||||
const commissions = configManager.getCommissions(tx.cryptoCode, deviceId, config)
|
||||
const tickerRate = BN(tx.rawTickerPrice)
|
||||
const discount = getDiscountRate(tx.discount, commissions[tx.direction])
|
||||
const rate = tickerRate.mul(discount).round(5)
|
||||
const unitScale = coinUtils.getCryptoCurrency(tx.cryptoCode).unitScale
|
||||
const unitScaleFactor = BN(10).pow(unitScale)
|
||||
|
||||
return truncateCrypto(BN(usableFiat).div(rate.div(unitScaleFactor)), tx.cryptoCode)
|
||||
}
|
||||
|
||||
function getDiscountRate (discount, commission) {
|
||||
const bnDiscount = discount ? BN(discount) : BN(0)
|
||||
const bnCommission = BN(commission)
|
||||
const percentageDiscount = BN(1).sub(bnDiscount.div(100))
|
||||
const percentageCommission = bnCommission.div(100)
|
||||
return BN(1).add(percentageDiscount.mul(percentageCommission))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
truncateCrypto,
|
||||
fiatToCrypto,
|
||||
getDiscountRate
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ const { scopedValue } = require('./admin/config-manager')
|
|||
|
||||
const GLOBAL = 'global'
|
||||
const ALL_CRYPTOS = _.values(COINS).sort()
|
||||
const ALL_CRYPTOS_STRING = 'ALL_COINS'
|
||||
const ALL_MACHINES = 'ALL_MACHINES'
|
||||
|
||||
const GLOBAL_SCOPE = {
|
||||
|
|
@ -66,7 +67,7 @@ function getConfigFields (codes, config) {
|
|||
}
|
||||
|
||||
function migrateCommissions (config) {
|
||||
const areArraysEquals = (arr1, arr2) => _.isEmpty(_.xor(arr1, arr2))
|
||||
const areArraysEquals = (arr1, arr2) => Array.isArray(arr1) && Array.isArray(arr2) && _.isEmpty(_.xor(arr1, arr2))
|
||||
const getMachine = _.get('scope.machine')
|
||||
const getCrypto = _.get('scope.crypto')
|
||||
const flattenCoins = _.compose(_.flatten, _.map(getCrypto))
|
||||
|
|
@ -116,7 +117,7 @@ function migrateCommissions (config) {
|
|||
commissions_overrides: allCommissionsOverrides.map(s => ({
|
||||
..._.fromPairs(s.values.map(f => [codes[f.code], f.value])),
|
||||
machine: s.scope.machine === GLOBAL ? ALL_MACHINES : s.scope.machine,
|
||||
cryptoCurrencies: s.scope.crypto,
|
||||
cryptoCurrencies: areArraysEquals(s.scope.crypto, ALL_CRYPTOS) ? [ALL_CRYPTOS_STRING] : s.scope.crypto,
|
||||
id: uuid.v4()
|
||||
}))
|
||||
})
|
||||
|
|
@ -162,17 +163,17 @@ function migrateCashOut (config) {
|
|||
|
||||
return {
|
||||
..._.fromPairs(
|
||||
global.map(f => [`cashout_${globalCodes[f.code]}`, f.value])
|
||||
global.map(f => [`cashOut_${globalCodes[f.code]}`, f.value])
|
||||
),
|
||||
..._.fromPairs(
|
||||
_.flatten(
|
||||
scoped.map(s => {
|
||||
const fields = s.values.map(f => [
|
||||
`cashout_${f.scope.machine}_${scopedCodes[f.code]}`,
|
||||
`cashOut_${f.scope.machine}_${scopedCodes[f.code]}`,
|
||||
f.value
|
||||
])
|
||||
|
||||
fields.push([`cashout_${s.scope.machine}_id`, s.scope.machine])
|
||||
fields.push([`cashOut_${s.scope.machine}_id`, s.scope.machine])
|
||||
|
||||
return fields
|
||||
})
|
||||
|
|
@ -334,6 +335,9 @@ function migrateTermsAndConditions (config) {
|
|||
}
|
||||
|
||||
function migrateComplianceTriggers (config) {
|
||||
|
||||
const suspensionDays = 1
|
||||
|
||||
const triggerTypes = {
|
||||
amount: 'txAmount',
|
||||
velocity: 'txVelocity',
|
||||
|
|
@ -343,24 +347,28 @@ function migrateComplianceTriggers (config) {
|
|||
|
||||
const requirements = {
|
||||
sms: 'sms',
|
||||
idData: 'idData',
|
||||
idPhoto: 'idPhoto',
|
||||
facePhoto: 'facePhoto',
|
||||
sanctions: 'sanctions'
|
||||
idData: 'idCardData',
|
||||
idPhoto: 'idCardPhoto',
|
||||
facePhoto: 'facephoto',
|
||||
sanctions: 'sanctions',
|
||||
suspend: 'suspend'
|
||||
}
|
||||
|
||||
function createTrigger (
|
||||
requirement,
|
||||
threshold
|
||||
threshold,
|
||||
suspensionDays
|
||||
) {
|
||||
return {
|
||||
const triggerConfig = {
|
||||
id: uuid.v4(),
|
||||
cashDirection: 'both',
|
||||
direction: 'both',
|
||||
threshold,
|
||||
thresholdDays: 1,
|
||||
triggerType: triggerTypes.volume,
|
||||
requirement
|
||||
}
|
||||
if (!requirement === 'suspend') return triggerConfig
|
||||
return _.assign(triggerConfig, { suspensionDays })
|
||||
}
|
||||
|
||||
const codes = [
|
||||
|
|
@ -373,7 +381,10 @@ function migrateComplianceTriggers (config) {
|
|||
'frontCameraVerificationActive',
|
||||
'frontCameraVerificationThreshold',
|
||||
'sanctionsVerificationActive',
|
||||
'sanctionsVerificationThreshold'
|
||||
'sanctionsVerificationThreshold',
|
||||
'hardLimitVerificationActive',
|
||||
'hardLimitVerificationThreshold',
|
||||
'rejectAddressReuseActive'
|
||||
]
|
||||
|
||||
const global = _.fromPairs(
|
||||
|
|
@ -406,7 +417,11 @@ function migrateComplianceTriggers (config) {
|
|||
createTrigger(requirements.sanctions, global.sanctionsVerificationThreshold)
|
||||
)
|
||||
}
|
||||
|
||||
if (global.hardLimitVerificationActive && _.isNumber(global.hardLimitVerificationThreshold)) {
|
||||
triggers.push(
|
||||
createTrigger(requirements.suspend, global.hardLimitVerificationThreshold, suspensionDays)
|
||||
)
|
||||
}
|
||||
return {
|
||||
triggers,
|
||||
['compliance_rejectAddressReuse']: global.rejectAddressReuseActive
|
||||
|
|
@ -440,7 +455,10 @@ function migrateAccounts (accounts) {
|
|||
'twilio'
|
||||
]
|
||||
|
||||
return _.pick(accountArray)(accounts)
|
||||
const services = _.keyBy('code', accounts)
|
||||
const serviceFields = _.mapValues(({ fields }) => _.keyBy('code', fields))(services)
|
||||
const allAccounts = _.mapValues(_.mapValues(_.get('value')))(serviceFields)
|
||||
return _.pick(accountArray)(allAccounts)
|
||||
}
|
||||
|
||||
function migrate (config, accounts) {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ const complianceOverrides = require('./compliance_overrides')
|
|||
const users = require('./users')
|
||||
const options = require('./options')
|
||||
const writeFile = util.promisify(fs.writeFile)
|
||||
|
||||
const notifierQueries = require('./notifier/queries')
|
||||
const notifierUtils = require('./notifier/utils')
|
||||
const NUM_RESULTS = 1000
|
||||
const idPhotoCardBasedir = _.get('idPhotoCardDir', options)
|
||||
const frontCameraBaseDir = _.get('frontCameraDir', options)
|
||||
|
|
@ -115,12 +116,20 @@ async function updateCustomer (id, data, userToken) {
|
|||
|
||||
const sql = Pgp.helpers.update(updateData, _.keys(updateData), 'customers') +
|
||||
' where id=$1'
|
||||
invalidateCustomerNotifications(id, formattedData)
|
||||
|
||||
await db.none(sql, [id])
|
||||
|
||||
return getCustomerById(id)
|
||||
}
|
||||
|
||||
const invalidateCustomerNotifications = (id, data) => {
|
||||
if (data.authorized_override !== 'verified') return Promise.resolve()
|
||||
|
||||
const detailB = notifierUtils.buildDetail({ code: 'BLOCKED', customerId: id })
|
||||
return notifierQueries.invalidateNotification(detailB, 'compliance')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get customer by id
|
||||
*
|
||||
|
|
@ -399,7 +408,7 @@ function populateOverrideUsernames (customer) {
|
|||
return users.getByIds(queryTokens)
|
||||
.then(usersList => {
|
||||
return _.map(userField => {
|
||||
const user = _.find({token: userField.token}, usersList)
|
||||
const user = _.find({ token: userField.token }, usersList)
|
||||
return {
|
||||
[userField.field]: user ? user.name : null
|
||||
}
|
||||
|
|
|
|||
5
lib/forex.js
Normal file
5
lib/forex.js
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
const axios = require('axios')
|
||||
|
||||
const getFiatRates = () => axios.get('https://bitpay.com/api/rates').then(response => response.data)
|
||||
|
||||
module.exports = { getFiatRates }
|
||||
|
|
@ -1,18 +1,17 @@
|
|||
const _ = require('lodash/fp')
|
||||
const axios = require('axios')
|
||||
|
||||
const logger = require('./logger')
|
||||
const db = require('./db')
|
||||
const pairing = require('./pairing')
|
||||
const notifier = require('./notifier')
|
||||
const { checkPings, checkStuckScreen } = require('./notifier')
|
||||
const dbm = require('./postgresql_interface')
|
||||
const configManager = require('./new-config-manager')
|
||||
const settingsLoader = require('./new-settings-loader')
|
||||
|
||||
module.exports = {getMachineName, getMachines, getMachine, getMachineNames, setMachine}
|
||||
const notifierUtils = require('./notifier/utils')
|
||||
const notifierQueries = require('./notifier/queries')
|
||||
|
||||
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 => ({
|
||||
deviceId: r.device_id,
|
||||
cashbox: r.cashbox,
|
||||
|
|
@ -36,13 +35,13 @@ function getConfig (defaultConfig) {
|
|||
}
|
||||
|
||||
function getMachineNames (config) {
|
||||
const fullyFunctionalStatus = {label: 'Fully functional', type: 'success'}
|
||||
const unresponsiveStatus = {label: 'Unresponsive', type: 'error'}
|
||||
const stuckStatus = {label: 'Stuck', type: 'error'}
|
||||
const fullyFunctionalStatus = { label: 'Fully functional', type: 'success' }
|
||||
const unresponsiveStatus = { label: 'Unresponsive', type: 'error' }
|
||||
const stuckStatus = { label: 'Stuck', type: 'error' }
|
||||
|
||||
return Promise.all([getMachines(), getConfig(config)])
|
||||
.then(([machines, config]) => Promise.all(
|
||||
[machines, notifier.checkPings(machines), dbm.machineEvents(), config]
|
||||
[machines, checkPings(machines), dbm.machineEvents(), config]
|
||||
))
|
||||
.then(([machines, pings, events, config]) => {
|
||||
const getStatus = (ping, stuck) => {
|
||||
|
|
@ -61,11 +60,11 @@ function getMachineNames (config) {
|
|||
const statuses = [
|
||||
getStatus(
|
||||
_.first(pings[r.deviceId]),
|
||||
_.first(notifier.checkStuckScreen(events, r.name))
|
||||
_.first(checkStuckScreen(events, r.name))
|
||||
)
|
||||
]
|
||||
|
||||
return _.assign(r, {cashOut, statuses})
|
||||
return _.assign(r, { cashOut, statuses })
|
||||
}
|
||||
|
||||
return _.map(addName, machines)
|
||||
|
|
@ -83,31 +82,37 @@ function getMachineNames (config) {
|
|||
* @returns {string} machine name
|
||||
*/
|
||||
function getMachineName (machineId) {
|
||||
const sql = 'select * from devices where device_id=$1'
|
||||
const sql = 'SELECT * FROM devices WHERE device_id=$1'
|
||||
return db.oneOrNone(sql, [machineId])
|
||||
.then(it => it.name)
|
||||
}
|
||||
|
||||
function getMachine (machineId) {
|
||||
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))
|
||||
}
|
||||
|
||||
function renameMachine (rec) {
|
||||
const sql = 'update devices set name=$1 where device_id=$2'
|
||||
const sql = 'UPDATE devices SET name=$1 WHERE device_id=$2'
|
||||
return db.none(sql, [rec.newName, rec.deviceId])
|
||||
}
|
||||
|
||||
function resetCashOutBills (rec) {
|
||||
const sql = 'update devices set cassette1=$1, cassette2=$2 where device_id=$3'
|
||||
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId])
|
||||
const detailB = notifierUtils.buildDetail({ deviceId: rec.deviceId })
|
||||
const sql = `UPDATE devices SET cassette1=$1, cassette2=$2 WHERE device_id=$3;`
|
||||
return db.none(sql, [rec.cassettes[0], rec.cassettes[1], rec.deviceId]).then(() => notifierQueries.invalidateNotification(detailB, 'fiatBalance'))
|
||||
}
|
||||
|
||||
function emptyCashInBills (rec) {
|
||||
const sql = 'update devices set cashbox=0 where device_id=$1'
|
||||
const sql = 'UPDATE devices SET cashbox=0 WHERE device_id=$1'
|
||||
return db.none(sql, [rec.deviceId])
|
||||
}
|
||||
|
||||
function setCassetteBills (rec) {
|
||||
const sql = 'update devices set cashbox=$1, cassette1=$2, cassette2=$3 where device_id=$4'
|
||||
return db.none(sql, [rec.cashbox, rec.cassettes[0], rec.cassettes[1], rec.deviceId])
|
||||
}
|
||||
|
||||
function unpair (rec) {
|
||||
return pairing.unpair(rec.deviceId)
|
||||
}
|
||||
|
|
@ -129,6 +134,7 @@ function setMachine (rec) {
|
|||
case 'rename': return renameMachine(rec)
|
||||
case 'emptyCashInBills': return emptyCashInBills(rec)
|
||||
case 'resetCashOutBills': return resetCashOutBills(rec)
|
||||
case 'setCassetteBills': return setCassetteBills(rec)
|
||||
case 'unpair': return unpair(rec)
|
||||
case 'reboot': return reboot(rec)
|
||||
case 'shutdown': return shutdown(rec)
|
||||
|
|
@ -136,3 +142,5 @@ function setMachine (rec) {
|
|||
default: throw new Error('No such action: ' + rec.action)
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getMachineName, getMachines, getMachine, getMachineNames, setMachine }
|
||||
|
|
|
|||
25
lib/new-admin/bills.js
Normal file
25
lib/new-admin/bills.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
const db = require('../db')
|
||||
|
||||
// Get all bills with device id
|
||||
const getBills = () => {
|
||||
return Promise.reject(new Error('This functionality hasn\'t been implemented yet'))
|
||||
/* return db.any(`
|
||||
SELECT d.device_id, b.fiat, b.created, d.cashbox
|
||||
FROM cash_in_txs
|
||||
INNER JOIN bills AS b ON b.cash_in_txs_id = cash_in_txs.id
|
||||
INNER JOIN devices as d ON d.device_id = cash_in_txs.device_id
|
||||
ORDER BY device_id, created DESC`
|
||||
)
|
||||
.then(res => {
|
||||
return res.map(item => ({
|
||||
fiat: item.fiat,
|
||||
deviceId: item.device_id,
|
||||
cashbox: item.cashbox,
|
||||
created: item.created
|
||||
}))
|
||||
}) */
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getBills
|
||||
}
|
||||
|
|
@ -22,7 +22,7 @@ const ALL_ACCOUNTS = [
|
|||
{ code: 'bitcoind', display: 'bitcoind', class: WALLET, cryptos: [BTC] },
|
||||
{ code: 'no-layer2', display: 'No Layer 2', class: LAYER_2, cryptos: ALL_CRYPTOS },
|
||||
{ code: 'infura', display: 'Infura', class: WALLET, cryptos: [ETH] },
|
||||
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH] },
|
||||
{ code: 'geth', display: 'geth (DEPRECATED)', class: WALLET, cryptos: [ETH], deprecated: true },
|
||||
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
|
||||
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
|
||||
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },
|
||||
|
|
|
|||
|
|
@ -12,15 +12,21 @@ const logs = require('../../logs')
|
|||
const settingsLoader = require('../../new-settings-loader')
|
||||
// const tokenManager = require('../../token-manager')
|
||||
const blacklist = require('../../blacklist')
|
||||
const machineEventsByIdBatch = require("../../postgresql_interface").machineEventsByIdBatch
|
||||
const machineEventsByIdBatch = require('../../postgresql_interface').machineEventsByIdBatch
|
||||
const promoCodeManager = require('../../promo-codes')
|
||||
const notifierQueries = require('../../notifier/queries')
|
||||
const bills = require('../bills')
|
||||
const anonymous = require('../../constants').anonymousCustomer
|
||||
|
||||
const serverVersion = require('../../../package.json').version
|
||||
|
||||
const transactions = require('../transactions')
|
||||
const funding = require('../funding')
|
||||
const forex = require('../../forex')
|
||||
const supervisor = require('../supervisor')
|
||||
const serverLogs = require('../server-logs')
|
||||
const pairing = require('../pairing')
|
||||
const plugins = require('../../plugins')
|
||||
|
||||
const {
|
||||
accounts: accountsConfig,
|
||||
coins,
|
||||
|
|
@ -81,6 +87,7 @@ const typeDefs = gql`
|
|||
frontCameraPath: String
|
||||
frontCameraOverride: String
|
||||
phone: String
|
||||
isAnonymous: Boolean
|
||||
smsOverride: String
|
||||
idCardData: JSONObject
|
||||
idCardDataOverride: String
|
||||
|
|
@ -130,6 +137,7 @@ const typeDefs = gql`
|
|||
display: String!
|
||||
class: String!
|
||||
cryptos: [String]
|
||||
deprecated: Boolean
|
||||
}
|
||||
|
||||
type MachineLog {
|
||||
|
|
@ -174,6 +182,12 @@ const typeDefs = gql`
|
|||
ip_address: String
|
||||
}
|
||||
|
||||
type PromoCode {
|
||||
id: ID!
|
||||
code: String!
|
||||
discount: Int!
|
||||
}
|
||||
|
||||
type Transaction {
|
||||
id: ID!
|
||||
txClass: String!
|
||||
|
|
@ -200,6 +214,7 @@ const typeDefs = gql`
|
|||
cashInFeeCrypto: String
|
||||
minimumTx: Float
|
||||
customerId: ID
|
||||
isAnonymous: Boolean
|
||||
txVersion: Int!
|
||||
termsAccepted: Boolean
|
||||
commissionPercentage: String
|
||||
|
|
@ -214,6 +229,7 @@ const typeDefs = gql`
|
|||
customerIdCardPhotoPath: String
|
||||
expired: Boolean
|
||||
machineName: String
|
||||
discount: Int
|
||||
}
|
||||
|
||||
type Blacklist {
|
||||
|
|
@ -232,6 +248,29 @@ const typeDefs = gql`
|
|||
deviceTime: Date
|
||||
}
|
||||
|
||||
type Rate {
|
||||
code: String
|
||||
name: String
|
||||
rate: Float
|
||||
}
|
||||
|
||||
type Notification {
|
||||
id: ID!
|
||||
type: String
|
||||
detail: JSON
|
||||
message: String
|
||||
created: Date
|
||||
read: Boolean
|
||||
valid: Boolean
|
||||
}
|
||||
|
||||
type Bills {
|
||||
fiat: Int
|
||||
deviceId: ID
|
||||
created: Date
|
||||
cashbox: Int
|
||||
}
|
||||
|
||||
type Query {
|
||||
countries: [Country]
|
||||
currencies: [Currency]
|
||||
|
|
@ -249,18 +288,38 @@ const typeDefs = gql`
|
|||
uptime: [ProcessStatus]
|
||||
serverLogs(from: Date, until: Date, limit: Int, offset: Int): [ServerLog]
|
||||
serverLogsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
||||
transactions(from: Date, until: Date, limit: Int, offset: Int): [Transaction]
|
||||
transactions(
|
||||
from: Date
|
||||
until: Date
|
||||
limit: Int
|
||||
offset: Int
|
||||
deviceId: ID
|
||||
): [Transaction]
|
||||
transactionsCsv(from: Date, until: Date, limit: Int, offset: Int): String
|
||||
accounts: JSONObject
|
||||
config: JSONObject
|
||||
blacklist: [Blacklist]
|
||||
# userTokens: [UserToken]
|
||||
promoCodes: [PromoCode]
|
||||
cryptoRates: JSONObject
|
||||
fiatRates: [Rate]
|
||||
notifications: [Notification]
|
||||
alerts: [Notification]
|
||||
hasUnreadNotifications: Boolean
|
||||
bills: [Bills]
|
||||
}
|
||||
|
||||
type SupportLogsResponse {
|
||||
id: ID!
|
||||
timestamp: Date!
|
||||
deviceId: ID
|
||||
}
|
||||
|
||||
enum MachineAction {
|
||||
rename
|
||||
emptyCashInBills
|
||||
resetCashOutBills
|
||||
setCassetteBills
|
||||
unpair
|
||||
reboot
|
||||
shutdown
|
||||
|
|
@ -268,14 +327,21 @@ const typeDefs = gql`
|
|||
}
|
||||
|
||||
type Mutation {
|
||||
machineAction(deviceId:ID!, action: MachineAction!, cassette1: Int, cassette2: Int, newName: String): Machine
|
||||
machineAction(deviceId:ID!, action: MachineAction!, cashbox: Int, cassette1: Int, cassette2: Int, newName: String): Machine
|
||||
setCustomer(customerId: ID!, customerInput: CustomerInput): Customer
|
||||
saveConfig(config: JSONObject): JSONObject
|
||||
resetConfig(schemaVersion: Int): JSONObject
|
||||
createPairingTotem(name: String!): String
|
||||
saveAccounts(accounts: JSONObject): JSONObject
|
||||
resetAccounts(schemaVersion: Int): JSONObject
|
||||
migrateConfigAndAccounts: JSONObject
|
||||
# revokeToken(token: String!): UserToken
|
||||
deleteBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||
insertBlacklistRow(cryptoCode: String!, address: String!): Blacklist
|
||||
createPromoCode(code: String!, discount: Int!): PromoCode
|
||||
deletePromoCode(codeId: ID!): PromoCode
|
||||
toggleClearNotification(id: ID!, read: Boolean!): Notification
|
||||
clearAllNotifications: Notification
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -292,7 +358,11 @@ const resolvers = {
|
|||
JSONObject: GraphQLJSONObject,
|
||||
Date: GraphQLDateTime,
|
||||
Customer: {
|
||||
transactions: parent => transactionsLoader.load(parent.id)
|
||||
transactions: parent => transactionsLoader.load(parent.id),
|
||||
isAnonymous: parent => (parent.id === anonymous.uuid)
|
||||
},
|
||||
Transaction: {
|
||||
isAnonymous: parent => (parent.customerId === anonymous.uuid)
|
||||
},
|
||||
Machine: {
|
||||
latestEvent: parent => machineEventsLoader.load(parent.deviceId)
|
||||
|
|
@ -318,33 +388,57 @@ const resolvers = {
|
|||
serverLogs.getServerLogs(from, until, limit, offset),
|
||||
serverLogsCsv: (...[, { from, until, limit, offset }]) =>
|
||||
serverLogs.getServerLogs(from, until, limit, offset).then(parseAsync),
|
||||
transactions: (...[, { from, until, limit, offset }]) =>
|
||||
transactions.batch(from, until, limit, offset),
|
||||
transactions: (...[, { from, until, limit, offset, deviceId }]) =>
|
||||
transactions.batch(from, until, limit, offset, deviceId),
|
||||
transactionsCsv: (...[, { from, until, limit, offset }]) =>
|
||||
transactions.batch(from, until, limit, offset).then(parseAsync),
|
||||
config: () => settingsLoader.loadLatestConfigOrNone(),
|
||||
accounts: () => settingsLoader.loadAccounts(),
|
||||
accounts: () => settingsLoader.showAccounts(),
|
||||
blacklist: () => blacklist.getBlacklist(),
|
||||
// userTokens: () => tokenManager.getTokenList()
|
||||
promoCodes: () => promoCodeManager.getAvailablePromoCodes(),
|
||||
cryptoRates: () =>
|
||||
settingsLoader.loadLatest().then(settings => {
|
||||
const pi = plugins(settings)
|
||||
return pi.getRawRates().then(r => {
|
||||
return {
|
||||
withCommissions: pi.buildRates(r),
|
||||
withoutCommissions: pi.buildRatesNoCommission(r)
|
||||
}
|
||||
})
|
||||
}),
|
||||
fiatRates: () => forex.getFiatRates(),
|
||||
notifications: () => notifierQueries.getNotifications(),
|
||||
hasUnreadNotifications: () => notifierQueries.hasUnreadNotifications(),
|
||||
alerts: () => notifierQueries.getAlerts(),
|
||||
bills: () => bills.getBills()
|
||||
},
|
||||
Mutation: {
|
||||
machineAction: (...[, { deviceId, action, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cassette1, cassette2, newName }),
|
||||
machineAction: (...[, { deviceId, action, cashbox, cassette1, cassette2, newName }]) => machineAction({ deviceId, action, cashbox, cassette1, cassette2, newName }),
|
||||
createPairingTotem: (...[, { name }]) => pairing.totem(name),
|
||||
saveAccounts: (...[, { accounts }]) => settingsLoader.saveAccounts(accounts),
|
||||
setCustomer: (root, args, context, info) => {
|
||||
resetAccounts: (...[, { schemaVersion }]) => settingsLoader.resetAccounts(schemaVersion),
|
||||
setCustomer: (root, { customerId, customerInput }, context, info) => {
|
||||
const token = context.req.cookies && context.req.cookies.token
|
||||
return customers.updateCustomer(args.customerId, args.customerInput, token)
|
||||
if (customerId === anonymous.uuid) return customers.getCustomerById(customerId)
|
||||
return customers.updateCustomer(customerId, customerInput, token)
|
||||
},
|
||||
saveConfig: (...[, { config }]) => settingsLoader.saveConfig(config)
|
||||
.then(it => {
|
||||
notify()
|
||||
return it
|
||||
}),
|
||||
resetConfig: (...[, { schemaVersion }]) => settingsLoader.resetConfig(schemaVersion),
|
||||
migrateConfigAndAccounts: () => settingsLoader.migrate(),
|
||||
deleteBlacklistRow: (...[, { cryptoCode, address }]) =>
|
||||
blacklist.deleteFromBlacklist(cryptoCode, address),
|
||||
insertBlacklistRow: (...[, { cryptoCode, address }]) =>
|
||||
blacklist.insertIntoBlacklist(cryptoCode, address),
|
||||
// revokeToken: (...[, { token }]) => tokenManager.revokeToken(token)
|
||||
createPromoCode: (...[, { code, discount }]) => promoCodeManager.createPromoCode(code, discount),
|
||||
deletePromoCode: (...[, { codeId }]) => promoCodeManager.deletePromoCode(codeId),
|
||||
toggleClearNotification: (...[, { id, read }]) => notifierQueries.setRead(id, read),
|
||||
clearAllNotifications: () => notifierQueries.markAllAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ function getMachine (machineId) {
|
|||
.then(machines => machines.find(({ deviceId }) => deviceId === machineId))
|
||||
}
|
||||
|
||||
function machineAction ({ deviceId, action, cassette1, cassette2, newName }) {
|
||||
function machineAction ({ deviceId, action, cashbox, cassette1, cassette2, newName }) {
|
||||
return getMachine(deviceId)
|
||||
.then(machine => {
|
||||
if (!machine) throw new UserInputError(`machine:${deviceId} not found`, { deviceId })
|
||||
return machine
|
||||
})
|
||||
.then(machineLoader.setMachine({ deviceId, action, cassettes: [cassette1, cassette2], newName }))
|
||||
.then(machineLoader.setMachine({ deviceId, action, cashbox, cassettes: [cassette1, cassette2], newName }))
|
||||
.then(getMachine(deviceId))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ function addNames (txs) {
|
|||
|
||||
const camelize = _.mapKeys(_.camelCase)
|
||||
|
||||
function batch (from = new Date(0).toISOString(), until = new Date().toISOString(), limit = null, offset = 0) {
|
||||
function batch (from = new Date(0).toISOString(), until = new Date().toISOString(), limit = null, offset = 0, id = null) {
|
||||
const packager = _.flow(_.flatten, _.orderBy(_.property('created'), ['desc']), _.map(camelize), addNames)
|
||||
|
||||
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
||||
|
|
@ -38,7 +38,9 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
|
|||
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
|
||||
from cash_in_txs as txs
|
||||
left outer join customers c on txs.customer_id = c.id
|
||||
where txs.created >= $2 and txs.created <= $3
|
||||
where txs.created >= $2 and txs.created <= $3 ${
|
||||
id !== null ? `and txs.device_id = $6` : ``
|
||||
}
|
||||
order by created desc limit $4 offset $5`
|
||||
|
||||
const cashOutSql = `select 'cashOut' as tx_class,
|
||||
|
|
@ -56,14 +58,22 @@ function batch (from = new Date(0).toISOString(), until = new Date().toISOString
|
|||
inner join cash_out_actions actions on txs.id = actions.tx_id
|
||||
and actions.action = 'provisionAddress'
|
||||
left outer join customers c on txs.customer_id = c.id
|
||||
where txs.created >= $2 and txs.created <= $3
|
||||
where txs.created >= $2 and txs.created <= $3 ${
|
||||
id !== null ? `and txs.device_id = $6` : ``
|
||||
}
|
||||
order by created desc limit $4 offset $5`
|
||||
|
||||
return Promise.all([
|
||||
db.any(cashInSql, [cashInTx.PENDING_INTERVAL, from, until, limit, offset]),
|
||||
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset])
|
||||
])
|
||||
.then(packager)
|
||||
db.any(cashInSql, [
|
||||
cashInTx.PENDING_INTERVAL,
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
offset,
|
||||
id
|
||||
]),
|
||||
db.any(cashOutSql, [REDEEMABLE_AGE, from, until, limit, offset, id])
|
||||
]).then(packager)
|
||||
}
|
||||
|
||||
function getCustomerTransactionsBatch (ids) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
const _ = require('lodash/fp')
|
||||
const logger = require('./logger')
|
||||
|
||||
const namespaces = {
|
||||
WALLETS: 'wallets',
|
||||
|
|
@ -19,13 +18,6 @@ const filter = namespace => _.pickBy((value, key) => _.startsWith(`${namespace}_
|
|||
const strip = key => _.mapKeys(stripl(`${key}_`))
|
||||
|
||||
const fromNamespace = _.curry((key, config) => _.compose(strip(key), filter(key))(config))
|
||||
const toNamespace = (key, config) => _.mapKeys(it => `${key}_${it}`)(config)
|
||||
|
||||
const resolveOverrides = (original, filter, overrides, overridesPath = 'overrides') => {
|
||||
if (_.isEmpty(overrides)) return _.omit(overridesPath, original)
|
||||
|
||||
return _.omit(overridesPath, _.assignAll([original, ..._.filter(filter)(overrides)]))
|
||||
}
|
||||
|
||||
const getCommissions = (cryptoCode, deviceId, config) => {
|
||||
const commissions = fromNamespace(namespaces.COMMISSIONS)(config)
|
||||
|
|
@ -55,7 +47,7 @@ const getLocale = (deviceId, it) => {
|
|||
const locale = fromNamespace(namespaces.LOCALE)(it)
|
||||
|
||||
const filter = _.matches({ machine: deviceId })
|
||||
return resolveOverrides(locale, filter, locale.overrides)
|
||||
return _.omit('overrides', _.assignAll([locale, ..._.filter(filter)(locale.overrides)]))
|
||||
}
|
||||
|
||||
const getGlobalLocale = it => getLocale(null, it)
|
||||
|
|
@ -78,17 +70,34 @@ const getAllCryptoCurrencies = (config) => {
|
|||
const getNotifications = (cryptoCurrency, machine, config) => {
|
||||
const notifications = fromNamespace(namespaces.NOTIFICATIONS)(config)
|
||||
|
||||
const cryptoFilter = _.matches({ cryptoCurrency })
|
||||
const withCryptoBalance = resolveOverrides(notifications, cryptoFilter, notifications.cryptoBalanceOverrides, 'cryptoBalanceOverrides')
|
||||
const smsSettings = fromNamespace('sms', notifications)
|
||||
const emailSettings = fromNamespace('email', notifications)
|
||||
const notificationCenterSettings = fromNamespace('notificationCenter', notifications)
|
||||
|
||||
const fiatFilter = _.matches({ machine })
|
||||
const withFiatBalance = resolveOverrides(withCryptoBalance, fiatFilter, withCryptoBalance.fiatBalanceOverrides, 'fiatBalanceOverrides')
|
||||
const notifNoOverrides = _.omit(['cryptoBalanceOverrides', 'fiatBalanceOverrides'], notifications)
|
||||
|
||||
const withSms = fromNamespace('sms', withFiatBalance)
|
||||
const withEmail = fromNamespace('email', withFiatBalance)
|
||||
const findByCryptoCurrency = _.find(_.matches({ cryptoCurrency }))
|
||||
const findByMachine = _.find(_.matches({ machine }))
|
||||
|
||||
const final = { ...withFiatBalance, sms: withSms, email: withEmail }
|
||||
return final
|
||||
const cryptoFields = ['cryptoHighBalance', 'cryptoLowBalance', 'highBalance', 'lowBalance']
|
||||
const fiatFields = ['fiatBalanceCassette1', 'fiatBalanceCassette2']
|
||||
|
||||
const getCryptoSettings = _.compose(_.pick(cryptoFields), _.defaultTo(notifications), findByCryptoCurrency)
|
||||
const cryptoSettings = getCryptoSettings(notifications.cryptoBalanceOverrides)
|
||||
|
||||
if (cryptoSettings.highBalance) {
|
||||
cryptoSettings['cryptoHighBalance'] = cryptoSettings.highBalance
|
||||
delete cryptoSettings.highBalance
|
||||
}
|
||||
|
||||
if (cryptoSettings.lowBalance) {
|
||||
cryptoSettings['cryptoLowBalance'] = cryptoSettings.lowBalance
|
||||
delete cryptoSettings.lowBalance
|
||||
}
|
||||
|
||||
const getFiatSettings = _.compose(_.pick(fiatFields), _.defaultTo(notifications), findByMachine)
|
||||
const fiatSettings = getFiatSettings(notifications.fiatBalanceOverrides)
|
||||
return { ...notifNoOverrides, sms: smsSettings, email: emailSettings, ...cryptoSettings, ...fiatSettings, notificationCenter: notificationCenterSettings }
|
||||
}
|
||||
|
||||
const getGlobalNotifications = config => getNotifications(null, null, config)
|
||||
|
|
|
|||
|
|
@ -1,21 +1,46 @@
|
|||
const _ = require('lodash/fp')
|
||||
const db = require('./db')
|
||||
const migration = require('./config-migration')
|
||||
|
||||
const OLD_SETTINGS_LOADER_SCHEMA_VERSION = 1
|
||||
const NEW_SETTINGS_LOADER_SCHEMA_VERSION = 2
|
||||
const PASSWORD_FILLED = 'PASSWORD_FILLED'
|
||||
const SECRET_FIELDS = [
|
||||
'bitgo.BTCWalletPassphrase',
|
||||
'bitgo.LTCWalletPassphrase',
|
||||
'bitgo.ZECWalletPassphrase',
|
||||
'bitgo.BCHWalletPassphrase',
|
||||
'bitgo.DASHWalletPassphrase',
|
||||
'bitstamp.secret',
|
||||
'infura.apiSecret',
|
||||
'itbit.clientSecret',
|
||||
'kraken.privateKey',
|
||||
'twilio.authToken'
|
||||
]
|
||||
|
||||
function saveAccounts (accountsToSave) {
|
||||
const sql = `update user_config set data = $2, valid = $3, schema_version = $4 where type = $1;
|
||||
insert into user_config (type, data, valid, schema_version)
|
||||
select $1, $2, $3, $4 where $1 not in (select type from user_config)`
|
||||
|
||||
const accountsSql = `update user_config set data = $2, valid = $3, schema_version = $4 where type = $1;
|
||||
insert into user_config (type, data, valid, schema_version)
|
||||
select $1, $2, $3, $4 where $1 not in (select type from user_config)`
|
||||
function saveAccounts (accounts) {
|
||||
return loadAccounts()
|
||||
.then(currentAccounts => {
|
||||
const newAccounts = _.assign(currentAccounts, accountsToSave)
|
||||
return db.none(sql, ['accounts', { accounts: newAccounts }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
const newAccounts = _.merge(currentAccounts, accounts)
|
||||
return db.none(accountsSql, ['accounts', { accounts: newAccounts }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
})
|
||||
}
|
||||
function resetAccounts (schemaVersion) {
|
||||
return db.none(
|
||||
accountsSql,
|
||||
[
|
||||
'accounts',
|
||||
{ accounts: NEW_SETTINGS_LOADER_SCHEMA_VERSION ? {} : [] },
|
||||
true,
|
||||
schemaVersion
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
function loadAccounts () {
|
||||
function loadAccounts (schemaVersion) {
|
||||
const sql = `select data
|
||||
from user_config
|
||||
where type=$1
|
||||
|
|
@ -24,22 +49,45 @@ function loadAccounts () {
|
|||
order by id desc
|
||||
limit 1`
|
||||
|
||||
return db.oneOrNone(sql, ['accounts', NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
return db.oneOrNone(sql, ['accounts', schemaVersion || NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
.then(_.compose(_.defaultTo({}), _.get('data.accounts')))
|
||||
}
|
||||
|
||||
function saveConfig (config) {
|
||||
const sql = 'insert into user_config (type, data, valid, schema_version) values ($1, $2, $3, $4)'
|
||||
|
||||
return loadLatestConfigOrNone()
|
||||
.then(currentConfig => {
|
||||
const newConfig = _.assign(currentConfig, config)
|
||||
return db.none(sql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
function showAccounts (schemaVersion) {
|
||||
return loadAccounts(schemaVersion)
|
||||
.then(accounts => {
|
||||
const filledSecretPaths = _.compact(_.map(path => {
|
||||
if (!_.isEmpty(_.get(path, accounts))) {
|
||||
return path
|
||||
}
|
||||
}, SECRET_FIELDS))
|
||||
return _.compose(_.map(path => _.assoc(path, PASSWORD_FILLED), filledSecretPaths))(accounts)
|
||||
})
|
||||
}
|
||||
|
||||
function loadLatest () {
|
||||
return Promise.all([loadLatestConfigOrNone(), loadAccounts()])
|
||||
const configSql = 'insert into user_config (type, data, valid, schema_version) values ($1, $2, $3, $4)'
|
||||
function saveConfig (config) {
|
||||
return loadLatestConfigOrNone()
|
||||
.then(currentConfig => {
|
||||
const newConfig = _.assign(currentConfig, config)
|
||||
return db.none(configSql, ['config', { config: newConfig }, true, NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
})
|
||||
}
|
||||
|
||||
function resetConfig (schemaVersion) {
|
||||
return db.none(
|
||||
configSql,
|
||||
[
|
||||
'config',
|
||||
{ config: schemaVersion === NEW_SETTINGS_LOADER_SCHEMA_VERSION ? {} : [] },
|
||||
true,
|
||||
schemaVersion
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
function loadLatest (schemaVersion) {
|
||||
return Promise.all([loadLatestConfigOrNone(schemaVersion), loadAccounts(schemaVersion)])
|
||||
.then(([config, accounts]) => ({
|
||||
config,
|
||||
accounts
|
||||
|
|
@ -62,7 +110,7 @@ function loadLatestConfig () {
|
|||
})
|
||||
}
|
||||
|
||||
function loadLatestConfigOrNone () {
|
||||
function loadLatestConfigOrNone (schemaVersion) {
|
||||
const sql = `select data
|
||||
from user_config
|
||||
where type=$1
|
||||
|
|
@ -70,7 +118,7 @@ function loadLatestConfigOrNone () {
|
|||
order by id desc
|
||||
limit 1`
|
||||
|
||||
return db.oneOrNone(sql, ['config', NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
return db.oneOrNone(sql, ['config', schemaVersion || NEW_SETTINGS_LOADER_SCHEMA_VERSION])
|
||||
.then(row => row ? row.data.config : {})
|
||||
}
|
||||
|
||||
|
|
@ -103,12 +151,27 @@ function load (versionId) {
|
|||
}))
|
||||
}
|
||||
|
||||
function migrate () {
|
||||
return loadLatest(OLD_SETTINGS_LOADER_SCHEMA_VERSION)
|
||||
.then(res => {
|
||||
const migrated = migration.migrate(res.config, res.accounts)
|
||||
saveConfig(migrated.config)
|
||||
saveAccounts(migrated.accounts)
|
||||
|
||||
return migrated
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
saveConfig,
|
||||
resetConfig,
|
||||
saveAccounts,
|
||||
resetAccounts,
|
||||
loadAccounts,
|
||||
showAccounts,
|
||||
loadLatest,
|
||||
loadLatestConfig,
|
||||
loadLatestConfigOrNone,
|
||||
load
|
||||
load,
|
||||
migrate
|
||||
}
|
||||
|
|
|
|||
351
lib/notifier.js
351
lib/notifier.js
|
|
@ -1,351 +0,0 @@
|
|||
const crypto = require('crypto')
|
||||
const _ = require('lodash/fp')
|
||||
const prettyMs = require('pretty-ms')
|
||||
const numeral = require('numeral')
|
||||
|
||||
const dbm = require('./postgresql_interface')
|
||||
const db = require('./db')
|
||||
const T = require('./time')
|
||||
const logger = require('./logger')
|
||||
|
||||
const STALE_STATE = 7 * T.minute
|
||||
const NETWORK_DOWN_TIME = 1 * T.minute
|
||||
const ALERT_SEND_INTERVAL = T.hour
|
||||
|
||||
const PING = 'PING'
|
||||
const STALE = 'STALE'
|
||||
const LOW_CRYPTO_BALANCE = 'LOW_CRYPTO_BALANCE'
|
||||
const HIGH_CRYPTO_BALANCE = 'HIGH_CRYPTO_BALANCE'
|
||||
const CASH_BOX_FULL = 'CASH_BOX_FULL'
|
||||
const LOW_CASH_OUT = 'LOW_CASH_OUT'
|
||||
|
||||
const CODES_DISPLAY = {
|
||||
PING: 'Machine Down',
|
||||
STALE: 'Machine Stuck',
|
||||
LOW_CRYPTO_BALANCE: 'Low Crypto Balance',
|
||||
HIGH_CRYPTO_BALANCE: 'High Crypto Balance',
|
||||
CASH_BOX_FULL: 'Cash box full',
|
||||
LOW_CASH_OUT: 'Low Cash-out'
|
||||
}
|
||||
|
||||
let alertFingerprint
|
||||
let lastAlertTime
|
||||
|
||||
function codeDisplay (code) {
|
||||
return CODES_DISPLAY[code]
|
||||
}
|
||||
|
||||
function jsonParse (event) {
|
||||
return _.set('note', JSON.parse(event.note), event)
|
||||
}
|
||||
|
||||
function sameState (a, b) {
|
||||
return a.note.txId === b.note.txId && a.note.state === b.note.state
|
||||
}
|
||||
|
||||
function sendNoAlerts (plugins, smsEnabled, emailEnabled) {
|
||||
const subject = '[Lamassu] All clear'
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(subject)(rec)
|
||||
}
|
||||
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(subject)(rec)
|
||||
rec = _.set(['email', 'body'])('No errors are reported for your machines.')(rec)
|
||||
}
|
||||
|
||||
return plugins.sendMessage(rec)
|
||||
}
|
||||
|
||||
function checkNotification (plugins) {
|
||||
const notifications = plugins.getNotificationConfig()
|
||||
const isActive = it => it.active && (it.balance || it.errors)
|
||||
const smsEnabled = isActive(notifications.sms)
|
||||
const emailEnabled = isActive(notifications.email)
|
||||
|
||||
if (!smsEnabled && !emailEnabled) return Promise.resolve()
|
||||
|
||||
return checkStatus(plugins)
|
||||
.then(alertRec => {
|
||||
const currentAlertFingerprint = buildAlertFingerprint(alertRec, notifications)
|
||||
if (!currentAlertFingerprint) {
|
||||
const inAlert = !!alertFingerprint
|
||||
alertFingerprint = null
|
||||
lastAlertTime = null
|
||||
if (inAlert) return sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
||||
}
|
||||
|
||||
const alertChanged = currentAlertFingerprint === alertFingerprint &&
|
||||
lastAlertTime - Date.now() < ALERT_SEND_INTERVAL
|
||||
if (alertChanged) return
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(printSmsAlerts(alertRec, notifications.sms))(rec)
|
||||
}
|
||||
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(alertSubject(alertRec, notifications.email))(rec)
|
||||
rec = _.set(['email', 'body'])(printEmailAlerts(alertRec, notifications.email))(rec)
|
||||
}
|
||||
|
||||
alertFingerprint = currentAlertFingerprint
|
||||
lastAlertTime = Date.now()
|
||||
|
||||
return plugins.sendMessage(rec)
|
||||
})
|
||||
.then(results => {
|
||||
if (results && results.length > 0) logger.debug('Successfully sent alerts')
|
||||
})
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
const getDeviceTime = _.flow(_.get('device_time'), Date.parse)
|
||||
|
||||
function dropRepeatsWith (comparator, arr) {
|
||||
const iteratee = (acc, val) => val === acc.last
|
||||
? acc
|
||||
: { arr: _.concat(acc.arr, val), last: val }
|
||||
|
||||
return _.reduce(iteratee, { arr: [] }, arr).arr
|
||||
}
|
||||
|
||||
function checkStuckScreen (deviceEvents, machineName) {
|
||||
const sortedEvents = _.sortBy(getDeviceTime, _.map(jsonParse, deviceEvents))
|
||||
const noRepeatEvents = dropRepeatsWith(sameState, sortedEvents)
|
||||
const lastEvent = _.last(noRepeatEvents)
|
||||
|
||||
if (!lastEvent) {
|
||||
return []
|
||||
}
|
||||
|
||||
const state = lastEvent.note.state
|
||||
const isIdle = lastEvent.note.isIdle
|
||||
|
||||
if (isIdle) {
|
||||
return []
|
||||
}
|
||||
|
||||
const age = Math.floor(lastEvent.age)
|
||||
if (age > STALE_STATE) {
|
||||
return [{ code: STALE, state, age, machineName }]
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function checkPing (device) {
|
||||
const sql = `select (EXTRACT(EPOCH FROM (now() - updated))) * 1000 AS age from machine_pings
|
||||
where device_id=$1`
|
||||
const deviceId = device.deviceId
|
||||
return db.oneOrNone(sql, [deviceId])
|
||||
.then(row => {
|
||||
if (!row) return [{ code: PING }]
|
||||
if (row.age > NETWORK_DOWN_TIME) return [{ code: PING, age: row.age, machineName: device.name }]
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function checkPings (devices) {
|
||||
const deviceIds = _.map('deviceId', devices)
|
||||
const promises = _.map(checkPing, devices)
|
||||
|
||||
return Promise.all(promises)
|
||||
.then(_.zipObject(deviceIds))
|
||||
}
|
||||
|
||||
function checkStatus (plugins) {
|
||||
const alerts = { devices: {}, deviceNames: {} }
|
||||
|
||||
return Promise.all([plugins.checkBalances(), dbm.machineEvents(), plugins.getMachineNames()])
|
||||
.then(([balances, events, devices]) => {
|
||||
return checkPings(devices)
|
||||
.then(pings => {
|
||||
alerts.general = _.filter(r => !r.deviceId, balances)
|
||||
devices.forEach(function (device) {
|
||||
const deviceId = device.deviceId
|
||||
const deviceName = device.name
|
||||
const deviceEvents = events.filter(function (eventRow) {
|
||||
return eventRow.device_id === deviceId
|
||||
})
|
||||
|
||||
const ping = pings[deviceId] || []
|
||||
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
||||
|
||||
if (!alerts.devices[deviceId]) alerts.devices[deviceId] = {}
|
||||
alerts.devices[deviceId].balanceAlerts = _.filter(['deviceId', deviceId], balances)
|
||||
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
||||
|
||||
alerts.deviceNames[deviceId] = deviceName
|
||||
})
|
||||
|
||||
return alerts
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function formatCurrency (num, code) {
|
||||
return numeral(num).format('0,0.00') + ' ' + code
|
||||
}
|
||||
|
||||
function emailAlert (alert) {
|
||||
switch (alert.code) {
|
||||
case PING:
|
||||
if (alert.age) {
|
||||
const pingAge = prettyMs(alert.age, { compact: true, verbose: true })
|
||||
return `Machine down for ${pingAge}`
|
||||
}
|
||||
return 'Machine down for a while.'
|
||||
case STALE:
|
||||
const stuckAge = prettyMs(alert.age, { compact: true, verbose: true })
|
||||
return `Machine is stuck on ${alert.state} screen for ${stuckAge}`
|
||||
case LOW_CRYPTO_BALANCE:
|
||||
const balance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
||||
return `Low balance in ${alert.cryptoCode} [${balance}]`
|
||||
case HIGH_CRYPTO_BALANCE:
|
||||
const highBalance = formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
||||
return `High balance in ${alert.cryptoCode} [${highBalance}]`
|
||||
case CASH_BOX_FULL:
|
||||
return `Cash box full on ${alert.machineName} [${alert.notes} banknotes]`
|
||||
case LOW_CASH_OUT:
|
||||
return `Cassette for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]`
|
||||
}
|
||||
}
|
||||
|
||||
function emailAlerts (alerts) {
|
||||
return alerts.map(emailAlert).join('\n') + '\n'
|
||||
}
|
||||
|
||||
function printEmailAlerts (alertRec, config) {
|
||||
let body = 'Errors were reported by your Lamassu Machines.\n'
|
||||
|
||||
if (config.balance && alertRec.general.length !== 0) {
|
||||
body = body + '\nGeneral errors:\n'
|
||||
body = body + emailAlerts(alertRec.general) + '\n'
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
const deviceName = alertRec.deviceNames[device]
|
||||
body = body + '\nErrors for ' + deviceName + ':\n'
|
||||
|
||||
let alerts = []
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
|
||||
body = body + emailAlerts(alerts)
|
||||
})
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
function alertSubject (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertTypes = _.map(codeDisplay, _.uniq(_.map('code', alerts))).sort()
|
||||
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
||||
}
|
||||
|
||||
function printSmsAlerts (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertsMap = _.groupBy('code', alerts)
|
||||
|
||||
const alertTypes = _.map(entry => {
|
||||
const code = entry[0]
|
||||
const machineNames = _.filter(_.negate(_.isEmpty), _.map('machineName', entry[1]))
|
||||
|
||||
return {
|
||||
codeDisplay: codeDisplay(code),
|
||||
machineNames
|
||||
}
|
||||
}, _.toPairs(alertsMap))
|
||||
|
||||
const mapByCodeDisplay = _.map(it => _.isEmpty(it.machineNames)
|
||||
? it.codeDisplay : `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
||||
)
|
||||
|
||||
const displayAlertTypes = _.compose(_.uniq, mapByCodeDisplay, _.sortBy('codeDisplay'))(alertTypes)
|
||||
|
||||
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
|
||||
}
|
||||
|
||||
function getAlertTypes (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (!config.active || (!config.balance && !config.errors)) return alerts
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.keys(alertRec.devices).forEach(function (device) {
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
})
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
function buildAlertFingerprint (alertRec, notifications) {
|
||||
const sms = getAlertTypes(alertRec, notifications.sms)
|
||||
const email = getAlertTypes(alertRec, notifications.email)
|
||||
|
||||
if (sms.length === 0 && email.length === 0) return null
|
||||
|
||||
const smsTypes = _.map(codeDisplay, _.uniq(_.map('code', sms))).sort()
|
||||
const emailTypes = _.map(codeDisplay, _.uniq(_.map('code', email))).sort()
|
||||
|
||||
const subject = _.concat(smsTypes, emailTypes).join(', ')
|
||||
|
||||
return crypto.createHash('sha256').update(subject).digest('hex')
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkNotification,
|
||||
checkPings,
|
||||
checkStuckScreen
|
||||
}
|
||||
44
lib/notifier/codes.js
Normal file
44
lib/notifier/codes.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
const T = require('../time')
|
||||
|
||||
const PING = 'PING'
|
||||
const STALE = 'STALE'
|
||||
const LOW_CRYPTO_BALANCE = 'LOW_CRYPTO_BALANCE'
|
||||
const HIGH_CRYPTO_BALANCE = 'HIGH_CRYPTO_BALANCE'
|
||||
const CASH_BOX_FULL = 'CASH_BOX_FULL'
|
||||
const LOW_CASH_OUT = 'LOW_CASH_OUT'
|
||||
|
||||
const CODES_DISPLAY = {
|
||||
PING: 'Machine Down',
|
||||
STALE: 'Machine Stuck',
|
||||
LOW_CRYPTO_BALANCE: 'Low Crypto Balance',
|
||||
HIGH_CRYPTO_BALANCE: 'High Crypto Balance',
|
||||
CASH_BOX_FULL: 'Cash box full',
|
||||
LOW_CASH_OUT: 'Low Cash-out'
|
||||
}
|
||||
|
||||
const NETWORK_DOWN_TIME = 1 * T.minute
|
||||
const STALE_STATE = 7 * T.minute
|
||||
const ALERT_SEND_INTERVAL = T.hour
|
||||
|
||||
const NOTIFICATION_TYPES = {
|
||||
HIGH_VALUE_TX: 'highValueTransaction',
|
||||
NORMAL_VALUE_TX: 'transaction',
|
||||
FIAT_BALANCE: 'fiatBalance',
|
||||
CRYPTO_BALANCE: 'cryptoBalance',
|
||||
COMPLIANCE: 'compliance',
|
||||
ERROR: 'error'
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PING,
|
||||
STALE,
|
||||
LOW_CRYPTO_BALANCE,
|
||||
HIGH_CRYPTO_BALANCE,
|
||||
CASH_BOX_FULL,
|
||||
LOW_CASH_OUT,
|
||||
CODES_DISPLAY,
|
||||
NETWORK_DOWN_TIME,
|
||||
STALE_STATE,
|
||||
ALERT_SEND_INTERVAL,
|
||||
NOTIFICATION_TYPES
|
||||
}
|
||||
87
lib/notifier/email.js
Normal file
87
lib/notifier/email.js
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
const _ = require('lodash/fp')
|
||||
const utils = require('./utils')
|
||||
|
||||
const email = require('../email')
|
||||
|
||||
const {
|
||||
PING,
|
||||
STALE,
|
||||
LOW_CRYPTO_BALANCE,
|
||||
HIGH_CRYPTO_BALANCE,
|
||||
CASH_BOX_FULL,
|
||||
LOW_CASH_OUT
|
||||
} = require('./codes')
|
||||
|
||||
function alertSubject (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertTypes = _.flow(_.map('code'), _.uniq, _.map(utils.codeDisplay), _.sortBy(o => o))(alerts)
|
||||
return '[Lamassu] Errors reported: ' + alertTypes.join(', ')
|
||||
}
|
||||
|
||||
function printEmailAlerts (alertRec, config) {
|
||||
let body = 'Errors were reported by your Lamassu Machines.\n'
|
||||
|
||||
if (config.balance && alertRec.general.length !== 0) {
|
||||
body += '\nGeneral errors:\n'
|
||||
body += emailAlerts(alertRec.general) + '\n'
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
const deviceName = alertRec.deviceNames[device]
|
||||
body += '\nErrors for ' + deviceName + ':\n'
|
||||
|
||||
const alerts = utils.deviceAlerts(config, alertRec, device)
|
||||
|
||||
body += emailAlerts(alerts)
|
||||
}, _.keys(alertRec.devices))
|
||||
return body
|
||||
}
|
||||
|
||||
function emailAlerts (alerts) {
|
||||
return _.join('\n', _.map(emailAlert, alerts)) + '\n'
|
||||
}
|
||||
|
||||
function emailAlert (alert) {
|
||||
switch (alert.code) {
|
||||
case PING:
|
||||
if (alert.age) {
|
||||
const pingAge = utils.formatAge(alert.age, { compact: true, verbose: true })
|
||||
return `Machine down for ${pingAge}`
|
||||
}
|
||||
return 'Machine down for a while.'
|
||||
case STALE: {
|
||||
const stuckAge = utils.formatAge(alert.age, { compact: true, verbose: true })
|
||||
return `Machine is stuck on ${alert.state} screen for ${stuckAge}`
|
||||
}
|
||||
case LOW_CRYPTO_BALANCE: {
|
||||
const balance = utils.formatCurrency(alert.fiatBalance.balance, alert.fiatCode)
|
||||
return `Low balance in ${alert.cryptoCode} [${balance}]`
|
||||
}
|
||||
case HIGH_CRYPTO_BALANCE: {
|
||||
const highBalance = utils.formatCurrency(
|
||||
alert.fiatBalance.balance,
|
||||
alert.fiatCode
|
||||
)
|
||||
return `High balance in ${alert.cryptoCode} [${highBalance}]`
|
||||
}
|
||||
case CASH_BOX_FULL:
|
||||
return `Cash box full on ${alert.machineName} [${alert.notes} banknotes]`
|
||||
case LOW_CASH_OUT:
|
||||
return `Cassette for ${alert.denomination} ${alert.fiatCode} low [${alert.notes} banknotes]`
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessage = email.sendMessage
|
||||
|
||||
module.exports = { alertSubject, printEmailAlerts, sendMessage }
|
||||
224
lib/notifier/index.js
Normal file
224
lib/notifier/index.js
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
const _ = require('lodash/fp')
|
||||
|
||||
const configManager = require('../new-config-manager')
|
||||
const logger = require('../logger')
|
||||
const queries = require('./queries')
|
||||
const settingsLoader = require('../new-settings-loader')
|
||||
const customers = require('../customers')
|
||||
|
||||
const notificationCenter = require('./notificationCenter')
|
||||
const utils = require('./utils')
|
||||
const emailFuncs = require('./email')
|
||||
const smsFuncs = require('./sms')
|
||||
const { STALE, STALE_STATE } = require('./codes')
|
||||
|
||||
function buildMessage (alerts, notifications) {
|
||||
const smsEnabled = utils.isActive(notifications.sms)
|
||||
const emailEnabled = utils.isActive(notifications.email)
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(
|
||||
smsFuncs.printSmsAlerts(alerts, notifications.sms)
|
||||
)(rec)
|
||||
}
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(
|
||||
emailFuncs.alertSubject(alerts, notifications.email)
|
||||
)(rec)
|
||||
rec = _.set(['email', 'body'])(
|
||||
emailFuncs.printEmailAlerts(alerts, notifications.email)
|
||||
)(rec)
|
||||
}
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
function checkNotification (plugins) {
|
||||
const notifications = plugins.getNotificationConfig()
|
||||
const smsEnabled = utils.isActive(notifications.sms)
|
||||
const emailEnabled = utils.isActive(notifications.email)
|
||||
|
||||
if (!smsEnabled && !emailEnabled) return Promise.resolve()
|
||||
|
||||
return getAlerts(plugins)
|
||||
.then(alerts => {
|
||||
notifyIfActive('errors', 'errorAlertsNotify', alerts)
|
||||
const currentAlertFingerprint = utils.buildAlertFingerprint(
|
||||
alerts,
|
||||
notifications
|
||||
)
|
||||
if (!currentAlertFingerprint) {
|
||||
const inAlert = !!utils.getAlertFingerprint()
|
||||
// variables for setAlertFingerprint: (fingerprint = null, lastAlertTime = null)
|
||||
utils.setAlertFingerprint(null, null)
|
||||
if (inAlert) return utils.sendNoAlerts(plugins, smsEnabled, emailEnabled)
|
||||
}
|
||||
if (utils.shouldNotAlert(currentAlertFingerprint)) return
|
||||
|
||||
const message = buildMessage(alerts, notifications)
|
||||
utils.setAlertFingerprint(currentAlertFingerprint, Date.now())
|
||||
return plugins.sendMessage(message)
|
||||
})
|
||||
.then(results => {
|
||||
if (results && results.length > 0) {
|
||||
logger.debug('Successfully sent alerts')
|
||||
}
|
||||
})
|
||||
.catch(logger.error)
|
||||
}
|
||||
|
||||
function getAlerts (plugins) {
|
||||
return Promise.all([
|
||||
plugins.checkBalances(),
|
||||
queries.machineEvents(),
|
||||
plugins.getMachineNames()
|
||||
]).then(([balances, events, devices]) => {
|
||||
notifyIfActive('balance', 'balancesNotify', balances)
|
||||
return buildAlerts(checkPings(devices), balances, events, devices)
|
||||
})
|
||||
}
|
||||
|
||||
function buildAlerts (pings, balances, events, devices) {
|
||||
const alerts = { devices: {}, deviceNames: {} }
|
||||
alerts.general = _.filter(r => !r.deviceId, balances)
|
||||
_.forEach(device => {
|
||||
const deviceId = device.deviceId
|
||||
const deviceName = device.name
|
||||
const deviceEvents = events.filter(function (eventRow) {
|
||||
return eventRow.device_id === deviceId
|
||||
})
|
||||
const ping = pings[deviceId] || []
|
||||
const stuckScreen = checkStuckScreen(deviceEvents, deviceName)
|
||||
|
||||
alerts.devices = _.set([deviceId, 'balanceAlerts'], _.filter(
|
||||
['deviceId', deviceId],
|
||||
balances
|
||||
), alerts.devices)
|
||||
alerts.devices[deviceId].deviceAlerts = _.isEmpty(ping) ? stuckScreen : ping
|
||||
|
||||
alerts.deviceNames[deviceId] = deviceName
|
||||
}, devices)
|
||||
|
||||
return alerts
|
||||
}
|
||||
|
||||
function checkPings (devices) {
|
||||
const deviceIds = _.map('deviceId', devices)
|
||||
const pings = _.map(utils.checkPing, devices)
|
||||
return _.zipObject(deviceIds)(pings)
|
||||
}
|
||||
|
||||
function checkStuckScreen (deviceEvents, machineName) {
|
||||
const sortedEvents = _.sortBy(
|
||||
utils.getDeviceTime,
|
||||
_.map(utils.parseEventNote, deviceEvents)
|
||||
)
|
||||
const lastEvent = _.last(sortedEvents)
|
||||
|
||||
if (!lastEvent) return []
|
||||
|
||||
const state = lastEvent.note.state
|
||||
const isIdle = lastEvent.note.isIdle
|
||||
|
||||
if (isIdle) return []
|
||||
|
||||
const age = Math.floor(lastEvent.age)
|
||||
if (age > STALE_STATE) return [{ code: STALE, state, age, machineName }]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function transactionNotify (tx, rec) {
|
||||
return settingsLoader.loadLatest().then(settings => {
|
||||
const notifSettings = configManager.getGlobalNotifications(settings.config)
|
||||
const highValueTx = tx.fiat.gt(notifSettings.highValueTransaction || Infinity)
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
|
||||
// for notification center
|
||||
const directionDisplay = isCashOut ? 'cash-out' : 'cash-in'
|
||||
const readyToNotify = !isCashOut || (tx.direction === 'cashOut' && rec.isRedemption)
|
||||
// awaiting for redesign. notification should not be sent if toggle in the settings table is disabled,
|
||||
// but currently we're sending notifications of high value tx even with the toggle disabled
|
||||
if (readyToNotify && !highValueTx) {
|
||||
notifyIfActive('transactions', 'notifCenterTransactionNotify', highValueTx, directionDisplay, tx.fiat, tx.fiatCode, tx.deviceId, tx.toAddress)
|
||||
} else if (readyToNotify && highValueTx) {
|
||||
notificationCenter.notifCenterTransactionNotify(highValueTx, directionDisplay, tx.fiat, tx.fiatCode, tx.deviceId, tx.toAddress)
|
||||
}
|
||||
|
||||
// alert through sms or email any transaction or high value transaction, if SMS || email alerts are enabled
|
||||
const cashOutConfig = configManager.getCashOut(tx.deviceId, settings.config)
|
||||
const zeroConfLimit = cashOutConfig.zeroConfLimit
|
||||
const zeroConf = isCashOut && tx.fiat.lte(zeroConfLimit)
|
||||
const notificationsEnabled = notifSettings.sms.transactions || notifSettings.email.transactions
|
||||
const customerPromise = tx.customerId ? customers.getById(tx.customerId) : Promise.resolve({})
|
||||
|
||||
if (!notificationsEnabled && !highValueTx) return Promise.resolve()
|
||||
if (zeroConf && isCashOut && !rec.isRedemption && !rec.error) return Promise.resolve()
|
||||
if (!zeroConf && rec.isRedemption) return sendRedemptionMessage(tx.id, rec.error)
|
||||
|
||||
return Promise.all([
|
||||
queries.getMachineName(tx.deviceId),
|
||||
customerPromise
|
||||
]).then(([machineName, customer]) => {
|
||||
return utils.buildTransactionMessage(tx, rec, highValueTx, machineName, customer)
|
||||
}).then(([msg, highValueTx]) => sendTransactionMessage(msg, highValueTx))
|
||||
})
|
||||
}
|
||||
|
||||
function sendRedemptionMessage (txId, error) {
|
||||
const subject = `Here's an update on transaction ${txId}`
|
||||
const body = error
|
||||
? `Error: ${error}`
|
||||
: 'It was just dispensed successfully'
|
||||
|
||||
const rec = {
|
||||
sms: {
|
||||
body: `${subject} - ${body}`
|
||||
},
|
||||
email: {
|
||||
subject,
|
||||
body
|
||||
}
|
||||
}
|
||||
return sendTransactionMessage(rec)
|
||||
}
|
||||
|
||||
function sendTransactionMessage (rec, isHighValueTx) {
|
||||
return settingsLoader.loadLatest().then(settings => {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
const promises = []
|
||||
|
||||
const emailActive =
|
||||
notifications.email.active &&
|
||||
(notifications.email.transactions || isHighValueTx)
|
||||
if (emailActive) promises.push(emailFuncs.sendMessage(settings, rec))
|
||||
|
||||
const smsActive =
|
||||
notifications.sms.active &&
|
||||
(notifications.sms.transactions || isHighValueTx)
|
||||
if (smsActive) promises.push(smsFuncs.sendMessage(settings, rec))
|
||||
|
||||
return Promise.all(promises)
|
||||
})
|
||||
}
|
||||
|
||||
// for notification center, check if type of notification is active before calling the respective notify function
|
||||
const notifyIfActive = (type, fnName, ...args) => {
|
||||
return settingsLoader.loadLatest().then(settings => {
|
||||
const notificationSettings = configManager.getGlobalNotifications(settings.config).notificationCenter
|
||||
if (!notificationCenter[fnName]) return Promise.reject(new Error(`Notification function ${fnName} for type ${type} does not exist`))
|
||||
if (!(notificationSettings.active && notificationSettings[type])) return Promise.resolve()
|
||||
return notificationCenter[fnName](...args)
|
||||
}).catch(console.error)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
transactionNotify,
|
||||
checkNotification,
|
||||
checkPings,
|
||||
checkStuckScreen,
|
||||
sendRedemptionMessage,
|
||||
notifyIfActive
|
||||
}
|
||||
187
lib/notifier/notificationCenter.js
Normal file
187
lib/notifier/notificationCenter.js
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
const _ = require('lodash/fp')
|
||||
|
||||
const queries = require('./queries')
|
||||
const utils = require('./utils')
|
||||
const codes = require('./codes')
|
||||
const customers = require('../customers')
|
||||
|
||||
const { NOTIFICATION_TYPES: {
|
||||
COMPLIANCE,
|
||||
CRYPTO_BALANCE,
|
||||
FIAT_BALANCE,
|
||||
ERROR,
|
||||
HIGH_VALUE_TX,
|
||||
NORMAL_VALUE_TX }
|
||||
} = codes
|
||||
|
||||
const { STALE, PING } = codes
|
||||
|
||||
const sanctionsNotify = (customer, phone) => {
|
||||
const code = 'SANCTIONS'
|
||||
const detailB = utils.buildDetail({ customerId: customer.id, code })
|
||||
|
||||
// if it's a new customer then phone comes as undefined
|
||||
if (phone) {
|
||||
return queries.addNotification(COMPLIANCE, `Blocked customer with phone ${phone} for being on the OFAC sanctions list`, detailB)
|
||||
}
|
||||
return customers.getById(customer.id).then(c => queries.addNotification(COMPLIANCE, `Blocked customer with phone ${c.phone} for being on the OFAC sanctions list`, detailB))
|
||||
}
|
||||
|
||||
const clearOldCustomerSuspendedNotifications = (customerId, deviceId) => {
|
||||
const detailB = utils.buildDetail({ code: 'SUSPENDED', customerId, deviceId })
|
||||
return queries.invalidateNotification(detailB, 'compliance')
|
||||
}
|
||||
|
||||
const customerComplianceNotify = (customer, deviceId, code, days = null) => {
|
||||
// code for now can be "BLOCKED", "SUSPENDED"
|
||||
const detailB = utils.buildDetail({ customerId: customer.id, code, deviceId })
|
||||
const date = new Date()
|
||||
if (days) {
|
||||
date.setDate(date.getDate() + days)
|
||||
}
|
||||
const message = code === 'SUSPENDED' ? `Customer suspended until ${date.toLocaleString()}` : `Customer blocked`
|
||||
|
||||
return clearOldCustomerSuspendedNotifications(customer.id, deviceId)
|
||||
.then(() => queries.getValidNotifications(COMPLIANCE, detailB))
|
||||
.then(res => {
|
||||
if (res.length > 0) return Promise.resolve()
|
||||
return queries.addNotification(COMPLIANCE, message, detailB)
|
||||
})
|
||||
}
|
||||
|
||||
const clearOldFiatNotifications = (balances) => {
|
||||
return queries.getAllValidNotifications(FIAT_BALANCE).then(notifications => {
|
||||
const filterByBalance = _.filter(notification => {
|
||||
const { cassette, deviceId } = notification.detail
|
||||
return !_.find(balance => balance.cassette === cassette && balance.deviceId === deviceId)(balances)
|
||||
})
|
||||
const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(notifications)
|
||||
const notInvalidated = _.filter(notification => {
|
||||
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||
}, notifications)
|
||||
return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated)
|
||||
})
|
||||
}
|
||||
|
||||
const fiatBalancesNotify = (fiatWarnings) => {
|
||||
return clearOldFiatNotifications(fiatWarnings).then(notInvalidated => {
|
||||
return fiatWarnings.forEach(balance => {
|
||||
if (_.find(o => {
|
||||
const { cassette, deviceId } = o.detail
|
||||
return cassette === balance.cassette && deviceId === balance.deviceId
|
||||
}, notInvalidated)) return
|
||||
const message = `Cash-out cassette ${balance.cassette} almost empty!`
|
||||
const detailB = utils.buildDetail({ deviceId: balance.deviceId, cassette: balance.cassette })
|
||||
return queries.addNotification(FIAT_BALANCE, message, detailB)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const clearOldCryptoNotifications = balances => {
|
||||
return queries.getAllValidNotifications(CRYPTO_BALANCE).then(res => {
|
||||
const filterByBalance = _.filter(notification => {
|
||||
const { cryptoCode, code } = notification.detail
|
||||
return !_.find(balance => balance.cryptoCode === cryptoCode && balance.code === code)(balances)
|
||||
})
|
||||
const indexesToInvalidate = _.compose(_.map('id'), filterByBalance)(res)
|
||||
|
||||
const notInvalidated = _.filter(notification => {
|
||||
return !_.find(id => notification.id === id)(indexesToInvalidate)
|
||||
}, res)
|
||||
return (indexesToInvalidate.length ? queries.batchInvalidate(indexesToInvalidate) : Promise.resolve()).then(() => notInvalidated)
|
||||
})
|
||||
}
|
||||
|
||||
const cryptoBalancesNotify = (cryptoWarnings) => {
|
||||
return clearOldCryptoNotifications(cryptoWarnings).then(notInvalidated => {
|
||||
return cryptoWarnings.forEach(balance => {
|
||||
// if notification exists in DB and wasnt invalidated then don't add a duplicate
|
||||
if (_.find(o => {
|
||||
const { code, cryptoCode } = o.detail
|
||||
return code === balance.code && cryptoCode === balance.cryptoCode
|
||||
}, notInvalidated)) return
|
||||
|
||||
const fiat = utils.formatCurrency(balance.fiatBalance.balance, balance.fiatCode)
|
||||
const message = `${balance.code === 'HIGH_CRYPTO_BALANCE' ? 'High' : 'Low'} balance in ${balance.cryptoCode} [${fiat}]`
|
||||
const detailB = utils.buildDetail({ cryptoCode: balance.cryptoCode, code: balance.code })
|
||||
return queries.addNotification(CRYPTO_BALANCE, message, detailB)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const balancesNotify = (balances) => {
|
||||
const cryptoFilter = o => o.code === 'HIGH_CRYPTO_BALANCE' || o.code === 'LOW_CRYPTO_BALANCE'
|
||||
const fiatFilter = o => o.code === 'LOW_CASH_OUT'
|
||||
const cryptoWarnings = _.filter(cryptoFilter, balances)
|
||||
const fiatWarnings = _.filter(fiatFilter, balances)
|
||||
return Promise.all([cryptoBalancesNotify(cryptoWarnings), fiatBalancesNotify(fiatWarnings)])
|
||||
}
|
||||
|
||||
const clearOldErrorNotifications = alerts => {
|
||||
return queries.getAllValidNotifications(ERROR)
|
||||
.then(res => {
|
||||
// for each valid notification in DB see if it exists in alerts
|
||||
// if the notification doesn't exist in alerts, it is not valid anymore
|
||||
const filterByAlert = _.filter(notification => {
|
||||
const { code, deviceId } = notification.detail
|
||||
return !_.find(alert => alert.code === code && alert.deviceId === deviceId)(alerts)
|
||||
})
|
||||
const indexesToInvalidate = _.compose(_.map('id'), filterByAlert)(res)
|
||||
if (!indexesToInvalidate.length) return Promise.resolve()
|
||||
return queries.batchInvalidate(indexesToInvalidate)
|
||||
})
|
||||
}
|
||||
|
||||
const errorAlertsNotify = (alertRec) => {
|
||||
const embedDeviceId = deviceId => _.assign({ deviceId })
|
||||
const mapToAlerts = _.map(it => _.map(embedDeviceId(it), alertRec.devices[it].deviceAlerts))
|
||||
const alerts = _.compose(_.flatten, mapToAlerts, _.keys)(alertRec.devices)
|
||||
|
||||
return clearOldErrorNotifications(alerts).then(() => {
|
||||
_.forEach(alert => {
|
||||
switch (alert.code) {
|
||||
case PING: {
|
||||
const detailB = utils.buildDetail({ code: PING, age: alert.age ? alert.age : -1, deviceId: alert.deviceId })
|
||||
return queries.getValidNotifications(ERROR, _.omit(['age'], detailB)).then(res => {
|
||||
if (res.length > 0) return Promise.resolve()
|
||||
const message = `Machine down`
|
||||
return queries.addNotification(ERROR, message, detailB)
|
||||
})
|
||||
}
|
||||
case STALE: {
|
||||
const detailB = utils.buildDetail({ code: STALE, deviceId: alert.deviceId })
|
||||
return queries.getValidNotifications(ERROR, detailB).then(res => {
|
||||
if (res.length > 0) return Promise.resolve()
|
||||
const message = `Machine is stuck on ${alert.state} screen`
|
||||
return queries.addNotification(ERROR, message, detailB)
|
||||
})
|
||||
}
|
||||
}
|
||||
}, alerts)
|
||||
})
|
||||
}
|
||||
|
||||
function notifCenterTransactionNotify (isHighValue, direction, fiat, fiatCode, deviceId, cryptoAddress) {
|
||||
const messageSuffix = isHighValue ? 'High value' : ''
|
||||
const message = `${messageSuffix} ${fiat} ${fiatCode} ${direction} transaction`
|
||||
const detailB = utils.buildDetail({ deviceId: deviceId, direction, fiat, fiatCode, cryptoAddress })
|
||||
return queries.addNotification(isHighValue ? HIGH_VALUE_TX : NORMAL_VALUE_TX, message, detailB)
|
||||
}
|
||||
|
||||
const blacklistNotify = (tx, isAddressReuse) => {
|
||||
const code = isAddressReuse ? 'REUSED' : 'BLOCKED'
|
||||
const name = isAddressReuse ? 'reused' : 'blacklisted'
|
||||
|
||||
const detailB = utils.buildDetail({ cryptoCode: tx.cryptoCode, code, cryptoAddress: tx.toAddress })
|
||||
const message = `Blocked ${name} address: ${tx.cryptoCode} ${tx.toAddress.substr(0, 10)}...`
|
||||
return queries.addNotification(COMPLIANCE, message, detailB)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
sanctionsNotify,
|
||||
customerComplianceNotify,
|
||||
balancesNotify,
|
||||
errorAlertsNotify,
|
||||
notifCenterTransactionNotify,
|
||||
blacklistNotify
|
||||
}
|
||||
94
lib/notifier/queries.js
Normal file
94
lib/notifier/queries.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
const { v4: uuidv4 } = require('uuid')
|
||||
const pgp = require('pg-promise')()
|
||||
const _ = require('lodash/fp')
|
||||
|
||||
const dbm = require('../postgresql_interface')
|
||||
const db = require('../db')
|
||||
|
||||
// types of notifications able to be inserted into db:
|
||||
/*
|
||||
highValueTransaction - for transactions of value higher than threshold
|
||||
fiatBalance - when the number of notes in cash cassettes falls below threshold
|
||||
cryptoBalance - when ammount of crypto balance in fiat falls below or above low/high threshold
|
||||
compliance - notifications related to warnings triggered by compliance settings
|
||||
error - notifications related to errors
|
||||
*/
|
||||
|
||||
function getMachineName (machineId) {
|
||||
const sql = 'SELECT * FROM devices WHERE device_id=$1'
|
||||
return db.oneOrNone(sql, [machineId])
|
||||
.then(it => it.name).catch(console.error)
|
||||
}
|
||||
|
||||
const addNotification = (type, message, detail) => {
|
||||
const sql = `INSERT INTO notifications (id, type, message, detail) VALUES ($1, $2, $3, $4)`
|
||||
return db.oneOrNone(sql, [uuidv4(), type, message, detail]).catch(console.error)
|
||||
}
|
||||
|
||||
const getAllValidNotifications = (type) => {
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't'`
|
||||
return db.any(sql, [type]).catch(console.error)
|
||||
}
|
||||
|
||||
const invalidateNotification = (detail, type) => {
|
||||
detail = _.omitBy(_.isEmpty, detail)
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE valid = 't' AND type = $1 AND detail::jsonb @> $2::jsonb`
|
||||
return db.none(sql, [type, detail]).catch(console.error).catch(console.error)
|
||||
}
|
||||
|
||||
const batchInvalidate = (ids) => {
|
||||
const formattedIds = _.map(pgp.as.text, ids).join(',')
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE id IN ($1^)`
|
||||
return db.none(sql, [formattedIds]).catch(console.error)
|
||||
}
|
||||
|
||||
const clearBlacklistNotification = (cryptoCode, cryptoAddress) => {
|
||||
const sql = `UPDATE notifications SET valid = 'f', read = 't' WHERE type = 'compliance' AND detail->>'cryptoCode' = $1 AND detail->>'cryptoAddress' = $2 AND (detail->>'code' = 'BLOCKED' OR detail->>'code' = 'REUSED')`
|
||||
return db.none(sql, [cryptoCode, cryptoAddress]).catch(console.error)
|
||||
}
|
||||
|
||||
const getValidNotifications = (type, detail) => {
|
||||
const sql = `SELECT * FROM notifications WHERE type = $1 AND valid = 't' AND detail @> $2`
|
||||
return db.any(sql, [type, detail]).catch(console.error)
|
||||
}
|
||||
|
||||
const getNotifications = () => {
|
||||
const sql = `SELECT * FROM notifications ORDER BY created DESC`
|
||||
return db.any(sql).catch(console.error)
|
||||
}
|
||||
const setRead = (id, read) => {
|
||||
const sql = `UPDATE notifications SET read = $1 WHERE id = $2`
|
||||
return db.none(sql, [read, id]).catch(console.error)
|
||||
}
|
||||
|
||||
const markAllAsRead = () => {
|
||||
const sql = `UPDATE notifications SET read = 't'`
|
||||
return db.none(sql).catch(console.error)
|
||||
}
|
||||
|
||||
const hasUnreadNotifications = () => {
|
||||
const sql = `SELECT EXISTS (SELECT 1 FROM notifications WHERE read = 'f' LIMIT 1)`
|
||||
return db.oneOrNone(sql).then(res => res.exists).catch(console.error)
|
||||
}
|
||||
|
||||
const getAlerts = () => {
|
||||
const types = ['fiatBalance', 'cryptoBalance', 'error']
|
||||
const sql = `SELECT * FROM notifications WHERE valid = 't' AND type IN ($1:list) ORDER BY created DESC`
|
||||
return db.any(sql, [types]).catch(console.error)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
machineEvents: dbm.machineEvents,
|
||||
addNotification,
|
||||
getAllValidNotifications,
|
||||
invalidateNotification,
|
||||
batchInvalidate,
|
||||
clearBlacklistNotification,
|
||||
getValidNotifications,
|
||||
getNotifications,
|
||||
setRead,
|
||||
markAllAsRead,
|
||||
hasUnreadNotifications,
|
||||
getAlerts,
|
||||
getMachineName
|
||||
}
|
||||
56
lib/notifier/sms.js
Normal file
56
lib/notifier/sms.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
const _ = require('lodash/fp')
|
||||
const utils = require('./utils')
|
||||
const sms = require('../sms')
|
||||
|
||||
function printSmsAlerts (alertRec, config) {
|
||||
let alerts = []
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, utils.deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
|
||||
if (alerts.length === 0) return null
|
||||
|
||||
const alertsMap = _.groupBy('code', alerts)
|
||||
|
||||
const alertTypes = _.map(entry => {
|
||||
const code = entry[0]
|
||||
const machineNames = _.filter(
|
||||
_.negate(_.isEmpty),
|
||||
_.map('machineName', entry[1])
|
||||
)
|
||||
|
||||
const cryptoCodes = _.filter(
|
||||
_.negate(_.isEmpty),
|
||||
_.map('cryptoCode', entry[1])
|
||||
)
|
||||
|
||||
return {
|
||||
codeDisplay: utils.codeDisplay(code),
|
||||
machineNames,
|
||||
cryptoCodes
|
||||
}
|
||||
}, _.toPairs(alertsMap))
|
||||
|
||||
const mapByCodeDisplay = _.map(it => {
|
||||
if (_.isEmpty(it.machineNames) && _.isEmpty(it.cryptoCodes)) return it.codeDisplay
|
||||
if (_.isEmpty(it.machineNames)) return `${it.codeDisplay} (${it.cryptoCodes.join(', ')})`
|
||||
return `${it.codeDisplay} (${it.machineNames.join(', ')})`
|
||||
})
|
||||
|
||||
const displayAlertTypes = _.compose(
|
||||
_.uniq,
|
||||
mapByCodeDisplay,
|
||||
_.sortBy('codeDisplay')
|
||||
)(alertTypes)
|
||||
|
||||
return '[Lamassu] Errors reported: ' + displayAlertTypes.join(', ')
|
||||
}
|
||||
|
||||
const sendMessage = sms.sendMessage
|
||||
|
||||
module.exports = { printSmsAlerts, sendMessage }
|
||||
28
lib/notifier/test/email.test.js
Normal file
28
lib/notifier/test/email.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
const email = require('../email')
|
||||
|
||||
const alertRec = {
|
||||
devices: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||
balanceAlerts: [],
|
||||
deviceAlerts: [
|
||||
{ code: 'PING', age: 602784301.446, machineName: 'Abc123' }
|
||||
]
|
||||
}
|
||||
},
|
||||
deviceNames: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123'
|
||||
},
|
||||
general: []
|
||||
}
|
||||
|
||||
const printEmailMsg = `Errors were reported by your Lamassu Machines.
|
||||
|
||||
Errors for Abc123:
|
||||
Machine down for ~6 days
|
||||
`
|
||||
|
||||
test('Print Email Alers', () => {
|
||||
expect(email.printEmailAlerts(alertRec, { active: true, errors: true })).toBe(
|
||||
printEmailMsg
|
||||
)
|
||||
})
|
||||
334
lib/notifier/test/notifier.test.js
Normal file
334
lib/notifier/test/notifier.test.js
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
const BigNumber = require('../../../lib/bn')
|
||||
|
||||
const notifier = require('..')
|
||||
const utils = require('../utils')
|
||||
const smsFuncs = require('../sms')
|
||||
|
||||
afterEach(() => {
|
||||
// https://stackoverflow.com/questions/58151010/difference-between-resetallmocks-resetmodules-resetmoduleregistry-restoreallm
|
||||
jest.restoreAllMocks()
|
||||
})
|
||||
|
||||
// mock plugins object with mock data to test functions
|
||||
const plugins = {
|
||||
sendMessage: rec => {
|
||||
return rec
|
||||
},
|
||||
getNotificationConfig: () => ({
|
||||
email_active: false,
|
||||
sms_active: true,
|
||||
email_errors: false,
|
||||
sms_errors: true,
|
||||
sms: { active: true, errors: true },
|
||||
email: { active: false, errors: false }
|
||||
}),
|
||||
getMachineNames: () => [
|
||||
{
|
||||
deviceId:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
cashbox: 0,
|
||||
cassette1: 444,
|
||||
cassette2: 222,
|
||||
version: '7.5.0-beta.0',
|
||||
model: 'unknown',
|
||||
pairedAt: '2020-11-13T16:20:31.624Z',
|
||||
lastPing: '2020-11-16T13:11:03.169Z',
|
||||
name: 'Abc123',
|
||||
paired: true,
|
||||
cashOut: true,
|
||||
statuses: [{ label: 'Unresponsive', type: 'error' }]
|
||||
}
|
||||
],
|
||||
checkBalances: () => []
|
||||
}
|
||||
|
||||
const tx = {
|
||||
id: 'bec8d452-9ea2-4846-841b-55a9df8bbd00',
|
||||
deviceId:
|
||||
'490ab16ee0c124512dc769be1f3e7ee3894ce1e5b4b8b975e134fb326e551e88',
|
||||
toAddress: 'bc1q7s4yy5n9vp6zhlf6mrw3cttdgx5l3ysr2mhc4v',
|
||||
cryptoAtoms: BigNumber(252100),
|
||||
cryptoCode: 'BTC',
|
||||
fiat: BigNumber(55),
|
||||
fiatCode: 'USD',
|
||||
fee: null,
|
||||
txHash: null,
|
||||
phone: null,
|
||||
error: null,
|
||||
created: '2020-12-04T16:28:11.016Z',
|
||||
send: true,
|
||||
sendConfirmed: false,
|
||||
timedout: false,
|
||||
sendTime: null,
|
||||
errorCode: null,
|
||||
operatorCompleted: false,
|
||||
sendPending: true,
|
||||
cashInFee: BigNumber(2),
|
||||
cashInFeeCrypto: BigNumber(9500),
|
||||
minimumTx: 5,
|
||||
customerId: '47ac1184-8102-11e7-9079-8f13a7117867',
|
||||
txVersion: 6,
|
||||
termsAccepted: false,
|
||||
commissionPercentage: BigNumber(0.11),
|
||||
rawTickerPrice: BigNumber(18937.4),
|
||||
isPaperWallet: false,
|
||||
direction: 'cashIn'
|
||||
}
|
||||
|
||||
const notifSettings = {
|
||||
email_active: false,
|
||||
sms_active: true,
|
||||
email_errors: false,
|
||||
sms_errors: true,
|
||||
sms_transactions: true,
|
||||
highValueTransaction: Infinity, // this will make highValueTx always false
|
||||
sms: {
|
||||
active: true,
|
||||
errors: true,
|
||||
transactions: false // force early return
|
||||
},
|
||||
email: {
|
||||
active: false,
|
||||
errors: false,
|
||||
transactions: false // force early return
|
||||
}
|
||||
}
|
||||
|
||||
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled', async () => {
|
||||
expect.assertions(1)
|
||||
await expect(
|
||||
notifier.checkNotification({
|
||||
getNotificationConfig: () => ({
|
||||
sms: { active: false, errors: false },
|
||||
email: { active: false, errors: false }
|
||||
})
|
||||
})
|
||||
).resolves.toBe(undefined)
|
||||
})
|
||||
|
||||
test('Exits checkNotifications with Promise.resolve() if SMS and Email are disabled even if errors or balance are defined to something', async () => {
|
||||
expect.assertions(1)
|
||||
await expect(
|
||||
notifier.checkNotification({
|
||||
getNotificationConfig: () => ({
|
||||
sms: { active: false, errors: true, balance: true },
|
||||
email: { active: false, errors: true, balance: true }
|
||||
})
|
||||
})
|
||||
).resolves.toBe(undefined)
|
||||
})
|
||||
|
||||
test("Check Pings should return code PING for devices that haven't been pinged recently", () => {
|
||||
expect(
|
||||
notifier.checkPings([
|
||||
{
|
||||
deviceId:
|
||||
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
|
||||
lastPing: '2020-11-16T13:11:03.169Z',
|
||||
name: 'Abc123'
|
||||
}
|
||||
])
|
||||
).toMatchObject({
|
||||
'7e531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': [
|
||||
{ code: 'PING', machineName: 'Abc123' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
test('Checkpings returns empty array as the value for the id prop, if the lastPing is more recent than 60 seconds', () => {
|
||||
expect(
|
||||
notifier.checkPings([
|
||||
{
|
||||
deviceId:
|
||||
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4',
|
||||
lastPing: new Date(),
|
||||
name: 'Abc123'
|
||||
}
|
||||
])
|
||||
).toMatchObject({
|
||||
'7a531a2666987aa27b9917ca17df7998f72771c57fdb21c90bc033999edd17e4': []
|
||||
})
|
||||
})
|
||||
|
||||
test('Check notification resolves to undefined if shouldNotAlert is called and is true', async () => {
|
||||
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
|
||||
mockShouldNotAlert.mockReturnValue(true)
|
||||
|
||||
const result = await notifier.checkNotification(plugins)
|
||||
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(undefined)
|
||||
})
|
||||
|
||||
test('Sendmessage is called if shouldNotAlert is called and is false', async () => {
|
||||
const mockShouldNotAlert = jest.spyOn(utils, 'shouldNotAlert')
|
||||
const mockSendMessage = jest.spyOn(plugins, 'sendMessage')
|
||||
|
||||
mockShouldNotAlert.mockReturnValue(false)
|
||||
const result = await notifier.checkNotification(plugins)
|
||||
|
||||
expect(mockShouldNotAlert).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendMessage).toHaveBeenCalledTimes(1)
|
||||
expect(result).toBe(undefined)
|
||||
})
|
||||
|
||||
test('If no alert fingerprint and inAlert is true, exits on call to sendNoAlerts', async () => {
|
||||
// mock utils.buildAlertFingerprint to return null
|
||||
// mock utils.getAlertFingerprint to be true which will make inAlert true
|
||||
|
||||
const buildFp = jest.spyOn(utils, 'buildAlertFingerprint')
|
||||
const mockGetFp = jest.spyOn(utils, 'getAlertFingerprint')
|
||||
const mockSendNoAlerts = jest.spyOn(utils, 'sendNoAlerts')
|
||||
|
||||
buildFp.mockReturnValue(null)
|
||||
mockGetFp.mockReturnValue(true)
|
||||
await notifier.checkNotification(plugins)
|
||||
|
||||
expect(mockGetFp).toHaveBeenCalledTimes(1)
|
||||
expect(mockSendNoAlerts).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// vvv tests for checkstuckscreen...
|
||||
test('checkStuckScreen returns [] when no events are found', () => {
|
||||
expect(notifier.checkStuckScreen([], 'Abc123')).toEqual([])
|
||||
})
|
||||
|
||||
test('checkStuckScreen returns [] if most recent event is idle', () => {
|
||||
// device_time is what matters for the sorting of the events by recency
|
||||
expect(
|
||||
notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '1999-11-23T19:30:29.177Z',
|
||||
age: 157352628.123
|
||||
},
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":true}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: 157352628.123
|
||||
}
|
||||
])
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
test('checkStuckScreen returns object array of length 1 with prop code: "STALE" if age > STALE_STATE', () => {
|
||||
// there is an age 0 and an isIdle true in the first object but it will be below the second one in the sorting order and thus ignored
|
||||
const result = notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":true}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '1999-11-23T19:30:29.177Z',
|
||||
age: 0
|
||||
},
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: 157352628.123
|
||||
}
|
||||
])
|
||||
expect(result[0]).toMatchObject({ code: 'STALE' })
|
||||
})
|
||||
|
||||
test('checkStuckScreen returns empty array if age < STALE_STATE', () => {
|
||||
const STALE_STATE = require('../codes').STALE_STATE
|
||||
const result1 = notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: 0
|
||||
}
|
||||
])
|
||||
const result2 = notifier.checkStuckScreen([
|
||||
{
|
||||
id: '48ae51c6-c5b4-485e-b81d-aa337fc025e2',
|
||||
device_id:
|
||||
'f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05',
|
||||
event_type: 'stateChange',
|
||||
note: '{"state":"chooseCoin","isIdle":false}',
|
||||
created: '2020-11-23T19:30:29.209Z',
|
||||
device_time: '2020-11-23T19:30:29.177Z',
|
||||
age: STALE_STATE
|
||||
}
|
||||
])
|
||||
expect(result1).toEqual([])
|
||||
expect(result2).toEqual([])
|
||||
})
|
||||
|
||||
test('calls sendRedemptionMessage if !zeroConf and rec.isRedemption', async () => {
|
||||
const configManager = require('../../new-config-manager')
|
||||
const settingsLoader = require('../../new-settings-loader')
|
||||
|
||||
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
|
||||
const getGlobalNotifications = jest.spyOn(configManager, 'getGlobalNotifications')
|
||||
const getCashOut = jest.spyOn(configManager, 'getCashOut')
|
||||
|
||||
// sendRedemptionMessage will cause this func to be called
|
||||
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => rec)
|
||||
|
||||
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true }, notificationCenter: { active: true } })
|
||||
|
||||
const response = await notifier.transactionNotify(tx, { isRedemption: true })
|
||||
|
||||
// this type of response implies sendRedemptionMessage was called
|
||||
expect(response[0]).toMatchObject({
|
||||
sms: {
|
||||
body: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00 - It was just dispensed successfully"
|
||||
},
|
||||
email: {
|
||||
subject: "Here's an update on transaction bec8d452-9ea2-4846-841b-55a9df8bbd00",
|
||||
body: 'It was just dispensed successfully'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('calls sendTransactionMessage if !zeroConf and !rec.isRedemption', async () => {
|
||||
const configManager = require('../../new-config-manager')
|
||||
const settingsLoader = require('../../new-settings-loader')
|
||||
const machineLoader = require('../../machine-loader')
|
||||
|
||||
const loadLatest = jest.spyOn(settingsLoader, 'loadLatest')
|
||||
const getGlobalNotifications = jest.spyOn(configManager, 'getGlobalNotifications')
|
||||
const getCashOut = jest.spyOn(configManager, 'getCashOut')
|
||||
const getMachineName = jest.spyOn(machineLoader, 'getMachineName')
|
||||
const buildTransactionMessage = jest.spyOn(utils, 'buildTransactionMessage')
|
||||
|
||||
// sendMessage on emailFuncs isn't called because it is disabled in getGlobalNotifications.mockReturnValue
|
||||
jest.spyOn(smsFuncs, 'sendMessage').mockImplementation((_, rec) => ({ prop: rec }))
|
||||
buildTransactionMessage.mockImplementation(() => ['mock message', false])
|
||||
|
||||
getMachineName.mockReturnValue('mockMachineName')
|
||||
getCashOut.mockReturnValue({ zeroConfLimit: -Infinity })
|
||||
loadLatest.mockReturnValue(Promise.resolve({}))
|
||||
getGlobalNotifications.mockReturnValue({ ...notifSettings, sms: { active: true, errors: true, transactions: true }, notificationCenter: { active: true } })
|
||||
|
||||
const response = await notifier.transactionNotify(tx, { isRedemption: false })
|
||||
|
||||
// If the return object is this, it means the code went through all the functions expected to go through if
|
||||
// getMachineName, buildTransactionMessage and sendTransactionMessage were called, in this order
|
||||
expect(response).toEqual([{prop: 'mock message'}])
|
||||
})
|
||||
22
lib/notifier/test/sms.test.js
Normal file
22
lib/notifier/test/sms.test.js
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
const sms = require('../sms')
|
||||
|
||||
const alertRec = {
|
||||
devices: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||
balanceAlerts: [],
|
||||
deviceAlerts: [
|
||||
{ code: 'PING', age: 602784301.446, machineName: 'Abc123' }
|
||||
]
|
||||
}
|
||||
},
|
||||
deviceNames: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123'
|
||||
},
|
||||
general: []
|
||||
}
|
||||
|
||||
test('Print SMS alerts', () => {
|
||||
expect(sms.printSmsAlerts(alertRec, { active: true, errors: true })).toBe(
|
||||
'[Lamassu] Errors reported: Machine Down (Abc123)'
|
||||
)
|
||||
})
|
||||
104
lib/notifier/test/utils.test.js
Normal file
104
lib/notifier/test/utils.test.js
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
const utils = require('../utils')
|
||||
|
||||
const plugins = {
|
||||
sendMessage: rec => {
|
||||
return rec
|
||||
}
|
||||
}
|
||||
|
||||
const alertRec = {
|
||||
devices: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: {
|
||||
balanceAlerts: [],
|
||||
deviceAlerts: [
|
||||
{ code: 'PING', age: 1605532263169, machineName: 'Abc123' }
|
||||
]
|
||||
}
|
||||
},
|
||||
deviceNames: {
|
||||
f02af604ca9010bd9ae04c427a24da90130da10d355f0a9b235886a89008fc05: 'Abc123'
|
||||
},
|
||||
general: []
|
||||
}
|
||||
|
||||
const notifications = {
|
||||
sms: { active: true, errors: true },
|
||||
email: { active: false, errors: false }
|
||||
}
|
||||
|
||||
describe('buildAlertFingerprint', () => {
|
||||
test('Build alert fingerprint returns null if no sms or email alerts', () => {
|
||||
expect(
|
||||
utils.buildAlertFingerprint(
|
||||
{
|
||||
devices: {},
|
||||
deviceNames: {},
|
||||
general: []
|
||||
},
|
||||
notifications
|
||||
)
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
test('Build alert fingerprint returns null if sms and email are disabled', () => {
|
||||
expect(
|
||||
utils.buildAlertFingerprint(alertRec, {
|
||||
sms: { active: false, errors: true },
|
||||
email: { active: false, errors: false }
|
||||
})
|
||||
).toBe(null)
|
||||
})
|
||||
|
||||
test('Build alert fingerprint returns hash if email or [sms] are enabled and there are alerts in alertrec', () => {
|
||||
expect(
|
||||
typeof utils.buildAlertFingerprint(alertRec, {
|
||||
sms: { active: true, errors: true },
|
||||
email: { active: false, errors: false }
|
||||
})
|
||||
).toBe('string')
|
||||
})
|
||||
|
||||
test('Build alert fingerprint returns hash if [email] or sms are enabled and there are alerts in alertrec', () => {
|
||||
expect(
|
||||
typeof utils.buildAlertFingerprint(alertRec, {
|
||||
sms: { active: false, errors: false },
|
||||
email: { active: true, errors: true }
|
||||
})
|
||||
).toBe('string')
|
||||
})
|
||||
})
|
||||
|
||||
describe('sendNoAlerts', () => {
|
||||
test('Send no alerts returns empty object with sms and email disabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, false, false)).toEqual({})
|
||||
})
|
||||
|
||||
test('Send no alerts returns object with sms prop with sms only enabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, true, false)).toEqual({
|
||||
sms: {
|
||||
body: '[Lamassu] All clear'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Send no alerts returns object with sms and email prop with both enabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, true, true)).toEqual({
|
||||
email: {
|
||||
body: 'No errors are reported for your machines.',
|
||||
subject: '[Lamassu] All clear'
|
||||
},
|
||||
sms: {
|
||||
body: '[Lamassu] All clear'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
test('Send no alerts returns object with email prop if only email is enabled', () => {
|
||||
expect(utils.sendNoAlerts(plugins, false, true)).toEqual({
|
||||
email: {
|
||||
body: 'No errors are reported for your machines.',
|
||||
subject: '[Lamassu] All clear'
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
200
lib/notifier/utils.js
Normal file
200
lib/notifier/utils.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
const _ = require('lodash/fp')
|
||||
const crypto = require('crypto')
|
||||
const numeral = require('numeral')
|
||||
const prettyMs = require('pretty-ms')
|
||||
|
||||
const coinUtils = require('../coin-utils')
|
||||
const {
|
||||
CODES_DISPLAY,
|
||||
NETWORK_DOWN_TIME,
|
||||
PING,
|
||||
ALERT_SEND_INTERVAL
|
||||
} = require('./codes')
|
||||
|
||||
const DETAIL_TEMPLATE = {
|
||||
deviceId: '',
|
||||
cryptoCode: '',
|
||||
code: '',
|
||||
cassette: '',
|
||||
age: '',
|
||||
customerId: '',
|
||||
cryptoAddress: '',
|
||||
direction: '',
|
||||
fiat: '',
|
||||
fiatCode: ''
|
||||
}
|
||||
|
||||
function parseEventNote (event) {
|
||||
return _.set('note', JSON.parse(event.note), event)
|
||||
}
|
||||
|
||||
function checkPing (device) {
|
||||
const age = Date.now() - (new Date(device.lastPing).getTime())
|
||||
if (age > NETWORK_DOWN_TIME) return [{ code: PING, age, machineName: device.name }]
|
||||
return []
|
||||
}
|
||||
|
||||
const getDeviceTime = _.flow(_.get('device_time'), Date.parse)
|
||||
|
||||
const isActive = it => it.active && (it.balance || it.errors)
|
||||
|
||||
const codeDisplay = code => CODES_DISPLAY[code]
|
||||
|
||||
const alertFingerprint = {
|
||||
fingerprint: null,
|
||||
lastAlertTime: null
|
||||
}
|
||||
|
||||
const getAlertFingerprint = () => alertFingerprint.fingerprint
|
||||
|
||||
const getLastAlertTime = () => alertFingerprint.lastAlertTime
|
||||
|
||||
const setAlertFingerprint = (fp, time = Date.now()) => {
|
||||
alertFingerprint.fingerprint = fp
|
||||
alertFingerprint.lastAlertTime = time
|
||||
}
|
||||
|
||||
const shouldNotAlert = currentAlertFingerprint => {
|
||||
return (
|
||||
currentAlertFingerprint === getAlertFingerprint() &&
|
||||
getLastAlertTime() - Date.now() < ALERT_SEND_INTERVAL
|
||||
)
|
||||
}
|
||||
|
||||
function buildAlertFingerprint (alertRec, notifications) {
|
||||
const sms = getAlertTypes(alertRec, notifications.sms)
|
||||
const email = getAlertTypes(alertRec, notifications.email)
|
||||
if (sms.length === 0 && email.length === 0) return null
|
||||
|
||||
const smsTypes = _.map(codeDisplay, _.uniq(_.map('code', sms))).sort()
|
||||
const emailTypes = _.map(codeDisplay, _.uniq(_.map('code', email))).sort()
|
||||
|
||||
const subject = _.concat(smsTypes, emailTypes).join(', ')
|
||||
return crypto.createHash('sha256').update(subject).digest('hex')
|
||||
}
|
||||
|
||||
function sendNoAlerts (plugins, smsEnabled, emailEnabled) {
|
||||
const subject = '[Lamassu] All clear'
|
||||
|
||||
let rec = {}
|
||||
if (smsEnabled) {
|
||||
rec = _.set(['sms', 'body'])(subject)(rec)
|
||||
}
|
||||
|
||||
if (emailEnabled) {
|
||||
rec = _.set(['email', 'subject'])(subject)(rec)
|
||||
rec = _.set(['email', 'body'])('No errors are reported for your machines.')(
|
||||
rec
|
||||
)
|
||||
}
|
||||
|
||||
return plugins.sendMessage(rec)
|
||||
}
|
||||
|
||||
const buildTransactionMessage = (tx, rec, highValueTx, machineName, customer) => {
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
const direction = isCashOut ? 'Cash Out' : 'Cash In'
|
||||
const crypto = `${coinUtils.toUnit(tx.cryptoAtoms, tx.cryptoCode)} ${
|
||||
tx.cryptoCode
|
||||
}`
|
||||
const fiat = `${tx.fiat} ${tx.fiatCode}`
|
||||
const customerName = customer.name || customer.id
|
||||
const phone = customer.phone ? `- Phone: ${customer.phone}` : ''
|
||||
|
||||
let status = null
|
||||
if (rec.error) {
|
||||
status = `Error - ${rec.error}`
|
||||
} else {
|
||||
status = !isCashOut
|
||||
? 'Successful'
|
||||
: !rec.isRedemption
|
||||
? 'Successful & awaiting redemption'
|
||||
: 'Successful & dispensed'
|
||||
}
|
||||
|
||||
const body = `
|
||||
- Transaction ID: ${tx.id}
|
||||
- Status: ${status}
|
||||
- Machine name: ${machineName}
|
||||
- ${direction}
|
||||
- ${fiat}
|
||||
- ${crypto}
|
||||
- Customer: ${customerName}
|
||||
${phone}
|
||||
`
|
||||
const smsSubject = `A ${highValueTx ? 'high value ' : ''}${direction.toLowerCase()} transaction just happened at ${machineName} for ${fiat}`
|
||||
const emailSubject = `A ${highValueTx ? 'high value ' : ''}transaction just happened`
|
||||
|
||||
return [{
|
||||
sms: {
|
||||
body: `${smsSubject} – ${status}`
|
||||
},
|
||||
email: {
|
||||
emailSubject,
|
||||
body
|
||||
}
|
||||
}, highValueTx]
|
||||
}
|
||||
|
||||
function formatCurrency (num, code) {
|
||||
return numeral(num).format('0,0.00') + ' ' + code
|
||||
}
|
||||
|
||||
function formatAge (age, settings) {
|
||||
return prettyMs(age, settings)
|
||||
}
|
||||
|
||||
function buildDetail (obj) {
|
||||
// obj validation
|
||||
const objKeys = _.keys(obj)
|
||||
const detailKeys = _.keys(DETAIL_TEMPLATE)
|
||||
if ((_.difference(objKeys, detailKeys)).length > 0) {
|
||||
return Promise.reject(new Error('Error when building detail object: invalid properties'))
|
||||
}
|
||||
return { ...DETAIL_TEMPLATE, ...obj }
|
||||
}
|
||||
|
||||
function deviceAlerts (config, alertRec, device) {
|
||||
let alerts = []
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].balanceAlerts)
|
||||
}
|
||||
|
||||
if (config.errors) {
|
||||
alerts = _.concat(alerts, alertRec.devices[device].deviceAlerts)
|
||||
}
|
||||
return alerts
|
||||
}
|
||||
|
||||
function getAlertTypes (alertRec, config) {
|
||||
let alerts = []
|
||||
if (!isActive(config)) return alerts
|
||||
|
||||
if (config.balance) {
|
||||
alerts = _.concat(alerts, alertRec.general)
|
||||
}
|
||||
|
||||
_.forEach(device => {
|
||||
alerts = _.concat(alerts, deviceAlerts(config, alertRec, device))
|
||||
}, _.keys(alertRec.devices))
|
||||
return alerts
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
codeDisplay,
|
||||
parseEventNote,
|
||||
getDeviceTime,
|
||||
checkPing,
|
||||
isActive,
|
||||
getAlertFingerprint,
|
||||
getLastAlertTime,
|
||||
setAlertFingerprint,
|
||||
shouldNotAlert,
|
||||
buildAlertFingerprint,
|
||||
sendNoAlerts,
|
||||
buildTransactionMessage,
|
||||
formatCurrency,
|
||||
formatAge,
|
||||
buildDetail,
|
||||
deviceAlerts
|
||||
}
|
||||
|
|
@ -3,6 +3,8 @@ const path = require('path')
|
|||
const os = require('os')
|
||||
const argv = require('minimist')(process.argv.slice(2))
|
||||
|
||||
const STRESS_TEST_DB = 'psql://postgres:postgres123@localhost/lamassu_stress'
|
||||
|
||||
/**
|
||||
* @return {{path: string, opts: any}}
|
||||
*/
|
||||
|
|
@ -25,17 +27,29 @@ function load () {
|
|||
|
||||
try {
|
||||
const globalConfigPath = path.resolve('/etc', 'lamassu', 'lamassu.json')
|
||||
return {
|
||||
const config = {
|
||||
path: globalConfigPath,
|
||||
opts: JSON.parse(fs.readFileSync(globalConfigPath))
|
||||
}
|
||||
|
||||
if (argv.testDB) {
|
||||
config.opts.postgresql = STRESS_TEST_DB
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (_) {
|
||||
try {
|
||||
const homeConfigPath = path.resolve(os.homedir(), '.lamassu', 'lamassu.json')
|
||||
return {
|
||||
const config = {
|
||||
path: homeConfigPath,
|
||||
opts: JSON.parse(fs.readFileSync(homeConfigPath))
|
||||
}
|
||||
|
||||
if (argv.testDB) {
|
||||
config.opts.postgresql = STRESS_TEST_DB
|
||||
}
|
||||
|
||||
return config
|
||||
} catch (_) {
|
||||
console.error("Couldn't open lamassu.json config file.")
|
||||
process.exit(1)
|
||||
|
|
|
|||
154
lib/plugins.js
154
lib/plugins.js
|
|
@ -1,4 +1,3 @@
|
|||
const uuid = require('uuid')
|
||||
const _ = require('lodash/fp')
|
||||
const argv = require('minimist')(process.argv.slice(2))
|
||||
const crypto = require('crypto')
|
||||
|
|
@ -21,6 +20,10 @@ const cashOutHelper = require('./cash-out/cash-out-helper')
|
|||
const machineLoader = require('./machine-loader')
|
||||
const customers = require('./customers')
|
||||
const coinUtils = require('./coin-utils')
|
||||
const commissionMath = require('./commission-math')
|
||||
const promoCodes = require('./promo-codes')
|
||||
|
||||
const notifier = require('./notifier')
|
||||
|
||||
const mapValuesWithKey = _.mapValues.convert({
|
||||
cap: false
|
||||
|
|
@ -33,15 +36,16 @@ const PONG_TTL = '1 week'
|
|||
const tradesQueues = {}
|
||||
|
||||
function plugins (settings, deviceId) {
|
||||
function buildRates (tickers) {
|
||||
|
||||
function internalBuildRates (tickers, withCommission = true) {
|
||||
const localeConfig = configManager.getLocale(deviceId, settings.config)
|
||||
const cryptoCodes = localeConfig.cryptoCurrencies
|
||||
|
||||
const rates = {}
|
||||
|
||||
cryptoCodes.forEach((cryptoCode, i) => {
|
||||
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
|
||||
const rateRec = tickers[i]
|
||||
const commissions = configManager.getCommissions(cryptoCode, deviceId, settings.config)
|
||||
|
||||
if (!rateRec) return
|
||||
|
||||
|
|
@ -53,15 +57,26 @@ function plugins (settings, deviceId) {
|
|||
|
||||
if (Date.now() - rateRec.timestamp > STALE_TICKER) return logger.warn('Stale rate for ' + cryptoCode)
|
||||
const rate = rateRec.rates
|
||||
rates[cryptoCode] = {
|
||||
|
||||
withCommission ? rates[cryptoCode] = {
|
||||
cashIn: rate.ask.mul(cashInCommission).round(5),
|
||||
cashOut: cashOutCommission && rate.bid.div(cashOutCommission).round(5)
|
||||
} : rates[cryptoCode] = {
|
||||
cashIn: rate.ask.round(5),
|
||||
cashOut: rate.bid.round(5)
|
||||
}
|
||||
})
|
||||
|
||||
return rates
|
||||
}
|
||||
|
||||
function buildRatesNoCommission (tickers) {
|
||||
return internalBuildRates(tickers, false)
|
||||
}
|
||||
|
||||
function buildRates (tickers) {
|
||||
return internalBuildRates(tickers, true)
|
||||
}
|
||||
|
||||
function getNotificationConfig () {
|
||||
return configManager.getGlobalNotifications(settings.config)
|
||||
}
|
||||
|
|
@ -124,18 +139,6 @@ function plugins (settings, deviceId) {
|
|||
]
|
||||
}
|
||||
|
||||
function getLcmOrBigx2 (n1, n2) {
|
||||
let big = Math.max(n1, n2);
|
||||
let small = Math.min(n1, n2);
|
||||
|
||||
let i = big * 2;
|
||||
while(i % small !== 0){
|
||||
i += lar;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
function buildAvailableCassettes (excludeTxId) {
|
||||
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
|
||||
|
||||
|
|
@ -143,7 +146,7 @@ function plugins (settings, deviceId) {
|
|||
|
||||
const denominations = [cashOutConfig.top, cashOutConfig.bottom]
|
||||
|
||||
const virtualCassettes = [getLcmOrBigx2(cashOutConfig.top, cashOutConfig.bottom)]
|
||||
const virtualCassettes = [Math.max(cashOutConfig.top, cashOutConfig.bottom) * 2]
|
||||
|
||||
return Promise.all([dbm.cassetteCounts(deviceId), cashOutHelper.redeemableTxs(deviceId, excludeTxId)])
|
||||
.then(([rec, _redeemableTxs]) => {
|
||||
|
|
@ -222,12 +225,13 @@ function plugins (settings, deviceId) {
|
|||
const testnetPromises = cryptoCodes.map(c => wallet.cryptoNetwork(settings, c))
|
||||
const pingPromise = recordPing(deviceTime, machineVersion, machineModel)
|
||||
const currentConfigVersionPromise = fetchCurrentConfigVersion()
|
||||
const currentAvailablePromoCodes = promoCodes.getNumberOfAvailablePromoCodes()
|
||||
|
||||
const promises = [
|
||||
buildAvailableCassettes(),
|
||||
pingPromise,
|
||||
currentConfigVersionPromise
|
||||
].concat(tickerPromises, balancePromises, testnetPromises)
|
||||
].concat(tickerPromises, balancePromises, testnetPromises, currentAvailablePromoCodes)
|
||||
|
||||
return Promise.all(promises)
|
||||
.then(arr => {
|
||||
|
|
@ -236,16 +240,18 @@ function plugins (settings, deviceId) {
|
|||
const cryptoCodesCount = cryptoCodes.length
|
||||
const tickers = arr.slice(3, cryptoCodesCount + 3)
|
||||
const balances = arr.slice(cryptoCodesCount + 3, 2 * cryptoCodesCount + 3)
|
||||
const testNets = arr.slice(2 * cryptoCodesCount + 3)
|
||||
const testNets = arr.slice(2 * cryptoCodesCount + 3, arr.length - 1)
|
||||
const coinParams = _.zip(cryptoCodes, testNets)
|
||||
const coinsWithoutRate = _.map(mapCoinSettings, coinParams)
|
||||
const areThereAvailablePromoCodes = arr[arr.length - 1] > 0
|
||||
|
||||
return {
|
||||
cassettes,
|
||||
rates: buildRates(tickers),
|
||||
balances: buildBalances(balances),
|
||||
coins: _.zipWith(_.assign, coinsWithoutRate, tickers),
|
||||
configVersion
|
||||
configVersion,
|
||||
areThereAvailablePromoCodes
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -350,78 +356,8 @@ function plugins (settings, deviceId) {
|
|||
}
|
||||
|
||||
function notifyOperator (tx, rec) {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
const notificationsEnabled = notifications.sms.transactions || notifications.email.transactions
|
||||
const highValueTx = tx.fiat.gt(notifications.highValueTransaction || Infinity)
|
||||
|
||||
if (!notificationsEnabled && !highValueTx) return Promise.resolve()
|
||||
|
||||
const isCashOut = tx.direction === 'cashOut'
|
||||
const zeroConf = isCashOut && isZeroConf(tx)
|
||||
|
||||
// 0-conf cash-out should only send notification on redemption
|
||||
if (zeroConf && isCashOut && !rec.isRedemption && !rec.error) return Promise.resolve()
|
||||
|
||||
if (!zeroConf && rec.isRedemption) return sendRedemptionMessage(tx.id, rec.error)
|
||||
|
||||
const customerPromise = tx.customerId ? customers.getById(tx.customerId) : Promise.resolve({})
|
||||
|
||||
return Promise.all([machineLoader.getMachineName(tx.deviceId), customerPromise])
|
||||
.then(([machineName, customer]) => {
|
||||
const direction = isCashOut ? 'Cash Out' : 'Cash In'
|
||||
const crypto = `${coinUtils.toUnit(tx.cryptoAtoms, tx.cryptoCode)} ${tx.cryptoCode}`
|
||||
const fiat = `${tx.fiat} ${tx.fiatCode}`
|
||||
const customerName = customer.name || customer.id
|
||||
const phone = customer.phone ? `- Phone: ${customer.phone}` : ''
|
||||
|
||||
let status
|
||||
if (rec.error) {
|
||||
status = `Error - ${rec.error}`
|
||||
} else {
|
||||
status = !isCashOut ? 'Successful' : !rec.isRedemption
|
||||
? 'Successful & awaiting redemption' : 'Successful & dispensed'
|
||||
}
|
||||
|
||||
const body = `
|
||||
- Transaction ID: ${tx.id}
|
||||
- Status: ${status}
|
||||
- Machine name: ${machineName}
|
||||
- ${direction}
|
||||
- ${fiat}
|
||||
- ${crypto}
|
||||
- Customer: ${customerName}
|
||||
${phone}
|
||||
`
|
||||
const subject = `A ${highValueTx ? 'high value ' : ''}transaction just happened`
|
||||
|
||||
return [{
|
||||
sms: {
|
||||
body: `${subject} - ${status}`
|
||||
},
|
||||
email: {
|
||||
subject,
|
||||
body
|
||||
}
|
||||
}, highValueTx]
|
||||
})
|
||||
.then(([rec, highValueTx]) => sendTransactionMessage(rec, highValueTx))
|
||||
}
|
||||
|
||||
function sendRedemptionMessage (txId, error) {
|
||||
const subject = `Here's an update on transaction ${txId}`
|
||||
const body = error ? `Error: ${error}` : 'It was just dispensed successfully'
|
||||
|
||||
const rec = {
|
||||
sms: {
|
||||
body: `${subject} - ${body}`
|
||||
},
|
||||
email: {
|
||||
subject,
|
||||
body
|
||||
}
|
||||
}
|
||||
return sendTransactionMessage(rec)
|
||||
// notify operator about new transaction and add high volume txs to database
|
||||
return notifier.transactionNotify(tx, rec)
|
||||
}
|
||||
|
||||
function clearOldLogs () {
|
||||
|
|
@ -440,18 +376,18 @@ function plugins (settings, deviceId) {
|
|||
* Trader functions
|
||||
*/
|
||||
|
||||
function buy (rec) {
|
||||
return buyAndSell(rec, true)
|
||||
function buy (rec, tx) {
|
||||
return buyAndSell(rec, true, tx)
|
||||
}
|
||||
|
||||
function sell (rec) {
|
||||
return buyAndSell(rec, false)
|
||||
}
|
||||
|
||||
function buyAndSell (rec, doBuy) {
|
||||
function buyAndSell (rec, doBuy, tx) {
|
||||
const cryptoCode = rec.cryptoCode
|
||||
const fiatCode = rec.fiatCode
|
||||
const cryptoAtoms = doBuy ? rec.cryptoAtoms : rec.cryptoAtoms.neg()
|
||||
const cryptoAtoms = doBuy ? commissionMath.fiatToCrypto(tx, rec, deviceId, settings.config) : rec.cryptoAtoms.neg()
|
||||
|
||||
const market = [fiatCode, cryptoCode].join('')
|
||||
|
||||
|
|
@ -611,20 +547,6 @@ function plugins (settings, deviceId) {
|
|||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
function sendTransactionMessage (rec, isHighValueTx) {
|
||||
const notifications = configManager.getGlobalNotifications(settings.config)
|
||||
|
||||
let promises = []
|
||||
|
||||
const emailActive = notifications.email.active && (notifications.email.transactions || isHighValueTx)
|
||||
if (emailActive) promises.push(email.sendMessage(settings, rec))
|
||||
|
||||
const smsActive = notifications.sms.active && (notifications.sms.transactions || isHighValueTx)
|
||||
if (smsActive) promises.push(sms.sendMessage(settings, rec))
|
||||
|
||||
return Promise.all(promises)
|
||||
}
|
||||
|
||||
function checkDevicesCashBalances (fiatCode, devices) {
|
||||
return _.map(device => checkDeviceCashBalances(fiatCode, device), devices)
|
||||
}
|
||||
|
|
@ -693,7 +615,6 @@ function plugins (settings, deviceId) {
|
|||
|
||||
function checkCryptoBalance (fiatCode, rec) {
|
||||
const [cryptoCode, fiatBalance] = rec
|
||||
|
||||
if (!fiatBalance) return null
|
||||
|
||||
const notifications = configManager.getNotifications(cryptoCode, null, settings.config)
|
||||
|
|
@ -703,14 +624,16 @@ function plugins (settings, deviceId) {
|
|||
const req = {
|
||||
cryptoCode,
|
||||
fiatBalance,
|
||||
fiatCode,
|
||||
fiatCode
|
||||
}
|
||||
|
||||
if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold))
|
||||
if (_.isFinite(lowAlertThreshold) && BN(fiatBalance.balance).lt(lowAlertThreshold)) {
|
||||
return _.set('code')('LOW_CRYPTO_BALANCE')(req)
|
||||
}
|
||||
|
||||
if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold))
|
||||
if (_.isFinite(highAlertThreshold) && BN(fiatBalance.balance).gt(highAlertThreshold)) {
|
||||
return _.set('code')('HIGH_CRYPTO_BALANCE')(req)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
|
@ -798,6 +721,7 @@ function plugins (settings, deviceId) {
|
|||
getRates,
|
||||
buildRates,
|
||||
getRawRates,
|
||||
buildRatesNoCommission,
|
||||
pollQueries,
|
||||
sendCoins,
|
||||
newAddress,
|
||||
|
|
|
|||
|
|
@ -8,16 +8,22 @@ const coinUtils = require('../../../coin-utils')
|
|||
const cryptoRec = coinUtils.getCryptoCurrency('BCH')
|
||||
const configPath = coinUtils.configPath(cryptoRec)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
|
||||
const rpcConfig = {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
function rpcConfig () {
|
||||
try {
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
return {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('wallet is currently not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function fetch (method, params) {
|
||||
return jsonRpc.fetch(rpcConfig, method, params)
|
||||
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||
}
|
||||
|
||||
function checkCryptoCode (cryptoCode) {
|
||||
|
|
|
|||
|
|
@ -8,16 +8,22 @@ const coinUtils = require('../../../coin-utils')
|
|||
const cryptoRec = coinUtils.getCryptoCurrency('BTC')
|
||||
const configPath = coinUtils.configPath(cryptoRec)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
|
||||
const rpcConfig = {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
function rpcConfig () {
|
||||
try {
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
return {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('wallet is currently not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function fetch (method, params) {
|
||||
return jsonRpc.fetch(rpcConfig, method, params)
|
||||
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||
}
|
||||
|
||||
function checkCryptoCode (cryptoCode) {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,22 @@ const E = require('../../../error')
|
|||
const cryptoRec = coinUtils.getCryptoCurrency('DASH')
|
||||
const configPath = coinUtils.configPath(cryptoRec)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
|
||||
const rpcConfig = {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
function rpcConfig () {
|
||||
try {
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
return {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('wallet is currently not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function fetch (method, params) {
|
||||
return jsonRpc.fetch(rpcConfig, method, params)
|
||||
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||
}
|
||||
|
||||
function checkCryptoCode (cryptoCode) {
|
||||
|
|
|
|||
|
|
@ -68,10 +68,13 @@ function checkCryptoCode (cryptoCode) {
|
|||
|
||||
function balance (account, cryptoCode) {
|
||||
return checkCryptoCode(cryptoCode)
|
||||
.then(() => pendingBalance(defaultAddress(account)))
|
||||
.then(() => confirmedBalance(defaultAddress(account)))
|
||||
}
|
||||
|
||||
const pendingBalance = address => _balance(true, address)
|
||||
const pendingBalance = address => {
|
||||
const promises = [_balance(true, address), _balance(false, address)]
|
||||
return Promise.all(promises).then(([pending, confirmed]) => pending - confirmed)
|
||||
}
|
||||
const confirmedBalance = address => _balance(false, address)
|
||||
|
||||
function _balance (includePending, address) {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,21 @@ const E = require('../../../error')
|
|||
const cryptoRec = coinUtils.getCryptoCurrency('LTC')
|
||||
const configPath = coinUtils.configPath(cryptoRec)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
|
||||
const rpcConfig = {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
function rpcConfig () {
|
||||
try {
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
return {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('wallet is currently not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function fetch (method, params) {
|
||||
return jsonRpc.fetch(rpcConfig, method, params)
|
||||
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||
}
|
||||
|
||||
function checkCryptoCode (cryptoCode) {
|
||||
|
|
|
|||
|
|
@ -9,16 +9,22 @@ const E = require('../../../error')
|
|||
const cryptoRec = coinUtils.getCryptoCurrency('ZEC')
|
||||
const configPath = coinUtils.configPath(cryptoRec)
|
||||
const unitScale = cryptoRec.unitScale
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
|
||||
const rpcConfig = {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
function rpcConfig () {
|
||||
try {
|
||||
const config = jsonRpc.parseConf(configPath)
|
||||
return {
|
||||
username: config.rpcuser,
|
||||
password: config.rpcpassword,
|
||||
port: config.rpcport || cryptoRec.defaultPort
|
||||
}
|
||||
} catch (err) {
|
||||
throw new Error('wallet is currently not installed')
|
||||
}
|
||||
}
|
||||
|
||||
function fetch (method, params) {
|
||||
return jsonRpc.fetch(rpcConfig, method, params)
|
||||
return jsonRpc.fetch(rpcConfig(), method, params)
|
||||
}
|
||||
|
||||
function checkCryptoCode (cryptoCode) {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ const complianceTriggers = require('./compliance-triggers')
|
|||
|
||||
const INCOMING_TX_INTERVAL = 30 * T.seconds
|
||||
const LIVE_INCOMING_TX_INTERVAL = 5 * T.seconds
|
||||
const INCOMING_TX_INTERVAL_FILTER = 1 * T.minute
|
||||
const LIVE_INCOMING_TX_INTERVAL_FILTER = 10 * T.seconds
|
||||
const UNNOTIFIED_INTERVAL = 10 * T.seconds
|
||||
const SWEEP_HD_INTERVAL = T.minute
|
||||
const TRADE_INTERVAL = 60 * T.seconds
|
||||
|
|
@ -27,6 +29,8 @@ const CHECK_NOTIFICATION_INTERVAL = 20 * T.seconds
|
|||
|
||||
const PENDING_INTERVAL = 10 * T.seconds
|
||||
|
||||
const coinFilter = ['ETH']
|
||||
|
||||
let _pi, _settings
|
||||
|
||||
function reload (__settings) {
|
||||
|
|
@ -71,16 +75,24 @@ function start (__settings) {
|
|||
pi().executeTrades()
|
||||
pi().pong()
|
||||
pi().clearOldLogs()
|
||||
cashOutTx.monitorLiveIncoming(settings())
|
||||
cashOutTx.monitorStaleIncoming(settings())
|
||||
cashOutTx.monitorLiveIncoming(settings(), false, coinFilter)
|
||||
cashOutTx.monitorStaleIncoming(settings(), false, coinFilter)
|
||||
if (!_.isEmpty(coinFilter)) {
|
||||
cashOutTx.monitorLiveIncoming(settings(), true, coinFilter)
|
||||
cashOutTx.monitorStaleIncoming(settings(), true, coinFilter)
|
||||
}
|
||||
cashOutTx.monitorUnnotified(settings())
|
||||
pi().sweepHd()
|
||||
notifier.checkNotification(pi())
|
||||
updateCoinAtmRadar()
|
||||
|
||||
setInterval(() => pi().executeTrades(), TRADE_INTERVAL)
|
||||
setInterval(() => cashOutTx.monitorLiveIncoming(settings()), LIVE_INCOMING_TX_INTERVAL)
|
||||
setInterval(() => cashOutTx.monitorStaleIncoming(settings()), INCOMING_TX_INTERVAL)
|
||||
setInterval(() => cashOutTx.monitorLiveIncoming(settings(), false, coinFilter), LIVE_INCOMING_TX_INTERVAL)
|
||||
setInterval(() => cashOutTx.monitorStaleIncoming(settings(), false, coinFilter), INCOMING_TX_INTERVAL)
|
||||
if (!_.isEmpty(coinFilter)) {
|
||||
setInterval(() => cashOutTx.monitorLiveIncoming(settings(), true, coinFilter), LIVE_INCOMING_TX_INTERVAL_FILTER)
|
||||
setInterval(() => cashOutTx.monitorStaleIncoming(settings(), true, coinFilter), INCOMING_TX_INTERVAL_FILTER)
|
||||
}
|
||||
setInterval(() => cashOutTx.monitorUnnotified(settings()), UNNOTIFIED_INTERVAL)
|
||||
setInterval(() => cashInTx.monitorPending(settings()), PENDING_INTERVAL)
|
||||
setInterval(() => pi().sweepHd(), SWEEP_HD_INTERVAL)
|
||||
|
|
|
|||
29
lib/promo-codes.js
Normal file
29
lib/promo-codes.js
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
const db = require('./db')
|
||||
const uuid = require('uuid')
|
||||
|
||||
function getAvailablePromoCodes () {
|
||||
const sql = `SELECT * FROM coupons WHERE soft_deleted=false`
|
||||
return db.any(sql)
|
||||
}
|
||||
|
||||
function getPromoCode (code) {
|
||||
const sql = `SELECT * FROM coupons WHERE code=$1 AND soft_deleted=false`
|
||||
return db.oneOrNone(sql, [code])
|
||||
}
|
||||
|
||||
function createPromoCode (code, discount) {
|
||||
const sql = `INSERT INTO coupons (id, code, discount) VALUES ($1, $2, $3) RETURNING *`
|
||||
return db.one(sql, [uuid.v4(), code, discount])
|
||||
}
|
||||
|
||||
function deletePromoCode (id) {
|
||||
const sql = `UPDATE coupons SET soft_deleted=true WHERE id=$1`
|
||||
return db.none(sql, [id])
|
||||
}
|
||||
|
||||
function getNumberOfAvailablePromoCodes () {
|
||||
const sql = `SELECT COUNT(id) FROM coupons WHERE soft_deleted=false`
|
||||
return db.one(sql).then(res => res.count)
|
||||
}
|
||||
|
||||
module.exports = { getAvailablePromoCodes, getPromoCode, createPromoCode, deletePromoCode, getNumberOfAvailablePromoCodes }
|
||||
|
|
@ -25,6 +25,10 @@ const E = require('./error')
|
|||
const customers = require('./customers')
|
||||
const logs = require('./logs')
|
||||
const compliance = require('./compliance')
|
||||
const promoCodes = require('./promo-codes')
|
||||
const BN = require('./bn')
|
||||
const commissionMath = require('./commission-math')
|
||||
const notifier = require('./notifier')
|
||||
|
||||
const version = require('../package.json').version
|
||||
|
||||
|
|
@ -65,8 +69,10 @@ function poll (req, res, next) {
|
|||
const triggers = configManager.getTriggers(settings.config)
|
||||
|
||||
const operatorInfo = configManager.getOperatorInfo(settings.config)
|
||||
const machineInfo = { deviceId: req.deviceId, deviceName: req.deviceName }
|
||||
const cashOutConfig = configManager.getCashOut(deviceId, settings.config)
|
||||
const receipt = configManager.getReceipt(settings.config)
|
||||
const terms = configManager.getTermsConditions(settings.config)
|
||||
|
||||
pids[deviceId] = { pid, ts: Date.now() }
|
||||
|
||||
|
|
@ -102,9 +108,9 @@ function poll (req, res, next) {
|
|||
hasLightning,
|
||||
receipt,
|
||||
operatorInfo,
|
||||
machineInfo,
|
||||
triggers
|
||||
}
|
||||
|
||||
// BACKWARDS_COMPATIBILITY 7.5
|
||||
// machines before 7.5 expect old compliance
|
||||
if (!machineVersion || semver.lt(machineVersion, '7.5.0-beta.0')) {
|
||||
|
|
@ -124,9 +130,8 @@ function poll (req, res, next) {
|
|||
// BACKWARDS_COMPATIBILITY 7.4.9
|
||||
// machines before 7.4.9 expect t&c on poll
|
||||
if (!machineVersion || semver.lt(machineVersion, '7.4.9')) {
|
||||
response.terms = config.termsScreenActive && config.termsScreenText ? createTerms(config) : null
|
||||
response.terms = createTerms(terms)
|
||||
}
|
||||
|
||||
return res.json(_.assign(response, results))
|
||||
})
|
||||
.catch(next)
|
||||
|
|
@ -213,6 +218,31 @@ function verifyTx (req, res, next) {
|
|||
.catch(next)
|
||||
}
|
||||
|
||||
function verifyPromoCode (req, res, next) {
|
||||
promoCodes.getPromoCode(req.body.codeInput)
|
||||
.then(promoCode => {
|
||||
if (!promoCode) return next()
|
||||
|
||||
const transaction = req.body.tx
|
||||
const commissions = configManager.getCommissions(transaction.cryptoCode, req.deviceId, req.settings.config)
|
||||
const tickerRate = BN(transaction.rawTickerPrice)
|
||||
const discount = commissionMath.getDiscountRate(promoCode.discount, commissions[transaction.direction])
|
||||
const rates = {
|
||||
[transaction.cryptoCode]: {
|
||||
[transaction.direction]: (transaction.direction === 'cashIn')
|
||||
? tickerRate.mul(discount).round(5)
|
||||
: tickerRate.div(discount).round(5)
|
||||
}
|
||||
}
|
||||
|
||||
respond(req, res, {
|
||||
promoCode: promoCode,
|
||||
newRates: rates
|
||||
})
|
||||
})
|
||||
.catch(next)
|
||||
}
|
||||
|
||||
function addOrUpdateCustomer (req) {
|
||||
const customerData = req.body
|
||||
const machineVersion = req.query.version
|
||||
|
|
@ -314,7 +344,10 @@ function triggerBlock (req, res, next) {
|
|||
const id = req.params.id
|
||||
|
||||
customers.update(id, { authorizedOverride: 'blocked' })
|
||||
.then(customer => respond(req, res, { customer }))
|
||||
.then(customer => {
|
||||
notifier.notifyIfActive('compliance', 'customerComplianceNotify', customer, req.deviceId, 'BLOCKED')
|
||||
return respond(req, res, { customer })
|
||||
})
|
||||
.catch(next)
|
||||
}
|
||||
|
||||
|
|
@ -330,7 +363,10 @@ function triggerSuspend (req, res, next) {
|
|||
const date = new Date()
|
||||
date.setDate(date.getDate() + days);
|
||||
customers.update(id, { suspendedUntil: date })
|
||||
.then(customer => respond(req, res, { customer }))
|
||||
.then(customer => {
|
||||
notifier.notifyIfActive('compliance', 'customerComplianceNotify', customer, req.deviceId, 'SUSPENDED', days)
|
||||
return respond(req, res, { customer })
|
||||
})
|
||||
.catch(next)
|
||||
}
|
||||
|
||||
|
|
@ -395,7 +431,11 @@ function errorHandler (err, req, res, next) {
|
|||
function respond (req, res, _body, _status) {
|
||||
const status = _status || 200
|
||||
const body = _body || {}
|
||||
|
||||
const customer = _.getOr({ sanctions: true }, ['customer'], body)
|
||||
// sanctions can be null for new customers so we can't use falsy checks
|
||||
if (customer.sanctions === false) {
|
||||
notifier.notifyIfActive('compliance', 'sanctionsNotify', customer, req.body.phone)
|
||||
}
|
||||
return res.status(status).json(body)
|
||||
}
|
||||
|
||||
|
|
@ -450,7 +490,8 @@ const configRequiredRoutes = [
|
|||
'/event',
|
||||
'/phone_code',
|
||||
'/customer',
|
||||
'/tx'
|
||||
'/tx',
|
||||
'/verify_promo_code'
|
||||
]
|
||||
|
||||
const app = express()
|
||||
|
|
@ -477,6 +518,7 @@ app.post('/state', stateChange)
|
|||
|
||||
app.post('/verify_user', verifyUser)
|
||||
app.post('/verify_transaction', verifyTx)
|
||||
app.post('/verify_promo_code', verifyPromoCode)
|
||||
|
||||
app.post('/phone_code', getCustomerWithPhoneCode)
|
||||
app.patch('/customer/:id', updateCustomer)
|
||||
|
|
|
|||
|
|
@ -209,11 +209,23 @@ function isStrictAddress (settings, cryptoCode, toAddress) {
|
|||
})
|
||||
}
|
||||
|
||||
const balance = mem(_balance, {
|
||||
const coinFilter = ['ETH']
|
||||
|
||||
const balance = (settings, cryptoCode) => {
|
||||
if (_.includes(coinFilter, cryptoCode)) return balanceFiltered(settings, cryptoCode)
|
||||
return balanceUnfiltered(settings, cryptoCode)
|
||||
}
|
||||
|
||||
const balanceUnfiltered = mem(_balance, {
|
||||
maxAge: FETCH_INTERVAL,
|
||||
cacheKey: (settings, cryptoCode) => cryptoCode
|
||||
})
|
||||
|
||||
const balanceFiltered = mem(_balance, {
|
||||
maxAge: 3 * FETCH_INTERVAL,
|
||||
cacheKey: (settings, cryptoCode) => cryptoCode
|
||||
})
|
||||
|
||||
module.exports = {
|
||||
balance,
|
||||
sendCoins,
|
||||
|
|
|
|||
19
migrations/1603886141913-coupon-codes.js
Normal file
19
migrations/1603886141913-coupon-codes.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
var db = require('./db')
|
||||
|
||||
exports.up = function (next) {
|
||||
const sql =
|
||||
[
|
||||
`CREATE TABLE coupons (
|
||||
id UUID PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
discount SMALLINT NOT NULL,
|
||||
soft_deleted BOOLEAN DEFAULT false )`,
|
||||
`CREATE UNIQUE INDEX uq_code ON coupons (code) WHERE NOT soft_deleted`
|
||||
]
|
||||
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
14
migrations/1604419505567-add-discount-to-txs.js
Normal file
14
migrations/1604419505567-add-discount-to-txs.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const db = require('./db')
|
||||
|
||||
exports.up = function (next) {
|
||||
var sql = [
|
||||
'ALTER TABLE cash_in_txs ADD COLUMN discount SMALLINT',
|
||||
'ALTER TABLE cash_out_txs ADD COLUMN discount SMALLINT'
|
||||
]
|
||||
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
15
migrations/1604934042127-clean-bills.js
Normal file
15
migrations/1604934042127-clean-bills.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
const db = require('./db')
|
||||
|
||||
exports.up = function (next) {
|
||||
var sql = [
|
||||
'ALTER TABLE bills DROP COLUMN crypto_atoms',
|
||||
'ALTER TABLE bills DROP COLUMN cash_in_fee_crypto',
|
||||
'ALTER TABLE bills DROP COLUMN crypto_atoms_after_fee'
|
||||
]
|
||||
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
38
migrations/1607009558538-create-notifications-table.js
Normal file
38
migrations/1607009558538-create-notifications-table.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
var db = require('./db')
|
||||
|
||||
const singleQuotify = (item) => `'${item}'`
|
||||
|
||||
var types = [
|
||||
'highValueTransaction',
|
||||
'transaction',
|
||||
'fiatBalance',
|
||||
'cryptoBalance',
|
||||
'compliance',
|
||||
'error'
|
||||
]
|
||||
.map(singleQuotify)
|
||||
.join(',')
|
||||
|
||||
exports.up = function (next) {
|
||||
const sql = [
|
||||
`
|
||||
CREATE TYPE notification_type AS ENUM ${'(' + types + ')'};
|
||||
CREATE TABLE "notifications" (
|
||||
"id" uuid NOT NULL PRIMARY KEY,
|
||||
"type" notification_type NOT NULL,
|
||||
"detail" JSONB,
|
||||
"message" TEXT NOT NULL,
|
||||
"created" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"read" BOOLEAN NOT NULL DEFAULT 'false',
|
||||
"valid" BOOLEAN NOT NULL DEFAULT 'true'
|
||||
);
|
||||
CREATE INDEX ON notifications (valid);
|
||||
CREATE INDEX ON notifications (read);`
|
||||
]
|
||||
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
13
migrations/1610716756175-id-card-raw.js
Normal file
13
migrations/1610716756175-id-card-raw.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
var db = require('./db')
|
||||
|
||||
exports.up = function (next) {
|
||||
var sql = [
|
||||
'ALTER TABLE customers ADD COLUMN id_card_data_raw text'
|
||||
]
|
||||
|
||||
db.multi(sql, next)
|
||||
}
|
||||
|
||||
exports.down = function (next) {
|
||||
next()
|
||||
}
|
||||
22771
new-lamassu-admin/package-lock.json
generated
22771
new-lamassu-admin/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@
|
|||
"axios": "0.19.0",
|
||||
"bignumber.js": "9.0.0",
|
||||
"classnames": "2.2.6",
|
||||
"d3": "^6.2.0",
|
||||
"downshift": "3.3.4",
|
||||
"file-saver": "2.0.2",
|
||||
"formik": "2.2.0",
|
||||
|
|
@ -36,7 +37,7 @@
|
|||
"react-virtualized": "^9.21.2",
|
||||
"sanctuary": "^2.0.1",
|
||||
"uuid": "^7.0.2",
|
||||
"yup": "0.29.3"
|
||||
"yup": "0.32.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-actions": "6.0.26",
|
||||
|
|
@ -47,11 +48,12 @@
|
|||
"@storybook/preset-create-react-app": "^3.1.4",
|
||||
"@storybook/react": "6.0.26",
|
||||
"@welldone-software/why-did-you-render": "^3.3.9",
|
||||
"eslint": "^7.19.0",
|
||||
"eslint-config-prettier": "^6.7.0",
|
||||
"eslint-config-prettier-standard": "^3.0.1",
|
||||
"eslint-config-standard": "^14.1.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-plugin-import": "^2.20.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-node": "^10.0.0",
|
||||
"eslint-plugin-prettier": "^3.1.2",
|
||||
"eslint-plugin-promise": "^4.2.1",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, shrink-to-fit=no"
|
||||
/>
|
||||
<meta name="robots" content="noindex"/>
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import CssBaseline from '@material-ui/core/CssBaseline'
|
||||
import Grid from '@material-ui/core/Grid'
|
||||
import Slide from '@material-ui/core/Slide'
|
||||
import {
|
||||
StylesProvider,
|
||||
jssPreset,
|
||||
|
|
@ -96,7 +97,17 @@ const Main = () => {
|
|||
{!is404 && wizardTested && <Header tree={tree} />}
|
||||
<main className={classes.wrapper}>
|
||||
{sidebar && !is404 && wizardTested && (
|
||||
<TitleSection title={parent.title}></TitleSection>
|
||||
<Slide
|
||||
direction="left"
|
||||
in={true}
|
||||
mountOnEnter
|
||||
unmountOnExit
|
||||
children={
|
||||
<div>
|
||||
<TitleSection title={parent.title}></TitleSection>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Grid container className={classes.grid}>
|
||||
|
|
|
|||
41
new-lamassu-admin/src/components/CollapsibleCard.js
Normal file
41
new-lamassu-admin/src/components/CollapsibleCard.js
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Grid from '@material-ui/core/Grid'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import PropTypes from 'prop-types'
|
||||
import React from 'react'
|
||||
|
||||
import { white } from 'src/styling/variables'
|
||||
|
||||
const cardState = Object.freeze({
|
||||
DEFAULT: 'default',
|
||||
SHRUNK: 'shrunk',
|
||||
EXPANDED: 'expanded'
|
||||
})
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
wordWrap: 'break-word',
|
||||
boxShadow: '0 0 4px 0 rgba(0, 0, 0, 0.08)',
|
||||
borderRadius: 12,
|
||||
padding: 24,
|
||||
backgroundColor: white
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const CollapsibleCard = ({ className, state, shrunkComponent, children }) => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<Grid item className={classnames(className, classes.card)}>
|
||||
{state === cardState.SHRUNK ? shrunkComponent : children}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
CollapsibleCard.propTypes = {
|
||||
shrunkComponent: PropTypes.node.isRequired
|
||||
}
|
||||
|
||||
export default CollapsibleCard
|
||||
export { cardState }
|
||||
|
|
@ -115,7 +115,6 @@ export const ConfirmDialog = memo(
|
|||
error={error}
|
||||
InputLabelProps={{ shrink: true }}
|
||||
onChange={handleChange}
|
||||
onBlur={() => setError(isOnErrorState)}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions className={classes.dialogActions}>
|
||||
|
|
|
|||
96
new-lamassu-admin/src/components/DeleteDialog.js
Normal file
96
new-lamassu-admin/src/components/DeleteDialog.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
makeStyles
|
||||
} from '@material-ui/core'
|
||||
import React from 'react'
|
||||
|
||||
import { Button, IconButton } from 'src/components/buttons'
|
||||
import { H4, P } from 'src/components/typography'
|
||||
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
||||
import { spacer } from 'src/styling/variables'
|
||||
|
||||
import ErrorMessage from './ErrorMessage'
|
||||
|
||||
const useStyles = makeStyles({
|
||||
content: {
|
||||
width: 434,
|
||||
padding: spacer * 2,
|
||||
paddingRight: spacer * 3.5
|
||||
},
|
||||
titleSection: {
|
||||
padding: spacer * 2,
|
||||
paddingRight: spacer * 1.5,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
margin: 0
|
||||
},
|
||||
actions: {
|
||||
padding: spacer * 4,
|
||||
paddingTop: spacer * 2
|
||||
},
|
||||
title: {
|
||||
margin: 0
|
||||
},
|
||||
closeButton: {
|
||||
padding: 0,
|
||||
marginTop: -(spacer / 2)
|
||||
}
|
||||
})
|
||||
|
||||
export const DialogTitle = ({ children, close }) => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<div className={classes.titleSection}>
|
||||
{children}
|
||||
{close && (
|
||||
<IconButton
|
||||
size={16}
|
||||
aria-label="close"
|
||||
onClick={close}
|
||||
className={classes.closeButton}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const DeleteDialog = ({
|
||||
title = 'Confirm Delete',
|
||||
open = false,
|
||||
onConfirmed,
|
||||
onDismissed,
|
||||
item = 'item',
|
||||
confirmationMessage = `Are you sure you want to delete this ${item}?`,
|
||||
errorMessage = ''
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<Dialog open={open} aria-labelledby="form-dialog-title">
|
||||
<DialogTitle close={() => onDismissed()}>
|
||||
<H4 className={classes.title}>{title}</H4>
|
||||
</DialogTitle>
|
||||
{errorMessage && (
|
||||
<DialogTitle>
|
||||
<ErrorMessage>
|
||||
{errorMessage.split(':').map(error => (
|
||||
<>
|
||||
{error}
|
||||
<br />
|
||||
</>
|
||||
))}
|
||||
</ErrorMessage>
|
||||
</DialogTitle>
|
||||
)}
|
||||
<DialogContent className={classes.content}>
|
||||
{confirmationMessage && <P>{confirmationMessage}</P>}
|
||||
</DialogContent>
|
||||
<DialogActions className={classes.actions}>
|
||||
<Button onClick={onConfirmed}>Confirm</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,161 @@
|
|||
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import gql from 'graphql-tag'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState, useEffect } from 'react'
|
||||
|
||||
import ActionButton from 'src/components/buttons/ActionButton'
|
||||
import { H5 } from 'src/components/typography'
|
||||
import { ReactComponent as NotificationIconZodiac } from 'src/styling/icons/menu/notification-zodiac.svg'
|
||||
import { ReactComponent as ClearAllIconInverse } from 'src/styling/icons/stage/spring/empty.svg'
|
||||
import { ReactComponent as ClearAllIcon } from 'src/styling/icons/stage/zodiac/empty.svg'
|
||||
import { ReactComponent as ShowUnreadIcon } from 'src/styling/icons/stage/zodiac/full.svg'
|
||||
|
||||
import styles from './NotificationCenter.styles'
|
||||
import NotificationRow from './NotificationRow'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const GET_NOTIFICATIONS = gql`
|
||||
query getNotifications {
|
||||
notifications {
|
||||
id
|
||||
type
|
||||
detail
|
||||
message
|
||||
created
|
||||
read
|
||||
valid
|
||||
}
|
||||
hasUnreadNotifications
|
||||
machines {
|
||||
deviceId
|
||||
name
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const TOGGLE_CLEAR_NOTIFICATION = gql`
|
||||
mutation toggleClearNotification($id: ID!, $read: Boolean!) {
|
||||
toggleClearNotification(id: $id, read: $read) {
|
||||
id
|
||||
read
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const CLEAR_ALL_NOTIFICATIONS = gql`
|
||||
mutation clearAllNotifications {
|
||||
clearAllNotifications {
|
||||
id
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const NotificationCenter = ({
|
||||
close,
|
||||
hasUnreadProp,
|
||||
buttonCoords,
|
||||
popperRef,
|
||||
refetchHasUnreadHeader
|
||||
}) => {
|
||||
const { data, loading } = useQuery(GET_NOTIFICATIONS, {
|
||||
pollInterval: 60000
|
||||
})
|
||||
const [xOffset, setXoffset] = useState(300)
|
||||
|
||||
const [showingUnread, setShowingUnread] = useState(false)
|
||||
const classes = useStyles({ buttonCoords, xOffset })
|
||||
const machines = R.compose(
|
||||
R.map(R.prop('name')),
|
||||
R.indexBy(R.prop('deviceId'))
|
||||
)(R.path(['machines'])(data) ?? [])
|
||||
const notifications = R.path(['notifications'])(data) ?? []
|
||||
const [hasUnread, setHasUnread] = useState(hasUnreadProp)
|
||||
|
||||
const [toggleClearNotification] = useMutation(TOGGLE_CLEAR_NOTIFICATION, {
|
||||
onError: () => console.error('Error while clearing notification'),
|
||||
refetchQueries: () => ['getNotifications']
|
||||
})
|
||||
const [clearAllNotifications] = useMutation(CLEAR_ALL_NOTIFICATIONS, {
|
||||
onError: () => console.error('Error while clearing all notifications'),
|
||||
refetchQueries: () => ['getNotifications']
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
setXoffset(popperRef.current.getBoundingClientRect().x)
|
||||
if (data && data.hasUnreadNotifications !== hasUnread) {
|
||||
refetchHasUnreadHeader()
|
||||
setHasUnread(!hasUnread)
|
||||
}
|
||||
}, [popperRef, data, hasUnread, refetchHasUnreadHeader])
|
||||
|
||||
const buildNotifications = () => {
|
||||
const notificationsToShow =
|
||||
!showingUnread || !hasUnread
|
||||
? notifications
|
||||
: R.filter(R.propEq('read', false))(notifications)
|
||||
return notificationsToShow.map(n => {
|
||||
return (
|
||||
<NotificationRow
|
||||
key={n.id}
|
||||
id={n.id}
|
||||
type={n.type}
|
||||
detail={n.detail}
|
||||
message={n.message}
|
||||
deviceName={machines[n.detail.deviceId]}
|
||||
created={n.created}
|
||||
read={n.read}
|
||||
valid={n.valid}
|
||||
toggleClear={() =>
|
||||
toggleClearNotification({
|
||||
variables: { id: n.id, read: !n.read }
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={classes.container}>
|
||||
<div className={classes.header}>
|
||||
<H5 className={classes.headerText}>Notifications</H5>
|
||||
<button onClick={close} className={classes.notificationIcon}>
|
||||
<NotificationIconZodiac />
|
||||
{hasUnread && <div className={classes.hasUnread} />}
|
||||
</button>
|
||||
</div>
|
||||
<div className={classes.actionButtons}>
|
||||
{hasUnread && (
|
||||
<ActionButton
|
||||
color="primary"
|
||||
Icon={ShowUnreadIcon}
|
||||
InverseIcon={ClearAllIconInverse}
|
||||
className={classes.clearAllButton}
|
||||
onClick={() => setShowingUnread(!showingUnread)}>
|
||||
{showingUnread ? 'Show all' : 'Show unread'}
|
||||
</ActionButton>
|
||||
)}
|
||||
{hasUnread && (
|
||||
<ActionButton
|
||||
color="primary"
|
||||
Icon={ClearAllIcon}
|
||||
InverseIcon={ClearAllIconInverse}
|
||||
className={classes.clearAllButton}
|
||||
onClick={clearAllNotifications}>
|
||||
Mark all as read
|
||||
</ActionButton>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.notificationsList}>
|
||||
{!loading && buildNotifications()}
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.background} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationCenter
|
||||
|
|
@ -0,0 +1,131 @@
|
|||
import {
|
||||
spacer,
|
||||
white,
|
||||
zircon,
|
||||
secondaryColor,
|
||||
spring3,
|
||||
comet
|
||||
} from 'src/styling/variables'
|
||||
|
||||
const styles = {
|
||||
background: {
|
||||
position: 'absolute',
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
left: 0,
|
||||
top: 0,
|
||||
zIndex: -1,
|
||||
backgroundColor: white,
|
||||
boxShadow: '0 0 14px 0 rgba(0, 0, 0, 0.24)'
|
||||
},
|
||||
container: {
|
||||
left: -200,
|
||||
top: -42,
|
||||
backgroundColor: white,
|
||||
height: '110vh'
|
||||
},
|
||||
header: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
},
|
||||
headerText: {
|
||||
marginTop: spacer * 2.5,
|
||||
marginLeft: spacer * 3
|
||||
},
|
||||
actionButtons: {
|
||||
display: 'flex',
|
||||
marginLeft: spacer * 2,
|
||||
height: 0
|
||||
},
|
||||
notificationIcon: ({ buttonCoords, xOffset }) => ({
|
||||
position: 'absolute',
|
||||
top: buttonCoords ? buttonCoords.y - 1 : 0,
|
||||
left: buttonCoords ? buttonCoords.x - xOffset : 0,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
boxShadow: '0px 0px 0px transparent',
|
||||
border: '0px solid transparent',
|
||||
textShadow: '0px 0px 0px transparent',
|
||||
outline: 'none'
|
||||
}),
|
||||
clearAllButton: {
|
||||
marginTop: -spacer * 2,
|
||||
marginLeft: spacer,
|
||||
backgroundColor: zircon
|
||||
},
|
||||
notificationsList: {
|
||||
width: 440,
|
||||
height: '90vh',
|
||||
maxHeight: '100vh',
|
||||
marginTop: spacer * 3,
|
||||
marginLeft: 0,
|
||||
marginRight: -50,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
backgroundColor: white,
|
||||
zIndex: 10
|
||||
},
|
||||
notificationRow: {
|
||||
position: 'relative',
|
||||
marginBottom: spacer / 2,
|
||||
paddingTop: spacer * 1.5
|
||||
},
|
||||
unread: {
|
||||
backgroundColor: spring3
|
||||
},
|
||||
notificationRowIcon: {
|
||||
alignSelf: 'center',
|
||||
'& > *': {
|
||||
marginLeft: spacer * 3
|
||||
}
|
||||
},
|
||||
unreadIcon: {
|
||||
marginLeft: spacer,
|
||||
marginTop: 5,
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
backgroundColor: secondaryColor,
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1
|
||||
},
|
||||
readIcon: {
|
||||
marginLeft: spacer,
|
||||
marginTop: 5,
|
||||
width: '12px',
|
||||
height: '12px',
|
||||
border: [[1, 'solid', comet]],
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
zIndex: 1
|
||||
},
|
||||
notificationTitle: {
|
||||
margin: 0,
|
||||
color: comet
|
||||
},
|
||||
notificationBody: {
|
||||
margin: 0
|
||||
},
|
||||
notificationSubtitle: {
|
||||
margin: 0,
|
||||
marginBottom: spacer,
|
||||
color: comet
|
||||
},
|
||||
stripes: {
|
||||
position: 'absolute',
|
||||
height: '100%',
|
||||
top: '0px',
|
||||
opacity: '60%'
|
||||
},
|
||||
hasUnread: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 16,
|
||||
width: '9px',
|
||||
height: '9px',
|
||||
backgroundColor: secondaryColor,
|
||||
borderRadius: '50%'
|
||||
}
|
||||
}
|
||||
|
||||
export default styles
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import Grid from '@material-ui/core/Grid'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import prettyMs from 'pretty-ms'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
|
||||
import { Label1, Label2, TL2 } from 'src/components/typography'
|
||||
import { ReactComponent as Wrench } from 'src/styling/icons/action/wrench/zodiac.svg'
|
||||
import { ReactComponent as Transaction } from 'src/styling/icons/arrow/transaction.svg'
|
||||
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
|
||||
import { ReactComponent as WarningIcon } from 'src/styling/icons/warning-icon/tomato.svg'
|
||||
|
||||
import styles from './NotificationCenter.styles'
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const types = {
|
||||
transaction: { display: 'Transactions', icon: <Transaction /> },
|
||||
highValueTransaction: { display: 'Transactions', icon: <Transaction /> },
|
||||
fiatBalance: { display: 'Maintenance', icon: <Wrench /> },
|
||||
cryptoBalance: { display: 'Maintenance', icon: <Wrench /> },
|
||||
compliance: { display: 'Compliance', icon: <WarningIcon /> },
|
||||
error: { display: 'Error', icon: <WarningIcon /> }
|
||||
}
|
||||
|
||||
const NotificationRow = ({
|
||||
id,
|
||||
type,
|
||||
detail,
|
||||
message,
|
||||
deviceName,
|
||||
created,
|
||||
read,
|
||||
valid,
|
||||
toggleClear
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const typeDisplay = R.path([type, 'display'])(types) ?? null
|
||||
const icon = R.path([type, 'icon'])(types) ?? <Wrench />
|
||||
const age = prettyMs(new Date().getTime() - new Date(created).getTime(), {
|
||||
compact: true,
|
||||
verbose: true
|
||||
})
|
||||
const notificationTitle =
|
||||
typeDisplay && deviceName
|
||||
? `${typeDisplay} - ${deviceName}`
|
||||
: !typeDisplay && deviceName
|
||||
? `${deviceName}`
|
||||
: `${typeDisplay}`
|
||||
|
||||
const iconClass = {
|
||||
[classes.readIcon]: read,
|
||||
[classes.unreadIcon]: !read
|
||||
}
|
||||
return (
|
||||
<Grid
|
||||
container
|
||||
className={classnames(
|
||||
classes.notificationRow,
|
||||
!read && valid ? classes.unread : ''
|
||||
)}>
|
||||
<Grid item xs={2} className={classes.notificationRowIcon}>
|
||||
{icon}
|
||||
</Grid>
|
||||
<Grid item container xs={7} direction="row">
|
||||
<Grid item xs={12}>
|
||||
<Label2 className={classes.notificationTitle}>
|
||||
{notificationTitle}
|
||||
</Label2>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TL2 className={classes.notificationBody}>{message}</TL2>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Label1 className={classes.notificationSubtitle}>{age}</Label1>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={3} style={{ zIndex: 1 }}>
|
||||
<div
|
||||
onClick={() => toggleClear(id)}
|
||||
className={classnames(iconClass)}
|
||||
/>
|
||||
</Grid>
|
||||
{!valid && <StripesSvg className={classes.stripes} />}
|
||||
</Grid>
|
||||
)
|
||||
}
|
||||
|
||||
export default NotificationRow
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
import NotificationCenter from './NotificationCenter'
|
||||
export default NotificationCenter
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import { useFormikContext } from 'formik'
|
||||
import React from 'react'
|
||||
import React, { useEffect } from 'react'
|
||||
import { Prompt } from 'react-router-dom'
|
||||
|
||||
const PROMPT_DEFAULT_MESSAGE =
|
||||
|
|
@ -8,9 +8,21 @@ const PROMPT_DEFAULT_MESSAGE =
|
|||
const PromptWhenDirty = ({ message = PROMPT_DEFAULT_MESSAGE }) => {
|
||||
const formik = useFormikContext()
|
||||
|
||||
return (
|
||||
<Prompt when={formik.dirty && formik.submitCount === 0} message={message} />
|
||||
)
|
||||
const hasChanges = formik.dirty && formik.submitCount === 0
|
||||
|
||||
useEffect(() => {
|
||||
if (hasChanges) {
|
||||
window.onbeforeunload = confirmExit
|
||||
} else {
|
||||
window.onbeforeunload = undefined
|
||||
}
|
||||
}, [hasChanges])
|
||||
|
||||
const confirmExit = () => {
|
||||
return PROMPT_DEFAULT_MESSAGE
|
||||
}
|
||||
|
||||
return <Prompt when={hasChanges} message={message} />
|
||||
}
|
||||
|
||||
export default PromptWhenDirty
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ const useStyles = makeStyles({
|
|||
})
|
||||
})
|
||||
|
||||
const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
|
||||
const usePopperHandler = width => {
|
||||
const classes = useStyles({ width })
|
||||
const [helpPopperAnchorEl, setHelpPopperAnchorEl] = useState(null)
|
||||
|
||||
|
|
@ -32,23 +32,56 @@ const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
|
|||
|
||||
const helpPopperOpen = Boolean(helpPopperAnchorEl)
|
||||
|
||||
return {
|
||||
classes,
|
||||
helpPopperAnchorEl,
|
||||
helpPopperOpen,
|
||||
handleOpenHelpPopper,
|
||||
handleCloseHelpPopper
|
||||
}
|
||||
}
|
||||
|
||||
const Tooltip = memo(({ children, width, Icon = HelpIcon }) => {
|
||||
const handler = usePopperHandler(width)
|
||||
|
||||
return (
|
||||
<ClickAwayListener onClickAway={handleCloseHelpPopper}>
|
||||
<ClickAwayListener onClickAway={handler.handleCloseHelpPopper}>
|
||||
<div>
|
||||
<button
|
||||
className={classes.transparentButton}
|
||||
onClick={handleOpenHelpPopper}>
|
||||
type="button"
|
||||
className={handler.classes.transparentButton}
|
||||
onClick={handler.handleOpenHelpPopper}>
|
||||
<Icon />
|
||||
</button>
|
||||
<Popper
|
||||
open={helpPopperOpen}
|
||||
anchorEl={helpPopperAnchorEl}
|
||||
open={handler.helpPopperOpen}
|
||||
anchorEl={handler.helpPopperAnchorEl}
|
||||
placement="bottom">
|
||||
<div className={classes.popoverContent}>{children}</div>
|
||||
<div className={handler.classes.popoverContent}>{children}</div>
|
||||
</Popper>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
)
|
||||
})
|
||||
|
||||
export default Tooltip
|
||||
const HoverableTooltip = memo(({ parentElements, children, width }) => {
|
||||
const handler = usePopperHandler(width)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
onMouseEnter={handler.handleOpenHelpPopper}
|
||||
onMouseLeave={handler.handleCloseHelpPopper}>
|
||||
{parentElements}
|
||||
</div>
|
||||
<Popper
|
||||
open={handler.helpPopperOpen}
|
||||
anchorEl={handler.helpPopperAnchorEl}
|
||||
placement="bottom">
|
||||
<div className={handler.classes.popoverContent}>{children}</div>
|
||||
</Popper>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export { Tooltip, HoverableTooltip }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import { useFormikContext, Form, Formik, Field as FormikField } from 'formik'
|
||||
import _ from 'lodash'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState, memo } from 'react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
|
|
@ -26,8 +26,11 @@ const BooleanCell = ({ name }) => {
|
|||
|
||||
const BooleanPropertiesTable = memo(
|
||||
({ title, disabled, data, elements, save, forcedEditing = false }) => {
|
||||
const initialValues = _.fromPairs(elements.map(it => [it.name, '']))
|
||||
const schemaValidation = _.fromPairs(
|
||||
const initialValues = R.fromPairs(
|
||||
elements.map(it => [it.name, data[it.name] ?? null])
|
||||
)
|
||||
|
||||
const schemaValidation = R.fromPairs(
|
||||
elements.map(it => [it.name, Yup.boolean().required()])
|
||||
)
|
||||
|
||||
|
|
@ -36,74 +39,84 @@ const BooleanPropertiesTable = memo(
|
|||
const classes = useStyles()
|
||||
|
||||
const innerSave = async value => {
|
||||
save(value)
|
||||
save(R.filter(R.complement(R.isNil), value))
|
||||
setEditing(false)
|
||||
}
|
||||
|
||||
const innerCancel = () => setEditing(false)
|
||||
|
||||
const radioButtonOptions = [
|
||||
{ display: 'Yes', code: 'true' },
|
||||
{ display: 'No', code: 'false' }
|
||||
]
|
||||
|
||||
return (
|
||||
<div className={classes.booleanPropertiesTableWrapper}>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
enableReinitialize
|
||||
onSubmit={innerSave}
|
||||
initialValues={data || initialValues}
|
||||
initialValues={initialValues}
|
||||
schemaValidation={schemaValidation}>
|
||||
<Form>
|
||||
<div className={classes.rowWrapper}>
|
||||
<H4>{title}</H4>
|
||||
{editing ? (
|
||||
<div className={classes.rightAligned}>
|
||||
<Link type="submit" color="primary">
|
||||
Save
|
||||
</Link>
|
||||
<Link
|
||||
className={classes.rightLink}
|
||||
onClick={innerCancel}
|
||||
color="secondary">
|
||||
Cancel
|
||||
</Link>
|
||||
{({ resetForm }) => {
|
||||
return (
|
||||
<Form>
|
||||
<div className={classes.rowWrapper}>
|
||||
<H4>{title}</H4>
|
||||
{editing ? (
|
||||
<div className={classes.rightAligned}>
|
||||
<Link type="submit" color="primary">
|
||||
Save
|
||||
</Link>
|
||||
<Link
|
||||
type="reset"
|
||||
className={classes.rightLink}
|
||||
onClick={() => {
|
||||
resetForm()
|
||||
setEditing(false)
|
||||
}}
|
||||
color="secondary">
|
||||
Cancel
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<IconButton
|
||||
className={classes.transparentButton}
|
||||
onClick={() => setEditing(true)}>
|
||||
{disabled ? <EditIconDisabled /> : <EditIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<IconButton
|
||||
className={classes.transparentButton}
|
||||
onClick={() => setEditing(true)}>
|
||||
{disabled ? <EditIconDisabled /> : <EditIcon />}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
<PromptWhenDirty />
|
||||
<Table className={classes.fillColumn}>
|
||||
<TableBody className={classes.fillColumn}>
|
||||
{elements.map((it, idx) => (
|
||||
<TableRow key={idx} size="sm" className={classes.tableRow}>
|
||||
<TableCell className={classes.leftTableCell}>
|
||||
{it.display}
|
||||
</TableCell>
|
||||
<TableCell className={classes.rightTableCell}>
|
||||
{editing && (
|
||||
<FormikField
|
||||
component={RadioGroup}
|
||||
name={it.name}
|
||||
options={radioButtonOptions}
|
||||
className={classnames(
|
||||
classes.radioButtons,
|
||||
classes.rightTableCell
|
||||
<PromptWhenDirty />
|
||||
<Table className={classes.fillColumn}>
|
||||
<TableBody className={classes.fillColumn}>
|
||||
{elements.map((it, idx) => (
|
||||
<TableRow
|
||||
key={idx}
|
||||
size="sm"
|
||||
className={classes.tableRow}>
|
||||
<TableCell className={classes.leftTableCell}>
|
||||
{it.display}
|
||||
</TableCell>
|
||||
<TableCell className={classes.rightTableCell}>
|
||||
{editing && (
|
||||
<FormikField
|
||||
component={RadioGroup}
|
||||
name={it.name}
|
||||
options={radioButtonOptions}
|
||||
className={classnames(
|
||||
classes.radioButtons,
|
||||
classes.rightTableCell
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{!editing && <BooleanCell name={it.name} />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Form>
|
||||
{!editing && <BooleanCell name={it.name} />}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Form>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { ClickAwayListener } from '@material-ui/core'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import React, { useState, memo } from 'react'
|
||||
|
|
@ -101,22 +102,24 @@ const IDButton = memo(
|
|||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
aria-describedby={id}
|
||||
onClick={handleClick}
|
||||
className={classnames(classNames, className)}
|
||||
{...props}>
|
||||
{Icon && !open && (
|
||||
<div className={classnames(iconClassNames)}>
|
||||
<Icon />
|
||||
</div>
|
||||
)}
|
||||
{InverseIcon && open && (
|
||||
<div className={classnames(iconClassNames)}>
|
||||
<InverseIcon />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
<ClickAwayListener onClickAway={handleClose}>
|
||||
<button
|
||||
aria-describedby={id}
|
||||
onClick={handleClick}
|
||||
className={classnames(classNames, className)}
|
||||
{...props}>
|
||||
{Icon && !open && (
|
||||
<div className={classnames(iconClassNames)}>
|
||||
<Icon />
|
||||
</div>
|
||||
)}
|
||||
{InverseIcon && open && (
|
||||
<div className={classnames(iconClassNames)}>
|
||||
<InverseIcon />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</ClickAwayListener>
|
||||
<Popover
|
||||
className={popoverClassname}
|
||||
id={id}
|
||||
|
|
|
|||
|
|
@ -25,17 +25,16 @@ export default {
|
|||
}
|
||||
},
|
||||
buttonIcon: {
|
||||
'& svg': {
|
||||
width: 16,
|
||||
height: 16,
|
||||
overflow: 'visible',
|
||||
'& g': {
|
||||
strokeWidth: 1.8
|
||||
}
|
||||
width: 16,
|
||||
height: 16,
|
||||
overflow: 'visible',
|
||||
'& g': {
|
||||
strokeWidth: 1.8
|
||||
}
|
||||
},
|
||||
buttonIconActiveLeft: {
|
||||
marginRight: 12
|
||||
marginRight: 12,
|
||||
marginLeft: 4
|
||||
},
|
||||
buttonIconActiveRight: {
|
||||
marginRight: 5,
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ import { makeStyles } from '@material-ui/core'
|
|||
import classnames from 'classnames'
|
||||
import { Field, useFormikContext } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React, { useContext } from 'react'
|
||||
import React, { useContext, useState } from 'react'
|
||||
|
||||
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||
import { Link, IconButton } from 'src/components/buttons'
|
||||
import { Td, Tr } from 'src/components/fake-table/Table'
|
||||
import { Switch } from 'src/components/inputs'
|
||||
|
|
@ -35,7 +36,8 @@ const ActionCol = ({ disabled, editing }) => {
|
|||
toggleWidth,
|
||||
forceAdd,
|
||||
clearError,
|
||||
actionColSize
|
||||
actionColSize,
|
||||
error
|
||||
} = useContext(TableCtx)
|
||||
|
||||
const disableEdit = disabled || (disableRowEdit && disableRowEdit(values))
|
||||
|
|
@ -44,6 +46,14 @@ const ActionCol = ({ disabled, editing }) => {
|
|||
resetForm()
|
||||
}
|
||||
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
|
||||
const onConfirmed = () => {
|
||||
onDelete(values.id).then(res => {
|
||||
if (!R.isNil(res)) setDeleteDialog(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{editing && (
|
||||
|
|
@ -74,9 +84,23 @@ const ActionCol = ({ disabled, editing }) => {
|
|||
)}
|
||||
{!editing && enableDelete && (
|
||||
<Td textAlign="center" width={deleteWidth}>
|
||||
<IconButton disabled={disabled} onClick={() => onDelete(values.id)}>
|
||||
<IconButton
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
setDeleteDialog(true)
|
||||
}}>
|
||||
{disabled ? <DisabledDeleteIcon /> : <DeleteIcon />}
|
||||
</IconButton>
|
||||
<DeleteDialog
|
||||
open={deleteDialog}
|
||||
setDeleteDialog={setDeleteDialog}
|
||||
onConfirmed={onConfirmed}
|
||||
onDismissed={() => {
|
||||
setDeleteDialog(false)
|
||||
clearError()
|
||||
}}
|
||||
errorMessage={error}
|
||||
/>
|
||||
</Td>
|
||||
)}
|
||||
{!editing && enableToggle && (
|
||||
|
|
@ -103,32 +127,33 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
|
|||
bold,
|
||||
width,
|
||||
textAlign,
|
||||
editingAlign = textAlign,
|
||||
suffix,
|
||||
SuffixComponent = TL2,
|
||||
textStyle = it => {},
|
||||
view = it => it?.toString(),
|
||||
inputProps = {}
|
||||
} = config
|
||||
|
||||
const { values } = useFormikContext()
|
||||
const classes = useStyles({ textAlign, size })
|
||||
|
||||
const isEditing = editing && editable
|
||||
const isField = !bypassField
|
||||
|
||||
const classes = useStyles({
|
||||
textAlign: isEditing ? editingAlign : textAlign,
|
||||
size
|
||||
})
|
||||
|
||||
const innerProps = {
|
||||
fullWidth: true,
|
||||
autoFocus: focus,
|
||||
size,
|
||||
bold,
|
||||
textAlign,
|
||||
textAlign: isEditing ? editingAlign : textAlign,
|
||||
...inputProps
|
||||
}
|
||||
|
||||
// Autocomplete
|
||||
if (innerProps.options && !innerProps.getLabel) {
|
||||
innerProps.getLabel = view
|
||||
}
|
||||
|
||||
const isEditing = editing && editable
|
||||
const isField = !bypassField
|
||||
|
||||
return (
|
||||
<Td
|
||||
className={{
|
||||
|
|
@ -144,16 +169,24 @@ const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
|
|||
<Field name={name} component={input} {...innerProps} />
|
||||
)}
|
||||
{isEditing && !isField && <config.input name={name} />}
|
||||
{!isEditing && values && <>{view(values[name], values)}</>}
|
||||
{!isEditing && values && (
|
||||
<div style={textStyle(values, isEditing)}>
|
||||
{view(values[name], values)}
|
||||
</div>
|
||||
)}
|
||||
{suffix && (
|
||||
<SuffixComponent className={classes.suffix}>{suffix}</SuffixComponent>
|
||||
<SuffixComponent
|
||||
className={classes.suffix}
|
||||
style={isEditing ? {} : textStyle(values, isEditing)}>
|
||||
{suffix}
|
||||
</SuffixComponent>
|
||||
)}
|
||||
</Td>
|
||||
)
|
||||
}
|
||||
|
||||
const groupStriped = elements => {
|
||||
const [toStripe, noStripe] = R.partition(R.has('stripe'))(elements)
|
||||
const [toStripe, noStripe] = R.partition(R.propEq('stripe', true))(elements)
|
||||
|
||||
if (!toStripe.length) {
|
||||
return elements
|
||||
|
|
@ -183,7 +216,7 @@ const ERow = ({ editing, disabled, lastOfGroup }) => {
|
|||
|
||||
const classes = useStyles()
|
||||
|
||||
const shouldStripe = stripeWhen && stripeWhen(values) && !editing
|
||||
const shouldStripe = stripeWhen && stripeWhen(values)
|
||||
|
||||
const innerElements = shouldStripe ? groupStriped(elements) : elements
|
||||
const [toSHeader] = R.partition(R.has('doubleHeader'))(elements)
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ const ETable = ({
|
|||
groupBy,
|
||||
sortBy,
|
||||
createText = 'Add override',
|
||||
forceAdd = false
|
||||
forceAdd = false,
|
||||
tbodyWrapperClass
|
||||
}) => {
|
||||
const [editingId, setEditingId] = useState(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
|
|
@ -180,53 +181,60 @@ const ETable = ({
|
|||
)}
|
||||
<Table>
|
||||
<Header />
|
||||
<TBody>
|
||||
{adding && (
|
||||
<Formik
|
||||
initialValues={{ id: v4(), ...initialValues }}
|
||||
onReset={onReset}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={innerSave}>
|
||||
<Form>
|
||||
<PromptWhenDirty />
|
||||
<ERow editing={true} disabled={forceDisable} />
|
||||
</Form>
|
||||
</Formik>
|
||||
)}
|
||||
{innerData.map((it, idx) => {
|
||||
const nextElement = innerData[idx + 1]
|
||||
|
||||
const canGroup = !!groupBy && nextElement
|
||||
const isFunction = R.type(groupBy) === 'Function'
|
||||
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
|
||||
|
||||
const isLastOfGroup =
|
||||
canGroup && groupFunction(it) !== groupFunction(nextElement)
|
||||
|
||||
return (
|
||||
<div className={tbodyWrapperClass}>
|
||||
<TBody>
|
||||
{adding && (
|
||||
<Formik
|
||||
key={it.id ?? idx}
|
||||
enableReinitialize
|
||||
initialValues={it}
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={{ id: v4(), ...initialValues }}
|
||||
onReset={onReset}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={innerSave}>
|
||||
<Form>
|
||||
<PromptWhenDirty />
|
||||
<ERow
|
||||
lastOfGroup={isLastOfGroup}
|
||||
editing={editingId === it.id}
|
||||
disabled={
|
||||
forceDisable ||
|
||||
(editingId && editingId !== it.id) ||
|
||||
adding
|
||||
}
|
||||
/>
|
||||
<ERow editing={true} disabled={forceDisable} />
|
||||
</Form>
|
||||
</Formik>
|
||||
)
|
||||
})}
|
||||
</TBody>
|
||||
)}
|
||||
{innerData.map((it, idx) => {
|
||||
const nextElement = innerData[idx + 1]
|
||||
|
||||
const canGroup = !!groupBy && nextElement
|
||||
const isFunction = R.type(groupBy) === 'Function'
|
||||
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
|
||||
|
||||
const isLastOfGroup =
|
||||
canGroup &&
|
||||
groupFunction(it) !== groupFunction(nextElement)
|
||||
|
||||
return (
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
key={it.id ?? idx}
|
||||
enableReinitialize
|
||||
initialValues={it}
|
||||
onReset={onReset}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={innerSave}>
|
||||
<Form>
|
||||
<PromptWhenDirty />
|
||||
<ERow
|
||||
lastOfGroup={isLastOfGroup}
|
||||
editing={editingId === it.id}
|
||||
disabled={
|
||||
forceDisable ||
|
||||
(editingId && editingId !== it.id) ||
|
||||
adding
|
||||
}
|
||||
/>
|
||||
</Form>
|
||||
</Formik>
|
||||
)
|
||||
})}
|
||||
</TBody>
|
||||
</div>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ const Td = ({
|
|||
[classes.size]: !header,
|
||||
[classes.bold]: !header && bold
|
||||
}
|
||||
|
||||
return <div className={classnames(className, classNames)}>{children}</div>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -72,7 +72,11 @@ export default {
|
|||
backgroundColor: tableErrorColor
|
||||
},
|
||||
mainContent: ({ size }) => {
|
||||
const minHeight = size === 'lg' ? 68 : 48
|
||||
const sizes = {
|
||||
sm: 34,
|
||||
lg: 68
|
||||
}
|
||||
const minHeight = sizes[size] || 48
|
||||
return {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ const Autocomplete = ({
|
|||
valueProp,
|
||||
multiple,
|
||||
onChange,
|
||||
getLabel,
|
||||
labelProp,
|
||||
value: outsideValue,
|
||||
error,
|
||||
fullWidth,
|
||||
|
|
@ -49,8 +49,10 @@ const Autocomplete = ({
|
|||
return multiple ? value : [value]
|
||||
}
|
||||
|
||||
const filter = (array, input) =>
|
||||
sort(array, input, { keys: ['code', 'display'] })
|
||||
const filter = (array, input) => {
|
||||
if (!input) return array
|
||||
return sort(array, input, { keys: [valueProp, labelProp] })
|
||||
}
|
||||
|
||||
const filterOptions = (array, { inputValue }) =>
|
||||
R.union(
|
||||
|
|
@ -68,7 +70,7 @@ const Autocomplete = ({
|
|||
multiple={multiple}
|
||||
value={value}
|
||||
onChange={innerOnChange}
|
||||
getOptionLabel={getLabel}
|
||||
getOptionLabel={R.path([labelProp])}
|
||||
forcePopupIcon={false}
|
||||
filterOptions={filterOptions}
|
||||
openOnFocus
|
||||
|
|
|
|||
|
|
@ -2,35 +2,34 @@ import React, { memo, useState } from 'react'
|
|||
|
||||
import { TextInput } from '../base'
|
||||
|
||||
const SecretInput = memo(({ value, onFocus, onBlur, ...props }) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const SecretInput = memo(
|
||||
({ value, onFocus, isPasswordFilled, onBlur, ...props }) => {
|
||||
const [focused, setFocused] = useState(false)
|
||||
const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬'
|
||||
const innerOnFocus = event => {
|
||||
setFocused(true)
|
||||
onFocus && onFocus(event)
|
||||
}
|
||||
|
||||
const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬'
|
||||
const previouslyFilled = !!value
|
||||
const tempValue = previouslyFilled ? '' : value
|
||||
const innerOnBlur = event => {
|
||||
setFocused(false)
|
||||
onBlur && onBlur(event)
|
||||
}
|
||||
|
||||
const innerOnFocus = event => {
|
||||
setFocused(true)
|
||||
onFocus && onFocus(event)
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
type="password"
|
||||
onFocus={innerOnFocus}
|
||||
onBlur={innerOnBlur}
|
||||
isPasswordFilled={isPasswordFilled}
|
||||
value={value}
|
||||
InputProps={{ value: value }}
|
||||
InputLabelProps={{ shrink: isPasswordFilled || value || focused }}
|
||||
placeholder={isPasswordFilled ? placeholder : ''}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const innerOnBlur = event => {
|
||||
setFocused(false)
|
||||
onBlur && onBlur(event)
|
||||
}
|
||||
|
||||
return (
|
||||
<TextInput
|
||||
{...props}
|
||||
type="password"
|
||||
onFocus={innerOnFocus}
|
||||
onBlur={innerOnBlur}
|
||||
value={value}
|
||||
InputProps={{ value: !focused ? tempValue : value }}
|
||||
InputLabelProps={{ shrink: previouslyFilled || focused }}
|
||||
placeholder={previouslyFilled ? placeholder : ''}
|
||||
/>
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
export default SecretInput
|
||||
|
|
|
|||
|
|
@ -4,13 +4,12 @@ import { useSelect } from 'downshift'
|
|||
import React from 'react'
|
||||
|
||||
import { ReactComponent as Arrowdown } from 'src/styling/icons/action/arrow/regular.svg'
|
||||
import { startCase } from 'src/utils/string'
|
||||
|
||||
import styles from './Select.styles'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
function Select({ label, items, ...props }) {
|
||||
function Select({ className, label, items, ...props }) {
|
||||
const classes = useStyles()
|
||||
|
||||
const {
|
||||
|
|
@ -35,17 +34,17 @@ function Select({ label, items, ...props }) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={classnames(selectClassNames)}>
|
||||
<label {...getLabelProps()}>{startCase(label)}</label>
|
||||
<div className={classnames(selectClassNames, className)}>
|
||||
<label {...getLabelProps()}>{label}</label>
|
||||
<button {...getToggleButtonProps()}>
|
||||
<span className={classes.selectedItem}>{startCase(selectedItem)}</span>
|
||||
<span className={classes.selectedItem}>{selectedItem.display}</span>
|
||||
<Arrowdown />
|
||||
</button>
|
||||
<ul {...getMenuProps()}>
|
||||
{isOpen &&
|
||||
items.map((item, index) => (
|
||||
<li key={`${item}${index}`} {...getItemProps({ item, index })}>
|
||||
<span>{startCase(item)}</span>
|
||||
items.map(({ code, display }, index) => (
|
||||
<li key={`${code}${index}`} {...getItemProps({ code, index })}>
|
||||
<span>{display}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const useStyles = makeStyles(styles)
|
|||
const TextInput = memo(
|
||||
({
|
||||
name,
|
||||
isPasswordFilled,
|
||||
onChange,
|
||||
onBlur,
|
||||
value,
|
||||
|
|
@ -26,8 +27,8 @@ const TextInput = memo(
|
|||
...props
|
||||
}) => {
|
||||
const classes = useStyles({ textAlign, width, size })
|
||||
const filled = !error && !R.isNil(value) && !R.isEmpty(value)
|
||||
|
||||
const isTextFilled = !error && !R.isNil(value) && !R.isEmpty(value)
|
||||
const filled = isPasswordFilled || isTextFilled
|
||||
const inputClasses = {
|
||||
[classes.bold]: bold
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,17 +13,27 @@ import { cashboxStyles, gridStyles } from './Cashbox.styles'
|
|||
const cashboxClasses = makeStyles(cashboxStyles)
|
||||
const gridClasses = makeStyles(gridStyles)
|
||||
|
||||
const Cashbox = ({ percent = 0, cashOut = false, className }) => {
|
||||
const Cashbox = ({
|
||||
percent = 0,
|
||||
cashOut = false,
|
||||
className,
|
||||
emptyPartClassName,
|
||||
labelClassName
|
||||
}) => {
|
||||
const classes = cashboxClasses({ percent, cashOut })
|
||||
const threshold = 51
|
||||
|
||||
return (
|
||||
<div className={classnames(className, classes.cashbox)}>
|
||||
<div className={classes.emptyPart}>
|
||||
{percent <= threshold && <Label2>{percent.toFixed(0)}%</Label2>}
|
||||
<div className={classnames(emptyPartClassName, classes.emptyPart)}>
|
||||
{percent <= threshold && (
|
||||
<Label2 className={labelClassName}>{percent.toFixed(0)}%</Label2>
|
||||
)}
|
||||
</div>
|
||||
<div className={classes.fullPart}>
|
||||
{percent > threshold && <Label2>{percent.toFixed(0)}%</Label2>}
|
||||
{percent > threshold && (
|
||||
<Label2 className={labelClassName}>{percent.toFixed(0)}%</Label2>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -31,19 +41,19 @@ const Cashbox = ({ percent = 0, cashOut = false, className }) => {
|
|||
|
||||
// https://support.lamassu.is/hc/en-us/articles/360025595552-Installing-the-Sintra-Forte
|
||||
// Sintra and Sintra Forte can have up to 500 notes per cashOut box and up to 1000 per cashIn box
|
||||
const CashIn = ({ capacity = 1000, notes = 0, total = 0 }) => {
|
||||
const percent = (100 * notes) / capacity
|
||||
const CashIn = ({ currency, notes, total }) => {
|
||||
const classes = gridClasses()
|
||||
return (
|
||||
<>
|
||||
<div className={classes.row}>
|
||||
<div>
|
||||
<Cashbox percent={percent} />
|
||||
</div>
|
||||
<div className={classes.col2}>
|
||||
<div>
|
||||
<div className={classes.innerRow}>
|
||||
<Info2 className={classes.noMarginText}>{notes} notes</Info2>
|
||||
<Label1 className={classes.noMarginText}>{total}</Label1>
|
||||
</div>
|
||||
<div className={classes.innerRow}>
|
||||
<Label1 className={classes.noMarginText}>
|
||||
{total} {currency.code}
|
||||
</Label1>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -94,7 +104,8 @@ const CashOut = ({
|
|||
denomination = 0,
|
||||
currency,
|
||||
notes,
|
||||
className
|
||||
className,
|
||||
editingMode = false
|
||||
}) => {
|
||||
const percent = (100 * notes) / capacity
|
||||
const classes = gridClasses()
|
||||
|
|
@ -104,20 +115,22 @@ const CashOut = ({
|
|||
<div className={classes.col}>
|
||||
<Cashbox className={className} percent={percent} cashOut />
|
||||
</div>
|
||||
<div className={classes.col2}>
|
||||
<div className={classes.innerRow}>
|
||||
<Info2 className={classes.noMarginText}>{notes}</Info2>
|
||||
<Chip
|
||||
className={classes.chip}
|
||||
label={`${denomination} ${currency.code}`}
|
||||
/>
|
||||
{!editingMode && (
|
||||
<div className={classes.col2}>
|
||||
<div className={classes.innerRow}>
|
||||
<Info2 className={classes.noMarginText}>{notes}</Info2>
|
||||
<Chip
|
||||
className={classes.chip}
|
||||
label={`${denomination} ${currency.code}`}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.innerRow}>
|
||||
<Label1 className={classes.noMarginText}>
|
||||
{notes * denomination} {currency.code}
|
||||
</Label1>
|
||||
</div>
|
||||
</div>
|
||||
<div className={classes.innerRow}>
|
||||
<Label1 className={classes.noMarginText}>
|
||||
{notes * denomination} {currency.code}
|
||||
</Label1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -48,8 +48,7 @@ const cashboxStyles = {
|
|||
|
||||
const gridStyles = {
|
||||
row: {
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between'
|
||||
display: 'flex'
|
||||
},
|
||||
innerRow: {
|
||||
display: 'flex',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import { makeStyles } from '@material-ui/core'
|
||||
import React, { memo, useState } from 'react'
|
||||
|
||||
import { CashOut } from 'src/components/inputs/cashbox/Cashbox'
|
||||
|
||||
import { NumberInput } from '../base'
|
||||
const useStyles = makeStyles({
|
||||
flex: {
|
||||
display: 'flex'
|
||||
},
|
||||
cashCassette: {
|
||||
width: 80,
|
||||
height: 36,
|
||||
marginRight: 16
|
||||
}
|
||||
})
|
||||
|
||||
const CashCassetteInput = memo(({ decimalPlaces, ...props }) => {
|
||||
const classes = useStyles()
|
||||
const { name, onChange, onBlur, value } = props.field
|
||||
const { touched, errors } = props.form
|
||||
const [notes, setNotes] = useState(value)
|
||||
const error = !!(touched[name] && errors[name])
|
||||
return (
|
||||
<div className={classes.flex}>
|
||||
<CashOut
|
||||
className={classes.cashCassette}
|
||||
notes={notes}
|
||||
editingMode={true}
|
||||
/>
|
||||
<NumberInput
|
||||
name={name}
|
||||
onChange={e => {
|
||||
setNotes(e.target.value)
|
||||
return onChange(e)
|
||||
}}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
error={error}
|
||||
decimalPlaces={decimalPlaces}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default CashCassetteInput
|
||||
|
|
@ -2,15 +2,16 @@ import React, { memo } from 'react'
|
|||
|
||||
import { SecretInput } from '../base'
|
||||
|
||||
const SecretInputFormik = memo(({ ...props }) => {
|
||||
const SecretInputFormik = memo(({ isPasswordFilled, ...props }) => {
|
||||
const { name, onChange, onBlur, value } = props.field
|
||||
const { touched, errors } = props.form
|
||||
|
||||
const error = !!(touched[name] && errors[name])
|
||||
const error = !isPasswordFilled && !!(touched[name] && errors[name])
|
||||
|
||||
return (
|
||||
<SecretInput
|
||||
name={name}
|
||||
isPasswordFilled={isPasswordFilled}
|
||||
onChange={onChange}
|
||||
onBlur={onBlur}
|
||||
value={value}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import Autocomplete from './Autocomplete'
|
||||
import CashCassetteInput from './CashCassetteInput'
|
||||
import Checkbox from './Checkbox'
|
||||
import NumberInput from './NumberInput'
|
||||
import RadioGroup from './RadioGroup'
|
||||
|
|
@ -11,5 +12,6 @@ export {
|
|||
TextInput,
|
||||
NumberInput,
|
||||
SecretInput,
|
||||
RadioGroup
|
||||
RadioGroup,
|
||||
CashCassetteInput
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,32 @@
|
|||
import { useQuery } from '@apollo/react-hooks'
|
||||
import ClickAwayListener from '@material-ui/core/ClickAwayListener'
|
||||
import Popper from '@material-ui/core/Popper'
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import classnames from 'classnames'
|
||||
import React, { memo, useState } from 'react'
|
||||
import gql from 'graphql-tag'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState, useEffect, useRef } from 'react'
|
||||
import { NavLink, useHistory } from 'react-router-dom'
|
||||
|
||||
import NotificationCenter from 'src/components/NotificationCenter'
|
||||
import ActionButton from 'src/components/buttons/ActionButton'
|
||||
import { H4 } from 'src/components/typography'
|
||||
import AddMachine from 'src/pages/AddMachine'
|
||||
import { ReactComponent as AddIconReverse } from 'src/styling/icons/button/add/white.svg'
|
||||
import { ReactComponent as AddIcon } from 'src/styling/icons/button/add/zodiac.svg'
|
||||
import { ReactComponent as Logo } from 'src/styling/icons/menu/logo.svg'
|
||||
import { ReactComponent as NotificationIcon } from 'src/styling/icons/menu/notification.svg'
|
||||
|
||||
import styles from './Header.styles'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const HAS_UNREAD = gql`
|
||||
query getUnread {
|
||||
hasUnreadNotifications
|
||||
}
|
||||
`
|
||||
|
||||
const Subheader = ({ item, classes }) => {
|
||||
const [prev, setPrev] = useState(null)
|
||||
|
||||
|
|
@ -44,23 +57,63 @@ const Subheader = ({ item, classes }) => {
|
|||
)
|
||||
}
|
||||
|
||||
const notNil = R.compose(R.not, R.isNil)
|
||||
|
||||
const Header = memo(({ tree }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [anchorEl, setAnchorEl] = useState(null)
|
||||
const [notifButtonCoords, setNotifButtonCoords] = useState({ x: 0, y: 0 })
|
||||
const [active, setActive] = useState()
|
||||
const [hasUnread, setHasUnread] = useState(false)
|
||||
|
||||
const { data, refetch } = useQuery(HAS_UNREAD, { pollInterval: 60000 })
|
||||
const notifCenterButtonRef = useRef()
|
||||
const popperRef = useRef()
|
||||
const history = useHistory()
|
||||
const classes = useStyles()
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.hasUnreadNotifications) return setHasUnread(true)
|
||||
// if not true, make sure it's false and not undefined
|
||||
if (notNil(data?.hasUnreadNotifications)) return setHasUnread(false)
|
||||
}, [data])
|
||||
|
||||
const onPaired = machine => {
|
||||
setOpen(false)
|
||||
history.push('/maintenance/machine-status', { id: machine.deviceId })
|
||||
}
|
||||
|
||||
// these inline styles prevent scroll bubbling: when the user reaches the bottom of the notifications list and keeps scrolling,
|
||||
// the body scrolls, stealing the focus from the notification center, preventing the admin from scrolling the notifications back up
|
||||
// on the first scroll, needing to move the mouse to recapture the focus on the notification center
|
||||
// it also disables the scrollbars caused by the notification center's background to the right of the page, but keeps the scrolling on the body enabled
|
||||
const onClickAway = () => {
|
||||
setAnchorEl(null)
|
||||
document.querySelector('#root').classList.remove('root-notifcenter-open')
|
||||
document.querySelector('body').classList.remove('body-notifcenter-open')
|
||||
}
|
||||
|
||||
const handleClick = event => {
|
||||
const coords = notifCenterButtonRef.current.getBoundingClientRect()
|
||||
setNotifButtonCoords({ x: coords.x, y: coords.y })
|
||||
|
||||
setAnchorEl(anchorEl ? null : event.currentTarget)
|
||||
document.querySelector('#root').classList.add('root-notifcenter-open')
|
||||
document.querySelector('body').classList.add('body-notifcenter-open')
|
||||
}
|
||||
|
||||
const popperOpen = Boolean(anchorEl)
|
||||
const id = popperOpen ? 'notifications-popper' : undefined
|
||||
return (
|
||||
<header>
|
||||
<header className={classes.headerContainer}>
|
||||
<div className={classes.header}>
|
||||
<div className={classes.content}>
|
||||
<div className={classes.logo}>
|
||||
<div
|
||||
onClick={() => {
|
||||
setActive(false)
|
||||
history.push('/dashboard')
|
||||
}}
|
||||
className={classnames(classes.logo, classes.logoLink)}>
|
||||
<Logo />
|
||||
<H4 className={classes.white}>Lamassu Admin</H4>
|
||||
</div>
|
||||
|
|
@ -85,6 +138,8 @@ const Header = memo(({ tree }) => {
|
|||
</NavLink>
|
||||
))}
|
||||
</ul>
|
||||
</nav>
|
||||
<div className={classes.actionButtonsContainer}>
|
||||
<ActionButton
|
||||
color="secondary"
|
||||
Icon={AddIcon}
|
||||
|
|
@ -92,7 +147,38 @@ const Header = memo(({ tree }) => {
|
|||
onClick={() => setOpen(true)}>
|
||||
Add machine
|
||||
</ActionButton>
|
||||
</nav>
|
||||
<ClickAwayListener onClickAway={onClickAway}>
|
||||
<div ref={notifCenterButtonRef}>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
className={classes.notificationIcon}>
|
||||
<NotificationIcon />
|
||||
{hasUnread && <div className={classes.hasUnread} />}
|
||||
</button>
|
||||
<Popper
|
||||
ref={popperRef}
|
||||
id={id}
|
||||
open={popperOpen}
|
||||
anchorEl={anchorEl}
|
||||
className={classes.popper}
|
||||
disablePortal={false}
|
||||
modifiers={{
|
||||
preventOverflow: {
|
||||
enabled: true,
|
||||
boundariesElement: 'viewport'
|
||||
}
|
||||
}}>
|
||||
<NotificationCenter
|
||||
popperRef={popperRef}
|
||||
buttonCoords={notifButtonCoords}
|
||||
close={onClickAway}
|
||||
hasUnreadProp={hasUnread}
|
||||
refetchHasUnreadHeader={refetch}
|
||||
/>
|
||||
</Popper>
|
||||
</div>
|
||||
</ClickAwayListener>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{active && active.children && (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
spacer,
|
||||
white,
|
||||
primaryColor,
|
||||
secondaryColor,
|
||||
placeholderColor,
|
||||
subheaderColor,
|
||||
fontColor
|
||||
|
|
@ -20,7 +21,10 @@ if (version === 8) {
|
|||
subheaderHeight = spacer * 7
|
||||
}
|
||||
|
||||
export default {
|
||||
const styles = {
|
||||
headerContainer: {
|
||||
position: 'relative'
|
||||
},
|
||||
header: {
|
||||
backgroundColor: primaryColor,
|
||||
color: white,
|
||||
|
|
@ -80,27 +84,6 @@ export default {
|
|||
border: 'none',
|
||||
color: white,
|
||||
backgroundColor: 'transparent'
|
||||
// '&:hover': {
|
||||
// color: white
|
||||
// },
|
||||
// '&:hover::after': {
|
||||
// width: '50%',
|
||||
// marginLeft: '-25%'
|
||||
// },
|
||||
// position: 'relative',
|
||||
// '&:after': {
|
||||
// content: '""',
|
||||
// display: 'block',
|
||||
// background: white,
|
||||
// width: 0,
|
||||
// height: 4,
|
||||
// left: '50%',
|
||||
// marginLeft: 0,
|
||||
// bottom: -8,
|
||||
// position: 'absolute',
|
||||
// borderRadius: 1000,
|
||||
// transition: [['all', '0.2s', 'cubic-bezier(0.95, 0.1, 0.45, 0.94)']]
|
||||
// }
|
||||
},
|
||||
forceSize: {
|
||||
display: 'inline-block',
|
||||
|
|
@ -164,5 +147,39 @@ export default {
|
|||
'& > svg': {
|
||||
marginRight: 16
|
||||
}
|
||||
},
|
||||
logoLink: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
actionButtonsContainer: {
|
||||
zIndex: 1,
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
minWidth: 200,
|
||||
transform: 'translateZ(0)'
|
||||
},
|
||||
notificationIcon: {
|
||||
marginTop: spacer / 2,
|
||||
cursor: 'pointer',
|
||||
background: 'transparent',
|
||||
boxShadow: '0px 0px 0px transparent',
|
||||
border: '0px solid transparent',
|
||||
textShadow: '0px 0px 0px transparent',
|
||||
outline: 'none'
|
||||
},
|
||||
hasUnread: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
left: 182,
|
||||
width: '9px',
|
||||
height: '9px',
|
||||
backgroundColor: secondaryColor,
|
||||
borderRadius: '50%'
|
||||
},
|
||||
popper: {
|
||||
zIndex: 1
|
||||
}
|
||||
}
|
||||
|
||||
export default styles
|
||||
|
|
|
|||
|
|
@ -4,13 +4,21 @@ import React from 'react'
|
|||
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
import Title from 'src/components/Title'
|
||||
import { Label1 } from 'src/components/typography'
|
||||
import { SubpageButton } from 'src/components/buttons'
|
||||
import { Info1, Label1 } from 'src/components/typography'
|
||||
|
||||
import styles from './TitleSection.styles'
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const TitleSection = ({ className, title, error, labels, children }) => {
|
||||
const TitleSection = ({
|
||||
className,
|
||||
title,
|
||||
error,
|
||||
labels,
|
||||
button,
|
||||
children
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
return (
|
||||
<div className={classnames(classes.titleWrapper, className)}>
|
||||
|
|
@ -19,6 +27,15 @@ const TitleSection = ({ className, title, error, labels, children }) => {
|
|||
{error && (
|
||||
<ErrorMessage className={classes.error}>Failed to save</ErrorMessage>
|
||||
)}
|
||||
{button && (
|
||||
<SubpageButton
|
||||
className={classes.subpageButton}
|
||||
Icon={button.icon}
|
||||
InverseIcon={button.inverseIcon}
|
||||
toggle={button.toggle}>
|
||||
<Info1 className={classes.buttonText}>{button.text}</Info1>
|
||||
</SubpageButton>
|
||||
)}
|
||||
</div>
|
||||
<Box display="flex" flexDirection="row">
|
||||
{(labels ?? []).map(({ icon, label }, idx) => (
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { backgroundColor } from 'src/styling/variables'
|
||||
|
||||
export default {
|
||||
titleWrapper: {
|
||||
display: 'flex',
|
||||
|
|
@ -6,11 +8,19 @@ export default {
|
|||
flexDirection: 'row'
|
||||
},
|
||||
titleAndButtonsContainer: {
|
||||
display: 'flex'
|
||||
display: 'flex',
|
||||
alignItems: 'center'
|
||||
},
|
||||
error: {
|
||||
marginLeft: 12
|
||||
},
|
||||
subpageButton: {
|
||||
marginLeft: 12
|
||||
},
|
||||
buttonText: {
|
||||
color: backgroundColor,
|
||||
fontSize: 15
|
||||
},
|
||||
icon: {
|
||||
marginRight: 6
|
||||
},
|
||||
|
|
|
|||
30
new-lamassu-admin/src/components/table/EmptyTable.js
Normal file
30
new-lamassu-admin/src/components/table/EmptyTable.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { makeStyles } from '@material-ui/core'
|
||||
import React, { memo } from 'react'
|
||||
|
||||
import { H4 } from 'src/components/typography'
|
||||
import { ReactComponent as EmptyTableIcon } from 'src/styling/icons/table/empty-table.svg'
|
||||
|
||||
const styles = {
|
||||
emptyTable: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 52
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const EmptyTable = memo(({ message }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
return (
|
||||
<div className={classes.emptyTable}>
|
||||
<EmptyTableIcon />
|
||||
<H4>{message}</H4>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
export default EmptyTable
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import EditCell from './EditCell'
|
||||
import EmptyTable from './EmptyTable'
|
||||
import Table from './Table'
|
||||
import TableBody from './TableBody'
|
||||
import TableCell from './TableCell'
|
||||
|
|
@ -8,6 +9,7 @@ import TableRow from './TableRow'
|
|||
|
||||
export {
|
||||
EditCell,
|
||||
EmptyTable,
|
||||
Table,
|
||||
TableCell,
|
||||
TableHead,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import {
|
|||
Td,
|
||||
Th
|
||||
} from 'src/components/fake-table/Table'
|
||||
import { EmptyTable } from 'src/components/table'
|
||||
import { H4 } from 'src/components/typography'
|
||||
import { ReactComponent as ExpandClosedIcon } from 'src/styling/icons/action/expand/closed.svg'
|
||||
import { ReactComponent as ExpandOpenIcon } from 'src/styling/icons/action/expand/open.svg'
|
||||
|
|
@ -35,7 +36,8 @@ const Row = ({
|
|||
expandRow,
|
||||
expWidth,
|
||||
expandable,
|
||||
onClick
|
||||
onClick,
|
||||
size
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
|
|
@ -45,14 +47,14 @@ const Row = ({
|
|||
[classes.row]: true,
|
||||
[classes.expanded]: expanded
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classes.rowWrapper}>
|
||||
<div className={classnames({ [classes.before]: expanded && id !== 0 })}>
|
||||
<Tr
|
||||
size={size}
|
||||
className={classnames(trClasses)}
|
||||
onClick={() => {
|
||||
expandable && expandRow(id)
|
||||
expandable && expandRow(id, data)
|
||||
onClick && onClick(data)
|
||||
}}
|
||||
error={data.error}
|
||||
|
|
@ -65,7 +67,7 @@ const Row = ({
|
|||
{expandable && (
|
||||
<Td width={expWidth} textAlign="center">
|
||||
<button
|
||||
onClick={() => expandRow(id)}
|
||||
onClick={() => expandRow(id, data)}
|
||||
className={classes.expandButton}>
|
||||
{expanded && <ExpandOpenIcon />}
|
||||
{!expanded && <ExpandClosedIcon />}
|
||||
|
|
@ -97,6 +99,7 @@ const DataTable = ({
|
|||
onClick,
|
||||
loading,
|
||||
emptyText,
|
||||
rowSize,
|
||||
...props
|
||||
}) => {
|
||||
const [expanded, setExpanded] = useState(initialExpanded)
|
||||
|
|
@ -109,12 +112,18 @@ const DataTable = ({
|
|||
|
||||
const classes = useStyles({ width })
|
||||
|
||||
const expandRow = id => {
|
||||
setExpanded(id === expanded ? null : id)
|
||||
const expandRow = (id, data) => {
|
||||
if (data.id) {
|
||||
cache.clear(data.id)
|
||||
setExpanded(data.id === expanded ? null : data.id)
|
||||
} else {
|
||||
cache.clear(id)
|
||||
setExpanded(id === expanded ? null : id)
|
||||
}
|
||||
}
|
||||
|
||||
const cache = new CellMeasurerCache({
|
||||
defaultHeight: 62,
|
||||
defaultHeight: 58,
|
||||
fixedWidth: true
|
||||
})
|
||||
|
||||
|
|
@ -126,20 +135,27 @@ const DataTable = ({
|
|||
key={key}
|
||||
parent={parent}
|
||||
rowIndex={index}>
|
||||
<div style={style}>
|
||||
<Row
|
||||
width={width}
|
||||
id={index}
|
||||
expWidth={expWidth}
|
||||
elements={elements}
|
||||
data={data[index]}
|
||||
Details={Details}
|
||||
expanded={index === expanded}
|
||||
expandRow={expandRow}
|
||||
expandable={expandable}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
{({ registerChild }) => (
|
||||
<div ref={registerChild} style={style}>
|
||||
<Row
|
||||
width={width}
|
||||
size={rowSize}
|
||||
id={data[index].id ? data[index].id : index}
|
||||
expWidth={expWidth}
|
||||
elements={elements}
|
||||
data={data[index]}
|
||||
Details={Details}
|
||||
expanded={
|
||||
data[index].id
|
||||
? data[index].id === expanded
|
||||
: index === expanded
|
||||
}
|
||||
expandRow={expandRow}
|
||||
expandable={expandable}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</CellMeasurer>
|
||||
)
|
||||
}
|
||||
|
|
@ -161,7 +177,7 @@ const DataTable = ({
|
|||
</THead>
|
||||
<TBody className={classes.body}>
|
||||
{loading && <H4>Loading...</H4>}
|
||||
{!loading && R.isEmpty(data) && <H4>{emptyText}</H4>}
|
||||
{!loading && R.isEmpty(data) && <EmptyTable message={emptyText} />}
|
||||
<AutoSizer disableWidth>
|
||||
{({ height }) => (
|
||||
<List
|
||||
|
|
@ -173,7 +189,7 @@ const DataTable = ({
|
|||
rowCount={data.length}
|
||||
rowHeight={cache.rowHeight}
|
||||
rowRenderer={rowRenderer}
|
||||
overscanRowCount={50}
|
||||
overscanRowCount={5}
|
||||
deferredMeasurementCache={cache}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -39,5 +39,12 @@ export default {
|
|||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
})
|
||||
}),
|
||||
emptyTable: {
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
marginTop: 52
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { Form, Formik, FastField } from 'formik'
|
|||
import gql from 'graphql-tag'
|
||||
import QRCode from 'qrcode.react'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
import React, { memo, useState, useEffect, useRef } from 'react'
|
||||
import * as Yup from 'yup'
|
||||
|
||||
import Title from 'src/components/Title'
|
||||
|
|
@ -15,6 +15,7 @@ import { TextInput } from 'src/components/inputs/formik'
|
|||
import Sidebar from 'src/components/layout/Sidebar'
|
||||
import { Info2, P } from 'src/components/typography'
|
||||
import { ReactComponent as CloseIcon } from 'src/styling/icons/action/close/zodiac.svg'
|
||||
import { ReactComponent as CompleteStageIconSpring } from 'src/styling/icons/stage/spring/complete.svg'
|
||||
import { ReactComponent as CompleteStageIconZodiac } from 'src/styling/icons/stage/zodiac/complete.svg'
|
||||
import { ReactComponent as CurrentStageIconZodiac } from 'src/styling/icons/stage/zodiac/current.svg'
|
||||
import { ReactComponent as EmptyStageIconZodiac } from 'src/styling/icons/stage/zodiac/empty.svg'
|
||||
|
|
@ -42,10 +43,26 @@ const useStyles = makeStyles(styles)
|
|||
const getSize = R.compose(R.length, R.pathOr([], ['machines']))
|
||||
|
||||
const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
|
||||
const timeout = useRef(null)
|
||||
const CLOSE_SCREEN_TIMEOUT = 2000
|
||||
const { data } = useQuery(GET_MACHINES, { pollInterval: 10000 })
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeout.current) {
|
||||
clearTimeout(timeout.current)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const addedMachine = data?.machines?.find(m => m.name === name)
|
||||
if (getSize(data) > count && addedMachine) onPaired(addedMachine)
|
||||
const hasNewMachine = getSize(data) > count && addedMachine
|
||||
if (hasNewMachine) {
|
||||
timeout.current = setTimeout(
|
||||
() => onPaired(addedMachine),
|
||||
CLOSE_SCREEN_TIMEOUT
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -57,17 +74,29 @@ const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
|
|||
<QRCode size={240} fgColor={primaryColor} value={qrCode} />
|
||||
</div>
|
||||
<div className={classes.qrTextWrapper}>
|
||||
<div className={classes.qrCodeWrapper}>
|
||||
<div className={classes.qrTextInfoWrapper}>
|
||||
<div className={classes.qrTextIcon}>
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<P className={classes.qrText}>
|
||||
To pair the machine you need scan the QR code with your machine.
|
||||
To do this either snap a picture of this QR code or download it
|
||||
through the button above and scan it with the scanning bay on your
|
||||
machine.
|
||||
</P>
|
||||
<div className={classes.textWrapper}>
|
||||
<P className={classes.qrText}>
|
||||
To pair the machine you need scan the QR code with your machine.
|
||||
To do this either snap a picture of this QR code or download it
|
||||
through the button above and scan it with the scanning bay on
|
||||
your machine.
|
||||
</P>
|
||||
</div>
|
||||
</div>
|
||||
{hasNewMachine && (
|
||||
<div className={classes.successMessageWrapper}>
|
||||
<div className={classes.successMessageIcon}>
|
||||
<CompleteStageIconSpring />
|
||||
</div>
|
||||
<Info2 className={classes.successMessage}>
|
||||
Machine has been successfully paired!
|
||||
</Info2>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -102,6 +131,8 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => {
|
|||
Machine Name (ex: Coffee shop 01)
|
||||
</Info2>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={({ name }) => {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ import {
|
|||
placeholderColor,
|
||||
backgroundColor,
|
||||
primaryColor,
|
||||
mainWidth
|
||||
mainWidth,
|
||||
spring2,
|
||||
spring3
|
||||
} from 'src/styling/variables'
|
||||
|
||||
const { tl2, p } = typographyStyles
|
||||
|
|
@ -55,12 +57,19 @@ const styles = {
|
|||
qrCodeWrapper: {
|
||||
display: 'flex'
|
||||
},
|
||||
qrTextInfoWrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row'
|
||||
},
|
||||
qrTextWrapper: {
|
||||
width: 381,
|
||||
marginLeft: 80,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center'
|
||||
flexDirection: 'column'
|
||||
},
|
||||
textWrapper: {
|
||||
display: 'flex',
|
||||
flexDirection: 'column'
|
||||
},
|
||||
qrTextIcon: {
|
||||
marginRight: 16
|
||||
|
|
@ -95,6 +104,24 @@ const styles = {
|
|||
},
|
||||
stepperPast: {
|
||||
border: [[1, 'solid', primaryColor]]
|
||||
},
|
||||
successMessageWrapper: {
|
||||
backgroundColor: spring3,
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
padding: '0px 10px',
|
||||
borderRadius: '8px'
|
||||
},
|
||||
successMessage: {
|
||||
color: spring2,
|
||||
margin: '8px 0px'
|
||||
},
|
||||
successMessageIcon: {
|
||||
marginRight: 16,
|
||||
marginBottom: 2,
|
||||
display: 'flex',
|
||||
flexDirection: 'col',
|
||||
alignItems: 'center'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import gql from 'graphql-tag'
|
|||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import Tooltip from 'src/components/Tooltip'
|
||||
import { Tooltip } from 'src/components/Tooltip'
|
||||
import { Link } from 'src/components/buttons'
|
||||
import { Switch } from 'src/components/inputs'
|
||||
import Sidebar from 'src/components/layout/Sidebar'
|
||||
|
|
@ -74,9 +74,14 @@ const Blacklist = () => {
|
|||
display: 'Bitcoin'
|
||||
})
|
||||
const [errorMsg, setErrorMsg] = useState(null)
|
||||
const [deleteDialog, setDeleteDialog] = useState(false)
|
||||
|
||||
const [deleteEntry] = useMutation(DELETE_ROW, {
|
||||
onError: () => console.error('Error while deleting row'),
|
||||
onError: ({ message }) => {
|
||||
const errorMessage = message ?? 'Error while deleting row'
|
||||
setErrorMsg(errorMessage)
|
||||
},
|
||||
onCompleted: () => setDeleteDialog(false),
|
||||
refetchQueries: () => ['getBlacklistData']
|
||||
})
|
||||
|
||||
|
|
@ -134,11 +139,11 @@ const Blacklist = () => {
|
|||
return (
|
||||
<>
|
||||
<TitleSection title="Blacklisted addresses">
|
||||
<div>
|
||||
<Link onClick={() => setShowModal(true)}>
|
||||
<Box display="flex" justifyContent="flex-end">
|
||||
<Link color="primary" onClick={() => setShowModal(true)}>
|
||||
Blacklist new addresses
|
||||
</Link>
|
||||
</div>
|
||||
</Box>
|
||||
</TitleSection>
|
||||
<Grid container className={classes.grid}>
|
||||
<Sidebar
|
||||
|
|
@ -181,6 +186,10 @@ const Blacklist = () => {
|
|||
data={formattedData}
|
||||
selectedCoin={clickedItem}
|
||||
handleDeleteEntry={handleDeleteEntry}
|
||||
errorMessage={errorMsg}
|
||||
setErrorMessage={setErrorMsg}
|
||||
deleteDialog={deleteDialog}
|
||||
setDeleteDialog={setDeleteDialog}
|
||||
/>
|
||||
</div>
|
||||
</Grid>
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ const BlackListModal = ({
|
|||
handleClose={onClose}
|
||||
open={true}>
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
initialValues={{
|
||||
address: ''
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import { DeleteDialog } from 'src/components/DeleteDialog'
|
||||
import { IconButton } from 'src/components/buttons'
|
||||
import DataTable from 'src/components/tables/DataTable'
|
||||
import { Label1 } from 'src/components/typography'
|
||||
|
|
@ -12,9 +13,19 @@ import styles from './Blacklist.styles'
|
|||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
|
||||
const BlacklistTable = ({
|
||||
data,
|
||||
selectedCoin,
|
||||
handleDeleteEntry,
|
||||
errorMessage,
|
||||
setErrorMessage,
|
||||
deleteDialog,
|
||||
setDeleteDialog
|
||||
}) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const [toBeDeleted, setToBeDeleted] = useState()
|
||||
|
||||
const elements = [
|
||||
{
|
||||
name: 'address',
|
||||
|
|
@ -37,12 +48,10 @@ const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
|
|||
view: it => (
|
||||
<IconButton
|
||||
className={classes.deleteButton}
|
||||
onClick={() =>
|
||||
handleDeleteEntry(
|
||||
R.path(['cryptoCode'], it),
|
||||
R.path(['address'], it)
|
||||
)
|
||||
}>
|
||||
onClick={() => {
|
||||
setDeleteDialog(true)
|
||||
setToBeDeleted(it)
|
||||
}}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
)
|
||||
|
|
@ -53,7 +62,29 @@ const BlacklistTable = ({ data, selectedCoin, handleDeleteEntry }) => {
|
|||
: data[R.keys(data)[0]]
|
||||
|
||||
return (
|
||||
<DataTable data={dataToShow} elements={elements} name="blacklistTable" />
|
||||
<>
|
||||
<DataTable
|
||||
data={dataToShow}
|
||||
elements={elements}
|
||||
emptyText="No blacklisted addresses so far"
|
||||
name="blacklistTable"
|
||||
/>
|
||||
<DeleteDialog
|
||||
open={deleteDialog}
|
||||
onDismissed={() => {
|
||||
setDeleteDialog(false)
|
||||
setErrorMessage(null)
|
||||
}}
|
||||
onConfirmed={() => {
|
||||
setErrorMessage(null)
|
||||
handleDeleteEntry(
|
||||
R.path(['cryptoCode'], toBeDeleted),
|
||||
R.path(['address'], toBeDeleted)
|
||||
)
|
||||
}}
|
||||
errorMessage={errorMessage}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import gql from 'graphql-tag'
|
|||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import Tooltip from 'src/components/Tooltip'
|
||||
import { Tooltip } from 'src/components/Tooltip'
|
||||
import { NamespacedTable as EditableTable } from 'src/components/editableTable'
|
||||
import { Switch } from 'src/components/inputs'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { EmptyTable } from 'src/components/table'
|
||||
import { P, Label2 } from 'src/components/typography'
|
||||
import { fromNamespace, toNamespace } from 'src/utils/config'
|
||||
|
||||
|
|
@ -71,6 +72,8 @@ const CashOut = ({ name: SCREEN_KEY }) => {
|
|||
save(toNamespace(id, { active: !namespaced?.active }))
|
||||
}
|
||||
|
||||
const wasNeverEnabled = it => R.compose(R.length, R.keys)(it) === 1
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleSection title="Cash-out">
|
||||
|
|
@ -101,7 +104,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
|
|||
<EditableTable
|
||||
namespaces={R.map(R.path(['deviceId']))(machines)}
|
||||
data={config}
|
||||
stripeWhen={it => !DenominationsSchema.isValidSync(it)}
|
||||
stripeWhen={wasNeverEnabled}
|
||||
enableEdit
|
||||
editWidth={134}
|
||||
enableToggle
|
||||
|
|
@ -113,6 +116,7 @@ const CashOut = ({ name: SCREEN_KEY }) => {
|
|||
disableRowEdit={R.compose(R.not, R.path(['active']))}
|
||||
elements={getElements(machines, locale)}
|
||||
/>
|
||||
{R.isEmpty(config) && <EmptyTable message="No machines so far" />}
|
||||
{wizard && (
|
||||
<Wizard
|
||||
machine={R.find(R.propEq('deviceId', wizard))(machines)}
|
||||
|
|
|
|||
|
|
@ -54,12 +54,22 @@ const Wizard = ({ machine, locale, onClose, save, error }) => {
|
|||
{
|
||||
type: 'top',
|
||||
display: 'Cassette 1 (Top)',
|
||||
component: Autocomplete
|
||||
component: Autocomplete,
|
||||
inputProps: {
|
||||
options: R.map(it => ({ code: it, display: it }))(options),
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'bottom',
|
||||
display: 'Cassette 2',
|
||||
component: Autocomplete
|
||||
component: Autocomplete,
|
||||
inputProps: {
|
||||
options: R.map(it => ({ code: it, display: it }))(options),
|
||||
labelProp: 'display',
|
||||
valueProp: 'code'
|
||||
}
|
||||
},
|
||||
{
|
||||
type: 'zeroConfLimit',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { makeStyles } from '@material-ui/core'
|
||||
import { Formik, Form, Field } from 'formik'
|
||||
import * as R from 'ramda'
|
||||
import React from 'react'
|
||||
|
||||
import ErrorMessage from 'src/components/ErrorMessage'
|
||||
|
|
@ -44,6 +43,8 @@ const WizardStep = ({
|
|||
|
||||
{step <= 2 && (
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
onSubmit={onContinue}
|
||||
initialValues={{ top: '', bottom: '' }}
|
||||
enableReinitialize
|
||||
|
|
@ -71,7 +72,7 @@ const WizardStep = ({
|
|||
name={type}
|
||||
options={options}
|
||||
valueProp={'code'}
|
||||
getLabel={R.path(['display'])}></Field>
|
||||
labelProp={'display'}></Field>
|
||||
<Info1 noMargin className={classes.suffix}>
|
||||
{fiatCurrency}
|
||||
</Info1>
|
||||
|
|
@ -96,6 +97,8 @@ const WizardStep = ({
|
|||
|
||||
{step === 3 && (
|
||||
<Formik
|
||||
validateOnBlur={false}
|
||||
validateOnChange={false}
|
||||
onSubmit={onContinue}
|
||||
initialValues={{ zeroConfLimit: '' }}
|
||||
enableReinitialize
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ const DenominationsSchema = Yup.object().shape({
|
|||
top: Yup.number()
|
||||
.label('Cassette 1 (Top)')
|
||||
.required()
|
||||
.min(0)
|
||||
.min(1)
|
||||
.max(currencyMax),
|
||||
bottom: Yup.number()
|
||||
.label('Cassette 2 (Bottom)')
|
||||
.required()
|
||||
.min(0)
|
||||
.min(1)
|
||||
.max(currencyMax),
|
||||
zeroConfLimit: Yup.number()
|
||||
.label('0-conf Limit')
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import { useQuery, useMutation } from '@apollo/react-hooks'
|
||||
import { makeStyles } from '@material-ui/core'
|
||||
import gql from 'graphql-tag'
|
||||
import * as R from 'ramda'
|
||||
import React, { useState } from 'react'
|
||||
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import Section from 'src/components/layout/Section'
|
||||
import TitleSection from 'src/components/layout/TitleSection'
|
||||
import { ReactComponent as ReverseListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/white.svg'
|
||||
import { ReactComponent as ListingViewIcon } from 'src/styling/icons/circle buttons/listing-view/zodiac.svg'
|
||||
import { ReactComponent as OverrideLabelIcon } from 'src/styling/icons/status/spring2.svg'
|
||||
import { fromNamespace, toNamespace, namespaces } from 'src/utils/config'
|
||||
|
||||
import {
|
||||
mainFields,
|
||||
overrides,
|
||||
schema,
|
||||
getOverridesSchema,
|
||||
defaults,
|
||||
overridesDefaults,
|
||||
getOrder
|
||||
} from './helper'
|
||||
import CommissionsDetails from './components/CommissionsDetails'
|
||||
import CommissionsList from './components/CommissionsList'
|
||||
|
||||
const styles = {
|
||||
listViewButton: {
|
||||
marginLeft: 4
|
||||
}
|
||||
}
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
|
|
@ -37,27 +40,27 @@ const SAVE_CONFIG = gql`
|
|||
saveConfig(config: $config)
|
||||
}
|
||||
`
|
||||
const removeCoinFromOverride = crypto => override =>
|
||||
R.mergeRight(override, {
|
||||
cryptoCurrencies: R.without([crypto], override.cryptoCurrencies)
|
||||
})
|
||||
|
||||
const Commissions = ({ name: SCREEN_KEY }) => {
|
||||
const [isEditingDefault, setEditingDefault] = useState(false)
|
||||
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
||||
const classes = useStyles()
|
||||
const [showMachines, setShowMachines] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const { data } = useQuery(GET_DATA)
|
||||
const [saveConfig, { error }] = useMutation(SAVE_CONFIG, {
|
||||
refetchQueries: () => ['getData']
|
||||
const [saveConfig] = useMutation(SAVE_CONFIG, {
|
||||
refetchQueries: () => ['getData'],
|
||||
onError: error => setError(error)
|
||||
})
|
||||
|
||||
const config = data?.config && fromNamespace(SCREEN_KEY)(data.config)
|
||||
const currency = R.path(['fiatCurrency'])(
|
||||
fromNamespace(namespaces.LOCALE)(data?.config)
|
||||
)
|
||||
const localeConfig =
|
||||
data?.config && fromNamespace(namespaces.LOCALE)(data.config)
|
||||
|
||||
const commission = config && !R.isEmpty(config) ? config : defaults
|
||||
const commissionOverrides = commission?.overrides ?? []
|
||||
|
||||
const orderedCommissionsOverrides = R.sortWith([
|
||||
R.ascend(getOrder),
|
||||
R.ascend(R.prop('machine'))
|
||||
])(commissionOverrides)
|
||||
const currency = R.prop('fiatCurrency')(localeConfig)
|
||||
const overrides = R.prop('overrides')(config)
|
||||
|
||||
const save = it => {
|
||||
const config = toNamespace(SCREEN_KEY)(it.commissions[0])
|
||||
|
|
@ -66,54 +69,75 @@ const Commissions = ({ name: SCREEN_KEY }) => {
|
|||
|
||||
const saveOverrides = it => {
|
||||
const config = toNamespace(SCREEN_KEY)(it)
|
||||
setError(null)
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
||||
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
||||
const saveOverridesFromList = it => (_, override) => {
|
||||
const cryptoOverriden = R.path(['cryptoCurrencies', 0], override)
|
||||
|
||||
const sameMachine = R.eqProps('machine', override)
|
||||
const notSameOverride = it => !R.eqProps('cryptoCurrencies', override, it)
|
||||
|
||||
const filterMachine = R.filter(R.both(sameMachine, notSameOverride))
|
||||
const removeCoin = removeCoinFromOverride(cryptoOverriden)
|
||||
|
||||
const machineOverrides = R.map(removeCoin)(filterMachine(it))
|
||||
|
||||
const overrides = machineOverrides.concat(
|
||||
R.filter(it => !sameMachine(it), it)
|
||||
)
|
||||
|
||||
const config = {
|
||||
commissions_overrides: R.prepend(override, overrides)
|
||||
}
|
||||
|
||||
return saveConfig({ variables: { config } })
|
||||
}
|
||||
|
||||
const labels = showMachines
|
||||
? [
|
||||
{
|
||||
label: 'Override value',
|
||||
icon: <OverrideLabelIcon />
|
||||
}
|
||||
]
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<TitleSection title="Commissions" />
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Default setup"
|
||||
rowSize="lg"
|
||||
titleLg
|
||||
name="commissions"
|
||||
enableEdit
|
||||
initialValues={commission}
|
||||
<TitleSection
|
||||
title="Commissions"
|
||||
labels={labels}
|
||||
button={{
|
||||
text: 'List view',
|
||||
icon: ListingViewIcon,
|
||||
inverseIcon: ReverseListingViewIcon,
|
||||
toggle: setShowMachines
|
||||
}}
|
||||
iconClassName={classes.listViewButton}
|
||||
/>
|
||||
|
||||
{!showMachines && (
|
||||
<CommissionsDetails
|
||||
config={config}
|
||||
currency={currency}
|
||||
data={data}
|
||||
error={error}
|
||||
save={save}
|
||||
validationSchema={schema}
|
||||
data={R.of(commission)}
|
||||
elements={mainFields(currency)}
|
||||
setEditing={onEditingDefault}
|
||||
forceDisable={isEditingOverrides}
|
||||
saveOverrides={saveOverrides}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Overrides"
|
||||
titleLg
|
||||
name="overrides"
|
||||
enableDelete
|
||||
enableEdit
|
||||
enableCreate
|
||||
groupBy={getOrder}
|
||||
initialValues={overridesDefaults}
|
||||
save={saveOverrides}
|
||||
validationSchema={getOverridesSchema(
|
||||
orderedCommissionsOverrides,
|
||||
data
|
||||
)}
|
||||
data={orderedCommissionsOverrides}
|
||||
elements={overrides(data, currency, orderedCommissionsOverrides)}
|
||||
setEditing={onEditingOverrides}
|
||||
forceDisable={isEditingDefault}
|
||||
)}
|
||||
{showMachines && (
|
||||
<CommissionsList
|
||||
config={config}
|
||||
localeConfig={localeConfig}
|
||||
currency={currency}
|
||||
data={data}
|
||||
error={error}
|
||||
saveOverrides={saveOverridesFromList(overrides)}
|
||||
/>
|
||||
</Section>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
import * as R from 'ramda'
|
||||
import React, { useState, memo } from 'react'
|
||||
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import Section from 'src/components/layout/Section'
|
||||
import {
|
||||
mainFields,
|
||||
overrides,
|
||||
schema,
|
||||
getOverridesSchema,
|
||||
defaults,
|
||||
overridesDefaults,
|
||||
getOrder
|
||||
} from 'src/pages/Commissions/helper'
|
||||
|
||||
const CommissionsDetails = memo(
|
||||
({ config, currency, data, error, save, saveOverrides }) => {
|
||||
const [isEditingDefault, setEditingDefault] = useState(false)
|
||||
const [isEditingOverrides, setEditingOverrides] = useState(false)
|
||||
|
||||
const commission = config && !R.isEmpty(config) ? config : defaults
|
||||
const commissionOverrides = commission?.overrides ?? []
|
||||
|
||||
const orderedCommissionsOverrides = R.sortWith([
|
||||
R.ascend(getOrder),
|
||||
R.ascend(R.prop('machine'))
|
||||
])(commissionOverrides)
|
||||
|
||||
const onEditingDefault = (it, editing) => setEditingDefault(editing)
|
||||
const onEditingOverrides = (it, editing) => setEditingOverrides(editing)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Default setup"
|
||||
rowSize="lg"
|
||||
titleLg
|
||||
name="commissions"
|
||||
enableEdit
|
||||
initialValues={commission}
|
||||
save={save}
|
||||
validationSchema={schema}
|
||||
data={R.of(commission)}
|
||||
elements={mainFields(currency)}
|
||||
setEditing={onEditingDefault}
|
||||
forceDisable={isEditingOverrides}
|
||||
/>
|
||||
</Section>
|
||||
<Section>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
title="Overrides"
|
||||
titleLg
|
||||
name="overrides"
|
||||
enableDelete
|
||||
enableEdit
|
||||
enableCreate
|
||||
groupBy={getOrder}
|
||||
initialValues={overridesDefaults}
|
||||
save={saveOverrides}
|
||||
validationSchema={getOverridesSchema(
|
||||
orderedCommissionsOverrides,
|
||||
data
|
||||
)}
|
||||
data={orderedCommissionsOverrides}
|
||||
elements={overrides(data, currency, orderedCommissionsOverrides)}
|
||||
setEditing={onEditingOverrides}
|
||||
forceDisable={isEditingDefault}
|
||||
/>
|
||||
</Section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default CommissionsDetails
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
import { makeStyles } from '@material-ui/core'
|
||||
import * as R from 'ramda'
|
||||
import React, { memo, useState } from 'react'
|
||||
|
||||
import { Table as EditableTable } from 'src/components/editableTable'
|
||||
import { Select } from 'src/components/inputs'
|
||||
import {
|
||||
overridesDefaults,
|
||||
getCommissions,
|
||||
getListCommissionsSchema,
|
||||
commissionsList
|
||||
} from 'src/pages/Commissions/helper'
|
||||
|
||||
const styles = {
|
||||
headerLine: {
|
||||
display: 'flex',
|
||||
justifyContent: '',
|
||||
marginBottom: 24
|
||||
},
|
||||
select: {
|
||||
marginRight: 24
|
||||
},
|
||||
tableWrapper: {
|
||||
flex: 1,
|
||||
display: 'block',
|
||||
overflowY: 'auto',
|
||||
width: '100%',
|
||||
maxHeight: '70vh'
|
||||
}
|
||||
}
|
||||
|
||||
const SHOW_ALL = {
|
||||
code: 'SHOW_ALL',
|
||||
display: 'Show all'
|
||||
}
|
||||
|
||||
const ORDER_OPTIONS = [
|
||||
{
|
||||
code: 'machine',
|
||||
display: 'Machine Name'
|
||||
},
|
||||
{
|
||||
code: 'cryptoCurrencies',
|
||||
display: 'Cryptocurrency'
|
||||
},
|
||||
{
|
||||
code: 'cashIn',
|
||||
display: 'Cash-in'
|
||||
},
|
||||
{
|
||||
code: 'cashOut',
|
||||
display: 'Cash-out'
|
||||
},
|
||||
{
|
||||
code: 'fixedFee',
|
||||
display: 'Fixed Fee'
|
||||
},
|
||||
{
|
||||
code: 'minimumTx',
|
||||
display: 'Minimum Tx'
|
||||
}
|
||||
]
|
||||
|
||||
const useStyles = makeStyles(styles)
|
||||
|
||||
const getElement = (code, display) => ({
|
||||
code: code,
|
||||
display: display || code
|
||||
})
|
||||
|
||||
const sortCommissionsBy = prop => {
|
||||
switch (prop) {
|
||||
case ORDER_OPTIONS[0]:
|
||||
return R.sortBy(R.find(R.propEq('code', R.prop('machine'))))
|
||||
case ORDER_OPTIONS[1]:
|
||||
return R.sortBy(R.path(['cryptoCurrencies', 0]))
|
||||
default:
|
||||
return R.sortBy(R.prop(prop.code))
|
||||
}
|
||||
}
|
||||
|
||||
const filterCommissions = (coinFilter, machineFilter) =>
|
||||
R.compose(
|
||||
R.filter(
|
||||
it => (machineFilter === SHOW_ALL) | (machineFilter.code === it.machine)
|
||||
),
|
||||
R.filter(
|
||||
it =>
|
||||
(coinFilter === SHOW_ALL) | (coinFilter.code === it.cryptoCurrencies[0])
|
||||
)
|
||||
)
|
||||
|
||||
const CommissionsList = memo(
|
||||
({ config, localeConfig, currency, data, error, saveOverrides }) => {
|
||||
const classes = useStyles()
|
||||
|
||||
const [machineFilter, setMachineFilter] = useState(SHOW_ALL)
|
||||
const [coinFilter, setCoinFilter] = useState(SHOW_ALL)
|
||||
const [orderProp, setOrderProp] = useState(ORDER_OPTIONS[0])
|
||||
|
||||
const coins = R.prop('cryptoCurrencies', localeConfig)
|
||||
|
||||
const getMachineCoins = deviceId => {
|
||||
const override = R.prop('overrides', localeConfig)?.find(
|
||||
R.propEq('machine', deviceId)
|
||||
)
|
||||
|
||||
const machineCoins = override
|
||||
? R.prop('cryptoCurrencies', override)
|
||||
: coins
|
||||
|
||||
return R.xprod([deviceId], machineCoins)
|
||||
}
|
||||
|
||||
const getMachineElement = it =>
|
||||
getElement(R.prop('deviceId', it), R.prop('name', it))
|
||||
|
||||
const cryptoData = R.map(getElement)(coins)
|
||||
|
||||
const machineData = R.sortBy(
|
||||
R.prop('display'),
|
||||
R.map(getMachineElement)(R.prop('machines', data))
|
||||
)
|
||||
|
||||
const machinesCoinsTuples = R.unnest(
|
||||
R.map(getMachineCoins)(machineData.map(R.prop('code')))
|
||||
)
|
||||
|
||||
const commissions = R.map(([deviceId, cryptoCode]) =>
|
||||
getCommissions(cryptoCode, deviceId, config)
|
||||
)(machinesCoinsTuples)
|
||||
|
||||
const tableData = R.compose(
|
||||
sortCommissionsBy(orderProp),
|
||||
filterCommissions(coinFilter, machineFilter)
|
||||
)(commissions)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={classes.headerLine}>
|
||||
<Select
|
||||
className={classes.select}
|
||||
onSelectedItemChange={setMachineFilter}
|
||||
label="Machines"
|
||||
default={SHOW_ALL}
|
||||
items={[SHOW_ALL].concat(machineData)}
|
||||
selectedItem={machineFilter}
|
||||
/>
|
||||
<Select
|
||||
className={classes.select}
|
||||
onSelectedItemChange={setCoinFilter}
|
||||
label="Cryptocurrency"
|
||||
default={SHOW_ALL}
|
||||
items={[SHOW_ALL].concat(cryptoData)}
|
||||
selectedItem={coinFilter}
|
||||
/>
|
||||
<Select
|
||||
onSelectedItemChange={setOrderProp}
|
||||
label="Sort by"
|
||||
default={ORDER_OPTIONS[0]}
|
||||
items={ORDER_OPTIONS}
|
||||
selectedItem={orderProp}
|
||||
/>
|
||||
</div>
|
||||
<div className={classes.tableWrapper}>
|
||||
<EditableTable
|
||||
error={error?.message}
|
||||
name="comissionsList"
|
||||
enableEdit
|
||||
save={saveOverrides}
|
||||
initialValues={overridesDefaults}
|
||||
validationSchema={getListCommissionsSchema()}
|
||||
data={tableData}
|
||||
elements={commissionsList(data, currency)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
export default CommissionsList
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue