Merge branch 'dev' into update_install-nix

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,7 +25,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, USDT] },
{ code: 'geth', display: 'geth (DEPRECATED)', class: WALLET, cryptos: [ETH, USDT], deprecated: true },
{ code: 'geth', display: 'geth', class: WALLET, cryptos: [ETH, USDT] },
{ code: 'zcashd', display: 'zcashd', class: WALLET, cryptos: [ZEC] },
{ code: 'litecoind', display: 'litecoind', class: WALLET, cryptos: [LTC] },
{ code: 'dashd', display: 'dashd', class: WALLET, cryptos: [DASH] },

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After

View file

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

4
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

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

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before After
Before After