Merge branch 'dev' into feat/lam-1291/stress-testing
* dev: (41 commits) build: use bullseye as target build chore: remove whitespace refactor: simplify denominations list construction fix: sort coins by descending denomination feat: address prompt feature toggle on ui feat: reuse last address option fix: performance issues on SystemPerformance chore: clarify requirements on comment feat: allow address reuse if same customer feat: address reuse is now per customer fix: hide anon and show phone on customers fix: dev environment restarts feat: batch diagnostics script fix: custom info request returns array fix: name on customer if custom data is filled build: testing cache hit build: server cache improvements build: node_modules was ignored on .dockerignored build: leftovers from npm chore: commented by mistake ...
This commit is contained in:
commit
5feee6d5df
105 changed files with 17323 additions and 31348 deletions
|
|
@ -9,6 +9,7 @@ const logger = require('../logger')
|
|||
const settingsLoader = require('../new-settings-loader')
|
||||
const configManager = require('../new-config-manager')
|
||||
const notifier = require('../notifier')
|
||||
const constants = require('../constants')
|
||||
|
||||
const cashInAtomic = require('./cash-in-atomic')
|
||||
const cashInLow = require('./cash-in-low')
|
||||
|
|
@ -194,14 +195,27 @@ function postProcess(r, pi, isBlacklisted, addressReuse, walletScore) {
|
|||
})
|
||||
}
|
||||
|
||||
// This feels like it can be simplified,
|
||||
// but it's the most concise query to express the requirement and its edge cases.
|
||||
// At most only one authenticated customer can use an address.
|
||||
// If the current customer is anon, we can still allow one other customer to use the address,
|
||||
// So we count distinct customers plus the current customer if they are not anonymous.
|
||||
// To prevent malicious blocking of address, we only check for txs with actual fiat
|
||||
function doesTxReuseAddress(tx) {
|
||||
const sql = `
|
||||
SELECT EXISTS (
|
||||
SELECT DISTINCT to_address FROM (
|
||||
SELECT to_address FROM cash_in_txs WHERE id != $1
|
||||
) AS x WHERE to_address = $2
|
||||
)`
|
||||
return db.one(sql, [tx.id, tx.toAddress]).then(({ exists }) => exists)
|
||||
SELECT COUNT(*) > 1 as exists
|
||||
FROM (SELECT DISTINCT customer_id
|
||||
FROM cash_in_txs
|
||||
WHERE to_address = $1
|
||||
AND customer_id != $3
|
||||
AND fiat > 0
|
||||
UNION
|
||||
SELECT $2
|
||||
WHERE $2 != $3) t;
|
||||
`
|
||||
return db
|
||||
.one(sql, [tx.toAddress, tx.customerId, constants.anonymousCustomer.uuid])
|
||||
.then(({ exists }) => exists)
|
||||
}
|
||||
|
||||
function getWalletScore(tx, pi) {
|
||||
|
|
|
|||
|
|
@ -9,11 +9,12 @@
|
|||
*/
|
||||
const prepare_denominations = denominations =>
|
||||
JSON.parse(JSON.stringify(denominations))
|
||||
.sort(([d1], [d2]) => d1 < d2)
|
||||
.sort(([d1], [d2]) => d2 - d1)
|
||||
.reduce(
|
||||
([csum, denoms], [denom, count]) => {
|
||||
csum += denom * count
|
||||
return [csum, [{ denom, count, csum }].concat(denoms)]
|
||||
denoms.push({ denom, count, csum })
|
||||
return [csum, denoms]
|
||||
},
|
||||
[0, []],
|
||||
)[1] /* ([csum, denoms]) => denoms */
|
||||
|
|
|
|||
|
|
@ -8,16 +8,17 @@ const fs = require('fs')
|
|||
const util = require('util')
|
||||
|
||||
const db = require('./db')
|
||||
const anonymous = require('../lib/constants').anonymousCustomer
|
||||
const complianceOverrides = require('./compliance_overrides')
|
||||
const writeFile = util.promisify(fs.writeFile)
|
||||
const notifierQueries = require('./notifier/queries')
|
||||
const notifierUtils = require('./notifier/utils')
|
||||
const NUM_RESULTS = 1000
|
||||
const sms = require('./sms')
|
||||
const settingsLoader = require('./new-settings-loader')
|
||||
const logger = require('./logger')
|
||||
const externalCompliance = require('./compliance-external')
|
||||
const {
|
||||
customers: { getCustomerList },
|
||||
} = require('typesafe-db')
|
||||
|
||||
const { APPROVED, RETRY } = require('./plugins/compliance/consts')
|
||||
|
||||
|
|
@ -483,28 +484,6 @@ function addComplianceOverrides(id, customer, userToken) {
|
|||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query all customers
|
||||
*
|
||||
* Add status as computed column,
|
||||
* which will indicate the name of the latest
|
||||
* compliance verfication completed by user.
|
||||
*
|
||||
* @returns {array} Array of customers populated with status field
|
||||
*/
|
||||
function batch() {
|
||||
const sql = `select * from customers
|
||||
where id != $1
|
||||
order by created desc limit $2`
|
||||
return db.any(sql, [anonymous.uuid, NUM_RESULTS]).then(customers =>
|
||||
Promise.all(
|
||||
_.map(customer => {
|
||||
return getCustomInfoRequestsData(customer).then(camelize)
|
||||
}, customers),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function getSlimCustomerByIdBatch(ids) {
|
||||
const sql = `SELECT id, phone, id_card_data
|
||||
FROM customers
|
||||
|
|
@ -512,88 +491,8 @@ function getSlimCustomerByIdBatch(ids) {
|
|||
return db.any(sql, [ids]).then(customers => _.map(camelize, customers))
|
||||
}
|
||||
|
||||
// TODO: getCustomersList and getCustomerById are very similar, so this should be refactored
|
||||
|
||||
/**
|
||||
* Query all customers, ordered by last activity
|
||||
* and with aggregate columns based on their
|
||||
* transactions
|
||||
*
|
||||
* @returns {array} Array of customers with it's transactions aggregations
|
||||
*/
|
||||
|
||||
function getCustomersList(
|
||||
phone = null,
|
||||
name = null,
|
||||
address = null,
|
||||
id = null,
|
||||
email = 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, email, 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, GREATEST(created, last_transaction, last_data_provided, last_auth_attempt) AS last_active, fiat AS last_tx_fiat,
|
||||
fiat_code AS last_tx_fiat_code, tx_class AS last_tx_class, custom_fields, notes, is_test_customer
|
||||
FROM (
|
||||
SELECT c.id, c.authorized_override,
|
||||
greatest(0, date_part('day', c.suspended_until - NOW())) AS days_suspended,
|
||||
c.suspended_until > NOW() AS is_suspended,
|
||||
c.front_camera_path, c.front_camera_override,
|
||||
c.phone, c.email, 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.last_auth_attempt,
|
||||
GREATEST(c.phone_at, c.email_at, c.id_card_data_at, c.front_camera_at, c.id_card_photo_at, c.us_ssn_at) AS last_data_provided,
|
||||
c.sanctions_at, c.sanctions_override, c.is_test_customer, c.created, t.tx_class, t.fiat, t.fiat_code, t.created as last_transaction, cn.notes,
|
||||
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,
|
||||
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, ccf.custom_fields
|
||||
FROM customers c LEFT OUTER JOIN (
|
||||
SELECT 'cashIn' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
|
||||
FROM cash_in_txs WHERE send_confirmed = true OR batched = true UNION
|
||||
SELECT 'cashOut' AS tx_class, id, fiat, fiat_code, created, customer_id, error_code
|
||||
FROM cash_out_txs WHERE confirmed_at IS NOT NULL) AS t ON c.id = t.customer_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT cf.customer_id, json_agg(json_build_object('id', cf.custom_field_id, 'label', cf.label, 'value', cf.value)) AS custom_fields FROM (
|
||||
SELECT ccfp.custom_field_id, ccfp.customer_id, cfd.label, ccfp.value FROM custom_field_definitions cfd
|
||||
LEFT OUTER JOIN customer_custom_field_pairs ccfp ON cfd.id = ccfp.custom_field_id
|
||||
) cf GROUP BY cf.customer_id
|
||||
) ccf ON c.id = ccf.customer_id
|
||||
LEFT OUTER JOIN (
|
||||
SELECT customer_id, coalesce(json_agg(customer_notes.*), '[]'::json) AS notes FROM customer_notes
|
||||
GROUP BY customer_notes.customer_id
|
||||
) cn ON c.id = cn.customer_id
|
||||
WHERE c.id != $2
|
||||
) AS cl WHERE rn = 1
|
||||
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)
|
||||
AND ($8 IS NULL OR email = $8)
|
||||
ORDER BY last_active DESC
|
||||
limit $3`
|
||||
return db
|
||||
.any(sql, [
|
||||
passableErrorCodes,
|
||||
anonymous.uuid,
|
||||
NUM_RESULTS,
|
||||
phone,
|
||||
name,
|
||||
address,
|
||||
id,
|
||||
email,
|
||||
])
|
||||
.then(customers =>
|
||||
Promise.all(
|
||||
_.map(
|
||||
customer => getCustomInfoRequestsData(customer).then(camelizeDeep),
|
||||
customers,
|
||||
),
|
||||
),
|
||||
)
|
||||
function getCustomersList() {
|
||||
return getCustomerList({ withCustomInfoRequest: true })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1081,12 +980,10 @@ function notifyApprovedExternalCompliance(settings, customerId) {
|
|||
|
||||
function checkExternalCompliance(settings) {
|
||||
return getOpenExternalCompliance().then(externals => {
|
||||
console.log(externals)
|
||||
const promises = _.map(external => {
|
||||
return externalCompliance
|
||||
.getStatus(settings, external.service, external.customer_id)
|
||||
.then(status => {
|
||||
console.log('status', status, external.customer_id, external.service)
|
||||
if (status.status.answer === RETRY)
|
||||
notifyRetryExternalCompliance(
|
||||
settings,
|
||||
|
|
@ -1112,12 +1009,16 @@ function addExternalCompliance(customerId, service, id) {
|
|||
return db.none(sql, [customerId, id, service])
|
||||
}
|
||||
|
||||
function getLastUsedAddress(id, cryptoCode) {
|
||||
const sql = `SELECT to_address FROM cash_in_txs WHERE customer_id=$1 AND crypto_code=$2 AND fiat > 0 ORDER BY created DESC LIMIT 1`
|
||||
return db.oneOrNone(sql, [id, cryptoCode]).then(it => it?.to_address)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
add,
|
||||
addWithEmail,
|
||||
get,
|
||||
getWithEmail,
|
||||
batch,
|
||||
getSlimCustomerByIdBatch,
|
||||
getCustomersList,
|
||||
getCustomerById,
|
||||
|
|
@ -1139,4 +1040,5 @@ module.exports = {
|
|||
updateLastAuthAttempt,
|
||||
addExternalCompliance,
|
||||
checkExternalCompliance,
|
||||
getLastUsedAddress,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ const eventBus = require('./event-bus')
|
|||
const DATABASE_NOT_REACHABLE = 'Database not reachable.'
|
||||
|
||||
const pgp = Pgp({
|
||||
pgNative: true,
|
||||
schema: 'public',
|
||||
error: (err, e) => {
|
||||
if (e.cn) logger.error(DATABASE_NOT_REACHABLE)
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ function getBitPayFxRate(
|
|||
fiatCodeProperty,
|
||||
rateProperty,
|
||||
) {
|
||||
return getFiatRates().then(({ data: fxRates }) => {
|
||||
return getFiatRates().then(fxRates => {
|
||||
const defaultFiatRate = findCurrencyRates(
|
||||
fxRates,
|
||||
defaultFiatMarket,
|
||||
|
|
@ -69,14 +69,15 @@ const getRate = (retries = 1, fiatCode, defaultFiatMarket) => {
|
|||
defaultFiatMarket,
|
||||
fiatCodeProperty,
|
||||
rateProperty,
|
||||
).catch(() => {
|
||||
// Switch service
|
||||
).catch(err => {
|
||||
const erroredService = API_QUEUE.shift()
|
||||
API_QUEUE.push(erroredService)
|
||||
if (retries >= MAX_ROTATIONS)
|
||||
throw new Error(`FOREX API error from ${erroredService.name}`)
|
||||
throw new Error(
|
||||
`FOREX API error from ${erroredService.name} ${err?.message}`,
|
||||
)
|
||||
|
||||
return getRate(++retries, fiatCode)
|
||||
return getRate(++retries, fiatCode, defaultFiatMarket)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -523,6 +523,43 @@ function diagnostics(rec) {
|
|||
)
|
||||
}
|
||||
|
||||
function batchDiagnostics(deviceIds, operatorId) {
|
||||
const diagnosticsDir = `${OPERATOR_DATA_DIR}/diagnostics/`
|
||||
|
||||
const removeDir = fsPromises
|
||||
.rm(diagnosticsDir, { recursive: true })
|
||||
.catch(err => {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
const sql = `UPDATE devices
|
||||
SET diagnostics_timestamp = NULL,
|
||||
diagnostics_scan_updated_at = NULL,
|
||||
diagnostics_front_updated_at = NULL
|
||||
WHERE device_id = ANY($1)`
|
||||
|
||||
// Send individual notifications for each machine
|
||||
const sendNotifications = deviceIds.map(deviceId =>
|
||||
db.none('NOTIFY $1:name, $2', [
|
||||
'machineAction',
|
||||
JSON.stringify({
|
||||
action: 'diagnostics',
|
||||
value: {
|
||||
deviceId,
|
||||
operatorId,
|
||||
action: 'diagnostics',
|
||||
},
|
||||
}),
|
||||
]),
|
||||
)
|
||||
|
||||
return removeDir
|
||||
.then(() => db.none(sql, [deviceIds]))
|
||||
.then(() => Promise.all(sendNotifications))
|
||||
}
|
||||
|
||||
function setMachine(rec, operatorId) {
|
||||
rec.operatorId = operatorId
|
||||
switch (rec.action) {
|
||||
|
|
@ -681,4 +718,5 @@ module.exports = {
|
|||
refillMachineUnits,
|
||||
updateDiagnostics,
|
||||
updateFailedQRScans,
|
||||
batchDiagnostics,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,18 +27,5 @@ function transaction() {
|
|||
|
||||
return db.any(sql)
|
||||
}
|
||||
function customer() {
|
||||
const sql = `SELECT DISTINCT * FROM (
|
||||
SELECT 'phone' AS type, phone AS value FROM customers WHERE phone IS NOT NULL UNION
|
||||
SELECT 'email' AS type, email AS value FROM customers WHERE email IS NOT NULL UNION
|
||||
SELECT 'name' AS type, id_card_data::json->>'firstName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NULL UNION
|
||||
SELECT 'name' AS type, id_card_data::json->>'lastName' AS value FROM customers WHERE id_card_data::json->>'firstName' IS NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
|
||||
SELECT 'name' AS type, concat(id_card_data::json->>'firstName', ' ', id_card_data::json->>'lastName') AS value FROM customers WHERE id_card_data::json->>'firstName' IS NOT NULL AND id_card_data::json->>'lastName' IS NOT NULL UNION
|
||||
SELECT 'address' as type, id_card_data::json->>'address' AS value FROM customers WHERE id_card_data::json->>'address' IS NOT NULL UNION
|
||||
SELECT 'id' AS type, id_card_data::json->>'documentNumber' AS value FROM customers WHERE id_card_data::json->>'documentNumber' IS NOT NULL
|
||||
) f`
|
||||
|
||||
return db.any(sql)
|
||||
}
|
||||
|
||||
module.exports = { transaction, customer }
|
||||
module.exports = { transaction }
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
const authentication = require('../modules/userManagement')
|
||||
const anonymous = require('../../../constants').anonymousCustomer
|
||||
const customers = require('../../../customers')
|
||||
const filters = require('../../filters')
|
||||
const customerNotes = require('../../../customer-notes')
|
||||
const machineLoader = require('../../../machine-loader')
|
||||
|
||||
|
|
@ -18,11 +17,9 @@ const resolvers = {
|
|||
isAnonymous: parent => parent.customerId === anonymous.uuid,
|
||||
},
|
||||
Query: {
|
||||
customers: (...[, { phone, email, name, address, id }]) =>
|
||||
customers.getCustomersList(phone, name, address, id, email),
|
||||
customers: () => customers.getCustomersList(),
|
||||
customer: (...[, { customerId }]) =>
|
||||
customers.getCustomerById(customerId).then(addLastUsedMachineName),
|
||||
customerFilters: () => filters.customer(),
|
||||
},
|
||||
Mutation: {
|
||||
setCustomer: (root, { customerId, customerInput }, context) => {
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function ticker(fiatCode, cryptoCode, tickerName) {
|
|||
return getCurrencyRates(ticker, fiatCode, cryptoCode)
|
||||
}
|
||||
|
||||
return getRate(RETRIES, tickerName, defaultFiatMarket(tickerName)).then(
|
||||
return getRate(RETRIES, fiatCode, defaultFiatMarket(tickerName)).then(
|
||||
({ fxRate }) => {
|
||||
try {
|
||||
return getCurrencyRates(
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ const loadRoutes = async () => {
|
|||
app.use(compression({ threshold: 500 }))
|
||||
app.use(helmet())
|
||||
app.use(nocache())
|
||||
app.use(express.json({ limit: '2mb' }))
|
||||
app.use(express.json({ limit: '25mb' }))
|
||||
|
||||
morgan.token('bytesRead', (_req, res) => res.bytesRead)
|
||||
morgan.token('bytesWritten', (_req, res) => res.bytesWritten)
|
||||
|
|
|
|||
|
|
@ -311,7 +311,13 @@ function getExternalComplianceLink(req, res, next) {
|
|||
.then(url => respond(req, res, { url }))
|
||||
}
|
||||
|
||||
function addOrUpdateCustomer(customerData, deviceId, config, isEmailAuth) {
|
||||
function addOrUpdateCustomer(
|
||||
customerData,
|
||||
deviceId,
|
||||
config,
|
||||
isEmailAuth,
|
||||
cryptoCode,
|
||||
) {
|
||||
const triggers = configManager.getTriggers(config)
|
||||
const maxDaysThreshold = complianceTriggers.maxDaysThreshold(triggers)
|
||||
|
||||
|
|
@ -346,6 +352,18 @@ function addOrUpdateCustomer(customerData, deviceId, config, isEmailAuth) {
|
|||
.getCustomerActiveIndividualDiscount(customer.id)
|
||||
.then(discount => ({ ...customer, discount }))
|
||||
})
|
||||
.then(customer => {
|
||||
const enableLastUsedAddress = !!configManager.getWalletSettings(
|
||||
cryptoCode,
|
||||
config,
|
||||
).enableLastUsedAddress
|
||||
if (!cryptoCode || !enableLastUsedAddress) return customer
|
||||
return customers
|
||||
.getLastUsedAddress(customer.id, cryptoCode)
|
||||
.then(lastUsedAddress => {
|
||||
return { ...customer, lastUsedAddress }
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function getOrAddCustomerPhone(req, res, next) {
|
||||
|
|
@ -354,6 +372,7 @@ function getOrAddCustomerPhone(req, res, next) {
|
|||
|
||||
const pi = plugins(req.settings, deviceId)
|
||||
const phone = req.body.phone
|
||||
const cryptoCode = req.query.cryptoCode
|
||||
|
||||
return pi
|
||||
.getPhoneCode(phone)
|
||||
|
|
@ -363,6 +382,7 @@ function getOrAddCustomerPhone(req, res, next) {
|
|||
deviceId,
|
||||
req.settings.config,
|
||||
false,
|
||||
cryptoCode,
|
||||
).then(customer => respond(req, res, { code, customer }))
|
||||
})
|
||||
.catch(err => {
|
||||
|
|
@ -375,6 +395,7 @@ function getOrAddCustomerPhone(req, res, next) {
|
|||
function getOrAddCustomerEmail(req, res, next) {
|
||||
const deviceId = req.deviceId
|
||||
const customerData = req.body
|
||||
const cryptoCode = req.query.cryptoCode
|
||||
|
||||
const pi = plugins(req.settings, req.deviceId)
|
||||
const email = req.body.email
|
||||
|
|
@ -387,6 +408,7 @@ function getOrAddCustomerEmail(req, res, next) {
|
|||
deviceId,
|
||||
req.settings.config,
|
||||
true,
|
||||
cryptoCode,
|
||||
).then(customer => respond(req, res, { code, customer }))
|
||||
})
|
||||
.catch(err => {
|
||||
|
|
|
|||
|
|
@ -53,7 +53,12 @@ function getTx(req, res, next) {
|
|||
return helpers
|
||||
.fetchStatusTx(req.params.id, req.query.status)
|
||||
.then(r => res.json(r))
|
||||
.catch(next)
|
||||
.catch(err => {
|
||||
if (err.name === 'HTTPError') {
|
||||
return res.status(err.code).send(err.message)
|
||||
}
|
||||
next(err)
|
||||
})
|
||||
}
|
||||
|
||||
return next(httpError('Not Found', 404))
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const T = require('./time')
|
|||
// FP operations on Postgres result in very big errors.
|
||||
// E.g.: 1853.013808 * 1000 = 1866149.494
|
||||
const REDEEMABLE_AGE = T.day / 1000
|
||||
const MAX_THRESHOLD_DAYS = 365 * 50 // 50 years maximum
|
||||
|
||||
function process(tx, pi) {
|
||||
const mtx = massage(tx)
|
||||
|
|
@ -92,7 +93,9 @@ function customerHistory(customerId, thresholdDays) {
|
|||
AND fiat > 0
|
||||
) ch WHERE NOT ch.expired ORDER BY ch.created`
|
||||
|
||||
const days = _.isNil(thresholdDays) ? 0 : thresholdDays
|
||||
const days = _.isNil(thresholdDays)
|
||||
? 0
|
||||
: Math.min(thresholdDays, MAX_THRESHOLD_DAYS)
|
||||
return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE])
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue