feat: transactions table
This commit is contained in:
parent
1ead9fe359
commit
d6166ce752
29 changed files with 1204 additions and 726 deletions
|
|
@ -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",
|
||||||
|
|
|
||||||
118
packages/admin-ui/src/components/TableFilters.jsx
Normal file
118
packages/admin-ui/src/components/TableFilters.jsx
Normal 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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,6 @@ const GET_DATA = gql`
|
||||||
) {
|
) {
|
||||||
fiatCode
|
fiatCode
|
||||||
fiat
|
fiat
|
||||||
fixedFee
|
|
||||||
commissionPercentage
|
|
||||||
created
|
created
|
||||||
txClass
|
txClass
|
||||||
error
|
error
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
91
packages/admin-ui/src/pages/Transactions/Filters.jsx
Normal file
91
packages/admin-ui/src/pages/Transactions/Filters.jsx
Normal 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..."
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
const defaultMaterialTableOpts = {
|
const defaultMaterialTableOpts = {
|
||||||
|
enableKeyboardShortcuts: false,
|
||||||
enableGlobalFilter: false,
|
enableGlobalFilter: false,
|
||||||
paginationDisplayMode: 'pages',
|
paginationDisplayMode: 'pages',
|
||||||
enableColumnActions: false,
|
enableColumnActions: false,
|
||||||
|
|
|
||||||
|
|
@ -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) => {
|
||||||
|
|
|
||||||
|
|
@ -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, [
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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, {}))
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * as customers from './customers.js'
|
export * as customers from './customers.js'
|
||||||
|
export * as transactions from './transactions.js'
|
||||||
|
|
|
||||||
30
packages/typesafe-db/src/interpolled-query-logger.ts
Normal file
30
packages/typesafe-db/src/interpolled-query-logger.ts
Normal 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
|
||||||
|
}
|
||||||
319
packages/typesafe-db/src/transactions.ts
Normal file
319
packages/typesafe-db/src/transactions.ts
Normal 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,
|
||||||
|
}
|
||||||
35
packages/typesafe-db/src/types/manual.types.d.ts
vendored
Normal file
35
packages/typesafe-db/src/types/manual.types.d.ts
vendored
Normal 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>
|
||||||
4
packages/typesafe-db/src/types/types.d.ts
vendored
4
packages/typesafe-db/src/types/types.d.ts
vendored
|
|
@ -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
15
pnpm-lock.yaml
generated
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue