Merge pull request #1889 from RafaelTaranto/feat/txs-new-table

LAM-1439 feat: txs new table
This commit is contained in:
Rafael Taranto 2025-06-24 12:26:22 +01:00 committed by GitHub
commit d0aaf6c170
32 changed files with 1244 additions and 723 deletions

View file

@ -3,6 +3,9 @@ const anonymous = require('../../../constants').anonymousCustomer
const customers = require('../../../customers')
const customerNotes = require('../../../customer-notes')
const machineLoader = require('../../../machine-loader')
const {
customers: { searchCustomers },
} = require('typesafe-db')
const addLastUsedMachineName = customer =>
(customer.lastUsedMachine
@ -20,6 +23,8 @@ const resolvers = {
customers: () => customers.getCustomersList(),
customer: (...[, { customerId }]) =>
customers.getCustomerById(customerId).then(addLastUsedMachineName),
searchCustomers: (...[, { searchTerm, limit = 20 }]) =>
searchCustomers(searchTerm, limit),
},
Mutation: {
setCustomer: (root, { customerId, customerInput }, context) => {

View file

@ -1,4 +1,3 @@
const DataLoader = require('dataloader')
const { parseAsync } = require('json2csv')
const filters = require('../../filters')
@ -8,15 +7,7 @@ const transactions = require('../../services/transactions')
const anonymous = require('../../../constants').anonymousCustomer
const logDateFormat = require('../../../logs').logDateFormat
const transactionsLoader = new DataLoader(
ids => transactions.getCustomerTransactionsBatch(ids),
{ cache: false },
)
const resolvers = {
Customer: {
transactions: parent => transactionsLoader.load(parent.id),
},
Transaction: {
isAnonymous: parent => parent.customerId === anonymous.uuid,
},
@ -32,6 +23,7 @@ const resolvers = {
txClass,
deviceId,
customerName,
customerId,
fiatCode,
cryptoCode,
toAddress,
@ -41,7 +33,7 @@ const resolvers = {
},
]
) =>
transactions.batch(
transactions.batch({
from,
until,
limit,
@ -49,13 +41,14 @@ const resolvers = {
txClass,
deviceId,
customerName,
customerId,
fiatCode,
cryptoCode,
toAddress,
status,
swept,
excludeTestingCustomers,
),
}),
transactionsCsv: (
...[
,
@ -67,6 +60,7 @@ const resolvers = {
txClass,
deviceId,
customerName,
customerId,
fiatCode,
cryptoCode,
toAddress,
@ -79,7 +73,7 @@ const resolvers = {
]
) =>
transactions
.batch(
.batch({
from,
until,
limit,
@ -87,6 +81,7 @@ const resolvers = {
txClass,
deviceId,
customerName,
customerId,
fiatCode,
cryptoCode,
toAddress,
@ -94,7 +89,7 @@ const resolvers = {
swept,
excludeTestingCustomers,
simplified,
)
})
.then(data =>
parseAsync(
logDateFormat(timezone, data, [

View file

@ -94,6 +94,13 @@ const typeDef = gql`
value: String
}
type CustomerSearchResult {
id: ID!
name: String
phone: String
email: String
}
type Query {
customers(
phone: String
@ -104,6 +111,8 @@ const typeDef = gql`
): [Customer] @auth
customer(customerId: ID!): Customer @auth
customerFilters: [Filter] @auth
searchCustomers(searchTerm: String!, limit: Int): [CustomerSearchResult]
@auth
}
type Mutation {

View file

@ -25,24 +25,21 @@ const typeDef = gql`
sendPending: Boolean
fixedFee: String
minimumTx: Float
customerId: ID
isAnonymous: Boolean
txVersion: Int!
termsAccepted: Boolean
commissionPercentage: String
rawTickerPrice: String
isPaperWallet: Boolean
customerPhone: String
customerEmail: String
customerIdCardDataNumber: String
customerIdCardDataExpiration: DateTimeISO
customerIdCardData: JSONObject
customerName: String
customerFrontCameraPath: String
customerIdCardPhotoPath: String
expired: Boolean
machineName: String
discount: Int
customerId: ID
customerPhone: String
customerEmail: String
customerIdCardData: JSONObject
customerFrontCameraPath: String
customerIdCardPhotoPath: String
txCustomerPhotoPath: String
txCustomerPhotoAt: DateTimeISO
batched: Boolean
@ -51,6 +48,12 @@ const typeDef = gql`
walletScore: Int
profit: String
swept: Boolean
status: String
paginationStats: PaginationStats
}
type PaginationStats {
totalCount: Int
}
type Filter {
@ -68,6 +71,7 @@ const typeDef = gql`
txClass: String
deviceId: String
customerName: String
customerId: ID
fiatCode: String
cryptoCode: String
toAddress: String
@ -83,6 +87,7 @@ const typeDef = gql`
txClass: String
deviceId: String
customerName: String
customerId: ID
fiatCode: String
cryptoCode: String
toAddress: String

View file

@ -1,220 +1,57 @@
const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const db = require('../../db')
const BN = require('../../bn')
const { utils: coinUtils } = require('@lamassu/coins')
const tx = require('../../tx')
const cashInTx = require('../../cash-in/cash-in-tx')
const { REDEEMABLE_AGE } = require('../../cash-out/cash-out-helper')
const {
REDEEMABLE_AGE,
CASH_OUT_TRANSACTION_STATES,
} = require('../../cash-out/cash-out-helper')
const NUM_RESULTS = 1000
transactions: { getTransactionList },
} = require('typesafe-db')
function addProfits(txs) {
return _.map(it => {
const profit = getProfit(it).toString()
return _.set('profit', profit, it)
}, txs)
return _.map(
it => ({
...it,
profit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString(),
}),
txs,
)
}
const camelize = _.mapKeys(_.camelCase)
const DEVICE_NAME_QUERY = `
CASE
WHEN ud.name IS NOT NULL THEN ud.name || ' (unpaired)'
WHEN d.name IS NOT NULL THEN d.name
ELSE 'Unpaired'
END AS machine_name
`
const DEVICE_NAME_JOINS = `
LEFT JOIN devices d ON txs.device_id = d.device_id
LEFT JOIN (
SELECT device_id, name, unpaired, paired
FROM unpaired_devices
) ud ON txs.device_id = ud.device_id
AND ud.unpaired >= txs.created
AND (txs.created >= ud.paired)
`
function batch(
function batch({
from = new Date(0).toISOString(),
until = new Date().toISOString(),
limit = null,
offset = 0,
txClass = null,
deviceId = null,
customerName = null,
fiatCode = null,
customerId = null,
cryptoCode = null,
toAddress = null,
status = null,
swept = null,
excludeTestingCustomers = false,
simplified,
) {
}) {
const isCsvExport = _.isBoolean(simplified)
const packager = _.flow(
_.flatten,
_.orderBy(_.property('created'), ['desc']),
_.map(
_.flow(
camelize,
_.mapKeys(k => (k == 'cashInFee' ? 'fixedFee' : k)),
),
),
addProfits,
return getTransactionList(
{
from,
until,
cryptoCode,
txClass,
deviceId,
toAddress,
customerId,
swept,
status,
excludeTestingCustomers,
},
{ limit, offset },
)
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
c.phone AS customer_phone,
c.email AS customer_email,
c.id_card_data_number AS customer_id_card_data_number,
c.id_card_data_expiration AS customer_id_card_data_expiration,
c.id_card_data AS customer_id_card_data,
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
c.front_camera_path AS customer_front_camera_path,
c.id_card_photo_path AS customer_id_card_photo_path,
txs.tx_customer_photo_at AS tx_customer_photo_at,
txs.tx_customer_photo_path AS tx_customer_photo_path,
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired,
tb.error_message AS batch_error,
${DEVICE_NAME_QUERY}
FROM (SELECT *, ${cashInTx.TRANSACTION_STATES} AS txStatus FROM cash_in_txs) AS txs
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
${DEVICE_NAME_JOINS}
LEFT OUTER JOIN transaction_batches tb ON txs.batch_id = tb.id
WHERE txs.created >= $2 AND txs.created <= $3
AND ($6 is null or $6 = 'Cash In')
AND ($7 is null or txs.device_id = $7)
AND ($8 is null or concat(c.id_card_data::json->>'firstName', ' ', c.id_card_data::json->>'lastName') = $8)
AND ($9 is null or txs.fiat_code = $9)
AND ($10 is null or txs.crypto_code = $10)
AND ($11 is null or txs.to_address = $11)
AND ($12 is null or txs.txStatus = $12)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
${isCsvExport && !simplified ? '' : 'AND (error IS NOT null OR tb.error_message IS NOT null OR fiat > 0)'}
ORDER BY created DESC limit $4 offset $5`
const cashOutSql = `SELECT 'cashOut' AS tx_class,
txs.*,
actions.tx_hash,
c.phone AS customer_phone,
c.email AS customer_email,
c.id_card_data_number AS customer_id_card_data_number,
c.id_card_data_expiration AS customer_id_card_data_expiration,
c.id_card_data AS customer_id_card_data,
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
c.front_camera_path AS customer_front_camera_path,
c.id_card_photo_path AS customer_id_card_photo_path,
txs.tx_customer_photo_at AS tx_customer_photo_at,
txs.tx_customer_photo_path AS tx_customer_photo_path,
(NOT txs.dispense AND extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) >= $1) AS expired,
${DEVICE_NAME_QUERY}
FROM (SELECT *, ${CASH_OUT_TRANSACTION_STATES} AS txStatus FROM cash_out_txs) txs
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
AND actions.action = 'provisionAddress'
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
${DEVICE_NAME_JOINS}
WHERE txs.created >= $2 AND txs.created <= $3
AND ($6 is null or $6 = 'Cash Out')
AND ($7 is null or txs.device_id = $7)
AND ($8 is null or concat(c.id_card_data::json->>'firstName', ' ', c.id_card_data::json->>'lastName') = $8)
AND ($9 is null or txs.fiat_code = $9)
AND ($10 is null or txs.crypto_code = $10)
AND ($11 is null or txs.to_address = $11)
AND ($12 is null or txs.txStatus = $12)
AND ($13 is null or txs.swept = $13)
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
${isCsvExport ? '' : 'AND fiat > 0'}
ORDER BY created DESC limit $4 offset $5`
// The swept filter is cash-out only, so omit the cash-in query entirely
const hasCashInOnlyFilters = false
const hasCashOutOnlyFilters = !_.isNil(swept)
let promises
if (hasCashInOnlyFilters && hasCashOutOnlyFilters) {
throw new Error(
'Trying to filter transactions with mutually exclusive filters',
)
}
if (hasCashInOnlyFilters) {
promises = [
db.any(cashInSql, [
cashInTx.PENDING_INTERVAL,
from,
until,
limit,
offset,
txClass,
deviceId,
customerName,
fiatCode,
cryptoCode,
toAddress,
status,
]),
]
} else if (hasCashOutOnlyFilters) {
promises = [
db.any(cashOutSql, [
REDEEMABLE_AGE,
from,
until,
limit,
offset,
txClass,
deviceId,
customerName,
fiatCode,
cryptoCode,
toAddress,
status,
swept,
]),
]
} else {
promises = [
db.any(cashInSql, [
cashInTx.PENDING_INTERVAL,
from,
until,
limit,
offset,
txClass,
deviceId,
customerName,
fiatCode,
cryptoCode,
toAddress,
status,
]),
db.any(cashOutSql, [
REDEEMABLE_AGE,
from,
until,
limit,
offset,
txClass,
deviceId,
customerName,
fiatCode,
cryptoCode,
toAddress,
status,
swept,
]),
]
}
return Promise.all(promises)
.then(packager)
.then(addProfits)
.then(res =>
!isCsvExport
? res
@ -239,12 +76,13 @@ function advancedBatch(data) {
'fiatCode',
'fee',
'status',
'fiatProfit',
'profit',
'cryptoAmount',
'dispense',
'notified',
'redeem',
'phone',
'email',
'error',
'fixedFee',
'created',
@ -278,7 +116,6 @@ function advancedBatch(data) {
'txVersion',
'publishedAt',
'termsAccepted',
'layer2Address',
'commissionPercentage',
'rawTickerPrice',
'receivedCryptoAtoms',
@ -289,7 +126,6 @@ function advancedBatch(data) {
'customerIdCardDataNumber',
'customerIdCardDataExpiration',
'customerIdCardData',
'customerName',
'sendTime',
'customerFrontCameraPath',
'customerIdCardPhotoPath',
@ -300,9 +136,6 @@ function advancedBatch(data) {
const addAdvancedFields = _.map(it => ({
...it,
status: getStatus(it),
fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString(),
fixedFee: it.fixedFee ?? null,
fee: it.fee ?? null,
}))
@ -328,18 +161,11 @@ function simplifiedBatch(data) {
'dispense',
'error',
'status',
'fiatProfit',
'profit',
'cryptoAmount',
]
const addSimplifiedFields = _.map(it => ({
...it,
status: getStatus(it),
fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString(),
}))
return _.compose(_.map(_.pick(fields)), addSimplifiedFields)(data)
return _.map(_.pick(fields))(data)
}
const getCryptoAmount = it =>
@ -363,150 +189,6 @@ const getProfit = it => {
: calcCashOutProfit(fiat, crypto, tickerPrice)
}
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.batchError) 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) {
const packager = _.flow(
it => {
return it
},
_.flatten,
_.orderBy(_.property('created'), ['desc']),
_.map(camelize),
)
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
c.phone AS customer_phone,
c.email AS customer_email,
c.id_card_data_number AS customer_id_card_data_number,
c.id_card_data_expiration AS customer_id_card_data_expiration,
c.id_card_data AS customer_id_card_data,
c.name AS customer_name,
c.front_camera_path AS customer_front_camera_path,
c.id_card_photo_path AS customer_id_card_photo_path,
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $2)) AS expired,
tb.error_message AS batch_error,
${DEVICE_NAME_QUERY}
FROM cash_in_txs AS txs
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
${DEVICE_NAME_JOINS}
LEFT OUTER JOIN transaction_batches tb ON txs.batch_id = tb.id
WHERE c.id IN ($1^)
ORDER BY created DESC limit $3`
const cashOutSql = `SELECT 'cashOut' AS tx_class,
txs.*,
actions.tx_hash,
c.phone AS customer_phone,
c.email AS customer_email,
c.id_card_data_number AS customer_id_card_data_number,
c.id_card_data_expiration AS customer_id_card_data_expiration,
c.id_card_data AS customer_id_card_data,
c.name AS customer_name,
c.front_camera_path AS customer_front_camera_path,
c.id_card_photo_path AS customer_id_card_photo_path,
(NOT txs.dispense AND extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) >= $3) AS expired,
${DEVICE_NAME_QUERY}
FROM cash_out_txs txs
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
AND actions.action = 'provisionAddress'
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
${DEVICE_NAME_JOINS}
WHERE c.id IN ($1^)
ORDER BY created DESC limit $2`
return Promise.all([
db.any(cashInSql, [
_.map(pgp.as.text, ids).join(','),
cashInTx.PENDING_INTERVAL,
NUM_RESULTS,
]),
db.any(cashOutSql, [
_.map(pgp.as.text, ids).join(','),
NUM_RESULTS,
REDEEMABLE_AGE,
]),
])
.then(packager)
.then(transactions => {
const transactionMap = _.groupBy('customerId', transactions)
return ids.map(id => transactionMap[id])
})
}
function single(txId) {
const packager = _.flow(_.compact, _.map(camelize))
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
c.phone AS customer_phone,
c.email AS customer_email,
c.id_card_data_number AS customer_id_card_data_number,
c.id_card_data_expiration AS customer_id_card_data_expiration,
c.id_card_data AS customer_id_card_data,
c.name AS customer_name,
c.front_camera_path AS customer_front_camera_path,
c.id_card_photo_path AS customer_id_card_photo_path,
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired,
tb.error_message AS batch_error,
${DEVICE_NAME_QUERY}
FROM cash_in_txs AS txs
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
${DEVICE_NAME_JOINS}
LEFT OUTER JOIN transaction_batches tb ON txs.batch_id = tb.id
WHERE id=$2`
const cashOutSql = `SELECT 'cashOut' AS tx_class,
txs.*,
actions.tx_hash,
c.phone AS customer_phone,
c.email AS customer_email,
c.id_card_data_number AS customer_id_card_data_number,
c.id_card_data_expiration AS customer_id_card_data_expiration,
c.id_card_data AS customer_id_card_data,
c.name AS customer_name,
c.front_camera_path AS customer_front_camera_path,
c.id_card_photo_path AS customer_id_card_photo_path,
(NOT txs.dispense AND extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) >= $2) AS expired,
${DEVICE_NAME_QUERY}
FROM cash_out_txs txs
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
AND actions.action = 'provisionAddress'
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
${DEVICE_NAME_JOINS}
WHERE id=$1`
return Promise.all([
db.oneOrNone(cashInSql, [cashInTx.PENDING_INTERVAL, txId]),
db.oneOrNone(cashOutSql, [txId, REDEEMABLE_AGE]),
])
.then(packager)
.then(_.head)
}
function cancel(txId) {
return tx.cancel(txId).then(() => single(txId))
}
function getTx(txId, txClass) {
const cashInSql = `select 'cashIn' as tx_class, txs.*,
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
@ -558,9 +240,6 @@ function updateTxCustomerPhoto(customerId, txId, direction, data) {
module.exports = {
batch,
single,
cancel,
getCustomerTransactionsBatch,
getTx,
getTxAssociatedData,
updateTxCustomerPhoto,

View file

@ -11,6 +11,7 @@ const PUBLISH_TIME = 3 * SECONDS
const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS
const CONFIRM_TIME = AUTHORIZE_TIME + 10 * SECONDS
const SUPPORTED_COINS = coinUtils.cryptoCurrencies()
const SUPPORTS_BATCHING = true
let t0
@ -162,6 +163,7 @@ function checkBlockchainStatus(cryptoCode) {
module.exports = {
NAME,
SUPPORTS_BATCHING,
balance,
sendCoinsBatch,
sendCoins,

View file

@ -11,11 +11,13 @@ const compliance = require('../compliance')
const complianceTriggers = require('../compliance-triggers')
const configManager = require('../new-config-manager')
const customers = require('../customers')
const txs = require('../new-admin/services/transactions')
const httpError = require('../route-helpers').httpError
const notifier = require('../notifier')
const respond = require('../respond')
const { getTx } = require('../new-admin/services/transactions.js')
const {
getTx,
updateTxCustomerPhoto: txsUpdateTxCustomerPhoto,
} = require('../new-admin/services/transactions.js')
const machineLoader = require('../machine-loader')
const { loadLatestConfig } = require('../new-settings-loader')
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
@ -207,13 +209,13 @@ function updateTxCustomerPhoto(req, res, next) {
const tcPhotoData = req.body.tcPhotoData
const direction = req.body.direction
Promise.all([customers.getById(customerId), txs.getTx(txId, direction)])
Promise.all([customers.getById(customerId), getTx(txId, direction)])
.then(([customer, tx]) => {
if (!customer || !tx) return
return customers
.updateTxCustomerPhoto(tcPhotoData)
.then(newPatch =>
txs.updateTxCustomerPhoto(customerId, txId, direction, newPatch),
txsUpdateTxCustomerPhoto(customerId, txId, direction, newPatch),
)
})
.then(() => respond(req, res, {}))

View file

@ -59,22 +59,6 @@ function massage(tx) {
return mapper(tx)
}
function cancel(txId) {
const promises = [
CashInTx.cancel(txId)
.then(() => true)
.catch(() => false),
CashOutTx.cancel(txId)
.then(() => true)
.catch(() => false),
]
return Promise.all(promises).then(r => {
if (_.some(r)) return
throw new Error('No such transaction')
})
}
function customerHistory(customerId, thresholdDays) {
const sql = `SELECT ch.id, ch.created, ch.fiat, ch.direction FROM (
SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction,
@ -99,4 +83,4 @@ function customerHistory(customerId, thresholdDays) {
return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE])
}
module.exports = { post, cancel, customerHistory }
module.exports = { post, customerHistory }