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", "license": "../LICENSE",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@apollo/client": "^3.13.7", "@apollo/client": "^3.13.8",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@lamassu/coins": "v1.6.1", "@lamassu/coins": "v1.6.1",
@ -22,6 +22,7 @@
"downshift": "9.0.9", "downshift": "9.0.9",
"file-saver": "2.0.2", "file-saver": "2.0.2",
"formik": "2.2.0", "formik": "2.2.0",
"immer": "^10.1.1",
"jss-plugin-extend": "^10.0.0", "jss-plugin-extend": "^10.0.0",
"jszip": "^3.6.0", "jszip": "^3.6.0",
"libphonenumber-js": "^1.11.15", "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 <button
type="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}> onMouseEnter={handler.openHelpPopper}>
<HelpIcon /> <HelpIcon />
</button> </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 until: $until
excludeTestingCustomers: $excludeTestingCustomers excludeTestingCustomers: $excludeTestingCustomers
) { ) {
id
txClass txClass
txHash
toAddress
commissionPercentage
expired expired
machineName
operatorCompleted
sendConfirmed sendConfirmed
dispense dispense
hasError: error hasError: error
deviceId deviceId
fiat fiat
fixedFee
fiatCode fiatCode
cryptoAtoms
cryptoCode
toAddress
created created
profit profit
} }

View file

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

View file

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

View file

@ -16,18 +16,8 @@ import DataTable from '../../../../components/tables/DataTable'
const NUM_LOG_RESULTS = 5 const NUM_LOG_RESULTS = 5
const GET_TRANSACTIONS = gql` const GET_TRANSACTIONS = gql`
query transactions( query transactions($limit: Int, $deviceId: String) {
$limit: Int transactions(limit: $limit, deviceId: $deviceId) {
$from: DateTimeISO
$until: DateTimeISO
$deviceId: String
) {
transactions(
limit: $limit
from: $from
until: $until
deviceId: $deviceId
) {
id id
txClass txClass
txHash txHash
@ -47,7 +37,6 @@ const GET_TRANSACTIONS = gql`
cryptoCode cryptoCode
toAddress toAddress
created created
customerName
customerIdCardData customerIdCardData
customerIdCardPhotoPath customerIdCardPhotoPath
customerFrontCameraPath 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 { toUnit, formatCryptoAddress } from '@lamassu/coins/lightUtils'
import BigNumber from 'bignumber.js' import BigNumber from 'bignumber.js'
import * as R from 'ramda' import * as R from 'ramda'
import React, { useEffect, useState } from 'react' import React, { useEffect, useMemo } from 'react'
import { useLocation } from 'wouter' import { useLocation } from 'wouter'
import LogsDowloaderPopover from '../../components/LogsDownloaderPopper' import LogsDowloaderPopover from '../../components/LogsDownloaderPopper'
import SearchBox from '../../components/SearchBox'
import SearchFilter from '../../components/SearchFilter'
import { HelpTooltip } from '../../components/Tooltip' import { HelpTooltip } from '../../components/Tooltip'
import DataTable from '../../components/tables/DataTable'
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react' import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react' import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
import CustomerLinkIcon from '../../styling/icons/month arrows/right.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 { formatDate } from '../../utils/timezones'
import DetailsRow from './DetailsCard' import DetailsRow from './DetailsCard'
import { getStatus } from './helper'
import TitleSection from '../../components/layout/TitleSection.jsx' 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 NUM_LOG_RESULTS = 1000
const GET_DATA = gql` const GET_DATA = gql`
query getData { query getData {
config 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` const GET_TRANSACTIONS = gql`
query transactions( query transactions(
$limit: Int $limit: Int
$offset: Int
$from: DateTimeISO $from: DateTimeISO
$until: DateTimeISO $until: DateTimeISO
$txClass: String $txClass: String
$deviceId: String $deviceId: String
$customerName: String $customerName: String
$customerId: ID
$fiatCode: String $fiatCode: String
$cryptoCode: String $cryptoCode: String
$toAddress: String $toAddress: String
@ -77,17 +100,22 @@ const GET_TRANSACTIONS = gql`
) { ) {
transactions( transactions(
limit: $limit limit: $limit
offset: $offset
from: $from from: $from
until: $until until: $until
txClass: $txClass txClass: $txClass
deviceId: $deviceId deviceId: $deviceId
customerName: $customerName customerName: $customerName
customerId: $customerId
fiatCode: $fiatCode fiatCode: $fiatCode
cryptoCode: $cryptoCode cryptoCode: $cryptoCode
toAddress: $toAddress toAddress: $toAddress
status: $status status: $status
swept: $swept swept: $swept
) { ) {
paginationStats {
totalCount
}
id id
txClass txClass
txHash txHash
@ -109,7 +137,6 @@ const GET_TRANSACTIONS = gql`
cryptoCode cryptoCode
toAddress toAddress
created created
customerName
customerIdCardData customerIdCardData
customerIdCardPhotoPath customerIdCardPhotoPath
customerFrontCameraPath customerFrontCameraPath
@ -126,203 +153,294 @@ const GET_TRANSACTIONS = gql`
walletScore walletScore
profit profit
swept swept
status
} }
} }
` `
const getFiltersObj = filters => const useTableStore = create(
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters) 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 Transactions = () => {
const [, navigate] = useLocation() const [, navigate] = useLocation()
const { variables, columnFilters, pagination, previousData, updateField } =
useTableStore()
const [filters, setFilters] = useState([]) const { data: configResponse } = useQuery(GET_DATA)
const { data: filtersResponse, loading: filtersLoading } = useQuery( const { data, loading } = useQuery(GET_TRANSACTIONS, {
GET_TRANSACTION_FILTERS, variables,
) notifyOnNetworkStatusChange: true,
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 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 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 => { const redirect = customerId => {
return navigate(`/compliance/customer/${customerId}`) return navigate(`/compliance/customer/${customerId}`)
} }
const elements = [ const mapColumnFiltersToVariables = filters => {
{ const filterMap = {
header: '', machineName: 'deviceId',
width: 32, customerId: 'customerId',
size: 'sm', cryptoAtoms: 'cryptoCode',
view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />), toAddress: 'toAddress',
}, status: 'status',
{ txClass: 'txClass',
header: 'Machine', swept: 'swept',
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 onFilterChange = filters => { return filters.reduce((acc, filter) => {
const filtersObject = getFiltersObj(filters) const mappedKey = filterMap[filter.id] || filter.id
if (mappedKey && filter.value !== undefined && filter.value !== '') {
setFilters(filters) acc[mappedKey] = filter.value
}
setVariables({ return acc
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 onFilterDelete = filter => { const listFilterChange = inputs => {
const newFilters = R.filter( const { limit, offset } = inputs ?? {}
f => !R.whereEq(R.pick(['type', 'value'], f), filter), const mappedFilters = mapColumnFiltersToVariables(columnFilters)
)(filters)
setFilters(newFilters) updateField('variables', {
limit,
const filtersObject = getFiltersObj(newFilters) offset,
...mappedFilters,
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 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 = ( const errorLabel = (
<svg width={12} height={12}> <svg width={12} height={12}>
<rect width={12} height={12} rx={3} fill={errorColor} /> <rect width={12} height={12} rx={3} fill={errorColor} />
@ -340,14 +458,7 @@ const Transactions = () => {
]} ]}
appendix={ appendix={
<div className="flex ml-4 gap-4"> <div className="flex ml-4 gap-4">
<SearchBox {
loading={filtersLoading}
filters={filters}
options={filterOptions}
inputPlaceholder={'Search transactions'}
onChange={onFilterChange}
/>
{txList && (
<LogsDowloaderPopover <LogsDowloaderPopover
title="Download logs" title="Download logs"
name="transactions" name="transactions"
@ -357,28 +468,11 @@ const Transactions = () => {
timezone={timezone} timezone={timezone}
args={{ timezone }} args={{ timezone }}
/> />
)} }
</div> </div>
} }
/> />
{filters.length > 0 && ( <MaterialReactTable table={table} />
<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}
/>
</> </>
) )
} }

View file

@ -26,7 +26,8 @@ const getStatus = it => {
const getStatusDetails = it => { const getStatusDetails = it => {
if (!R.isNil(it.hasError)) return it.hasError 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 return null
} }

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,230 +1,72 @@
const _ = require('lodash/fp') const _ = require('lodash/fp')
const pgp = require('pg-promise')()
const db = require('../../db') const db = require('../../db')
const BN = require('../../bn') const BN = require('../../bn')
const { utils: coinUtils } = require('@lamassu/coins') const { utils: coinUtils } = require('@lamassu/coins')
const tx = require('../../tx')
const cashInTx = require('../../cash-in/cash-in-tx') const cashInTx = require('../../cash-in/cash-in-tx')
const { REDEEMABLE_AGE } = require('../../cash-out/cash-out-helper')
const { const {
REDEEMABLE_AGE, transactions: { getTransactionList },
CASH_OUT_TRANSACTION_STATES, } = require('typesafe-db')
} = require('../../cash-out/cash-out-helper')
const NUM_RESULTS = 1000
function addProfits(txs) { function addProfits(txs) {
return _.map(it => { return _.map(
const profit = getProfit(it).toString() it => ({
return _.set('profit', profit, it) ...it,
}, txs) profit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString(),
}),
txs,
)
} }
const camelize = _.mapKeys(_.camelCase) function batch({
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(
from = new Date(0).toISOString(), from = new Date(0).toISOString(),
until = new Date().toISOString(), until = new Date().toISOString(),
limit = null, limit = null,
offset = 0, offset = 0,
txClass = null, txClass = null,
deviceId = null, deviceId = null,
customerName = null, customerId = null,
fiatCode = null,
cryptoCode = null, cryptoCode = null,
toAddress = null, toAddress = null,
status = null, status = null,
swept = null, swept = null,
excludeTestingCustomers = false, excludeTestingCustomers = false,
simplified, simplified,
) { }) {
const isCsvExport = _.isBoolean(simplified) const isCsvExport = _.isBoolean(simplified)
const packager = _.flow( return (
_.flatten, Promise.all([
_.orderBy(_.property('created'), ['desc']), getTransactionList(
_.map( {
_.flow( from,
camelize, until,
_.mapKeys(k => (k == 'cashInFee' ? 'fixedFee' : k)), 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) { function advancedBatch(data) {
@ -239,7 +81,7 @@ function advancedBatch(data) {
'fiatCode', 'fiatCode',
'fee', 'fee',
'status', 'status',
'fiatProfit', 'profit',
'cryptoAmount', 'cryptoAmount',
'dispense', 'dispense',
'notified', 'notified',
@ -300,9 +142,6 @@ function advancedBatch(data) {
const addAdvancedFields = _.map(it => ({ const addAdvancedFields = _.map(it => ({
...it, ...it,
status: getStatus(it),
fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString(),
fixedFee: it.fixedFee ?? null, fixedFee: it.fixedFee ?? null,
fee: it.fee ?? null, fee: it.fee ?? null,
})) }))
@ -328,18 +167,11 @@ function simplifiedBatch(data) {
'dispense', 'dispense',
'error', 'error',
'status', 'status',
'fiatProfit', 'profit',
'cryptoAmount', 'cryptoAmount',
] ]
const addSimplifiedFields = _.map(it => ({ return _.map(_.pick(fields))(data)
...it,
status: getStatus(it),
fiatProfit: getProfit(it).toString(),
cryptoAmount: getCryptoAmount(it).toString(),
}))
return _.compose(_.map(_.pick(fields)), addSimplifiedFields)(data)
} }
const getCryptoAmount = it => const getCryptoAmount = it =>
@ -363,150 +195,6 @@ const getProfit = it => {
: calcCashOutProfit(fiat, crypto, tickerPrice) : 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) { function getTx(txId, txClass) {
const cashInSql = `select 'cashIn' as tx_class, txs.*, const cashInSql = `select 'cashIn' as tx_class, txs.*,
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired ((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
@ -558,9 +246,6 @@ function updateTxCustomerPhoto(customerId, txId, direction, data) {
module.exports = { module.exports = {
batch, batch,
single,
cancel,
getCustomerTransactionsBatch,
getTx, getTx,
getTxAssociatedData, getTxAssociatedData,
updateTxCustomerPhoto, updateTxCustomerPhoto,

View file

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

View file

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

View file

@ -59,22 +59,6 @@ function massage(tx) {
return mapper(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) { function customerHistory(customerId, thresholdDays) {
const sql = `SELECT ch.id, ch.created, ch.fiat, ch.direction FROM ( const sql = `SELECT ch.id, ch.created, ch.fiat, ch.direction FROM (
SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction, 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]) 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", "version": "11.0.0-beta.0",
"license": "../LICENSE", "license": "../LICENSE",
"type": "module", "type": "module",
"dependencies": {
"kysely": "^0.28.2",
"pg": "^8.16.0"
},
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
"@types/pg": "^8.11.10", "@types/pg": "^8.11.10",
@ -18,11 +22,17 @@
"scripts": { "scripts": {
"build": "tsc --build", "build": "tsc --build",
"dev": "tsc --watch", "dev": "tsc --watch",
"generate-types": "kysely-codegen --camel-case --out-file ./src/types/types.d.ts", "generate-types": "kysely-codegen",
"postinstall": "npm run build" "postinstall": "npm run build"
}, },
"dependencies": { "kysely-codegen": {
"kysely": "^0.28.2", "camelCase": true,
"pg": "^8.16.0" "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 db from './db.js'
import { ExpressionBuilder } from 'kysely'
import { Customers, DB, EditedCustomerData } from './types/types.js'
import { jsonArrayFrom } from 'kysely/helpers/postgres' import { jsonArrayFrom } from 'kysely/helpers/postgres'
import type {
type CustomerEB = ExpressionBuilder<DB & { c: Customers }, 'c'> CustomerEB,
type CustomerWithEditedEB = ExpressionBuilder< CustomerWithEditedDataEB,
DB & { c: Customers } & { e: EditedCustomerData | null }, } from './types/manual.types.js'
'c' | 'e'
>
const ANON_ID = '47ac1184-8102-11e7-9079-8f13a7117867' const ANON_ID = '47ac1184-8102-11e7-9079-8f13a7117867'
const TX_PASSTHROUGH_ERROR_CODES = [ const TX_PASSTHROUGH_ERROR_CODES = [
@ -28,7 +25,7 @@ function transactionUnion(eb: CustomerEB) {
]) ])
.where(({ eb, and, or, ref }) => .where(({ eb, and, or, ref }) =>
and([ and([
eb('customerId', '=', ref('c.id')), eb('customerId', '=', ref('cst.id')),
or([eb('sendConfirmed', '=', true), eb('batched', '=', true)]), or([eb('sendConfirmed', '=', true), eb('batched', '=', true)]),
]), ]),
) )
@ -44,7 +41,7 @@ function transactionUnion(eb: CustomerEB) {
]) ])
.where(({ eb, and, ref }) => .where(({ eb, and, ref }) =>
and([ and([
eb('customerId', '=', ref('c.id')), eb('customerId', '=', ref('cst.id')),
eb('confirmedAt', 'is not', null), eb('confirmedAt', 'is not', null),
]), ]),
), ),
@ -92,20 +89,20 @@ function joinTxsTotals(eb: CustomerEB) {
.as('txStats') .as('txStats')
} }
function selectNewestIdCardData(eb: CustomerWithEditedEB, ref: any) { function selectNewestIdCardData({ eb, ref }: CustomerWithEditedDataEB) {
return eb return eb
.case() .case()
.when( .when(
eb.and([ eb.and([
eb(ref('e.idCardDataAt'), 'is not', null), eb(ref('cstED.idCardDataAt'), 'is not', null),
eb.or([ eb.or([
eb(ref('c.idCardDataAt'), 'is', null), eb(ref('cst.idCardDataAt'), 'is', null),
eb(ref('e.idCardDataAt'), '>', ref('c.idCardDataAt')), eb(ref('cstED.idCardDataAt'), '>', ref('cst.idCardDataAt')),
]), ]),
]), ]),
) )
.then(ref('e.idCardData')) .then(ref('cstED.idCardData'))
.else(ref('c.idCardData')) .else(ref('cst.idCardData'))
.end() .end()
} }
@ -122,58 +119,58 @@ function getCustomerList(
options: GetCustomerListOptions = defaultOptions, options: GetCustomerListOptions = defaultOptions,
): Promise<any[]> { ): Promise<any[]> {
return db return db
.selectFrom('customers as c') .selectFrom('customers as cst')
.leftJoin('editedCustomerData as e', 'e.customerId', 'c.id') .leftJoin('editedCustomerData as cstED', 'cstED.customerId', 'cst.id')
.leftJoinLateral(joinTxsTotals, join => join.onTrue()) .leftJoinLateral(joinTxsTotals, join => join.onTrue())
.leftJoinLateral(joinLatestTx, join => join.onTrue()) .leftJoinLateral(joinLatestTx, join => join.onTrue())
.select(({ eb, fn, val, ref }) => [ .select(({ eb, fn, val }) => [
'c.id', 'cst.id',
'c.phone', 'cst.phone',
'c.authorizedOverride', 'cst.authorizedOverride',
'c.frontCameraPath', 'cst.frontCameraPath',
'c.frontCameraOverride', 'cst.frontCameraOverride',
'c.idCardPhotoPath', 'cst.idCardPhotoPath',
'c.idCardPhotoOverride', 'cst.idCardPhotoOverride',
selectNewestIdCardData(eb, ref).as('idCardData'), selectNewestIdCardData(eb).as('idCardData'),
'c.idCardDataOverride', 'cst.idCardDataOverride',
'c.email', 'cst.email',
'c.usSsn', 'cst.usSsn',
'c.usSsnOverride', 'cst.usSsnOverride',
'c.sanctions', 'cst.sanctions',
'c.sanctionsOverride', 'cst.sanctionsOverride',
'txStats.totalSpent', 'txStats.totalSpent',
'txStats.totalTxs', 'txStats.totalTxs',
ref('lastTx.fiatCode').as('lastTxFiatCode'), 'lastTx.fiatCode as lastTxFiatCode',
ref('lastTx.fiat').as('lastTxFiat'), 'lastTx.fiat as lastTxFiat',
ref('lastTx.txClass').as('lastTxClass'), 'lastTx.txClass as lastTxClass',
fn<Date>('GREATEST', [ fn<Date>('GREATEST', [
'c.created', 'cst.created',
'lastTx.created', 'lastTx.created',
'c.phoneAt', 'cst.phoneAt',
'c.emailAt', 'cst.emailAt',
'c.idCardDataAt', 'cst.idCardDataAt',
'c.frontCameraAt', 'cst.frontCameraAt',
'c.idCardPhotoAt', 'cst.idCardPhotoAt',
'c.usSsnAt', 'cst.usSsnAt',
'c.lastAuthAttempt', 'cst.lastAuthAttempt',
]).as('lastActive'), ]).as('lastActive'),
eb('c.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'), eb('cst.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
fn<number>('GREATEST', [ fn<number>('GREATEST', [
val(0), val(0),
fn<number>('date_part', [ fn<number>('date_part', [
val('day'), val('day'),
eb('c.suspendedUntil', '-', fn<Date>('NOW', [])), eb('cst.suspendedUntil', '-', fn<Date>('NOW', [])),
]), ]),
]).as('daysSuspended'), ]).as('daysSuspended'),
]) ])
.where('c.id', '!=', ANON_ID) .where('cst.id', '!=', ANON_ID)
.$if(options.withCustomInfoRequest, qb => .$if(options.withCustomInfoRequest, qb =>
qb.select(({ eb, ref }) => qb.select(({ eb, ref }) =>
jsonArrayFrom( jsonArrayFrom(
eb eb
.selectFrom('customersCustomInfoRequests') .selectFrom('customersCustomInfoRequests')
.selectAll() .selectAll()
.where('customerId', '=', ref('c.id')), .where('customerId', '=', ref('cst.id')),
).as('customInfoRequestData'), ).as('customInfoRequestData'),
), ),
) )
@ -181,4 +178,39 @@ function getCustomerList(
.execute() .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 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 frontCameraOverrideBy: string | null
frontCameraPath: string | null frontCameraPath: string | null
id: string id: string
idCardData: Json | null idCardData: { firstName: string; lastName: string }
idCardDataAt: Timestamp | null idCardDataAt: Timestamp | null
idCardDataExpiration: Timestamp | null idCardDataExpiration: Timestamp | null
idCardDataNumber: string | null idCardDataNumber: string | null
@ -495,7 +495,7 @@ export interface EditedCustomerData {
frontCameraAt: Timestamp | null frontCameraAt: Timestamp | null
frontCameraBy: string | null frontCameraBy: string | null
frontCameraPath: string | null frontCameraPath: string | null
idCardData: Json | null idCardData: { firstName: string; lastName: string }
idCardDataAt: Timestamp | null idCardDataAt: Timestamp | null
idCardDataBy: string | null idCardDataBy: string | null
idCardPhotoAt: Timestamp | null idCardPhotoAt: Timestamp | null

15
pnpm-lock.yaml generated
View file

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