Merge pull request #614 from lamassu/dev

7.5.0-beta.3
This commit is contained in:
Josh Harvey 2021-03-16 13:05:28 +00:00 committed by GitHub
commit cec919297e
232 changed files with 17611 additions and 16861 deletions

2
.gitignore vendored
View file

@ -32,6 +32,8 @@ scratch/
seeds/
mnemonics/
certs/
test/stress/machines
test/stress/config.json
lamassu.json
terraform.*

View file

@ -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}`

View file

@ -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)

View file

@ -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()

View file

@ -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',

View file

@ -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(sql2, [deviceID, dbBills.length])
.then(() => {
return t.none(sql)
})
.then(() => bills)
}

View file

@ -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
}

View file

@ -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({})

View file

@ -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) {

View file

@ -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
View 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
}

View file

@ -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) {

View file

@ -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
View 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 }

View file

@ -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
View 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
}

View file

@ -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] },

View file

@ -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()
}
}

View file

@ -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))
}

View file

@ -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) {

View file

@ -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)

View file

@ -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
}

View file

@ -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
View 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
View 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
View 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
}

View 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
View 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
View 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 }

View 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
)
})

View 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'}])
})

View 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)'
)
})

View 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
View 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
}

View file

@ -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)

View file

@ -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,

View file

@ -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 = {
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) {

View file

@ -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 = {
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) {

View file

@ -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 = {
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) {

View file

@ -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) {

View file

@ -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 = {
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) {

View file

@ -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 = {
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) {

View file

@ -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
View 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 }

View file

@ -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)

View file

@ -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,

View 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()
}

View 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()
}

View 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()
}

View 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()
}

View 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()
}

File diff suppressed because it is too large Load diff

View file

@ -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",

View file

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

View file

@ -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 && (
<Slide
direction="left"
in={true}
mountOnEnter
unmountOnExit
children={
<div>
<TitleSection title={parent.title}></TitleSection>
</div>
}
/>
)}
<Grid container className={classes.grid}>

View 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 }

View file

@ -115,7 +115,6 @@ export const ConfirmDialog = memo(
error={error}
InputLabelProps={{ shrink: true }}
onChange={handleChange}
onBlur={() => setError(isOnErrorState)}
/>
</DialogContent>
<DialogActions className={classes.dialogActions}>

View 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>
)
}

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
import NotificationCenter from './NotificationCenter'
export default NotificationCenter

View file

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

View file

@ -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 }

View file

@ -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,24 +39,25 @@ 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}>
{({ resetForm }) => {
return (
<Form>
<div className={classes.rowWrapper}>
<H4>{title}</H4>
@ -63,8 +67,12 @@ const BooleanPropertiesTable = memo(
Save
</Link>
<Link
type="reset"
className={classes.rightLink}
onClick={innerCancel}
onClick={() => {
resetForm()
setEditing(false)
}}
color="secondary">
Cancel
</Link>
@ -81,7 +89,10 @@ const BooleanPropertiesTable = memo(
<Table className={classes.fillColumn}>
<TableBody className={classes.fillColumn}>
{elements.map((it, idx) => (
<TableRow key={idx} size="sm" className={classes.tableRow}>
<TableRow
key={idx}
size="sm"
className={classes.tableRow}>
<TableCell className={classes.leftTableCell}>
{it.display}
</TableCell>
@ -104,6 +115,8 @@ const BooleanPropertiesTable = memo(
</TableBody>
</Table>
</Form>
)
}}
</Formik>
</div>
)

View file

@ -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,6 +102,7 @@ const IDButton = memo(
return (
<>
<ClickAwayListener onClickAway={handleClose}>
<button
aria-describedby={id}
onClick={handleClick}
@ -117,6 +119,7 @@ const IDButton = memo(
</div>
)}
</button>
</ClickAwayListener>
<Popover
className={popoverClassname}
id={id}

View file

@ -25,17 +25,16 @@ export default {
}
},
buttonIcon: {
'& svg': {
width: 16,
height: 16,
overflow: 'visible',
'& g': {
strokeWidth: 1.8
}
}
},
buttonIconActiveLeft: {
marginRight: 12
marginRight: 12,
marginLeft: 4
},
buttonIconActiveRight: {
marginRight: 5,

View file

@ -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)

View file

@ -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,9 +181,12 @@ const ETable = ({
)}
<Table>
<Header />
<div className={tbodyWrapperClass}>
<TBody>
{adding && (
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={{ id: v4(), ...initialValues }}
onReset={onReset}
validationSchema={validationSchema}
@ -201,10 +205,13 @@ const ETable = ({
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
const isLastOfGroup =
canGroup && groupFunction(it) !== groupFunction(nextElement)
canGroup &&
groupFunction(it) !== groupFunction(nextElement)
return (
<Formik
validateOnBlur={false}
validateOnChange={false}
key={it.id ?? idx}
enableReinitialize
initialValues={it}
@ -227,6 +234,7 @@ const ETable = ({
)
})}
</TBody>
</div>
</Table>
</>
)}

View file

@ -53,7 +53,6 @@ const Td = ({
[classes.size]: !header,
[classes.bold]: !header && bold
}
return <div className={classnames(className, classNames)}>{children}</div>
}

View file

@ -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',

View file

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

View file

@ -2,13 +2,10 @@ import React, { memo, useState } from 'react'
import { TextInput } from '../base'
const SecretInput = memo(({ value, onFocus, onBlur, ...props }) => {
const SecretInput = memo(
({ value, onFocus, isPasswordFilled, onBlur, ...props }) => {
const [focused, setFocused] = useState(false)
const placeholder = '⚬ ⚬ ⚬ This field is set ⚬ ⚬ ⚬'
const previouslyFilled = !!value
const tempValue = previouslyFilled ? '' : value
const innerOnFocus = event => {
setFocused(true)
onFocus && onFocus(event)
@ -25,12 +22,14 @@ const SecretInput = memo(({ value, onFocus, onBlur, ...props }) => {
type="password"
onFocus={innerOnFocus}
onBlur={innerOnBlur}
isPasswordFilled={isPasswordFilled}
value={value}
InputProps={{ value: !focused ? tempValue : value }}
InputLabelProps={{ shrink: previouslyFilled || focused }}
placeholder={previouslyFilled ? placeholder : ''}
InputProps={{ value: value }}
InputLabelProps={{ shrink: isPasswordFilled || value || focused }}
placeholder={isPasswordFilled ? placeholder : ''}
/>
)
})
}
)
export default SecretInput

View file

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

View file

@ -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
}

View file

@ -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,6 +115,7 @@ const CashOut = ({
<div className={classes.col}>
<Cashbox className={className} percent={percent} cashOut />
</div>
{!editingMode && (
<div className={classes.col2}>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes}</Info2>
@ -118,6 +130,7 @@ const CashOut = ({
</Label1>
</div>
</div>
)}
</div>
</>
)

View file

@ -48,8 +48,7 @@ const cashboxStyles = {
const gridStyles = {
row: {
display: 'flex',
justifyContent: 'space-between'
display: 'flex'
},
innerRow: {
display: 'flex',

View file

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

View file

@ -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}

View file

@ -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
}

View file

@ -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 && (

View file

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

View file

@ -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) => (

View file

@ -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
},

View 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

View file

@ -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,

View file

@ -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 => {
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}>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
<Row
width={width}
id={index}
size={rowSize}
id={data[index].id ? data[index].id : index}
expWidth={expWidth}
elements={elements}
data={data[index]}
Details={Details}
expanded={index === expanded}
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}
/>
)}

View file

@ -39,5 +39,12 @@ export default {
flex: 1,
display: 'flex',
flexDirection: 'column'
})
}),
emptyTable: {
width: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginTop: 52
}
}

View file

@ -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,18 +74,30 @@ 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>
<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.
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 }) => {

View file

@ -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'
}
}

View file

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

View file

@ -42,6 +42,8 @@ const BlackListModal = ({
handleClose={onClose}
open={true}>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={{
address: ''
}}

View file

@ -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}
/>
</>
)
}

View file

@ -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)}

View file

@ -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',

View file

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

View file

@ -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')

View file

@ -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>
)}
</>
)
}

View file

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

View file

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