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(sql)
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
}
@ -433,7 +442,7 @@ function batch () {
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
/**
* Query all customers, ordered by last activity
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*
@ -471,7 +480,7 @@ function getCustomersList () {
}
/**
* Query all customers, ordered by last activity
* Query all customers, ordered by last activity
* and with aggregate columns based on their
* transactions
*

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,20 +35,20 @@ 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) => {
if (ping && ping.age) return unresponsiveStatus
if (stuck && stuck.age) return stuckStatus
return fullyFunctionalStatus
}
@ -57,15 +56,15 @@ function getMachineNames (config) {
const cashOutConfig = configManager.getCashOut(r.deviceId, config)
const cashOut = !!cashOutConfig.active
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)
@ -308,9 +378,9 @@ const resolvers = {
customers: () => customers.getCustomersList(),
customer: (...[, { customerId }]) => customers.getCustomerById(customerId),
funding: () => funding.getFunding(),
machineLogs: (...[, { deviceId, from, until, limit, offset }]) =>
machineLogs: (...[, { deviceId, from, until, limit, offset }]) =>
logs.simpleGetMachineLogs(deviceId, from, until, limit, offset),
machineLogsCsv: (...[, { deviceId, from, until, limit, offset }]) =>
machineLogsCsv: (...[, { deviceId, from, until, limit, offset }]) =>
logs.simpleGetMachineLogs(deviceId, from, until, limit, offset).then(parseAsync),
serverVersion: () => serverVersion,
uptime: () => supervisor.getAllProcessInfo(),
@ -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 = {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
function rpcConfig () {
try {
const config = jsonRpc.parseConf(configPath)
return {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
}
} catch (err) {
throw new Error('wallet is currently not installed')
}
}
function fetch (method, params) {
return jsonRpc.fetch(rpcConfig, method, params)
return jsonRpc.fetch(rpcConfig(), method, params)
}
function checkCryptoCode (cryptoCode) {

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 = {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
function rpcConfig () {
try {
const config = jsonRpc.parseConf(configPath)
return {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
}
} catch (err) {
throw new Error('wallet is currently not installed')
}
}
function fetch (method, params) {
return jsonRpc.fetch(rpcConfig, method, params)
return jsonRpc.fetch(rpcConfig(), method, params)
}
function checkCryptoCode (cryptoCode) {

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 = {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
function rpcConfig () {
try {
const config = jsonRpc.parseConf(configPath)
return {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
}
} catch (err) {
throw new Error('wallet is currently not installed')
}
}
function fetch (method, params) {
return jsonRpc.fetch(rpcConfig, method, params)
return jsonRpc.fetch(rpcConfig(), method, params)
}
function checkCryptoCode (cryptoCode) {

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 = {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
function rpcConfig () {
try {
const config = jsonRpc.parseConf(configPath)
return {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
}
} catch (err) {
throw new Error('wallet is currently not installed')
}
}
function fetch (method, params) {
return jsonRpc.fetch(rpcConfig, method, params)
return jsonRpc.fetch(rpcConfig(), method, params)
}
function checkCryptoCode (cryptoCode) {

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 = {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
function rpcConfig () {
try {
const config = jsonRpc.parseConf(configPath)
return {
username: config.rpcuser,
password: config.rpcpassword,
port: config.rpcport || cryptoRec.defaultPort
}
} catch (err) {
throw new Error('wallet is currently not installed')
}
}
function fetch (method, params) {
return jsonRpc.fetch(rpcConfig, method, params)
return jsonRpc.fetch(rpcConfig(), method, params)
}
function checkCryptoCode (cryptoCode) {

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

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,74 +39,84 @@ const BooleanPropertiesTable = memo(
const classes = useStyles()
const innerSave = async value => {
save(value)
save(R.filter(R.complement(R.isNil), value))
setEditing(false)
}
const innerCancel = () => setEditing(false)
const radioButtonOptions = [
{ display: 'Yes', code: 'true' },
{ display: 'No', code: 'false' }
]
return (
<div className={classes.booleanPropertiesTableWrapper}>
<Formik
validateOnBlur={false}
validateOnChange={false}
enableReinitialize
onSubmit={innerSave}
initialValues={data || initialValues}
initialValues={initialValues}
schemaValidation={schemaValidation}>
<Form>
<div className={classes.rowWrapper}>
<H4>{title}</H4>
{editing ? (
<div className={classes.rightAligned}>
<Link type="submit" color="primary">
Save
</Link>
<Link
className={classes.rightLink}
onClick={innerCancel}
color="secondary">
Cancel
</Link>
{({ resetForm }) => {
return (
<Form>
<div className={classes.rowWrapper}>
<H4>{title}</H4>
{editing ? (
<div className={classes.rightAligned}>
<Link type="submit" color="primary">
Save
</Link>
<Link
type="reset"
className={classes.rightLink}
onClick={() => {
resetForm()
setEditing(false)
}}
color="secondary">
Cancel
</Link>
</div>
) : (
<IconButton
className={classes.transparentButton}
onClick={() => setEditing(true)}>
{disabled ? <EditIconDisabled /> : <EditIcon />}
</IconButton>
)}
</div>
) : (
<IconButton
className={classes.transparentButton}
onClick={() => setEditing(true)}>
{disabled ? <EditIconDisabled /> : <EditIcon />}
</IconButton>
)}
</div>
<PromptWhenDirty />
<Table className={classes.fillColumn}>
<TableBody className={classes.fillColumn}>
{elements.map((it, idx) => (
<TableRow key={idx} size="sm" className={classes.tableRow}>
<TableCell className={classes.leftTableCell}>
{it.display}
</TableCell>
<TableCell className={classes.rightTableCell}>
{editing && (
<FormikField
component={RadioGroup}
name={it.name}
options={radioButtonOptions}
className={classnames(
classes.radioButtons,
classes.rightTableCell
<PromptWhenDirty />
<Table className={classes.fillColumn}>
<TableBody className={classes.fillColumn}>
{elements.map((it, idx) => (
<TableRow
key={idx}
size="sm"
className={classes.tableRow}>
<TableCell className={classes.leftTableCell}>
{it.display}
</TableCell>
<TableCell className={classes.rightTableCell}>
{editing && (
<FormikField
component={RadioGroup}
name={it.name}
options={radioButtonOptions}
className={classnames(
classes.radioButtons,
classes.rightTableCell
)}
/>
)}
/>
)}
{!editing && <BooleanCell name={it.name} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Form>
{!editing && <BooleanCell name={it.name} />}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Form>
)
}}
</Formik>
</div>
)

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,22 +102,24 @@ const IDButton = memo(
return (
<>
<button
aria-describedby={id}
onClick={handleClick}
className={classnames(classNames, className)}
{...props}>
{Icon && !open && (
<div className={classnames(iconClassNames)}>
<Icon />
</div>
)}
{InverseIcon && open && (
<div className={classnames(iconClassNames)}>
<InverseIcon />
</div>
)}
</button>
<ClickAwayListener onClickAway={handleClose}>
<button
aria-describedby={id}
onClick={handleClick}
className={classnames(classNames, className)}
{...props}>
{Icon && !open && (
<div className={classnames(iconClassNames)}>
<Icon />
</div>
)}
{InverseIcon && open && (
<div className={classnames(iconClassNames)}>
<InverseIcon />
</div>
)}
</button>
</ClickAwayListener>
<Popover
className={popoverClassname}
id={id}

View file

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

View file

@ -1,239 +1,272 @@
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 { Link, IconButton } from 'src/components/buttons'
import { Td, Tr } from 'src/components/fake-table/Table'
import { Switch } from 'src/components/inputs'
import { TL2 } from 'src/components/typography'
import { ReactComponent as DisabledDeleteIcon } from 'src/styling/icons/action/delete/disabled.svg'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import { ReactComponent as DisabledEditIcon } from 'src/styling/icons/action/edit/disabled.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
import TableCtx from './Context'
import styles from './Row.styles'
const useStyles = makeStyles(styles)
const ActionCol = ({ disabled, editing }) => {
const classes = useStyles()
const { values, submitForm, resetForm } = useFormikContext()
const {
editWidth,
onEdit,
enableEdit,
enableDelete,
disableRowEdit,
onDelete,
deleteWidth,
enableToggle,
onToggle,
toggleWidth,
forceAdd,
clearError,
actionColSize
} = useContext(TableCtx)
const disableEdit = disabled || (disableRowEdit && disableRowEdit(values))
const cancel = () => {
clearError()
resetForm()
}
return (
<>
{editing && (
<Td textAlign="center" width={actionColSize}>
<Link
className={classes.saveButton}
type="submit"
color="primary"
onClick={submitForm}>
Save
</Link>
{!forceAdd && (
<Link color="secondary" onClick={cancel}>
Cancel
</Link>
)}
</Td>
)}
{!editing && enableEdit && (
<Td textAlign="center" width={editWidth}>
<IconButton
disabled={disableEdit}
className={classes.editButton}
onClick={() => onEdit && onEdit(values.id)}>
{disableEdit ? <DisabledEditIcon /> : <EditIcon />}
</IconButton>
</Td>
)}
{!editing && enableDelete && (
<Td textAlign="center" width={deleteWidth}>
<IconButton disabled={disabled} onClick={() => onDelete(values.id)}>
{disabled ? <DisabledDeleteIcon /> : <DeleteIcon />}
</IconButton>
</Td>
)}
{!editing && enableToggle && (
<Td textAlign="center" width={toggleWidth}>
<Switch
checked={!!values.active}
value={!!values.active}
disabled={disabled}
onChange={() => onToggle(values.id)}
/>
</Td>
)}
</>
)
}
const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
const {
name,
bypassField,
input,
editable = true,
size,
bold,
width,
textAlign,
suffix,
SuffixComponent = TL2,
view = it => it?.toString(),
inputProps = {}
} = config
const { values } = useFormikContext()
const classes = useStyles({ textAlign, size })
const innerProps = {
fullWidth: true,
autoFocus: focus,
size,
bold,
textAlign,
...inputProps
}
// Autocomplete
if (innerProps.options && !innerProps.getLabel) {
innerProps.getLabel = view
}
const isEditing = editing && editable
const isField = !bypassField
return (
<Td
className={{
[classes.extraPaddingRight]: extraPaddingRight,
[classes.extraPadding]: extraPadding,
[classes.withSuffix]: suffix
}}
width={width}
size={size}
bold={bold}
textAlign={textAlign}>
{isEditing && isField && (
<Field name={name} component={input} {...innerProps} />
)}
{isEditing && !isField && <config.input name={name} />}
{!isEditing && values && <>{view(values[name], values)}</>}
{suffix && (
<SuffixComponent className={classes.suffix}>{suffix}</SuffixComponent>
)}
</Td>
)
}
const groupStriped = elements => {
const [toStripe, noStripe] = R.partition(R.has('stripe'))(elements)
if (!toStripe.length) {
return elements
}
const index = R.indexOf(toStripe[0], elements)
const width = R.compose(R.sum, R.map(R.path(['width'])))(toStripe)
return R.insert(
index,
{ width, editable: false, view: () => <StripesSvg /> },
noStripe
)
}
const ERow = ({ editing, disabled, lastOfGroup }) => {
const { touched, errors, values } = useFormikContext()
const {
elements,
enableEdit,
enableDelete,
error,
enableToggle,
rowSize,
stripeWhen
} = useContext(TableCtx)
const classes = useStyles()
const shouldStripe = stripeWhen && stripeWhen(values) && !editing
const innerElements = shouldStripe ? groupStriped(elements) : elements
const [toSHeader] = R.partition(R.has('doubleHeader'))(elements)
const extraPaddingIndex = toSHeader?.length
? R.indexOf(toSHeader[0], elements)
: -1
const extraPaddingRightIndex = toSHeader?.length
? R.indexOf(toSHeader[toSHeader.length - 1], elements)
: -1
const elementToFocusIndex = innerElements.findIndex(
it => it.editable === undefined || it.editable
)
const classNames = {
[classes.lastOfGroup]: lastOfGroup
}
const touchedErrors = R.pick(R.keys(touched), errors)
const hasTouchedErrors = touchedErrors && R.keys(touchedErrors).length > 0
const hasErrors = hasTouchedErrors || !!error
const errorMessage =
error || (touchedErrors && R.values(touchedErrors).join(', '))
return (
<Tr
className={classnames(classNames)}
size={rowSize}
error={editing && hasErrors}
errorMessage={errorMessage}>
{innerElements.map((it, idx) => {
return (
<ECol
key={idx}
config={it}
editing={editing}
focus={idx === elementToFocusIndex && editing}
extraPaddingRight={extraPaddingRightIndex === idx}
extraPadding={extraPaddingIndex === idx}
/>
)
})}
{(enableEdit || enableDelete || enableToggle) && (
<ActionCol disabled={disabled} editing={editing} />
)}
</Tr>
)
}
export default ERow
import { makeStyles } from '@material-ui/core'
import classnames from 'classnames'
import { Field, useFormikContext } from 'formik'
import * as R from 'ramda'
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'
import { TL2 } from 'src/components/typography'
import { ReactComponent as DisabledDeleteIcon } from 'src/styling/icons/action/delete/disabled.svg'
import { ReactComponent as DeleteIcon } from 'src/styling/icons/action/delete/enabled.svg'
import { ReactComponent as DisabledEditIcon } from 'src/styling/icons/action/edit/disabled.svg'
import { ReactComponent as EditIcon } from 'src/styling/icons/action/edit/enabled.svg'
import { ReactComponent as StripesSvg } from 'src/styling/icons/stripes.svg'
import TableCtx from './Context'
import styles from './Row.styles'
const useStyles = makeStyles(styles)
const ActionCol = ({ disabled, editing }) => {
const classes = useStyles()
const { values, submitForm, resetForm } = useFormikContext()
const {
editWidth,
onEdit,
enableEdit,
enableDelete,
disableRowEdit,
onDelete,
deleteWidth,
enableToggle,
onToggle,
toggleWidth,
forceAdd,
clearError,
actionColSize,
error
} = useContext(TableCtx)
const disableEdit = disabled || (disableRowEdit && disableRowEdit(values))
const cancel = () => {
clearError()
resetForm()
}
const [deleteDialog, setDeleteDialog] = useState(false)
const onConfirmed = () => {
onDelete(values.id).then(res => {
if (!R.isNil(res)) setDeleteDialog(false)
})
}
return (
<>
{editing && (
<Td textAlign="center" width={actionColSize}>
<Link
className={classes.saveButton}
type="submit"
color="primary"
onClick={submitForm}>
Save
</Link>
{!forceAdd && (
<Link color="secondary" onClick={cancel}>
Cancel
</Link>
)}
</Td>
)}
{!editing && enableEdit && (
<Td textAlign="center" width={editWidth}>
<IconButton
disabled={disableEdit}
className={classes.editButton}
onClick={() => onEdit && onEdit(values.id)}>
{disableEdit ? <DisabledEditIcon /> : <EditIcon />}
</IconButton>
</Td>
)}
{!editing && enableDelete && (
<Td textAlign="center" width={deleteWidth}>
<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 && (
<Td textAlign="center" width={toggleWidth}>
<Switch
checked={!!values.active}
value={!!values.active}
disabled={disabled}
onChange={() => onToggle(values.id)}
/>
</Td>
)}
</>
)
}
const ECol = ({ editing, focus, config, extraPaddingRight, extraPadding }) => {
const {
name,
bypassField,
input,
editable = true,
size,
bold,
width,
textAlign,
editingAlign = textAlign,
suffix,
SuffixComponent = TL2,
textStyle = it => {},
view = it => it?.toString(),
inputProps = {}
} = config
const { values } = useFormikContext()
const isEditing = editing && editable
const isField = !bypassField
const classes = useStyles({
textAlign: isEditing ? editingAlign : textAlign,
size
})
const innerProps = {
fullWidth: true,
autoFocus: focus,
size,
bold,
textAlign: isEditing ? editingAlign : textAlign,
...inputProps
}
return (
<Td
className={{
[classes.extraPaddingRight]: extraPaddingRight,
[classes.extraPadding]: extraPadding,
[classes.withSuffix]: suffix
}}
width={width}
size={size}
bold={bold}
textAlign={textAlign}>
{isEditing && isField && (
<Field name={name} component={input} {...innerProps} />
)}
{isEditing && !isField && <config.input name={name} />}
{!isEditing && values && (
<div style={textStyle(values, isEditing)}>
{view(values[name], values)}
</div>
)}
{suffix && (
<SuffixComponent
className={classes.suffix}
style={isEditing ? {} : textStyle(values, isEditing)}>
{suffix}
</SuffixComponent>
)}
</Td>
)
}
const groupStriped = elements => {
const [toStripe, noStripe] = R.partition(R.propEq('stripe', true))(elements)
if (!toStripe.length) {
return elements
}
const index = R.indexOf(toStripe[0], elements)
const width = R.compose(R.sum, R.map(R.path(['width'])))(toStripe)
return R.insert(
index,
{ width, editable: false, view: () => <StripesSvg /> },
noStripe
)
}
const ERow = ({ editing, disabled, lastOfGroup }) => {
const { touched, errors, values } = useFormikContext()
const {
elements,
enableEdit,
enableDelete,
error,
enableToggle,
rowSize,
stripeWhen
} = useContext(TableCtx)
const classes = useStyles()
const shouldStripe = stripeWhen && stripeWhen(values)
const innerElements = shouldStripe ? groupStriped(elements) : elements
const [toSHeader] = R.partition(R.has('doubleHeader'))(elements)
const extraPaddingIndex = toSHeader?.length
? R.indexOf(toSHeader[0], elements)
: -1
const extraPaddingRightIndex = toSHeader?.length
? R.indexOf(toSHeader[toSHeader.length - 1], elements)
: -1
const elementToFocusIndex = innerElements.findIndex(
it => it.editable === undefined || it.editable
)
const classNames = {
[classes.lastOfGroup]: lastOfGroup
}
const touchedErrors = R.pick(R.keys(touched), errors)
const hasTouchedErrors = touchedErrors && R.keys(touchedErrors).length > 0
const hasErrors = hasTouchedErrors || !!error
const errorMessage =
error || (touchedErrors && R.values(touchedErrors).join(', '))
return (
<Tr
className={classnames(classNames)}
size={rowSize}
error={editing && hasErrors}
errorMessage={errorMessage}>
{innerElements.map((it, idx) => {
return (
<ECol
key={idx}
config={it}
editing={editing}
focus={idx === elementToFocusIndex && editing}
extraPaddingRight={extraPaddingRightIndex === idx}
extraPadding={extraPaddingIndex === idx}
/>
)
})}
{(enableEdit || enableDelete || enableToggle) && (
<ActionCol disabled={disabled} editing={editing} />
)}
</Tr>
)
}
export default ERow

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,53 +181,60 @@ const ETable = ({
)}
<Table>
<Header />
<TBody>
{adding && (
<Formik
initialValues={{ id: v4(), ...initialValues }}
onReset={onReset}
validationSchema={validationSchema}
onSubmit={innerSave}>
<Form>
<PromptWhenDirty />
<ERow editing={true} disabled={forceDisable} />
</Form>
</Formik>
)}
{innerData.map((it, idx) => {
const nextElement = innerData[idx + 1]
const canGroup = !!groupBy && nextElement
const isFunction = R.type(groupBy) === 'Function'
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
const isLastOfGroup =
canGroup && groupFunction(it) !== groupFunction(nextElement)
return (
<div className={tbodyWrapperClass}>
<TBody>
{adding && (
<Formik
key={it.id ?? idx}
enableReinitialize
initialValues={it}
validateOnBlur={false}
validateOnChange={false}
initialValues={{ id: v4(), ...initialValues }}
onReset={onReset}
validationSchema={validationSchema}
onSubmit={innerSave}>
<Form>
<PromptWhenDirty />
<ERow
lastOfGroup={isLastOfGroup}
editing={editingId === it.id}
disabled={
forceDisable ||
(editingId && editingId !== it.id) ||
adding
}
/>
<ERow editing={true} disabled={forceDisable} />
</Form>
</Formik>
)
})}
</TBody>
)}
{innerData.map((it, idx) => {
const nextElement = innerData[idx + 1]
const canGroup = !!groupBy && nextElement
const isFunction = R.type(groupBy) === 'Function'
const groupFunction = isFunction ? groupBy : R.prop(groupBy)
const isLastOfGroup =
canGroup &&
groupFunction(it) !== groupFunction(nextElement)
return (
<Formik
validateOnBlur={false}
validateOnChange={false}
key={it.id ?? idx}
enableReinitialize
initialValues={it}
onReset={onReset}
validationSchema={validationSchema}
onSubmit={innerSave}>
<Form>
<PromptWhenDirty />
<ERow
lastOfGroup={isLastOfGroup}
editing={editingId === it.id}
disabled={
forceDisable ||
(editingId && editingId !== it.id) ||
adding
}
/>
</Form>
</Formik>
)
})}
</TBody>
</div>
</Table>
</>
)}

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

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,20 +115,22 @@ const CashOut = ({
<div className={classes.col}>
<Cashbox className={className} percent={percent} cashOut />
</div>
<div className={classes.col2}>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes}</Info2>
<Chip
className={classes.chip}
label={`${denomination} ${currency.code}`}
/>
{!editingMode && (
<div className={classes.col2}>
<div className={classes.innerRow}>
<Info2 className={classes.noMarginText}>{notes}</Info2>
<Chip
className={classes.chip}
label={`${denomination} ${currency.code}`}
/>
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{notes * denomination} {currency.code}
</Label1>
</div>
</div>
<div className={classes.innerRow}>
<Label1 className={classes.noMarginText}>
{notes * denomination} {currency.code}
</Label1>
</div>
</div>
)}
</div>
</>
)

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

@ -1,23 +1,24 @@
import React, { memo } from 'react'
import { SecretInput } from '../base'
const SecretInputFormik = memo(({ ...props }) => {
const { name, onChange, onBlur, value } = props.field
const { touched, errors } = props.form
const error = !!(touched[name] && errors[name])
return (
<SecretInput
name={name}
onChange={onChange}
onBlur={onBlur}
value={value}
error={error}
{...props}
/>
)
})
export default SecretInputFormik
import React, { memo } from 'react'
import { SecretInput } from '../base'
const SecretInputFormik = memo(({ isPasswordFilled, ...props }) => {
const { name, onChange, onBlur, value } = props.field
const { touched, errors } = props.form
const error = !isPasswordFilled && !!(touched[name] && errors[name])
return (
<SecretInput
name={name}
isPasswordFilled={isPasswordFilled}
onChange={onChange}
onBlur={onBlur}
value={value}
error={error}
{...props}
/>
)
})
export default SecretInputFormik

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 => {
setExpanded(id === expanded ? null : id)
const expandRow = (id, data) => {
if (data.id) {
cache.clear(data.id)
setExpanded(data.id === expanded ? null : data.id)
} else {
cache.clear(id)
setExpanded(id === expanded ? null : id)
}
}
const cache = new CellMeasurerCache({
defaultHeight: 62,
defaultHeight: 58,
fixedWidth: true
})
@ -126,20 +135,27 @@ const DataTable = ({
key={key}
parent={parent}
rowIndex={index}>
<div style={style}>
<Row
width={width}
id={index}
expWidth={expWidth}
elements={elements}
data={data[index]}
Details={Details}
expanded={index === expanded}
expandRow={expandRow}
expandable={expandable}
onClick={onClick}
/>
</div>
{({ registerChild }) => (
<div ref={registerChild} style={style}>
<Row
width={width}
size={rowSize}
id={data[index].id ? data[index].id : index}
expWidth={expWidth}
elements={elements}
data={data[index]}
Details={Details}
expanded={
data[index].id
? data[index].id === expanded
: index === expanded
}
expandRow={expandRow}
expandable={expandable}
onClick={onClick}
/>
</div>
)}
</CellMeasurer>
)
}
@ -161,7 +177,7 @@ const DataTable = ({
</THead>
<TBody className={classes.body}>
{loading && <H4>Loading...</H4>}
{!loading && R.isEmpty(data) && <H4>{emptyText}</H4>}
{!loading && R.isEmpty(data) && <EmptyTable message={emptyText} />}
<AutoSizer disableWidth>
{({ height }) => (
<List
@ -173,7 +189,7 @@ const DataTable = ({
rowCount={data.length}
rowHeight={cache.rowHeight}
rowRenderer={rowRenderer}
overscanRowCount={50}
overscanRowCount={5}
deferredMeasurementCache={cache}
/>
)}

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,17 +74,29 @@ const QrCodeComponent = ({ classes, qrCode, name, count, onPaired }) => {
<QRCode size={240} fgColor={primaryColor} value={qrCode} />
</div>
<div className={classes.qrTextWrapper}>
<div className={classes.qrCodeWrapper}>
<div className={classes.qrTextInfoWrapper}>
<div className={classes.qrTextIcon}>
<WarningIcon />
</div>
<P className={classes.qrText}>
To pair the machine you need scan the QR code with your machine.
To do this either snap a picture of this QR code or download it
through the button above and scan it with the scanning bay on your
machine.
</P>
<div className={classes.textWrapper}>
<P className={classes.qrText}>
To pair the machine you need scan the QR code with your machine.
To do this either snap a picture of this QR code or download it
through the button above and scan it with the scanning bay on
your machine.
</P>
</div>
</div>
{hasNewMachine && (
<div className={classes.successMessageWrapper}>
<div className={classes.successMessageIcon}>
<CompleteStageIconSpring />
</div>
<Info2 className={classes.successMessage}>
Machine has been successfully paired!
</Info2>
</div>
)}
</div>
</div>
</>
@ -102,6 +131,8 @@ const MachineNameComponent = ({ nextStep, classes, setQrCode, setName }) => {
Machine Name (ex: Coffee shop 01)
</Info2>
<Formik
validateOnBlur={false}
validateOnChange={false}
initialValues={initialValues}
validationSchema={validationSchema}
onSubmit={({ name }) => {

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