feat: transactions table

This commit is contained in:
Rafael Taranto 2025-06-05 14:02:07 +01:00
parent 1ead9fe359
commit d6166ce752
29 changed files with 1204 additions and 726 deletions

View file

@ -4,7 +4,7 @@
"license": "../LICENSE",
"type": "module",
"dependencies": {
"@apollo/client": "^3.13.7",
"@apollo/client": "^3.13.8",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0",
"@lamassu/coins": "v1.6.1",
@ -22,6 +22,7 @@
"downshift": "9.0.9",
"file-saver": "2.0.2",
"formik": "2.2.0",
"immer": "^10.1.1",
"jss-plugin-extend": "^10.0.0",
"jszip": "^3.6.0",
"libphonenumber-js": "^1.11.15",

View file

@ -0,0 +1,118 @@
import React, { useState, useEffect } from 'react'
import {
Autocomplete,
TextField,
Select,
MenuItem,
FormControl,
} from '@mui/material'
import { AsyncAutocomplete } from './inputs/base/AsyncAutocomplete.jsx'
export const SelectFilter = ({ column, options = [] }) => {
const columnFilterValue = column.getFilterValue()
return (
<FormControl variant="standard" size="small" fullWidth>
<Select
value={columnFilterValue || ''}
onChange={event => {
column.setFilterValue(event.target.value || undefined)
}}
displayEmpty
variant="standard">
<MenuItem value="">All</MenuItem>
{options.map(option => (
<MenuItem key={option.value} value={option.value}>
{option.label}
</MenuItem>
))}
</Select>
</FormControl>
)
}
export const AutocompleteFilter = ({
column,
options = [],
placeholder = 'Filter...',
renderOption,
getOptionLabel = option => option.label || '',
}) => {
const columnFilterValue = column.getFilterValue()
const selectedOption =
options.find(option => option.value === columnFilterValue) || null
return (
<Autocomplete
options={options}
value={selectedOption}
onChange={(event, newValue) => {
column.setFilterValue(newValue?.value || '')
}}
getOptionLabel={getOptionLabel}
isOptionEqualToValue={(option, value) => option?.value === value?.value}
renderOption={renderOption}
renderInput={params => (
<TextField
{...params}
variant="standard"
placeholder={placeholder}
size="small"
fullWidth
/>
)}
size="small"
fullWidth
slotProps={{
listbox: {
style: { maxHeight: 200 },
},
popper: {
style: { width: 'auto' },
},
}}
/>
)
}
export const TextFilter = ({ column, placeholder = 'Filter...' }) => {
const columnFilterValue = column.getFilterValue()
return (
<TextField
value={columnFilterValue ?? ''}
onChange={event => {
column.setFilterValue(event.target.value || undefined)
}}
placeholder={placeholder}
variant="standard"
size="small"
fullWidth
/>
)
}
export const AsyncAutocompleteFilter = ({ column, ...props }) => {
const [selectedOption, setSelectedOption] = useState(null)
const columnFilterValue = column.getFilterValue()
const getOptionId = props.getOptionId || (option => option.id)
useEffect(() => {
if (!columnFilterValue) {
setSelectedOption(null)
}
}, [columnFilterValue])
const handleChange = (event, newValue) => {
column.setFilterValue(newValue ? getOptionId(newValue) : '')
setSelectedOption(newValue)
}
return (
<AsyncAutocomplete
{...props}
value={selectedOption}
onChange={handleChange}
/>
)
}

View file

@ -41,7 +41,7 @@ const HelpTooltip = memo(({ children, width }) => {
)}
<button
type="button"
className="border-0 bg-transparent outline-0 cursor-pointer mt-1"
className="flex justify-center align-center border-0 bg-transparent outline-0 cursor-pointer px-1"
onMouseEnter={handler.openHelpPopper}>
<HelpIcon />
</button>

View file

@ -0,0 +1,75 @@
import React, { useState, useRef } from 'react'
import { Autocomplete, TextField } from '@mui/material'
export const AsyncAutocomplete = ({
value,
onChange,
onSearch,
getOptionLabel,
getOptionId = option => option.id,
placeholder = 'Search...',
noOptionsText = 'Type to start searching...',
minSearchLength = 2,
debounceMs = 300,
variant = 'standard',
size = 'small',
fullWidth = true,
...textFieldProps
}) => {
const [options, setOptions] = useState([])
const timeoutRef = useRef(null)
// Simple debounce using timeout
const debouncedSearch = searchTerm => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
}
timeoutRef.current = setTimeout(() => {
onSearch(searchTerm).then(results => {
setOptions(results)
})
}, debounceMs)
}
const handleInputChange = (event, newInputValue, reason) => {
// Only search when user is typing, not when selecting an option
if (
reason === 'input' &&
newInputValue &&
newInputValue.length > minSearchLength
) {
debouncedSearch(newInputValue)
}
}
const handleBlur = () => {
setOptions([])
}
return (
<Autocomplete
options={options}
value={value}
onChange={onChange}
onInputChange={handleInputChange}
getOptionLabel={getOptionLabel}
isOptionEqualToValue={(option, value) =>
getOptionId(option) === getOptionId(value)
}
noOptionsText={noOptionsText}
renderInput={params => (
<TextField
{...params}
variant={variant}
placeholder={placeholder}
size={size}
fullWidth={fullWidth}
onBlur={handleBlur}
{...textFieldProps}
/>
)}
size={size}
fullWidth={fullWidth}
/>
)
}

View file

@ -62,24 +62,14 @@ const GET_TRANSACTIONS = gql`
until: $until
excludeTestingCustomers: $excludeTestingCustomers
) {
id
txClass
txHash
toAddress
commissionPercentage
expired
machineName
operatorCompleted
sendConfirmed
dispense
hasError: error
deviceId
fiat
fixedFee
fiatCode
cryptoAtoms
cryptoCode
toAddress
created
profit
}

View file

@ -34,6 +34,20 @@ import { getFormattedPhone, getName, formatPhotosData } from './helper'
const GET_CUSTOMER = gql`
query customer($customerId: ID!) {
config
transactions(customerId: $customerId, limit: 20) {
txClass
id
fiat
fiatCode
cryptoAtoms
cryptoCode
created
machineName
errorMessage: error
error: errorCode
txCustomerPhotoAt
txCustomerPhotoPath
}
customer(customerId: $customerId) {
id
authorizedOverride
@ -81,20 +95,6 @@ const GET_CUSTOMER = gql`
created
lastEditedAt
}
transactions {
txClass
id
fiat
fiatCode
cryptoAtoms
cryptoCode
created
machineName
errorMessage: error
error: errorCode
txCustomerPhotoAt
txCustomerPhotoPath
}
customInfoRequests {
customerId
override
@ -459,7 +459,7 @@ const CustomerProfile = memo(() => {
const configData = R.path(['config'])(customerResponse) ?? []
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
const customerData = R.path(['customer'])(customerResponse) ?? []
const rawTransactions = R.path(['transactions'])(customerData) ?? []
const rawTransactions = R.path(['transactions'])(customerResponse) ?? []
const sortedTransactions = R.sort(R.descend(R.prop('cryptoAtoms')))(
rawTransactions,
)

View file

@ -32,8 +32,6 @@ const GET_DATA = gql`
) {
fiatCode
fiat
fixedFee
commissionPercentage
created
txClass
error

View file

@ -16,18 +16,8 @@ import DataTable from '../../../../components/tables/DataTable'
const NUM_LOG_RESULTS = 5
const GET_TRANSACTIONS = gql`
query transactions(
$limit: Int
$from: DateTimeISO
$until: DateTimeISO
$deviceId: String
) {
transactions(
limit: $limit
from: $from
until: $until
deviceId: $deviceId
) {
query transactions($limit: Int, $deviceId: String) {
transactions(limit: $limit, deviceId: $deviceId) {
id
txClass
txHash
@ -47,7 +37,6 @@ const GET_TRANSACTIONS = gql`
cryptoCode
toAddress
created
customerName
customerIdCardData
customerIdCardPhotoPath
customerFrontCameraPath

View file

@ -0,0 +1,91 @@
import React from 'react'
import {
AutocompleteFilter,
SelectFilter,
AsyncAutocompleteFilter,
} from '../../components/TableFilters'
export const DirectionFilter = ({ column }) => {
const options = [
{ label: 'Cash-in', value: 'cashIn' },
{ label: 'Cash-out', value: 'cashOut' },
]
return <SelectFilter column={column} options={options} />
}
export const SweptFilter = ({ column }) => {
const options = [
{ label: 'Yes', value: true },
{ label: 'No', value: false },
]
return <SelectFilter column={column} options={options} />
}
export const StatusFilter = ({ column }) => {
const options = [
{ label: 'Cancelled', value: 'Cancelled' },
{ label: 'Error', value: 'Error' },
{ label: 'Success', value: 'Success' },
{ label: 'Expired', value: 'Expired' },
{ label: 'Pending', value: 'Pending' },
{ label: 'Sent', value: 'Sent' },
]
return <SelectFilter column={column} options={options} />
}
export const MachineFilter = ({ column, machines }) => {
const machineOptions = machines.map(machine => ({
label: machine.name,
value: machine.deviceId,
}))
const renderOption = (props, option) => (
<li {...props}>
<div>
<div>{option.label}</div>
<div style={{ fontSize: '0.8em', color: '#666' }}>{option.value}</div>
</div>
</li>
)
return (
<AutocompleteFilter
column={column}
options={machineOptions}
placeholder="Filter machines..."
renderOption={renderOption}
/>
)
}
export const CryptoFilter = ({ column, cryptoCurrencies }) => {
const cryptoOptions = cryptoCurrencies.map(crypto => ({
label: crypto.code,
value: crypto.code,
}))
return (
<AutocompleteFilter
column={column}
options={cryptoOptions}
placeholder="Filter crypto..."
/>
)
}
export const CustomerFilter = ({ column, onSearch }) => (
<AsyncAutocompleteFilter
column={column}
onSearch={onSearch}
getOptionLabel={option => {
const name = option.name || 'Unknown'
const contact = option.phone || option.email || ''
return contact ? `${name} (${contact})` : name
}}
placeholder="Search customers..."
noOptionsText="Type to start searching..."
/>
)

View file

@ -1,14 +1,13 @@
import { useQuery, gql } from '@apollo/client'
import { useQuery, useLazyQuery, gql } from '@apollo/client'
import { create } from 'zustand'
import { immer } from 'zustand/middleware/immer'
import { toUnit, formatCryptoAddress } from '@lamassu/coins/lightUtils'
import BigNumber from 'bignumber.js'
import * as R from 'ramda'
import React, { useEffect, useState } from 'react'
import React, { useEffect, useMemo } from 'react'
import { useLocation } from 'wouter'
import LogsDowloaderPopover from '../../components/LogsDownloaderPopper'
import SearchBox from '../../components/SearchBox'
import SearchFilter from '../../components/SearchFilter'
import { HelpTooltip } from '../../components/Tooltip'
import DataTable from '../../components/tables/DataTable'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
import CustomerLinkIcon from '../../styling/icons/month arrows/right.svg?react'
@ -20,14 +19,46 @@ import * as Customer from '../../utils/customer'
import { formatDate } from '../../utils/timezones'
import DetailsRow from './DetailsCard'
import { getStatus } from './helper'
import TitleSection from '../../components/layout/TitleSection.jsx'
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'
import {
alignRight,
defaultMaterialTableOpts,
} from '../../utils/materialReactTableOpts.js'
import { getStatusDetails } from './helper.js'
import {
CustomerFilter,
MachineFilter,
DirectionFilter,
CryptoFilter,
StatusFilter,
SweptFilter,
} from './Filters.jsx'
const NUM_LOG_RESULTS = 1000
const GET_DATA = gql`
query getData {
config
machines {
name
deviceId
}
cryptoCurrencies {
code
display
}
}
`
const SEARCH_CUSTOMERS = gql`
query searchCustomers($searchTerm: String!, $limit: Int) {
searchCustomers(searchTerm: $searchTerm, limit: $limit) {
id
name
phone
email
}
}
`
@ -51,24 +82,16 @@ const GET_TRANSACTIONS_CSV = gql`
}
`
const GET_TRANSACTION_FILTERS = gql`
query filters {
transactionFilters {
type
value
label
}
}
`
const GET_TRANSACTIONS = gql`
query transactions(
$limit: Int
$offset: Int
$from: DateTimeISO
$until: DateTimeISO
$txClass: String
$deviceId: String
$customerName: String
$customerId: ID
$fiatCode: String
$cryptoCode: String
$toAddress: String
@ -77,17 +100,22 @@ const GET_TRANSACTIONS = gql`
) {
transactions(
limit: $limit
offset: $offset
from: $from
until: $until
txClass: $txClass
deviceId: $deviceId
customerName: $customerName
customerId: $customerId
fiatCode: $fiatCode
cryptoCode: $cryptoCode
toAddress: $toAddress
status: $status
swept: $swept
) {
paginationStats {
totalCount
}
id
txClass
txHash
@ -109,7 +137,6 @@ const GET_TRANSACTIONS = gql`
cryptoCode
toAddress
created
customerName
customerIdCardData
customerIdCardPhotoPath
customerFrontCameraPath
@ -126,203 +153,294 @@ const GET_TRANSACTIONS = gql`
walletScore
profit
swept
status
}
}
`
const getFiltersObj = filters =>
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
const useTableStore = create(
immer(set => ({
variables: { limit: NUM_LOG_RESULTS },
columnFilters: [],
pagination: {
pageIndex: 0,
pageSize: 10,
},
previousData: [],
updateField: (field, updates) =>
set(state => {
if (typeof updates === 'function') {
state[field] = updates(state[field])
} else {
state[field] = updates
}
}),
})),
)
const Transactions = () => {
const [, navigate] = useLocation()
const { variables, columnFilters, pagination, previousData, updateField } =
useTableStore()
const [filters, setFilters] = useState([])
const { data: filtersResponse, loading: filtersLoading } = useQuery(
GET_TRANSACTION_FILTERS,
)
const [variables, setVariables] = useState({ limit: NUM_LOG_RESULTS })
const {
data: txData,
loading: transactionsLoading,
refetch,
startPolling,
stopPolling,
} = useQuery(GET_TRANSACTIONS, { variables })
useEffect(() => {
startPolling(10000)
return stopPolling
const { data: configResponse } = useQuery(GET_DATA)
const { data, loading } = useQuery(GET_TRANSACTIONS, {
variables,
notifyOnNetworkStatusChange: true,
})
const txList = txData?.transactions ?? []
const [searchCustomersQuery] = useLazyQuery(SEARCH_CUSTOMERS)
const displayData = useMemo(() => {
const formattedData = (data?.transactions ?? []).map(row => ({
...row,
toAddress: formatCryptoAddress(row.cryptoCode, row.toAddress),
}))
return loading && previousData.length > 0 ? previousData : formattedData
}, [data])
useEffect(() => {
if (!loading && displayData && displayData.length > 0) {
updateField('previousData', displayData)
}
}, [displayData, loading])
useEffect(() => {
listFilterChange({
offset: pagination.pageIndex * pagination.pageSize,
limit: pagination.pageSize,
})
}, [pagination, columnFilters])
const { data: configResponse, configLoading } = useQuery(GET_DATA)
const timezone = R.path(['config', 'locale_timezone'], configResponse)
const machines = configResponse?.machines || []
const cryptoCurrencies = configResponse?.cryptoCurrencies || []
const columns = useMemo(
() => [
{
header: 'Direction',
accessorKey: 'txClass',
Filter: DirectionFilter,
grow: false,
size: 50,
muiTableBodyCellProps: {
align: 'center',
},
Cell: ({ cell }) =>
cell.getValue() === 'cashOut' ? <TxOutIcon /> : <TxInIcon />,
},
{
accessorKey: 'id',
header: 'ID',
size: 315,
},
{
accessorKey: 'swept',
header: 'Swept',
Filter: SweptFilter,
size: 50,
Cell: ({ cell }) => (cell.getValue() ? 'Yes' : 'No'),
},
{
accessorKey: 'machineName',
header: 'Machine',
Filter: ({ column }) => (
<MachineFilter column={column} machines={machines} />
),
size: 160,
maxSize: 160,
},
{
accessorKey: 'customerId',
header: 'Customer',
size: 202,
Filter: ({ column }) => (
<CustomerFilter column={column} onSearch={searchCustomers} />
),
Cell: ({ row }) => (
<div className="flex items-center justify-between w-full">
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
{Customer.displayName(row.original)}
</div>
{!row.original.isAnonymous && (
<div
className="cursor-pointer flex"
data-cy="customer-link"
onClick={() => redirect(row.original.customerId)}>
{getStatusDetails(row.original) ? (
<CustomerLinkWhiteIcon />
) : (
<CustomerLinkIcon />
)}
</div>
)}
</div>
),
},
{
accessorKey: 'fiat',
header: 'Cash',
enableColumnFilter: false,
size: 144,
...alignRight,
Cell: ({ cell, row }) =>
`${Number.parseFloat(cell.getValue())} ${row.original.fiatCode}`,
},
{
accessorKey: 'cryptoAtoms',
header: 'Crypto',
Filter: ({ column }) => (
<CryptoFilter column={column} cryptoCurrencies={cryptoCurrencies} />
),
size: 150,
...alignRight,
Cell: ({ cell, row }) =>
`${toUnit(new BigNumber(cell.getValue()), row.original.cryptoCode)} ${
row.original.cryptoCode
}`,
},
{
accessorKey: 'toAddress',
header: 'Address',
size: 140,
muiTableBodyCellProps: {
className: 'overflow-hidden whitespace-nowrap text-ellipsis',
},
},
{
accessorKey: 'created',
header: 'Date',
enableColumnFilter: false,
Cell: ({ cell }) =>
timezone && formatDate(cell.getValue(), timezone, 'yyyy-MM-dd HH:mm'),
...alignRight,
size: 155,
},
{
accessorKey: 'status',
header: 'Status',
size: 80,
Filter: StatusFilter,
Cell: ({ cell }) => {
if (cell.getValue() === 'Pending')
return (
<div className="flex items-center justify-between w-full">
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
Pending
</div>
<HelpTooltip width={200}>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
label="Cancelling cash-out transactions"
bottomSpace="0"
/>
</HelpTooltip>
</div>
)
else return cell.getValue()
},
},
],
[machines, cryptoCurrencies, timezone],
)
const table = useMaterialReactTable({
enableColumnResizing: true,
...defaultMaterialTableOpts,
initialState: {
...defaultMaterialTableOpts.initialState,
columnVisibility: {
id: false,
swept: false,
},
},
columns: columns,
rowCount: displayData?.[0]?.paginationStats?.totalCount ?? 0,
getRowId: it => it.id,
data: displayData || [],
manualFiltering: true,
manualPagination: true,
enableSorting: false,
onPaginationChange: it => {
console.log('PAGINATION', it)
updateField('pagination', it)
},
muiFilterTextFieldProps: {
size: 'small',
},
muiFilterCheckboxProps: { size: 'small' },
onColumnFiltersChange: it => updateField('columnFilters', it),
enableExpandAll: false,
state: {
columnFilters,
pagination,
isLoading: loading,
},
muiTableBodyRowProps: ({ row }) => ({
sx: {
backgroundColor: getStatusDetails(row.original) ? '#ffeceb' : '',
},
}),
displayColumnDefOptions: {
'mrt-row-expand': {
header: '',
},
},
muiExpandButtonProps: ({ row, table }) => ({
onClick: () => table.setExpanded({ [row.id]: !row.getIsExpanded() }), //set only this row to be expanded
}),
renderDetailPanel: ({ row }) =>
row.original ? (
<DetailsRow it={row.original} timezone={timezone} />
) : null,
})
const searchCustomers = async searchTerm => {
const { data } = await searchCustomersQuery({
variables: { searchTerm, limit: 20 },
})
return data?.searchCustomers || []
}
const redirect = customerId => {
return navigate(`/compliance/customer/${customerId}`)
}
const elements = [
{
header: '',
width: 32,
size: 'sm',
view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />),
},
{
header: 'Machine',
name: 'machineName',
width: 160,
size: 'sm',
view: R.path(['machineName']),
},
{
header: 'Customer',
width: 202,
size: 'sm',
view: it => (
<div className="flex items-center justify-between mr-4">
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
{Customer.displayName(it)}
</div>
{!it.isAnonymous && (
<div
data-cy="customer-link"
onClick={() => redirect(it.customerId)}>
{it.hasError || it.batchError ? (
<CustomerLinkWhiteIcon />
) : (
<CustomerLinkIcon />
)}
</div>
)}
</div>
),
},
{
header: 'Cash',
width: 144,
textAlign: 'right',
size: 'sm',
view: it => `${Number.parseFloat(it.fiat)} ${it.fiatCode}`,
},
{
header: 'Crypto',
width: 150,
textAlign: 'right',
size: 'sm',
view: it =>
`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode)} ${
it.cryptoCode
}`,
},
{
header: 'Address',
view: it => formatCryptoAddress(it.cryptoCode, it.toAddress),
className: 'overflow-hidden whitespace-nowrap text-ellipsis',
size: 'sm',
width: 140,
},
{
header: 'Date',
view: it =>
timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm'),
textAlign: 'right',
size: 'sm',
width: 195,
},
{
header: 'Status',
view: it => {
if (getStatus(it) === 'Pending')
return (
<div className="flex items-center">
{'Pending'}
<HelpTooltip width={285}>
<SupportLinkButton
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
label="Cancelling cash-out transactions"
bottomSpace="0"
/>
</HelpTooltip>
</div>
)
else return getStatus(it)
},
textAlign: 'left',
size: 'sm',
width: 80,
},
]
const mapColumnFiltersToVariables = filters => {
const filterMap = {
machineName: 'deviceId',
customerId: 'customerId',
cryptoAtoms: 'cryptoCode',
toAddress: 'toAddress',
status: 'status',
txClass: 'txClass',
swept: 'swept',
}
const onFilterChange = filters => {
const filtersObject = getFiltersObj(filters)
setFilters(filters)
setVariables({
limit: NUM_LOG_RESULTS,
txClass: filtersObject.type,
deviceId: filtersObject.machine,
customerName: filtersObject.customer,
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status,
swept: filtersObject.swept && filtersObject.swept === 'Swept',
})
refetch && refetch()
return filters.reduce((acc, filter) => {
const mappedKey = filterMap[filter.id] || filter.id
if (mappedKey && filter.value !== undefined && filter.value !== '') {
acc[mappedKey] = filter.value
}
return acc
}, {})
}
const onFilterDelete = filter => {
const newFilters = R.filter(
f => !R.whereEq(R.pick(['type', 'value'], f), filter),
)(filters)
const listFilterChange = inputs => {
const { limit, offset } = inputs ?? {}
const mappedFilters = mapColumnFiltersToVariables(columnFilters)
setFilters(newFilters)
const filtersObject = getFiltersObj(newFilters)
setVariables({
limit: NUM_LOG_RESULTS,
txClass: filtersObject.type,
deviceId: filtersObject.machine,
customerName: filtersObject.customer,
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status,
swept: filtersObject.swept && filtersObject.swept === 'Swept',
updateField('variables', {
limit,
offset,
...mappedFilters,
})
refetch && refetch()
}
const deleteAllFilters = () => {
setFilters([])
const filtersObject = getFiltersObj([])
setVariables({
limit: NUM_LOG_RESULTS,
txClass: filtersObject.type,
deviceId: filtersObject.machine,
customerName: filtersObject.customer,
fiatCode: filtersObject.fiat,
cryptoCode: filtersObject.crypto,
toAddress: filtersObject.address,
status: filtersObject.status,
swept: filtersObject.swept && filtersObject.swept === 'Swept',
})
refetch && refetch()
}
const filterOptions = R.path(['transactionFilters'])(filtersResponse)
const loading = transactionsLoading || filtersLoading || configLoading
const errorLabel = (
<svg width={12} height={12}>
<rect width={12} height={12} rx={3} fill={errorColor} />
@ -340,14 +458,7 @@ const Transactions = () => {
]}
appendix={
<div className="flex ml-4 gap-4">
<SearchBox
loading={filtersLoading}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search transactions'}
onChange={onFilterChange}
/>
{txList && (
{
<LogsDowloaderPopover
title="Download logs"
name="transactions"
@ -357,28 +468,11 @@ const Transactions = () => {
timezone={timezone}
args={{ timezone }}
/>
)}
}
</div>
}
/>
{filters.length > 0 && (
<SearchFilter
entries={txList.length}
filters={filters}
onFilterDelete={onFilterDelete}
deleteAllFilters={deleteAllFilters}
/>
)}
<DataTable
loading={loading}
emptyText="No transactions so far"
elements={elements}
data={txList}
Details={DetailsRow}
expandable
rowSize="sm"
timezone={timezone}
/>
<MaterialReactTable table={table} />
</>
)
}

View file

@ -26,7 +26,8 @@ const getStatus = it => {
const getStatusDetails = it => {
if (!R.isNil(it.hasError)) return it.hasError
if (!R.isNil(it.batchError)) return `Batch error: ${it.batchError}`
if (!R.isNil(it.batchError) && it.txClass === 'cashIn')
return `Batch error: ${it.batchError}`
return null
}

View file

@ -26,15 +26,12 @@ const formatName = idCardData => {
/* Expects a transaction object */
const displayName = ({
isAnonymous,
customerName,
customerIdCardData,
customerPhone,
customerEmail,
}) =>
isAnonymous
? 'Anonymous'
: customerName ||
customerEmail ||
R.defaultTo(customerPhone, formatName(customerIdCardData))
: formatName(customerIdCardData) || customerEmail || customerPhone
export { displayName, formatFullName, formatName }

View file

@ -1,4 +1,5 @@
const defaultMaterialTableOpts = {
enableKeyboardShortcuts: false,
enableGlobalFilter: false,
paginationDisplayMode: 'pages',
enableColumnActions: false,

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: String
fiatCode: String
cryptoCode: String
toAddress: String

View file

@ -1,230 +1,72 @@
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)),
return (
Promise.all([
getTransactionList(
{
from,
until,
cryptoCode,
txClass,
deviceId,
toAddress,
customerId,
swept,
status,
excludeTestingCustomers,
},
{ limit, offset },
),
),
addProfits,
])
// Promise.all(promises)
.then(it => addProfits(it[0]))
.then(res =>
!isCsvExport
? res
: // GQL transactions and transactionsCsv both use this function and
// if we don't check for the correct simplified value, the Transactions page polling
// will continuously build a csv in the background
simplified
? simplifiedBatch(res)
: advancedBatch(res),
)
)
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(res =>
!isCsvExport
? res
: // GQL transactions and transactionsCsv both use this function and
// if we don't check for the correct simplified value, the Transactions page polling
// will continuously build a csv in the background
simplified
? simplifiedBatch(res)
: advancedBatch(res),
)
}
function advancedBatch(data) {
@ -239,7 +81,7 @@ function advancedBatch(data) {
'fiatCode',
'fee',
'status',
'fiatProfit',
'profit',
'cryptoAmount',
'dispense',
'notified',
@ -300,9 +142,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 +167,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 +195,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 +246,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 }

View file

@ -3,6 +3,10 @@
"version": "11.0.0-beta.0",
"license": "../LICENSE",
"type": "module",
"dependencies": {
"kysely": "^0.28.2",
"pg": "^8.16.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/pg": "^8.11.10",
@ -18,11 +22,17 @@
"scripts": {
"build": "tsc --build",
"dev": "tsc --watch",
"generate-types": "kysely-codegen --camel-case --out-file ./src/types/types.d.ts",
"generate-types": "kysely-codegen",
"postinstall": "npm run build"
},
"dependencies": {
"kysely": "^0.28.2",
"pg": "^8.16.0"
"kysely-codegen": {
"camelCase": true,
"outFile": "./src/types/types.d.ts",
"overrides": {
"columns": {
"customers.id_card_data": "{firstName:string, lastName:string}",
"edited_customer_data.id_card_data": "{firstName:string, lastName:string}"
}
}
}
}

View file

@ -1,13 +1,10 @@
import { sql } from 'kysely'
import db from './db.js'
import { ExpressionBuilder } from 'kysely'
import { Customers, DB, EditedCustomerData } from './types/types.js'
import { jsonArrayFrom } from 'kysely/helpers/postgres'
type CustomerEB = ExpressionBuilder<DB & { c: Customers }, 'c'>
type CustomerWithEditedEB = ExpressionBuilder<
DB & { c: Customers } & { e: EditedCustomerData | null },
'c' | 'e'
>
import type {
CustomerEB,
CustomerWithEditedDataEB,
} from './types/manual.types.js'
const ANON_ID = '47ac1184-8102-11e7-9079-8f13a7117867'
const TX_PASSTHROUGH_ERROR_CODES = [
@ -28,7 +25,7 @@ function transactionUnion(eb: CustomerEB) {
])
.where(({ eb, and, or, ref }) =>
and([
eb('customerId', '=', ref('c.id')),
eb('customerId', '=', ref('cst.id')),
or([eb('sendConfirmed', '=', true), eb('batched', '=', true)]),
]),
)
@ -44,7 +41,7 @@ function transactionUnion(eb: CustomerEB) {
])
.where(({ eb, and, ref }) =>
and([
eb('customerId', '=', ref('c.id')),
eb('customerId', '=', ref('cst.id')),
eb('confirmedAt', 'is not', null),
]),
),
@ -92,20 +89,20 @@ function joinTxsTotals(eb: CustomerEB) {
.as('txStats')
}
function selectNewestIdCardData(eb: CustomerWithEditedEB, ref: any) {
function selectNewestIdCardData({ eb, ref }: CustomerWithEditedDataEB) {
return eb
.case()
.when(
eb.and([
eb(ref('e.idCardDataAt'), 'is not', null),
eb(ref('cstED.idCardDataAt'), 'is not', null),
eb.or([
eb(ref('c.idCardDataAt'), 'is', null),
eb(ref('e.idCardDataAt'), '>', ref('c.idCardDataAt')),
eb(ref('cst.idCardDataAt'), 'is', null),
eb(ref('cstED.idCardDataAt'), '>', ref('cst.idCardDataAt')),
]),
]),
)
.then(ref('e.idCardData'))
.else(ref('c.idCardData'))
.then(ref('cstED.idCardData'))
.else(ref('cst.idCardData'))
.end()
}
@ -122,58 +119,58 @@ function getCustomerList(
options: GetCustomerListOptions = defaultOptions,
): Promise<any[]> {
return db
.selectFrom('customers as c')
.leftJoin('editedCustomerData as e', 'e.customerId', 'c.id')
.selectFrom('customers as cst')
.leftJoin('editedCustomerData as cstED', 'cstED.customerId', 'cst.id')
.leftJoinLateral(joinTxsTotals, join => join.onTrue())
.leftJoinLateral(joinLatestTx, join => join.onTrue())
.select(({ eb, fn, val, ref }) => [
'c.id',
'c.phone',
'c.authorizedOverride',
'c.frontCameraPath',
'c.frontCameraOverride',
'c.idCardPhotoPath',
'c.idCardPhotoOverride',
selectNewestIdCardData(eb, ref).as('idCardData'),
'c.idCardDataOverride',
'c.email',
'c.usSsn',
'c.usSsnOverride',
'c.sanctions',
'c.sanctionsOverride',
.select(({ eb, fn, val }) => [
'cst.id',
'cst.phone',
'cst.authorizedOverride',
'cst.frontCameraPath',
'cst.frontCameraOverride',
'cst.idCardPhotoPath',
'cst.idCardPhotoOverride',
selectNewestIdCardData(eb).as('idCardData'),
'cst.idCardDataOverride',
'cst.email',
'cst.usSsn',
'cst.usSsnOverride',
'cst.sanctions',
'cst.sanctionsOverride',
'txStats.totalSpent',
'txStats.totalTxs',
ref('lastTx.fiatCode').as('lastTxFiatCode'),
ref('lastTx.fiat').as('lastTxFiat'),
ref('lastTx.txClass').as('lastTxClass'),
'lastTx.fiatCode as lastTxFiatCode',
'lastTx.fiat as lastTxFiat',
'lastTx.txClass as lastTxClass',
fn<Date>('GREATEST', [
'c.created',
'cst.created',
'lastTx.created',
'c.phoneAt',
'c.emailAt',
'c.idCardDataAt',
'c.frontCameraAt',
'c.idCardPhotoAt',
'c.usSsnAt',
'c.lastAuthAttempt',
'cst.phoneAt',
'cst.emailAt',
'cst.idCardDataAt',
'cst.frontCameraAt',
'cst.idCardPhotoAt',
'cst.usSsnAt',
'cst.lastAuthAttempt',
]).as('lastActive'),
eb('c.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
eb('cst.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
fn<number>('GREATEST', [
val(0),
fn<number>('date_part', [
val('day'),
eb('c.suspendedUntil', '-', fn<Date>('NOW', [])),
eb('cst.suspendedUntil', '-', fn<Date>('NOW', [])),
]),
]).as('daysSuspended'),
])
.where('c.id', '!=', ANON_ID)
.where('cst.id', '!=', ANON_ID)
.$if(options.withCustomInfoRequest, qb =>
qb.select(({ eb, ref }) =>
jsonArrayFrom(
eb
.selectFrom('customersCustomInfoRequests')
.selectAll()
.where('customerId', '=', ref('c.id')),
.where('customerId', '=', ref('cst.id')),
).as('customInfoRequestData'),
),
)
@ -181,4 +178,39 @@ function getCustomerList(
.execute()
}
export { getCustomerList }
function searchCustomers(searchTerm: string, limit: number = 20): Promise<any> {
const searchPattern = `%${searchTerm}%`
return db
.selectFrom(
db
.selectFrom('customers as cst')
.leftJoin('editedCustomerData as cstED', 'cstED.customerId', 'cst.id')
.select(({ eb, fn }) => [
'cst.id',
'cst.phone',
'cst.email',
sql`CONCAT(
COALESCE(${selectNewestIdCardData(eb)}->>'firstName', ''),
' ',
COALESCE(${selectNewestIdCardData(eb)}->>'lastName', '')
)`.as('customerName'),
])
.where('cst.id', '!=', ANON_ID)
.as('customers_with_names'),
)
.selectAll()
.select('customerName as name')
.where(({ eb, or }) =>
or([
eb('phone', 'ilike', searchPattern),
eb('email', 'ilike', searchPattern),
eb('customerName', 'ilike', searchPattern),
]),
)
.orderBy('id')
.limit(limit)
.execute()
}
export { getCustomerList, selectNewestIdCardData, searchCustomers }

View file

@ -1 +1,2 @@
export * as customers from './customers.js'
export * as transactions from './transactions.js'

View file

@ -0,0 +1,30 @@
export function logQuery(compiledQuery: {
sql: string
parameters: readonly unknown[]
}) {
const { sql, parameters } = compiledQuery
let interpolatedSql = sql
let paramIndex = 0
interpolatedSql = sql.replace(/\$\d+|\?/g, () => {
const param = parameters[paramIndex++]
if (param === null || param === undefined) {
return 'NULL'
} else if (typeof param === 'string') {
return `'${param.replace(/'/g, "''")}'`
} else if (typeof param === 'boolean') {
return param.toString()
} else if (param instanceof Date) {
return `'${param.toISOString()}'`
} else if (typeof param === 'object') {
return `'${JSON.stringify(param).replace(/'/g, "''")}'`
} else {
return String(param)
}
})
console.log('📝 Query:', interpolatedSql)
return interpolatedSql
}

View file

@ -0,0 +1,319 @@
import { sql } from 'kysely'
import db from './db.js'
import type {
CashInWithBatchEB,
CashOutEB,
CustomerWithEditedDataEB,
DevicesAndUnpairedDevicesEB,
} from './types/manual.types.js'
import { selectNewestIdCardData } from './customers.js'
const PENDING_INTERVAL = '60 minutes'
const REDEEMABLE_INTERVAL = '24 hours'
function getDeviceName(eb: DevicesAndUnpairedDevicesEB) {
return eb
.case()
.when(eb('ud.name', 'is not', null))
.then(eb('ud.name', '||', ' (unpaired)'))
.when(eb('d.name', 'is not', null))
.then(eb.ref('d.name'))
.else('Unpaired')
.end()
}
function customerData({ eb, ref }: CustomerWithEditedDataEB) {
return [
ref('cst.phone').as('customerPhone'),
ref('cst.email').as('customerEmail'),
selectNewestIdCardData(eb).as('customerIdCardData'),
ref('cst.frontCameraPath').as('customerFrontCameraPath'),
ref('cst.idCardPhotoPath').as('customerIdCardPhotoPath'),
ref('cst.isTestCustomer').as('isTestCustomer'),
]
}
function isCashInExpired(eb: CashInWithBatchEB) {
return eb.and([
eb.not('txIn.sendConfirmed'),
eb(
'txIn.created',
'<=',
sql<Date>`now() - interval '${sql.raw(PENDING_INTERVAL)}'`,
),
])
}
function isCashOutExpired(eb: CashOutEB) {
return eb.and([
eb.not('txOut.dispense'),
eb(
eb.fn.coalesce('txOut.confirmed_at', 'txOut.created'),
'<=',
sql<Date>`now() - interval '${sql.raw(REDEEMABLE_INTERVAL)}'`,
),
])
}
function cashOutTransactionStates(eb: CashOutEB) {
return eb
.case()
.when(eb('txOut.error', '=', eb.val('Operator cancel')))
.then('Cancelled')
.when(eb('txOut.error', 'is not', null))
.then('Error')
.when(eb.ref('txOut.dispense'))
.then('Success')
.when(isCashOutExpired(eb))
.then('Expired')
.else('Pending')
.end()
}
function cashInTransactionStates(eb: CashInWithBatchEB) {
const operatorCancel = eb.and([
eb.ref('txIn.operatorCompleted'),
eb('txIn.error', '=', eb.val('Operator cancel')),
])
const hasError = eb.or([
eb('txIn.error', 'is not', null),
eb('txInB.errorMessage', 'is not', null),
])
return eb
.case()
.when(operatorCancel)
.then('Cancelled')
.when(hasError)
.then('Error')
.when(eb.ref('txIn.sendConfirmed'))
.then('Sent')
.when(isCashInExpired(eb))
.then('Expired')
.else('Pending')
.end()
}
function getCashOutTransactionList() {
return db
.selectFrom('cashOutTxs as txOut')
.leftJoin('customers as cst', 'cst.id', 'txOut.customerId')
.leftJoin('editedCustomerData as cstED', 'cst.id', 'cstED.customerId')
.innerJoin('cashOutActions as txOutActions', join =>
join
.onRef('txOut.id', '=', 'txOutActions.txId')
.on('txOutActions.action', '=', 'provisionAddress'),
)
.leftJoin('devices as d', 'd.deviceId', 'txOut.deviceId')
.leftJoin('unpairedDevices as ud', join =>
join
.onRef('txOut.deviceId', '=', 'ud.deviceId')
.on('ud.unpaired', '>=', eb => eb.ref('txOut.created'))
.on('txOut.created', '>=', eb => eb.ref('ud.paired')),
)
.select(({ eb, val }) => [
'txOut.id',
val('cashOut').as('txClass'),
'txOut.deviceId',
'txOut.toAddress',
'txOut.cryptoAtoms',
'txOut.cryptoCode',
'txOut.fiat',
'txOut.fiatCode',
'txOut.phone', // TODO why does this has phone? Why not get from customer?
'txOut.error',
'txOut.created',
'txOut.timedout',
'txOut.errorCode',
'txOut.fixedFee',
'txOut.txVersion',
'txOut.termsAccepted',
'txOut.commissionPercentage',
'txOut.rawTickerPrice',
isCashOutExpired(eb).as('expired'),
getDeviceName(eb).as('machineName'),
'txOut.discount',
cashOutTransactionStates(eb).as('status'),
'txOut.customerId',
...customerData(eb),
'txOut.txCustomerPhotoPath',
'txOut.txCustomerPhotoAt',
'txOut.walletScore',
// cash-in only
val(null).as('fee'),
val(null).as('txHash'),
val(false).as('send'),
val(false).as('sendConfirmed'),
val(null).as('sendTime'),
val(false).as('operatorCompleted'),
val(false).as('sendPending'),
val(0).as('minimumTx'),
val(null).as('isPaperWallet'),
val(false).as('batched'),
val(null).as('batchTime'),
val(null).as('batchError'),
// cash-out only
'txOut.dispense',
'txOut.swept',
])
}
function getCashInTransactionList() {
return db
.selectFrom('cashInTxs as txIn')
.leftJoin('customers as cst', 'cst.id', 'txIn.customerId')
.leftJoin('editedCustomerData as cstED', 'cst.id', 'cstED.customerId')
.leftJoin('transactionBatches as txInB', 'txInB.id', 'txIn.batchId')
.leftJoin('devices as d', 'd.deviceId', 'txIn.deviceId')
.leftJoin('unpairedDevices as ud', join =>
join
.onRef('txIn.deviceId', '=', 'ud.deviceId')
.on('ud.unpaired', '>=', eb => eb.ref('txIn.created'))
.on('txIn.created', '>=', eb => eb.ref('ud.paired')),
)
.select(({ eb, val }) => [
'txIn.id',
val('cashIn').as('txClass'),
'txIn.deviceId',
'txIn.toAddress',
'txIn.cryptoAtoms',
'txIn.cryptoCode',
'txIn.fiat',
'txIn.fiatCode',
'txIn.phone', // TODO why does this has phone? Why not get from customer?
'txIn.error',
'txIn.created',
'txIn.timedout',
'txIn.errorCode',
'txIn.cashInFee as fixedFee',
'txIn.txVersion',
'txIn.termsAccepted',
'txIn.commissionPercentage',
'txIn.rawTickerPrice',
isCashInExpired(eb).as('expired'),
getDeviceName(eb).as('machineName'),
'txIn.discount',
cashInTransactionStates(eb).as('status'),
'txIn.customerId',
...customerData(eb),
'txIn.txCustomerPhotoPath',
'txIn.txCustomerPhotoAt',
'txIn.walletScore',
// cash-in only
'txIn.fee',
'txIn.txHash',
'txIn.send',
'txIn.sendConfirmed',
'txIn.sendTime',
'txIn.operatorCompleted',
'txIn.sendPending',
'txIn.minimumTx',
'txIn.isPaperWallet',
'txInB.errorMessage as batchError',
'txIn.batched',
'txIn.batchTime',
// cash-out only
val(false).as('dispense'),
val(false).as('swept'),
])
}
interface PaginationParams {
limit?: number
offset?: number
}
interface FilterParams {
from?: Date
until?: Date
toAddress?: string
txClass?: string
deviceId?: string
customerId?: string
cryptoCode?: string
swept?: boolean
status?: string
excludeTestingCustomers?: boolean
}
async function getTransactionList(
filters: FilterParams,
pagination?: PaginationParams,
) {
let query = db
.selectFrom(() =>
getCashInTransactionList()
.unionAll(getCashOutTransactionList())
.as('transactions'),
)
.selectAll('transactions')
.select(eb =>
sql<{
totalCount: number
}>`json_build_object(${sql.lit('totalCount')}, ${eb.fn.count('transactions.id').over()})`.as(
'paginationStats',
),
)
.orderBy('transactions.created', 'desc')
if (filters.toAddress) {
query = query.where(
'transactions.toAddress',
'like',
`%${filters.toAddress}%`,
)
}
if (filters.from) {
query = query.where('transactions.created', '>=', filters.from)
}
if (filters.until) {
query = query.where('transactions.created', '<=', filters.until)
}
if (filters.deviceId) {
query = query.where('transactions.deviceId', '=', filters.deviceId)
}
if (filters.txClass) {
query = query.where('transactions.txClass', '=', filters.txClass)
}
if (filters.customerId) {
query = query.where('transactions.customerId', '=', filters.customerId)
}
if (filters.cryptoCode) {
query = query.where('transactions.cryptoCode', '=', filters.cryptoCode)
}
if (filters.swept) {
query = query.where('transactions.swept', '=', filters.swept)
}
if (filters.status) {
query = query.where('transactions.status', '=', filters.status)
}
if (filters.excludeTestingCustomers) {
query = query.where('transactions.isTestCustomer', '=', false)
}
if (pagination?.limit) {
query = query.limit(pagination.limit)
}
if (pagination?.offset) {
query = query.offset(pagination.offset)
}
return query.execute()
}
export {
getTransactionList,
getCashInTransactionList,
getCashOutTransactionList,
}

View file

@ -0,0 +1,35 @@
import type { ExpressionBuilder } from 'kysely'
import {
CashInTxs,
Customers,
DB,
Devices,
EditedCustomerData,
TransactionBatches,
UnpairedDevices,
} from './types.js'
import { Nullable } from 'kysely/dist/esm/index.js'
export type CustomerEB = ExpressionBuilder<DB & { cst: Customers }, 'cst'>
export type CustomerWithEditedDataEB = ExpressionBuilder<
DB & { cst: Customers } & { cstED: EditedCustomerData },
'cst' | 'cstED'
>
export type CashInEB = ExpressionBuilder<DB & { txIn: CashInTxs }, 'txIn'>
export type CashInWithBatchEB = ExpressionBuilder<
DB & { txIn: CashInTxs } & {
txInB: TransactionBatches
},
'txIn' | 'txInB'
>
export type CashOutEB = ExpressionBuilder<DB & { txOut: CashOutTxs }, 'txOut'>
export type DevicesAndUnpairedDevicesEB = ExpressionBuilder<
DB & { d: Nullable<Devices> } & {
ud: Nullable<UnpairedDevices>
},
'd' | 'ud'
>
export type GenericEB = ExpressionBuilder<DB, any>

View file

@ -399,7 +399,7 @@ export interface Customers {
frontCameraOverrideBy: string | null
frontCameraPath: string | null
id: string
idCardData: Json | null
idCardData: { firstName: string; lastName: string }
idCardDataAt: Timestamp | null
idCardDataExpiration: Timestamp | null
idCardDataNumber: string | null
@ -495,7 +495,7 @@ export interface EditedCustomerData {
frontCameraAt: Timestamp | null
frontCameraBy: string | null
frontCameraPath: string | null
idCardData: Json | null
idCardData: { firstName: string; lastName: string }
idCardDataAt: Timestamp | null
idCardDataBy: string | null
idCardPhotoAt: Timestamp | null

15
pnpm-lock.yaml generated
View file

@ -51,7 +51,7 @@ importers:
packages/admin-ui:
dependencies:
'@apollo/client':
specifier: ^3.13.7
specifier: ^3.13.8
version: 3.13.8(@types/react@19.1.5)(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@emotion/react':
specifier: ^11.14.0
@ -104,6 +104,9 @@ importers:
formik:
specifier: 2.2.0
version: 2.2.0(react@18.3.1)
immer:
specifier: ^10.1.1
version: 10.1.1
jss-plugin-extend:
specifier: ^10.0.0
version: 10.10.0
@ -163,7 +166,7 @@ importers:
version: 1.6.1
zustand:
specifier: ^4.5.7
version: 4.5.7(@types/react@19.1.5)(react@18.3.1)
version: 4.5.7(@types/react@19.1.5)(immer@10.1.1)(react@18.3.1)
devDependencies:
'@tailwindcss/vite':
specifier: ^4.1.4
@ -4205,6 +4208,9 @@ packages:
immediate@3.0.6:
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
immer@10.1.1:
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
import-fresh@3.3.1:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
@ -11853,6 +11859,8 @@ snapshots:
immediate@3.0.6: {}
immer@10.1.1: {}
import-fresh@3.3.1:
dependencies:
parent-module: 1.0.1
@ -15084,9 +15092,10 @@ snapshots:
zod@3.25.23: {}
zustand@4.5.7(@types/react@19.1.5)(react@18.3.1):
zustand@4.5.7(@types/react@19.1.5)(immer@10.1.1)(react@18.3.1):
dependencies:
use-sync-external-store: 1.5.0(react@18.3.1)
optionalDependencies:
'@types/react': 19.1.5
immer: 10.1.1
react: 18.3.1