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",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.13.7",
|
||||
"@apollo/client": "^3.13.8",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.0",
|
||||
"@lamassu/coins": "v1.6.1",
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"downshift": "9.0.9",
|
||||
"file-saver": "2.0.2",
|
||||
"formik": "2.2.0",
|
||||
"immer": "^10.1.1",
|
||||
"jss-plugin-extend": "^10.0.0",
|
||||
"jszip": "^3.6.0",
|
||||
"libphonenumber-js": "^1.11.15",
|
||||
|
|
|
|||
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
|
||||
type="button"
|
||||
className="border-0 bg-transparent outline-0 cursor-pointer mt-1"
|
||||
className="flex justify-center align-center border-0 bg-transparent outline-0 cursor-pointer px-1"
|
||||
onMouseEnter={handler.openHelpPopper}>
|
||||
<HelpIcon />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
excludeTestingCustomers: $excludeTestingCustomers
|
||||
) {
|
||||
id
|
||||
txClass
|
||||
txHash
|
||||
toAddress
|
||||
commissionPercentage
|
||||
expired
|
||||
machineName
|
||||
operatorCompleted
|
||||
sendConfirmed
|
||||
dispense
|
||||
hasError: error
|
||||
deviceId
|
||||
fiat
|
||||
fixedFee
|
||||
fiatCode
|
||||
cryptoAtoms
|
||||
cryptoCode
|
||||
toAddress
|
||||
created
|
||||
profit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,20 @@ import { getFormattedPhone, getName, formatPhotosData } from './helper'
|
|||
const GET_CUSTOMER = gql`
|
||||
query customer($customerId: ID!) {
|
||||
config
|
||||
transactions(customerId: $customerId, limit: 20) {
|
||||
txClass
|
||||
id
|
||||
fiat
|
||||
fiatCode
|
||||
cryptoAtoms
|
||||
cryptoCode
|
||||
created
|
||||
machineName
|
||||
errorMessage: error
|
||||
error: errorCode
|
||||
txCustomerPhotoAt
|
||||
txCustomerPhotoPath
|
||||
}
|
||||
customer(customerId: $customerId) {
|
||||
id
|
||||
authorizedOverride
|
||||
|
|
@ -81,20 +95,6 @@ const GET_CUSTOMER = gql`
|
|||
created
|
||||
lastEditedAt
|
||||
}
|
||||
transactions {
|
||||
txClass
|
||||
id
|
||||
fiat
|
||||
fiatCode
|
||||
cryptoAtoms
|
||||
cryptoCode
|
||||
created
|
||||
machineName
|
||||
errorMessage: error
|
||||
error: errorCode
|
||||
txCustomerPhotoAt
|
||||
txCustomerPhotoPath
|
||||
}
|
||||
customInfoRequests {
|
||||
customerId
|
||||
override
|
||||
|
|
@ -459,7 +459,7 @@ const CustomerProfile = memo(() => {
|
|||
const configData = R.path(['config'])(customerResponse) ?? []
|
||||
const locale = configData && fromNamespace(namespaces.LOCALE, configData)
|
||||
const customerData = R.path(['customer'])(customerResponse) ?? []
|
||||
const rawTransactions = R.path(['transactions'])(customerData) ?? []
|
||||
const rawTransactions = R.path(['transactions'])(customerResponse) ?? []
|
||||
const sortedTransactions = R.sort(R.descend(R.prop('cryptoAtoms')))(
|
||||
rawTransactions,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -32,8 +32,6 @@ const GET_DATA = gql`
|
|||
) {
|
||||
fiatCode
|
||||
fiat
|
||||
fixedFee
|
||||
commissionPercentage
|
||||
created
|
||||
txClass
|
||||
error
|
||||
|
|
|
|||
|
|
@ -16,18 +16,8 @@ import DataTable from '../../../../components/tables/DataTable'
|
|||
const NUM_LOG_RESULTS = 5
|
||||
|
||||
const GET_TRANSACTIONS = gql`
|
||||
query transactions(
|
||||
$limit: Int
|
||||
$from: DateTimeISO
|
||||
$until: DateTimeISO
|
||||
$deviceId: String
|
||||
) {
|
||||
transactions(
|
||||
limit: $limit
|
||||
from: $from
|
||||
until: $until
|
||||
deviceId: $deviceId
|
||||
) {
|
||||
query transactions($limit: Int, $deviceId: String) {
|
||||
transactions(limit: $limit, deviceId: $deviceId) {
|
||||
id
|
||||
txClass
|
||||
txHash
|
||||
|
|
@ -47,7 +37,6 @@ const GET_TRANSACTIONS = gql`
|
|||
cryptoCode
|
||||
toAddress
|
||||
created
|
||||
customerName
|
||||
customerIdCardData
|
||||
customerIdCardPhotoPath
|
||||
customerFrontCameraPath
|
||||
|
|
|
|||
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 BigNumber from 'bignumber.js'
|
||||
import * as R from 'ramda'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useMemo } from 'react'
|
||||
import { useLocation } from 'wouter'
|
||||
import LogsDowloaderPopover from '../../components/LogsDownloaderPopper'
|
||||
import SearchBox from '../../components/SearchBox'
|
||||
import SearchFilter from '../../components/SearchFilter'
|
||||
import { HelpTooltip } from '../../components/Tooltip'
|
||||
import DataTable from '../../components/tables/DataTable'
|
||||
import TxInIcon from '../../styling/icons/direction/cash-in.svg?react'
|
||||
import TxOutIcon from '../../styling/icons/direction/cash-out.svg?react'
|
||||
import CustomerLinkIcon from '../../styling/icons/month arrows/right.svg?react'
|
||||
|
|
@ -20,14 +19,46 @@ import * as Customer from '../../utils/customer'
|
|||
import { formatDate } from '../../utils/timezones'
|
||||
|
||||
import DetailsRow from './DetailsCard'
|
||||
import { getStatus } from './helper'
|
||||
import TitleSection from '../../components/layout/TitleSection.jsx'
|
||||
import { MaterialReactTable, useMaterialReactTable } from 'material-react-table'
|
||||
import {
|
||||
alignRight,
|
||||
defaultMaterialTableOpts,
|
||||
} from '../../utils/materialReactTableOpts.js'
|
||||
import { getStatusDetails } from './helper.js'
|
||||
import {
|
||||
CustomerFilter,
|
||||
MachineFilter,
|
||||
DirectionFilter,
|
||||
CryptoFilter,
|
||||
StatusFilter,
|
||||
SweptFilter,
|
||||
} from './Filters.jsx'
|
||||
|
||||
const NUM_LOG_RESULTS = 1000
|
||||
|
||||
const GET_DATA = gql`
|
||||
query getData {
|
||||
config
|
||||
machines {
|
||||
name
|
||||
deviceId
|
||||
}
|
||||
cryptoCurrencies {
|
||||
code
|
||||
display
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const SEARCH_CUSTOMERS = gql`
|
||||
query searchCustomers($searchTerm: String!, $limit: Int) {
|
||||
searchCustomers(searchTerm: $searchTerm, limit: $limit) {
|
||||
id
|
||||
name
|
||||
phone
|
||||
email
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
|
|
@ -51,24 +82,16 @@ const GET_TRANSACTIONS_CSV = gql`
|
|||
}
|
||||
`
|
||||
|
||||
const GET_TRANSACTION_FILTERS = gql`
|
||||
query filters {
|
||||
transactionFilters {
|
||||
type
|
||||
value
|
||||
label
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const GET_TRANSACTIONS = gql`
|
||||
query transactions(
|
||||
$limit: Int
|
||||
$offset: Int
|
||||
$from: DateTimeISO
|
||||
$until: DateTimeISO
|
||||
$txClass: String
|
||||
$deviceId: String
|
||||
$customerName: String
|
||||
$customerId: ID
|
||||
$fiatCode: String
|
||||
$cryptoCode: String
|
||||
$toAddress: String
|
||||
|
|
@ -77,17 +100,22 @@ const GET_TRANSACTIONS = gql`
|
|||
) {
|
||||
transactions(
|
||||
limit: $limit
|
||||
offset: $offset
|
||||
from: $from
|
||||
until: $until
|
||||
txClass: $txClass
|
||||
deviceId: $deviceId
|
||||
customerName: $customerName
|
||||
customerId: $customerId
|
||||
fiatCode: $fiatCode
|
||||
cryptoCode: $cryptoCode
|
||||
toAddress: $toAddress
|
||||
status: $status
|
||||
swept: $swept
|
||||
) {
|
||||
paginationStats {
|
||||
totalCount
|
||||
}
|
||||
id
|
||||
txClass
|
||||
txHash
|
||||
|
|
@ -109,7 +137,6 @@ const GET_TRANSACTIONS = gql`
|
|||
cryptoCode
|
||||
toAddress
|
||||
created
|
||||
customerName
|
||||
customerIdCardData
|
||||
customerIdCardPhotoPath
|
||||
customerFrontCameraPath
|
||||
|
|
@ -126,203 +153,294 @@ const GET_TRANSACTIONS = gql`
|
|||
walletScore
|
||||
profit
|
||||
swept
|
||||
status
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const getFiltersObj = filters =>
|
||||
R.reduce((s, f) => ({ ...s, [f.type]: f.value }), {}, filters)
|
||||
const useTableStore = create(
|
||||
immer(set => ({
|
||||
variables: { limit: NUM_LOG_RESULTS },
|
||||
columnFilters: [],
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
},
|
||||
previousData: [],
|
||||
|
||||
updateField: (field, updates) =>
|
||||
set(state => {
|
||||
if (typeof updates === 'function') {
|
||||
state[field] = updates(state[field])
|
||||
} else {
|
||||
state[field] = updates
|
||||
}
|
||||
}),
|
||||
})),
|
||||
)
|
||||
|
||||
const Transactions = () => {
|
||||
const [, navigate] = useLocation()
|
||||
const { variables, columnFilters, pagination, previousData, updateField } =
|
||||
useTableStore()
|
||||
|
||||
const [filters, setFilters] = useState([])
|
||||
const { data: filtersResponse, loading: filtersLoading } = useQuery(
|
||||
GET_TRANSACTION_FILTERS,
|
||||
)
|
||||
const [variables, setVariables] = useState({ limit: NUM_LOG_RESULTS })
|
||||
const {
|
||||
data: txData,
|
||||
loading: transactionsLoading,
|
||||
refetch,
|
||||
startPolling,
|
||||
stopPolling,
|
||||
} = useQuery(GET_TRANSACTIONS, { variables })
|
||||
|
||||
useEffect(() => {
|
||||
startPolling(10000)
|
||||
return stopPolling
|
||||
const { data: configResponse } = useQuery(GET_DATA)
|
||||
const { data, loading } = useQuery(GET_TRANSACTIONS, {
|
||||
variables,
|
||||
notifyOnNetworkStatusChange: true,
|
||||
})
|
||||
|
||||
const txList = txData?.transactions ?? []
|
||||
const [searchCustomersQuery] = useLazyQuery(SEARCH_CUSTOMERS)
|
||||
|
||||
const displayData = useMemo(() => {
|
||||
const formattedData = (data?.transactions ?? []).map(row => ({
|
||||
...row,
|
||||
toAddress: formatCryptoAddress(row.cryptoCode, row.toAddress),
|
||||
}))
|
||||
|
||||
return loading && previousData.length > 0 ? previousData : formattedData
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
if (!loading && displayData && displayData.length > 0) {
|
||||
updateField('previousData', displayData)
|
||||
}
|
||||
}, [displayData, loading])
|
||||
|
||||
useEffect(() => {
|
||||
listFilterChange({
|
||||
offset: pagination.pageIndex * pagination.pageSize,
|
||||
limit: pagination.pageSize,
|
||||
})
|
||||
}, [pagination, columnFilters])
|
||||
|
||||
const { data: configResponse, configLoading } = useQuery(GET_DATA)
|
||||
const timezone = R.path(['config', 'locale_timezone'], configResponse)
|
||||
|
||||
const machines = configResponse?.machines || []
|
||||
const cryptoCurrencies = configResponse?.cryptoCurrencies || []
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: 'Direction',
|
||||
accessorKey: 'txClass',
|
||||
Filter: DirectionFilter,
|
||||
grow: false,
|
||||
size: 50,
|
||||
muiTableBodyCellProps: {
|
||||
align: 'center',
|
||||
},
|
||||
Cell: ({ cell }) =>
|
||||
cell.getValue() === 'cashOut' ? <TxOutIcon /> : <TxInIcon />,
|
||||
},
|
||||
{
|
||||
accessorKey: 'id',
|
||||
header: 'ID',
|
||||
size: 315,
|
||||
},
|
||||
{
|
||||
accessorKey: 'swept',
|
||||
header: 'Swept',
|
||||
Filter: SweptFilter,
|
||||
size: 50,
|
||||
Cell: ({ cell }) => (cell.getValue() ? 'Yes' : 'No'),
|
||||
},
|
||||
{
|
||||
accessorKey: 'machineName',
|
||||
header: 'Machine',
|
||||
Filter: ({ column }) => (
|
||||
<MachineFilter column={column} machines={machines} />
|
||||
),
|
||||
size: 160,
|
||||
maxSize: 160,
|
||||
},
|
||||
{
|
||||
accessorKey: 'customerId',
|
||||
header: 'Customer',
|
||||
size: 202,
|
||||
Filter: ({ column }) => (
|
||||
<CustomerFilter column={column} onSearch={searchCustomers} />
|
||||
),
|
||||
Cell: ({ row }) => (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{Customer.displayName(row.original)}
|
||||
</div>
|
||||
{!row.original.isAnonymous && (
|
||||
<div
|
||||
className="cursor-pointer flex"
|
||||
data-cy="customer-link"
|
||||
onClick={() => redirect(row.original.customerId)}>
|
||||
{getStatusDetails(row.original) ? (
|
||||
<CustomerLinkWhiteIcon />
|
||||
) : (
|
||||
<CustomerLinkIcon />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'fiat',
|
||||
header: 'Cash',
|
||||
enableColumnFilter: false,
|
||||
size: 144,
|
||||
...alignRight,
|
||||
Cell: ({ cell, row }) =>
|
||||
`${Number.parseFloat(cell.getValue())} ${row.original.fiatCode}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'cryptoAtoms',
|
||||
header: 'Crypto',
|
||||
Filter: ({ column }) => (
|
||||
<CryptoFilter column={column} cryptoCurrencies={cryptoCurrencies} />
|
||||
),
|
||||
size: 150,
|
||||
...alignRight,
|
||||
Cell: ({ cell, row }) =>
|
||||
`${toUnit(new BigNumber(cell.getValue()), row.original.cryptoCode)} ${
|
||||
row.original.cryptoCode
|
||||
}`,
|
||||
},
|
||||
{
|
||||
accessorKey: 'toAddress',
|
||||
header: 'Address',
|
||||
size: 140,
|
||||
muiTableBodyCellProps: {
|
||||
className: 'overflow-hidden whitespace-nowrap text-ellipsis',
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: 'created',
|
||||
header: 'Date',
|
||||
enableColumnFilter: false,
|
||||
Cell: ({ cell }) =>
|
||||
timezone && formatDate(cell.getValue(), timezone, 'yyyy-MM-dd HH:mm'),
|
||||
...alignRight,
|
||||
size: 155,
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
size: 80,
|
||||
Filter: StatusFilter,
|
||||
Cell: ({ cell }) => {
|
||||
if (cell.getValue() === 'Pending')
|
||||
return (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
Pending
|
||||
</div>
|
||||
<HelpTooltip width={200}>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
|
||||
label="Cancelling cash-out transactions"
|
||||
bottomSpace="0"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)
|
||||
else return cell.getValue()
|
||||
},
|
||||
},
|
||||
],
|
||||
[machines, cryptoCurrencies, timezone],
|
||||
)
|
||||
|
||||
const table = useMaterialReactTable({
|
||||
enableColumnResizing: true,
|
||||
...defaultMaterialTableOpts,
|
||||
initialState: {
|
||||
...defaultMaterialTableOpts.initialState,
|
||||
columnVisibility: {
|
||||
id: false,
|
||||
swept: false,
|
||||
},
|
||||
},
|
||||
columns: columns,
|
||||
rowCount: displayData?.[0]?.paginationStats?.totalCount ?? 0,
|
||||
getRowId: it => it.id,
|
||||
data: displayData || [],
|
||||
manualFiltering: true,
|
||||
manualPagination: true,
|
||||
enableSorting: false,
|
||||
onPaginationChange: it => {
|
||||
console.log('PAGINATION', it)
|
||||
updateField('pagination', it)
|
||||
},
|
||||
muiFilterTextFieldProps: {
|
||||
size: 'small',
|
||||
},
|
||||
muiFilterCheckboxProps: { size: 'small' },
|
||||
onColumnFiltersChange: it => updateField('columnFilters', it),
|
||||
enableExpandAll: false,
|
||||
state: {
|
||||
columnFilters,
|
||||
pagination,
|
||||
isLoading: loading,
|
||||
},
|
||||
muiTableBodyRowProps: ({ row }) => ({
|
||||
sx: {
|
||||
backgroundColor: getStatusDetails(row.original) ? '#ffeceb' : '',
|
||||
},
|
||||
}),
|
||||
displayColumnDefOptions: {
|
||||
'mrt-row-expand': {
|
||||
header: '',
|
||||
},
|
||||
},
|
||||
muiExpandButtonProps: ({ row, table }) => ({
|
||||
onClick: () => table.setExpanded({ [row.id]: !row.getIsExpanded() }), //set only this row to be expanded
|
||||
}),
|
||||
renderDetailPanel: ({ row }) =>
|
||||
row.original ? (
|
||||
<DetailsRow it={row.original} timezone={timezone} />
|
||||
) : null,
|
||||
})
|
||||
|
||||
const searchCustomers = async searchTerm => {
|
||||
const { data } = await searchCustomersQuery({
|
||||
variables: { searchTerm, limit: 20 },
|
||||
})
|
||||
return data?.searchCustomers || []
|
||||
}
|
||||
|
||||
const redirect = customerId => {
|
||||
return navigate(`/compliance/customer/${customerId}`)
|
||||
}
|
||||
|
||||
const elements = [
|
||||
{
|
||||
header: '',
|
||||
width: 32,
|
||||
size: 'sm',
|
||||
view: it => (it.txClass === 'cashOut' ? <TxOutIcon /> : <TxInIcon />),
|
||||
},
|
||||
{
|
||||
header: 'Machine',
|
||||
name: 'machineName',
|
||||
width: 160,
|
||||
size: 'sm',
|
||||
view: R.path(['machineName']),
|
||||
},
|
||||
{
|
||||
header: 'Customer',
|
||||
width: 202,
|
||||
size: 'sm',
|
||||
view: it => (
|
||||
<div className="flex items-center justify-between mr-4">
|
||||
<div className="overflow-hidden whitespace-nowrap text-ellipsis">
|
||||
{Customer.displayName(it)}
|
||||
</div>
|
||||
{!it.isAnonymous && (
|
||||
<div
|
||||
data-cy="customer-link"
|
||||
onClick={() => redirect(it.customerId)}>
|
||||
{it.hasError || it.batchError ? (
|
||||
<CustomerLinkWhiteIcon />
|
||||
) : (
|
||||
<CustomerLinkIcon />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Cash',
|
||||
width: 144,
|
||||
textAlign: 'right',
|
||||
size: 'sm',
|
||||
view: it => `${Number.parseFloat(it.fiat)} ${it.fiatCode}`,
|
||||
},
|
||||
{
|
||||
header: 'Crypto',
|
||||
width: 150,
|
||||
textAlign: 'right',
|
||||
size: 'sm',
|
||||
view: it =>
|
||||
`${toUnit(new BigNumber(it.cryptoAtoms), it.cryptoCode)} ${
|
||||
it.cryptoCode
|
||||
}`,
|
||||
},
|
||||
{
|
||||
header: 'Address',
|
||||
view: it => formatCryptoAddress(it.cryptoCode, it.toAddress),
|
||||
className: 'overflow-hidden whitespace-nowrap text-ellipsis',
|
||||
size: 'sm',
|
||||
width: 140,
|
||||
},
|
||||
{
|
||||
header: 'Date',
|
||||
view: it =>
|
||||
timezone && formatDate(it.created, timezone, 'yyyy-MM-dd HH:mm'),
|
||||
textAlign: 'right',
|
||||
size: 'sm',
|
||||
width: 195,
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
view: it => {
|
||||
if (getStatus(it) === 'Pending')
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
{'Pending'}
|
||||
<HelpTooltip width={285}>
|
||||
<SupportLinkButton
|
||||
link="https://support.lamassu.is/hc/en-us/articles/115001210452-Cancelling-cash-out-transactions"
|
||||
label="Cancelling cash-out transactions"
|
||||
bottomSpace="0"
|
||||
/>
|
||||
</HelpTooltip>
|
||||
</div>
|
||||
)
|
||||
else return getStatus(it)
|
||||
},
|
||||
textAlign: 'left',
|
||||
size: 'sm',
|
||||
width: 80,
|
||||
},
|
||||
]
|
||||
const mapColumnFiltersToVariables = filters => {
|
||||
const filterMap = {
|
||||
machineName: 'deviceId',
|
||||
customerId: 'customerId',
|
||||
cryptoAtoms: 'cryptoCode',
|
||||
toAddress: 'toAddress',
|
||||
status: 'status',
|
||||
txClass: 'txClass',
|
||||
swept: 'swept',
|
||||
}
|
||||
|
||||
const onFilterChange = filters => {
|
||||
const filtersObject = getFiltersObj(filters)
|
||||
|
||||
setFilters(filters)
|
||||
|
||||
setVariables({
|
||||
limit: NUM_LOG_RESULTS,
|
||||
txClass: filtersObject.type,
|
||||
deviceId: filtersObject.machine,
|
||||
customerName: filtersObject.customer,
|
||||
fiatCode: filtersObject.fiat,
|
||||
cryptoCode: filtersObject.crypto,
|
||||
toAddress: filtersObject.address,
|
||||
status: filtersObject.status,
|
||||
swept: filtersObject.swept && filtersObject.swept === 'Swept',
|
||||
})
|
||||
|
||||
refetch && refetch()
|
||||
return filters.reduce((acc, filter) => {
|
||||
const mappedKey = filterMap[filter.id] || filter.id
|
||||
if (mappedKey && filter.value !== undefined && filter.value !== '') {
|
||||
acc[mappedKey] = filter.value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
const onFilterDelete = filter => {
|
||||
const newFilters = R.filter(
|
||||
f => !R.whereEq(R.pick(['type', 'value'], f), filter),
|
||||
)(filters)
|
||||
const listFilterChange = inputs => {
|
||||
const { limit, offset } = inputs ?? {}
|
||||
const mappedFilters = mapColumnFiltersToVariables(columnFilters)
|
||||
|
||||
setFilters(newFilters)
|
||||
|
||||
const filtersObject = getFiltersObj(newFilters)
|
||||
|
||||
setVariables({
|
||||
limit: NUM_LOG_RESULTS,
|
||||
txClass: filtersObject.type,
|
||||
deviceId: filtersObject.machine,
|
||||
customerName: filtersObject.customer,
|
||||
fiatCode: filtersObject.fiat,
|
||||
cryptoCode: filtersObject.crypto,
|
||||
toAddress: filtersObject.address,
|
||||
status: filtersObject.status,
|
||||
swept: filtersObject.swept && filtersObject.swept === 'Swept',
|
||||
updateField('variables', {
|
||||
limit,
|
||||
offset,
|
||||
...mappedFilters,
|
||||
})
|
||||
|
||||
refetch && refetch()
|
||||
}
|
||||
|
||||
const deleteAllFilters = () => {
|
||||
setFilters([])
|
||||
const filtersObject = getFiltersObj([])
|
||||
|
||||
setVariables({
|
||||
limit: NUM_LOG_RESULTS,
|
||||
txClass: filtersObject.type,
|
||||
deviceId: filtersObject.machine,
|
||||
customerName: filtersObject.customer,
|
||||
fiatCode: filtersObject.fiat,
|
||||
cryptoCode: filtersObject.crypto,
|
||||
toAddress: filtersObject.address,
|
||||
status: filtersObject.status,
|
||||
swept: filtersObject.swept && filtersObject.swept === 'Swept',
|
||||
})
|
||||
|
||||
refetch && refetch()
|
||||
}
|
||||
|
||||
const filterOptions = R.path(['transactionFilters'])(filtersResponse)
|
||||
|
||||
const loading = transactionsLoading || filtersLoading || configLoading
|
||||
|
||||
const errorLabel = (
|
||||
<svg width={12} height={12}>
|
||||
<rect width={12} height={12} rx={3} fill={errorColor} />
|
||||
|
|
@ -340,14 +458,7 @@ const Transactions = () => {
|
|||
]}
|
||||
appendix={
|
||||
<div className="flex ml-4 gap-4">
|
||||
<SearchBox
|
||||
loading={filtersLoading}
|
||||
filters={filters}
|
||||
options={filterOptions}
|
||||
inputPlaceholder={'Search transactions'}
|
||||
onChange={onFilterChange}
|
||||
/>
|
||||
{txList && (
|
||||
{
|
||||
<LogsDowloaderPopover
|
||||
title="Download logs"
|
||||
name="transactions"
|
||||
|
|
@ -357,28 +468,11 @@ const Transactions = () => {
|
|||
timezone={timezone}
|
||||
args={{ timezone }}
|
||||
/>
|
||||
)}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{filters.length > 0 && (
|
||||
<SearchFilter
|
||||
entries={txList.length}
|
||||
filters={filters}
|
||||
onFilterDelete={onFilterDelete}
|
||||
deleteAllFilters={deleteAllFilters}
|
||||
/>
|
||||
)}
|
||||
<DataTable
|
||||
loading={loading}
|
||||
emptyText="No transactions so far"
|
||||
elements={elements}
|
||||
data={txList}
|
||||
Details={DetailsRow}
|
||||
expandable
|
||||
rowSize="sm"
|
||||
timezone={timezone}
|
||||
/>
|
||||
<MaterialReactTable table={table} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,8 @@ const getStatus = it => {
|
|||
|
||||
const getStatusDetails = it => {
|
||||
if (!R.isNil(it.hasError)) return it.hasError
|
||||
if (!R.isNil(it.batchError)) return `Batch error: ${it.batchError}`
|
||||
if (!R.isNil(it.batchError) && it.txClass === 'cashIn')
|
||||
return `Batch error: ${it.batchError}`
|
||||
return null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,15 +26,12 @@ const formatName = idCardData => {
|
|||
/* Expects a transaction object */
|
||||
const displayName = ({
|
||||
isAnonymous,
|
||||
customerName,
|
||||
customerIdCardData,
|
||||
customerPhone,
|
||||
customerEmail,
|
||||
}) =>
|
||||
isAnonymous
|
||||
? 'Anonymous'
|
||||
: customerName ||
|
||||
customerEmail ||
|
||||
R.defaultTo(customerPhone, formatName(customerIdCardData))
|
||||
: formatName(customerIdCardData) || customerEmail || customerPhone
|
||||
|
||||
export { displayName, formatFullName, formatName }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const defaultMaterialTableOpts = {
|
||||
enableKeyboardShortcuts: false,
|
||||
enableGlobalFilter: false,
|
||||
paginationDisplayMode: 'pages',
|
||||
enableColumnActions: false,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ const anonymous = require('../../../constants').anonymousCustomer
|
|||
const customers = require('../../../customers')
|
||||
const customerNotes = require('../../../customer-notes')
|
||||
const machineLoader = require('../../../machine-loader')
|
||||
const {
|
||||
customers: { searchCustomers },
|
||||
} = require('typesafe-db')
|
||||
|
||||
const addLastUsedMachineName = customer =>
|
||||
(customer.lastUsedMachine
|
||||
|
|
@ -20,6 +23,8 @@ const resolvers = {
|
|||
customers: () => customers.getCustomersList(),
|
||||
customer: (...[, { customerId }]) =>
|
||||
customers.getCustomerById(customerId).then(addLastUsedMachineName),
|
||||
searchCustomers: (...[, { searchTerm, limit = 20 }]) =>
|
||||
searchCustomers(searchTerm, limit),
|
||||
},
|
||||
Mutation: {
|
||||
setCustomer: (root, { customerId, customerInput }, context) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
const DataLoader = require('dataloader')
|
||||
const { parseAsync } = require('json2csv')
|
||||
|
||||
const filters = require('../../filters')
|
||||
|
|
@ -8,15 +7,7 @@ const transactions = require('../../services/transactions')
|
|||
const anonymous = require('../../../constants').anonymousCustomer
|
||||
const logDateFormat = require('../../../logs').logDateFormat
|
||||
|
||||
const transactionsLoader = new DataLoader(
|
||||
ids => transactions.getCustomerTransactionsBatch(ids),
|
||||
{ cache: false },
|
||||
)
|
||||
|
||||
const resolvers = {
|
||||
Customer: {
|
||||
transactions: parent => transactionsLoader.load(parent.id),
|
||||
},
|
||||
Transaction: {
|
||||
isAnonymous: parent => parent.customerId === anonymous.uuid,
|
||||
},
|
||||
|
|
@ -32,6 +23,7 @@ const resolvers = {
|
|||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
customerId,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
|
|
@ -41,7 +33,7 @@ const resolvers = {
|
|||
},
|
||||
]
|
||||
) =>
|
||||
transactions.batch(
|
||||
transactions.batch({
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
|
|
@ -49,13 +41,14 @@ const resolvers = {
|
|||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
customerId,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
status,
|
||||
swept,
|
||||
excludeTestingCustomers,
|
||||
),
|
||||
}),
|
||||
transactionsCsv: (
|
||||
...[
|
||||
,
|
||||
|
|
@ -67,6 +60,7 @@ const resolvers = {
|
|||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
customerId,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
|
|
@ -79,7 +73,7 @@ const resolvers = {
|
|||
]
|
||||
) =>
|
||||
transactions
|
||||
.batch(
|
||||
.batch({
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
|
|
@ -87,6 +81,7 @@ const resolvers = {
|
|||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
customerId,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
|
|
@ -94,7 +89,7 @@ const resolvers = {
|
|||
swept,
|
||||
excludeTestingCustomers,
|
||||
simplified,
|
||||
)
|
||||
})
|
||||
.then(data =>
|
||||
parseAsync(
|
||||
logDateFormat(timezone, data, [
|
||||
|
|
|
|||
|
|
@ -94,6 +94,13 @@ const typeDef = gql`
|
|||
value: String
|
||||
}
|
||||
|
||||
type CustomerSearchResult {
|
||||
id: ID!
|
||||
name: String
|
||||
phone: String
|
||||
email: String
|
||||
}
|
||||
|
||||
type Query {
|
||||
customers(
|
||||
phone: String
|
||||
|
|
@ -104,6 +111,8 @@ const typeDef = gql`
|
|||
): [Customer] @auth
|
||||
customer(customerId: ID!): Customer @auth
|
||||
customerFilters: [Filter] @auth
|
||||
searchCustomers(searchTerm: String!, limit: Int): [CustomerSearchResult]
|
||||
@auth
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
|
|
|
|||
|
|
@ -25,24 +25,21 @@ const typeDef = gql`
|
|||
sendPending: Boolean
|
||||
fixedFee: String
|
||||
minimumTx: Float
|
||||
customerId: ID
|
||||
isAnonymous: Boolean
|
||||
txVersion: Int!
|
||||
termsAccepted: Boolean
|
||||
commissionPercentage: String
|
||||
rawTickerPrice: String
|
||||
isPaperWallet: Boolean
|
||||
customerPhone: String
|
||||
customerEmail: String
|
||||
customerIdCardDataNumber: String
|
||||
customerIdCardDataExpiration: DateTimeISO
|
||||
customerIdCardData: JSONObject
|
||||
customerName: String
|
||||
customerFrontCameraPath: String
|
||||
customerIdCardPhotoPath: String
|
||||
expired: Boolean
|
||||
machineName: String
|
||||
discount: Int
|
||||
customerId: ID
|
||||
customerPhone: String
|
||||
customerEmail: String
|
||||
customerIdCardData: JSONObject
|
||||
customerFrontCameraPath: String
|
||||
customerIdCardPhotoPath: String
|
||||
txCustomerPhotoPath: String
|
||||
txCustomerPhotoAt: DateTimeISO
|
||||
batched: Boolean
|
||||
|
|
@ -51,6 +48,12 @@ const typeDef = gql`
|
|||
walletScore: Int
|
||||
profit: String
|
||||
swept: Boolean
|
||||
status: String
|
||||
paginationStats: PaginationStats
|
||||
}
|
||||
|
||||
type PaginationStats {
|
||||
totalCount: Int
|
||||
}
|
||||
|
||||
type Filter {
|
||||
|
|
@ -68,6 +71,7 @@ const typeDef = gql`
|
|||
txClass: String
|
||||
deviceId: String
|
||||
customerName: String
|
||||
customerId: ID
|
||||
fiatCode: String
|
||||
cryptoCode: String
|
||||
toAddress: String
|
||||
|
|
@ -83,6 +87,7 @@ const typeDef = gql`
|
|||
txClass: String
|
||||
deviceId: String
|
||||
customerName: String
|
||||
customerId: String
|
||||
fiatCode: String
|
||||
cryptoCode: String
|
||||
toAddress: String
|
||||
|
|
|
|||
|
|
@ -1,230 +1,72 @@
|
|||
const _ = require('lodash/fp')
|
||||
const pgp = require('pg-promise')()
|
||||
|
||||
const db = require('../../db')
|
||||
const BN = require('../../bn')
|
||||
const { utils: coinUtils } = require('@lamassu/coins')
|
||||
const tx = require('../../tx')
|
||||
const cashInTx = require('../../cash-in/cash-in-tx')
|
||||
const { REDEEMABLE_AGE } = require('../../cash-out/cash-out-helper')
|
||||
const {
|
||||
REDEEMABLE_AGE,
|
||||
CASH_OUT_TRANSACTION_STATES,
|
||||
} = require('../../cash-out/cash-out-helper')
|
||||
|
||||
const NUM_RESULTS = 1000
|
||||
transactions: { getTransactionList },
|
||||
} = require('typesafe-db')
|
||||
|
||||
function addProfits(txs) {
|
||||
return _.map(it => {
|
||||
const profit = getProfit(it).toString()
|
||||
return _.set('profit', profit, it)
|
||||
}, txs)
|
||||
return _.map(
|
||||
it => ({
|
||||
...it,
|
||||
profit: getProfit(it).toString(),
|
||||
cryptoAmount: getCryptoAmount(it).toString(),
|
||||
}),
|
||||
txs,
|
||||
)
|
||||
}
|
||||
|
||||
const camelize = _.mapKeys(_.camelCase)
|
||||
|
||||
const DEVICE_NAME_QUERY = `
|
||||
CASE
|
||||
WHEN ud.name IS NOT NULL THEN ud.name || ' (unpaired)'
|
||||
WHEN d.name IS NOT NULL THEN d.name
|
||||
ELSE 'Unpaired'
|
||||
END AS machine_name
|
||||
`
|
||||
|
||||
const DEVICE_NAME_JOINS = `
|
||||
LEFT JOIN devices d ON txs.device_id = d.device_id
|
||||
LEFT JOIN (
|
||||
SELECT device_id, name, unpaired, paired
|
||||
FROM unpaired_devices
|
||||
) ud ON txs.device_id = ud.device_id
|
||||
AND ud.unpaired >= txs.created
|
||||
AND (txs.created >= ud.paired)
|
||||
`
|
||||
|
||||
function batch(
|
||||
function batch({
|
||||
from = new Date(0).toISOString(),
|
||||
until = new Date().toISOString(),
|
||||
limit = null,
|
||||
offset = 0,
|
||||
txClass = null,
|
||||
deviceId = null,
|
||||
customerName = null,
|
||||
fiatCode = null,
|
||||
customerId = null,
|
||||
cryptoCode = null,
|
||||
toAddress = null,
|
||||
status = null,
|
||||
swept = null,
|
||||
excludeTestingCustomers = false,
|
||||
simplified,
|
||||
) {
|
||||
}) {
|
||||
const isCsvExport = _.isBoolean(simplified)
|
||||
const packager = _.flow(
|
||||
_.flatten,
|
||||
_.orderBy(_.property('created'), ['desc']),
|
||||
_.map(
|
||||
_.flow(
|
||||
camelize,
|
||||
_.mapKeys(k => (k == 'cashInFee' ? 'fixedFee' : k)),
|
||||
return (
|
||||
Promise.all([
|
||||
getTransactionList(
|
||||
{
|
||||
from,
|
||||
until,
|
||||
cryptoCode,
|
||||
txClass,
|
||||
deviceId,
|
||||
toAddress,
|
||||
customerId,
|
||||
swept,
|
||||
status,
|
||||
excludeTestingCustomers,
|
||||
},
|
||||
{ limit, offset },
|
||||
),
|
||||
),
|
||||
addProfits,
|
||||
])
|
||||
// Promise.all(promises)
|
||||
.then(it => addProfits(it[0]))
|
||||
.then(res =>
|
||||
!isCsvExport
|
||||
? res
|
||||
: // GQL transactions and transactionsCsv both use this function and
|
||||
// if we don't check for the correct simplified value, the Transactions page polling
|
||||
// will continuously build a csv in the background
|
||||
simplified
|
||||
? simplifiedBatch(res)
|
||||
: advancedBatch(res),
|
||||
)
|
||||
)
|
||||
|
||||
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
|
||||
c.phone AS customer_phone,
|
||||
c.email AS customer_email,
|
||||
c.id_card_data_number AS customer_id_card_data_number,
|
||||
c.id_card_data_expiration AS customer_id_card_data_expiration,
|
||||
c.id_card_data AS customer_id_card_data,
|
||||
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
txs.tx_customer_photo_at AS tx_customer_photo_at,
|
||||
txs.tx_customer_photo_path AS tx_customer_photo_path,
|
||||
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired,
|
||||
tb.error_message AS batch_error,
|
||||
${DEVICE_NAME_QUERY}
|
||||
FROM (SELECT *, ${cashInTx.TRANSACTION_STATES} AS txStatus FROM cash_in_txs) AS txs
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
${DEVICE_NAME_JOINS}
|
||||
LEFT OUTER JOIN transaction_batches tb ON txs.batch_id = tb.id
|
||||
WHERE txs.created >= $2 AND txs.created <= $3
|
||||
AND ($6 is null or $6 = 'Cash In')
|
||||
AND ($7 is null or txs.device_id = $7)
|
||||
AND ($8 is null or concat(c.id_card_data::json->>'firstName', ' ', c.id_card_data::json->>'lastName') = $8)
|
||||
AND ($9 is null or txs.fiat_code = $9)
|
||||
AND ($10 is null or txs.crypto_code = $10)
|
||||
AND ($11 is null or txs.to_address = $11)
|
||||
AND ($12 is null or txs.txStatus = $12)
|
||||
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
|
||||
${isCsvExport && !simplified ? '' : 'AND (error IS NOT null OR tb.error_message IS NOT null OR fiat > 0)'}
|
||||
ORDER BY created DESC limit $4 offset $5`
|
||||
|
||||
const cashOutSql = `SELECT 'cashOut' AS tx_class,
|
||||
txs.*,
|
||||
actions.tx_hash,
|
||||
c.phone AS customer_phone,
|
||||
c.email AS customer_email,
|
||||
c.id_card_data_number AS customer_id_card_data_number,
|
||||
c.id_card_data_expiration AS customer_id_card_data_expiration,
|
||||
c.id_card_data AS customer_id_card_data,
|
||||
array_to_string(array[c.id_card_data::json->>'firstName', c.id_card_data::json->>'lastName'], ' ') AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
txs.tx_customer_photo_at AS tx_customer_photo_at,
|
||||
txs.tx_customer_photo_path AS tx_customer_photo_path,
|
||||
(NOT txs.dispense AND extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) >= $1) AS expired,
|
||||
${DEVICE_NAME_QUERY}
|
||||
FROM (SELECT *, ${CASH_OUT_TRANSACTION_STATES} AS txStatus FROM cash_out_txs) txs
|
||||
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
|
||||
AND actions.action = 'provisionAddress'
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
${DEVICE_NAME_JOINS}
|
||||
WHERE txs.created >= $2 AND txs.created <= $3
|
||||
AND ($6 is null or $6 = 'Cash Out')
|
||||
AND ($7 is null or txs.device_id = $7)
|
||||
AND ($8 is null or concat(c.id_card_data::json->>'firstName', ' ', c.id_card_data::json->>'lastName') = $8)
|
||||
AND ($9 is null or txs.fiat_code = $9)
|
||||
AND ($10 is null or txs.crypto_code = $10)
|
||||
AND ($11 is null or txs.to_address = $11)
|
||||
AND ($12 is null or txs.txStatus = $12)
|
||||
AND ($13 is null or txs.swept = $13)
|
||||
${excludeTestingCustomers ? `AND c.is_test_customer is false` : ``}
|
||||
${isCsvExport ? '' : 'AND fiat > 0'}
|
||||
ORDER BY created DESC limit $4 offset $5`
|
||||
|
||||
// The swept filter is cash-out only, so omit the cash-in query entirely
|
||||
const hasCashInOnlyFilters = false
|
||||
const hasCashOutOnlyFilters = !_.isNil(swept)
|
||||
|
||||
let promises
|
||||
|
||||
if (hasCashInOnlyFilters && hasCashOutOnlyFilters) {
|
||||
throw new Error(
|
||||
'Trying to filter transactions with mutually exclusive filters',
|
||||
)
|
||||
}
|
||||
|
||||
if (hasCashInOnlyFilters) {
|
||||
promises = [
|
||||
db.any(cashInSql, [
|
||||
cashInTx.PENDING_INTERVAL,
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
offset,
|
||||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
status,
|
||||
]),
|
||||
]
|
||||
} else if (hasCashOutOnlyFilters) {
|
||||
promises = [
|
||||
db.any(cashOutSql, [
|
||||
REDEEMABLE_AGE,
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
offset,
|
||||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
status,
|
||||
swept,
|
||||
]),
|
||||
]
|
||||
} else {
|
||||
promises = [
|
||||
db.any(cashInSql, [
|
||||
cashInTx.PENDING_INTERVAL,
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
offset,
|
||||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
status,
|
||||
]),
|
||||
db.any(cashOutSql, [
|
||||
REDEEMABLE_AGE,
|
||||
from,
|
||||
until,
|
||||
limit,
|
||||
offset,
|
||||
txClass,
|
||||
deviceId,
|
||||
customerName,
|
||||
fiatCode,
|
||||
cryptoCode,
|
||||
toAddress,
|
||||
status,
|
||||
swept,
|
||||
]),
|
||||
]
|
||||
}
|
||||
|
||||
return Promise.all(promises)
|
||||
.then(packager)
|
||||
.then(res =>
|
||||
!isCsvExport
|
||||
? res
|
||||
: // GQL transactions and transactionsCsv both use this function and
|
||||
// if we don't check for the correct simplified value, the Transactions page polling
|
||||
// will continuously build a csv in the background
|
||||
simplified
|
||||
? simplifiedBatch(res)
|
||||
: advancedBatch(res),
|
||||
)
|
||||
}
|
||||
|
||||
function advancedBatch(data) {
|
||||
|
|
@ -239,7 +81,7 @@ function advancedBatch(data) {
|
|||
'fiatCode',
|
||||
'fee',
|
||||
'status',
|
||||
'fiatProfit',
|
||||
'profit',
|
||||
'cryptoAmount',
|
||||
'dispense',
|
||||
'notified',
|
||||
|
|
@ -300,9 +142,6 @@ function advancedBatch(data) {
|
|||
|
||||
const addAdvancedFields = _.map(it => ({
|
||||
...it,
|
||||
status: getStatus(it),
|
||||
fiatProfit: getProfit(it).toString(),
|
||||
cryptoAmount: getCryptoAmount(it).toString(),
|
||||
fixedFee: it.fixedFee ?? null,
|
||||
fee: it.fee ?? null,
|
||||
}))
|
||||
|
|
@ -328,18 +167,11 @@ function simplifiedBatch(data) {
|
|||
'dispense',
|
||||
'error',
|
||||
'status',
|
||||
'fiatProfit',
|
||||
'profit',
|
||||
'cryptoAmount',
|
||||
]
|
||||
|
||||
const addSimplifiedFields = _.map(it => ({
|
||||
...it,
|
||||
status: getStatus(it),
|
||||
fiatProfit: getProfit(it).toString(),
|
||||
cryptoAmount: getCryptoAmount(it).toString(),
|
||||
}))
|
||||
|
||||
return _.compose(_.map(_.pick(fields)), addSimplifiedFields)(data)
|
||||
return _.map(_.pick(fields))(data)
|
||||
}
|
||||
|
||||
const getCryptoAmount = it =>
|
||||
|
|
@ -363,150 +195,6 @@ const getProfit = it => {
|
|||
: calcCashOutProfit(fiat, crypto, tickerPrice)
|
||||
}
|
||||
|
||||
const getCashOutStatus = it => {
|
||||
if (it.hasError) return 'Error'
|
||||
if (it.dispense) return 'Success'
|
||||
if (it.expired) return 'Expired'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
const getCashInStatus = it => {
|
||||
if (it.operatorCompleted) return 'Cancelled'
|
||||
if (it.hasError) return 'Error'
|
||||
if (it.batchError) return 'Error'
|
||||
if (it.sendConfirmed) return 'Sent'
|
||||
if (it.expired) return 'Expired'
|
||||
return 'Pending'
|
||||
}
|
||||
|
||||
const getStatus = it => {
|
||||
if (it.txClass === 'cashOut') {
|
||||
return getCashOutStatus(it)
|
||||
}
|
||||
return getCashInStatus(it)
|
||||
}
|
||||
|
||||
function getCustomerTransactionsBatch(ids) {
|
||||
const packager = _.flow(
|
||||
it => {
|
||||
return it
|
||||
},
|
||||
_.flatten,
|
||||
_.orderBy(_.property('created'), ['desc']),
|
||||
_.map(camelize),
|
||||
)
|
||||
|
||||
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
|
||||
c.phone AS customer_phone,
|
||||
c.email AS customer_email,
|
||||
c.id_card_data_number AS customer_id_card_data_number,
|
||||
c.id_card_data_expiration AS customer_id_card_data_expiration,
|
||||
c.id_card_data AS customer_id_card_data,
|
||||
c.name AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $2)) AS expired,
|
||||
tb.error_message AS batch_error,
|
||||
${DEVICE_NAME_QUERY}
|
||||
FROM cash_in_txs AS txs
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
${DEVICE_NAME_JOINS}
|
||||
LEFT OUTER JOIN transaction_batches tb ON txs.batch_id = tb.id
|
||||
WHERE c.id IN ($1^)
|
||||
ORDER BY created DESC limit $3`
|
||||
|
||||
const cashOutSql = `SELECT 'cashOut' AS tx_class,
|
||||
txs.*,
|
||||
actions.tx_hash,
|
||||
c.phone AS customer_phone,
|
||||
c.email AS customer_email,
|
||||
c.id_card_data_number AS customer_id_card_data_number,
|
||||
c.id_card_data_expiration AS customer_id_card_data_expiration,
|
||||
c.id_card_data AS customer_id_card_data,
|
||||
c.name AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
(NOT txs.dispense AND extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) >= $3) AS expired,
|
||||
${DEVICE_NAME_QUERY}
|
||||
FROM cash_out_txs txs
|
||||
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
|
||||
AND actions.action = 'provisionAddress'
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
${DEVICE_NAME_JOINS}
|
||||
WHERE c.id IN ($1^)
|
||||
ORDER BY created DESC limit $2`
|
||||
return Promise.all([
|
||||
db.any(cashInSql, [
|
||||
_.map(pgp.as.text, ids).join(','),
|
||||
cashInTx.PENDING_INTERVAL,
|
||||
NUM_RESULTS,
|
||||
]),
|
||||
db.any(cashOutSql, [
|
||||
_.map(pgp.as.text, ids).join(','),
|
||||
NUM_RESULTS,
|
||||
REDEEMABLE_AGE,
|
||||
]),
|
||||
])
|
||||
.then(packager)
|
||||
.then(transactions => {
|
||||
const transactionMap = _.groupBy('customerId', transactions)
|
||||
return ids.map(id => transactionMap[id])
|
||||
})
|
||||
}
|
||||
|
||||
function single(txId) {
|
||||
const packager = _.flow(_.compact, _.map(camelize))
|
||||
|
||||
const cashInSql = `SELECT 'cashIn' AS tx_class, txs.*,
|
||||
c.phone AS customer_phone,
|
||||
c.email AS customer_email,
|
||||
c.id_card_data_number AS customer_id_card_data_number,
|
||||
c.id_card_data_expiration AS customer_id_card_data_expiration,
|
||||
c.id_card_data AS customer_id_card_data,
|
||||
c.name AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
((NOT txs.send_confirmed) AND (txs.created <= now() - interval $1)) AS expired,
|
||||
tb.error_message AS batch_error,
|
||||
${DEVICE_NAME_QUERY}
|
||||
FROM cash_in_txs AS txs
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
${DEVICE_NAME_JOINS}
|
||||
LEFT OUTER JOIN transaction_batches tb ON txs.batch_id = tb.id
|
||||
WHERE id=$2`
|
||||
|
||||
const cashOutSql = `SELECT 'cashOut' AS tx_class,
|
||||
txs.*,
|
||||
actions.tx_hash,
|
||||
c.phone AS customer_phone,
|
||||
c.email AS customer_email,
|
||||
c.id_card_data_number AS customer_id_card_data_number,
|
||||
c.id_card_data_expiration AS customer_id_card_data_expiration,
|
||||
c.id_card_data AS customer_id_card_data,
|
||||
c.name AS customer_name,
|
||||
c.front_camera_path AS customer_front_camera_path,
|
||||
c.id_card_photo_path AS customer_id_card_photo_path,
|
||||
(NOT txs.dispense AND extract(epoch FROM (now() - greatest(txs.created, txs.confirmed_at))) >= $2) AS expired,
|
||||
${DEVICE_NAME_QUERY}
|
||||
FROM cash_out_txs txs
|
||||
INNER JOIN cash_out_actions actions ON txs.id = actions.tx_id
|
||||
AND actions.action = 'provisionAddress'
|
||||
LEFT OUTER JOIN customers c ON txs.customer_id = c.id
|
||||
${DEVICE_NAME_JOINS}
|
||||
WHERE id=$1`
|
||||
|
||||
return Promise.all([
|
||||
db.oneOrNone(cashInSql, [cashInTx.PENDING_INTERVAL, txId]),
|
||||
db.oneOrNone(cashOutSql, [txId, REDEEMABLE_AGE]),
|
||||
])
|
||||
.then(packager)
|
||||
.then(_.head)
|
||||
}
|
||||
|
||||
function cancel(txId) {
|
||||
return tx.cancel(txId).then(() => single(txId))
|
||||
}
|
||||
|
||||
function getTx(txId, txClass) {
|
||||
const cashInSql = `select 'cashIn' as tx_class, txs.*,
|
||||
((not txs.send_confirmed) and (txs.created <= now() - interval $1)) as expired
|
||||
|
|
@ -558,9 +246,6 @@ function updateTxCustomerPhoto(customerId, txId, direction, data) {
|
|||
|
||||
module.exports = {
|
||||
batch,
|
||||
single,
|
||||
cancel,
|
||||
getCustomerTransactionsBatch,
|
||||
getTx,
|
||||
getTxAssociatedData,
|
||||
updateTxCustomerPhoto,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const PUBLISH_TIME = 3 * SECONDS
|
|||
const AUTHORIZE_TIME = PUBLISH_TIME + 5 * SECONDS
|
||||
const CONFIRM_TIME = AUTHORIZE_TIME + 10 * SECONDS
|
||||
const SUPPORTED_COINS = coinUtils.cryptoCurrencies()
|
||||
const SUPPORTS_BATCHING = true
|
||||
|
||||
let t0
|
||||
|
||||
|
|
@ -162,6 +163,7 @@ function checkBlockchainStatus(cryptoCode) {
|
|||
|
||||
module.exports = {
|
||||
NAME,
|
||||
SUPPORTS_BATCHING,
|
||||
balance,
|
||||
sendCoinsBatch,
|
||||
sendCoins,
|
||||
|
|
|
|||
|
|
@ -11,11 +11,13 @@ const compliance = require('../compliance')
|
|||
const complianceTriggers = require('../compliance-triggers')
|
||||
const configManager = require('../new-config-manager')
|
||||
const customers = require('../customers')
|
||||
const txs = require('../new-admin/services/transactions')
|
||||
const httpError = require('../route-helpers').httpError
|
||||
const notifier = require('../notifier')
|
||||
const respond = require('../respond')
|
||||
const { getTx } = require('../new-admin/services/transactions.js')
|
||||
const {
|
||||
getTx,
|
||||
updateTxCustomerPhoto: txsUpdateTxCustomerPhoto,
|
||||
} = require('../new-admin/services/transactions.js')
|
||||
const machineLoader = require('../machine-loader')
|
||||
const { loadLatestConfig } = require('../new-settings-loader')
|
||||
const customInfoRequestQueries = require('../new-admin/services/customInfoRequests')
|
||||
|
|
@ -207,13 +209,13 @@ function updateTxCustomerPhoto(req, res, next) {
|
|||
const tcPhotoData = req.body.tcPhotoData
|
||||
const direction = req.body.direction
|
||||
|
||||
Promise.all([customers.getById(customerId), txs.getTx(txId, direction)])
|
||||
Promise.all([customers.getById(customerId), getTx(txId, direction)])
|
||||
.then(([customer, tx]) => {
|
||||
if (!customer || !tx) return
|
||||
return customers
|
||||
.updateTxCustomerPhoto(tcPhotoData)
|
||||
.then(newPatch =>
|
||||
txs.updateTxCustomerPhoto(customerId, txId, direction, newPatch),
|
||||
txsUpdateTxCustomerPhoto(customerId, txId, direction, newPatch),
|
||||
)
|
||||
})
|
||||
.then(() => respond(req, res, {}))
|
||||
|
|
|
|||
|
|
@ -59,22 +59,6 @@ function massage(tx) {
|
|||
return mapper(tx)
|
||||
}
|
||||
|
||||
function cancel(txId) {
|
||||
const promises = [
|
||||
CashInTx.cancel(txId)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
CashOutTx.cancel(txId)
|
||||
.then(() => true)
|
||||
.catch(() => false),
|
||||
]
|
||||
|
||||
return Promise.all(promises).then(r => {
|
||||
if (_.some(r)) return
|
||||
throw new Error('No such transaction')
|
||||
})
|
||||
}
|
||||
|
||||
function customerHistory(customerId, thresholdDays) {
|
||||
const sql = `SELECT ch.id, ch.created, ch.fiat, ch.direction FROM (
|
||||
SELECT txIn.id, txIn.created, txIn.fiat, 'cashIn' AS direction,
|
||||
|
|
@ -99,4 +83,4 @@ function customerHistory(customerId, thresholdDays) {
|
|||
return db.any(sql, [customerId, `${days} days`, '60 minutes', REDEEMABLE_AGE])
|
||||
}
|
||||
|
||||
module.exports = { post, cancel, customerHistory }
|
||||
module.exports = { post, customerHistory }
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
"version": "11.0.0-beta.0",
|
||||
"license": "../LICENSE",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"kysely": "^0.28.2",
|
||||
"pg": "^8.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.10",
|
||||
|
|
@ -18,11 +22,17 @@
|
|||
"scripts": {
|
||||
"build": "tsc --build",
|
||||
"dev": "tsc --watch",
|
||||
"generate-types": "kysely-codegen --camel-case --out-file ./src/types/types.d.ts",
|
||||
"generate-types": "kysely-codegen",
|
||||
"postinstall": "npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"kysely": "^0.28.2",
|
||||
"pg": "^8.16.0"
|
||||
"kysely-codegen": {
|
||||
"camelCase": true,
|
||||
"outFile": "./src/types/types.d.ts",
|
||||
"overrides": {
|
||||
"columns": {
|
||||
"customers.id_card_data": "{firstName:string, lastName:string}",
|
||||
"edited_customer_data.id_card_data": "{firstName:string, lastName:string}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,10 @@
|
|||
import { sql } from 'kysely'
|
||||
import db from './db.js'
|
||||
import { ExpressionBuilder } from 'kysely'
|
||||
import { Customers, DB, EditedCustomerData } from './types/types.js'
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres'
|
||||
|
||||
type CustomerEB = ExpressionBuilder<DB & { c: Customers }, 'c'>
|
||||
type CustomerWithEditedEB = ExpressionBuilder<
|
||||
DB & { c: Customers } & { e: EditedCustomerData | null },
|
||||
'c' | 'e'
|
||||
>
|
||||
import type {
|
||||
CustomerEB,
|
||||
CustomerWithEditedDataEB,
|
||||
} from './types/manual.types.js'
|
||||
|
||||
const ANON_ID = '47ac1184-8102-11e7-9079-8f13a7117867'
|
||||
const TX_PASSTHROUGH_ERROR_CODES = [
|
||||
|
|
@ -28,7 +25,7 @@ function transactionUnion(eb: CustomerEB) {
|
|||
])
|
||||
.where(({ eb, and, or, ref }) =>
|
||||
and([
|
||||
eb('customerId', '=', ref('c.id')),
|
||||
eb('customerId', '=', ref('cst.id')),
|
||||
or([eb('sendConfirmed', '=', true), eb('batched', '=', true)]),
|
||||
]),
|
||||
)
|
||||
|
|
@ -44,7 +41,7 @@ function transactionUnion(eb: CustomerEB) {
|
|||
])
|
||||
.where(({ eb, and, ref }) =>
|
||||
and([
|
||||
eb('customerId', '=', ref('c.id')),
|
||||
eb('customerId', '=', ref('cst.id')),
|
||||
eb('confirmedAt', 'is not', null),
|
||||
]),
|
||||
),
|
||||
|
|
@ -92,20 +89,20 @@ function joinTxsTotals(eb: CustomerEB) {
|
|||
.as('txStats')
|
||||
}
|
||||
|
||||
function selectNewestIdCardData(eb: CustomerWithEditedEB, ref: any) {
|
||||
function selectNewestIdCardData({ eb, ref }: CustomerWithEditedDataEB) {
|
||||
return eb
|
||||
.case()
|
||||
.when(
|
||||
eb.and([
|
||||
eb(ref('e.idCardDataAt'), 'is not', null),
|
||||
eb(ref('cstED.idCardDataAt'), 'is not', null),
|
||||
eb.or([
|
||||
eb(ref('c.idCardDataAt'), 'is', null),
|
||||
eb(ref('e.idCardDataAt'), '>', ref('c.idCardDataAt')),
|
||||
eb(ref('cst.idCardDataAt'), 'is', null),
|
||||
eb(ref('cstED.idCardDataAt'), '>', ref('cst.idCardDataAt')),
|
||||
]),
|
||||
]),
|
||||
)
|
||||
.then(ref('e.idCardData'))
|
||||
.else(ref('c.idCardData'))
|
||||
.then(ref('cstED.idCardData'))
|
||||
.else(ref('cst.idCardData'))
|
||||
.end()
|
||||
}
|
||||
|
||||
|
|
@ -122,58 +119,58 @@ function getCustomerList(
|
|||
options: GetCustomerListOptions = defaultOptions,
|
||||
): Promise<any[]> {
|
||||
return db
|
||||
.selectFrom('customers as c')
|
||||
.leftJoin('editedCustomerData as e', 'e.customerId', 'c.id')
|
||||
.selectFrom('customers as cst')
|
||||
.leftJoin('editedCustomerData as cstED', 'cstED.customerId', 'cst.id')
|
||||
.leftJoinLateral(joinTxsTotals, join => join.onTrue())
|
||||
.leftJoinLateral(joinLatestTx, join => join.onTrue())
|
||||
.select(({ eb, fn, val, ref }) => [
|
||||
'c.id',
|
||||
'c.phone',
|
||||
'c.authorizedOverride',
|
||||
'c.frontCameraPath',
|
||||
'c.frontCameraOverride',
|
||||
'c.idCardPhotoPath',
|
||||
'c.idCardPhotoOverride',
|
||||
selectNewestIdCardData(eb, ref).as('idCardData'),
|
||||
'c.idCardDataOverride',
|
||||
'c.email',
|
||||
'c.usSsn',
|
||||
'c.usSsnOverride',
|
||||
'c.sanctions',
|
||||
'c.sanctionsOverride',
|
||||
.select(({ eb, fn, val }) => [
|
||||
'cst.id',
|
||||
'cst.phone',
|
||||
'cst.authorizedOverride',
|
||||
'cst.frontCameraPath',
|
||||
'cst.frontCameraOverride',
|
||||
'cst.idCardPhotoPath',
|
||||
'cst.idCardPhotoOverride',
|
||||
selectNewestIdCardData(eb).as('idCardData'),
|
||||
'cst.idCardDataOverride',
|
||||
'cst.email',
|
||||
'cst.usSsn',
|
||||
'cst.usSsnOverride',
|
||||
'cst.sanctions',
|
||||
'cst.sanctionsOverride',
|
||||
'txStats.totalSpent',
|
||||
'txStats.totalTxs',
|
||||
ref('lastTx.fiatCode').as('lastTxFiatCode'),
|
||||
ref('lastTx.fiat').as('lastTxFiat'),
|
||||
ref('lastTx.txClass').as('lastTxClass'),
|
||||
'lastTx.fiatCode as lastTxFiatCode',
|
||||
'lastTx.fiat as lastTxFiat',
|
||||
'lastTx.txClass as lastTxClass',
|
||||
fn<Date>('GREATEST', [
|
||||
'c.created',
|
||||
'cst.created',
|
||||
'lastTx.created',
|
||||
'c.phoneAt',
|
||||
'c.emailAt',
|
||||
'c.idCardDataAt',
|
||||
'c.frontCameraAt',
|
||||
'c.idCardPhotoAt',
|
||||
'c.usSsnAt',
|
||||
'c.lastAuthAttempt',
|
||||
'cst.phoneAt',
|
||||
'cst.emailAt',
|
||||
'cst.idCardDataAt',
|
||||
'cst.frontCameraAt',
|
||||
'cst.idCardPhotoAt',
|
||||
'cst.usSsnAt',
|
||||
'cst.lastAuthAttempt',
|
||||
]).as('lastActive'),
|
||||
eb('c.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
|
||||
eb('cst.suspendedUntil', '>', fn<Date>('NOW', [])).as('isSuspended'),
|
||||
fn<number>('GREATEST', [
|
||||
val(0),
|
||||
fn<number>('date_part', [
|
||||
val('day'),
|
||||
eb('c.suspendedUntil', '-', fn<Date>('NOW', [])),
|
||||
eb('cst.suspendedUntil', '-', fn<Date>('NOW', [])),
|
||||
]),
|
||||
]).as('daysSuspended'),
|
||||
])
|
||||
.where('c.id', '!=', ANON_ID)
|
||||
.where('cst.id', '!=', ANON_ID)
|
||||
.$if(options.withCustomInfoRequest, qb =>
|
||||
qb.select(({ eb, ref }) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('customersCustomInfoRequests')
|
||||
.selectAll()
|
||||
.where('customerId', '=', ref('c.id')),
|
||||
.where('customerId', '=', ref('cst.id')),
|
||||
).as('customInfoRequestData'),
|
||||
),
|
||||
)
|
||||
|
|
@ -181,4 +178,39 @@ function getCustomerList(
|
|||
.execute()
|
||||
}
|
||||
|
||||
export { getCustomerList }
|
||||
function searchCustomers(searchTerm: string, limit: number = 20): Promise<any> {
|
||||
const searchPattern = `%${searchTerm}%`
|
||||
|
||||
return db
|
||||
.selectFrom(
|
||||
db
|
||||
.selectFrom('customers as cst')
|
||||
.leftJoin('editedCustomerData as cstED', 'cstED.customerId', 'cst.id')
|
||||
.select(({ eb, fn }) => [
|
||||
'cst.id',
|
||||
'cst.phone',
|
||||
'cst.email',
|
||||
sql`CONCAT(
|
||||
COALESCE(${selectNewestIdCardData(eb)}->>'firstName', ''),
|
||||
' ',
|
||||
COALESCE(${selectNewestIdCardData(eb)}->>'lastName', '')
|
||||
)`.as('customerName'),
|
||||
])
|
||||
.where('cst.id', '!=', ANON_ID)
|
||||
.as('customers_with_names'),
|
||||
)
|
||||
.selectAll()
|
||||
.select('customerName as name')
|
||||
.where(({ eb, or }) =>
|
||||
or([
|
||||
eb('phone', 'ilike', searchPattern),
|
||||
eb('email', 'ilike', searchPattern),
|
||||
eb('customerName', 'ilike', searchPattern),
|
||||
]),
|
||||
)
|
||||
.orderBy('id')
|
||||
.limit(limit)
|
||||
.execute()
|
||||
}
|
||||
|
||||
export { getCustomerList, selectNewestIdCardData, searchCustomers }
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
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
|
||||
frontCameraPath: string | null
|
||||
id: string
|
||||
idCardData: Json | null
|
||||
idCardData: { firstName: string; lastName: string }
|
||||
idCardDataAt: Timestamp | null
|
||||
idCardDataExpiration: Timestamp | null
|
||||
idCardDataNumber: string | null
|
||||
|
|
@ -495,7 +495,7 @@ export interface EditedCustomerData {
|
|||
frontCameraAt: Timestamp | null
|
||||
frontCameraBy: string | null
|
||||
frontCameraPath: string | null
|
||||
idCardData: Json | null
|
||||
idCardData: { firstName: string; lastName: string }
|
||||
idCardDataAt: Timestamp | null
|
||||
idCardDataBy: string | null
|
||||
idCardPhotoAt: Timestamp | null
|
||||
|
|
|
|||
15
pnpm-lock.yaml
generated
15
pnpm-lock.yaml
generated
|
|
@ -51,7 +51,7 @@ importers:
|
|||
packages/admin-ui:
|
||||
dependencies:
|
||||
'@apollo/client':
|
||||
specifier: ^3.13.7
|
||||
specifier: ^3.13.8
|
||||
version: 3.13.8(@types/react@19.1.5)(graphql@16.11.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
'@emotion/react':
|
||||
specifier: ^11.14.0
|
||||
|
|
@ -104,6 +104,9 @@ importers:
|
|||
formik:
|
||||
specifier: 2.2.0
|
||||
version: 2.2.0(react@18.3.1)
|
||||
immer:
|
||||
specifier: ^10.1.1
|
||||
version: 10.1.1
|
||||
jss-plugin-extend:
|
||||
specifier: ^10.0.0
|
||||
version: 10.10.0
|
||||
|
|
@ -163,7 +166,7 @@ importers:
|
|||
version: 1.6.1
|
||||
zustand:
|
||||
specifier: ^4.5.7
|
||||
version: 4.5.7(@types/react@19.1.5)(react@18.3.1)
|
||||
version: 4.5.7(@types/react@19.1.5)(immer@10.1.1)(react@18.3.1)
|
||||
devDependencies:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.1.4
|
||||
|
|
@ -4205,6 +4208,9 @@ packages:
|
|||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
immer@10.1.1:
|
||||
resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
|
@ -11853,6 +11859,8 @@ snapshots:
|
|||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
immer@10.1.1: {}
|
||||
|
||||
import-fresh@3.3.1:
|
||||
dependencies:
|
||||
parent-module: 1.0.1
|
||||
|
|
@ -15084,9 +15092,10 @@ snapshots:
|
|||
|
||||
zod@3.25.23: {}
|
||||
|
||||
zustand@4.5.7(@types/react@19.1.5)(react@18.3.1):
|
||||
zustand@4.5.7(@types/react@19.1.5)(immer@10.1.1)(react@18.3.1):
|
||||
dependencies:
|
||||
use-sync-external-store: 1.5.0(react@18.3.1)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.1.5
|
||||
immer: 10.1.1
|
||||
react: 18.3.1
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue