From d6166ce752392b8ca06018cf63c72bd3e04c9fab Mon Sep 17 00:00:00 2001 From: Rafael Taranto Date: Thu, 5 Jun 2025 14:02:07 +0100 Subject: [PATCH] feat: transactions table --- packages/admin-ui/package.json | 3 +- .../admin-ui/src/components/TableFilters.jsx | 118 ++++ packages/admin-ui/src/components/Tooltip.jsx | 2 +- .../inputs/base/AsyncAutocomplete.jsx | 75 +++ .../src/pages/Analytics/Analytics.jsx | 10 - .../src/pages/Customers/CustomerProfile.jsx | 30 +- .../SystemPerformance/SystemPerformance.jsx | 2 - .../Transactions/Transactions.jsx | 15 +- .../src/pages/Transactions/Filters.jsx | 91 +++ .../src/pages/Transactions/Transactions.jsx | 528 +++++++++++------- .../admin-ui/src/pages/Transactions/helper.js | 3 +- packages/admin-ui/src/utils/customer.js | 5 +- .../src/utils/materialReactTableOpts.js | 1 + .../graphql/resolvers/customer.resolver.js | 5 + .../graphql/resolvers/transaction.resolver.js | 21 +- .../new-admin/graphql/types/customer.type.js | 9 + .../graphql/types/transaction.type.js | 23 +- .../lib/new-admin/services/transactions.js | 407 ++------------ .../plugins/wallet/mock-wallet/mock-wallet.js | 2 + packages/server/lib/routes/customerRoutes.js | 10 +- packages/server/lib/tx.js | 18 +- packages/typesafe-db/package.json | 18 +- packages/typesafe-db/src/customers.ts | 130 +++-- packages/typesafe-db/src/index.ts | 1 + .../src/interpolled-query-logger.ts | 30 + packages/typesafe-db/src/transactions.ts | 319 +++++++++++ .../typesafe-db/src/types/manual.types.d.ts | 35 ++ packages/typesafe-db/src/types/types.d.ts | 4 +- pnpm-lock.yaml | 15 +- 29 files changed, 1204 insertions(+), 726 deletions(-) create mode 100644 packages/admin-ui/src/components/TableFilters.jsx create mode 100644 packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx create mode 100644 packages/admin-ui/src/pages/Transactions/Filters.jsx create mode 100644 packages/typesafe-db/src/interpolled-query-logger.ts create mode 100644 packages/typesafe-db/src/transactions.ts create mode 100644 packages/typesafe-db/src/types/manual.types.d.ts diff --git a/packages/admin-ui/package.json b/packages/admin-ui/package.json index e969b7f5..cfc37b30 100644 --- a/packages/admin-ui/package.json +++ b/packages/admin-ui/package.json @@ -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", diff --git a/packages/admin-ui/src/components/TableFilters.jsx b/packages/admin-ui/src/components/TableFilters.jsx new file mode 100644 index 00000000..697788a0 --- /dev/null +++ b/packages/admin-ui/src/components/TableFilters.jsx @@ -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 ( + + + + ) +} + +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 ( + { + column.setFilterValue(newValue?.value || '') + }} + getOptionLabel={getOptionLabel} + isOptionEqualToValue={(option, value) => option?.value === value?.value} + renderOption={renderOption} + renderInput={params => ( + + )} + size="small" + fullWidth + slotProps={{ + listbox: { + style: { maxHeight: 200 }, + }, + popper: { + style: { width: 'auto' }, + }, + }} + /> + ) +} + +export const TextFilter = ({ column, placeholder = 'Filter...' }) => { + const columnFilterValue = column.getFilterValue() + + return ( + { + 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 ( + + ) +} diff --git a/packages/admin-ui/src/components/Tooltip.jsx b/packages/admin-ui/src/components/Tooltip.jsx index 217402fb..feb85b98 100644 --- a/packages/admin-ui/src/components/Tooltip.jsx +++ b/packages/admin-ui/src/components/Tooltip.jsx @@ -41,7 +41,7 @@ const HelpTooltip = memo(({ children, width }) => { )} diff --git a/packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx b/packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx new file mode 100644 index 00000000..4c37d3ab --- /dev/null +++ b/packages/admin-ui/src/components/inputs/base/AsyncAutocomplete.jsx @@ -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 ( + + getOptionId(option) === getOptionId(value) + } + noOptionsText={noOptionsText} + renderInput={params => ( + + )} + size={size} + fullWidth={fullWidth} + /> + ) +} diff --git a/packages/admin-ui/src/pages/Analytics/Analytics.jsx b/packages/admin-ui/src/pages/Analytics/Analytics.jsx index eedcd92b..13bb4deb 100644 --- a/packages/admin-ui/src/pages/Analytics/Analytics.jsx +++ b/packages/admin-ui/src/pages/Analytics/Analytics.jsx @@ -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 } diff --git a/packages/admin-ui/src/pages/Customers/CustomerProfile.jsx b/packages/admin-ui/src/pages/Customers/CustomerProfile.jsx index 5a102a3b..38257f2b 100644 --- a/packages/admin-ui/src/pages/Customers/CustomerProfile.jsx +++ b/packages/admin-ui/src/pages/Customers/CustomerProfile.jsx @@ -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, ) diff --git a/packages/admin-ui/src/pages/Dashboard/SystemPerformance/SystemPerformance.jsx b/packages/admin-ui/src/pages/Dashboard/SystemPerformance/SystemPerformance.jsx index cbe0032e..c8017452 100644 --- a/packages/admin-ui/src/pages/Dashboard/SystemPerformance/SystemPerformance.jsx +++ b/packages/admin-ui/src/pages/Dashboard/SystemPerformance/SystemPerformance.jsx @@ -32,8 +32,6 @@ const GET_DATA = gql` ) { fiatCode fiat - fixedFee - commissionPercentage created txClass error diff --git a/packages/admin-ui/src/pages/Machines/MachineComponents/Transactions/Transactions.jsx b/packages/admin-ui/src/pages/Machines/MachineComponents/Transactions/Transactions.jsx index 1f4bd813..d6f5d692 100644 --- a/packages/admin-ui/src/pages/Machines/MachineComponents/Transactions/Transactions.jsx +++ b/packages/admin-ui/src/pages/Machines/MachineComponents/Transactions/Transactions.jsx @@ -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 diff --git a/packages/admin-ui/src/pages/Transactions/Filters.jsx b/packages/admin-ui/src/pages/Transactions/Filters.jsx new file mode 100644 index 00000000..8354bda2 --- /dev/null +++ b/packages/admin-ui/src/pages/Transactions/Filters.jsx @@ -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 +} + +export const SweptFilter = ({ column }) => { + const options = [ + { label: 'Yes', value: true }, + { label: 'No', value: false }, + ] + + return +} + +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 +} + +export const MachineFilter = ({ column, machines }) => { + const machineOptions = machines.map(machine => ({ + label: machine.name, + value: machine.deviceId, + })) + + const renderOption = (props, option) => ( +
  • +
    +
    {option.label}
    +
    {option.value}
    +
    +
  • + ) + + return ( + + ) +} + +export const CryptoFilter = ({ column, cryptoCurrencies }) => { + const cryptoOptions = cryptoCurrencies.map(crypto => ({ + label: crypto.code, + value: crypto.code, + })) + + return ( + + ) +} + +export const CustomerFilter = ({ column, onSearch }) => ( + { + const name = option.name || 'Unknown' + const contact = option.phone || option.email || '' + return contact ? `${name} (${contact})` : name + }} + placeholder="Search customers..." + noOptionsText="Type to start searching..." + /> +) diff --git a/packages/admin-ui/src/pages/Transactions/Transactions.jsx b/packages/admin-ui/src/pages/Transactions/Transactions.jsx index 4f090c4a..0759b2ff 100644 --- a/packages/admin-ui/src/pages/Transactions/Transactions.jsx +++ b/packages/admin-ui/src/pages/Transactions/Transactions.jsx @@ -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' ? : , + }, + { + 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 }) => ( + + ), + size: 160, + maxSize: 160, + }, + { + accessorKey: 'customerId', + header: 'Customer', + size: 202, + Filter: ({ column }) => ( + + ), + Cell: ({ row }) => ( +
    +
    + {Customer.displayName(row.original)} +
    + {!row.original.isAnonymous && ( +
    redirect(row.original.customerId)}> + {getStatusDetails(row.original) ? ( + + ) : ( + + )} +
    + )} +
    + ), + }, + { + 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 }) => ( + + ), + 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 ( +
    +
    + Pending +
    + + + +
    + ) + 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 ? ( + + ) : 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' ? : ), - }, - { - header: 'Machine', - name: 'machineName', - width: 160, - size: 'sm', - view: R.path(['machineName']), - }, - { - header: 'Customer', - width: 202, - size: 'sm', - view: it => ( -
    -
    - {Customer.displayName(it)} -
    - {!it.isAnonymous && ( -
    redirect(it.customerId)}> - {it.hasError || it.batchError ? ( - - ) : ( - - )} -
    - )} -
    - ), - }, - { - 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 ( -
    - {'Pending'} - - - -
    - ) - 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 = ( @@ -340,14 +458,7 @@ const Transactions = () => { ]} appendix={
    - - {txList && ( + { { timezone={timezone} args={{ timezone }} /> - )} + }
    } /> - {filters.length > 0 && ( - - )} - + ) } diff --git a/packages/admin-ui/src/pages/Transactions/helper.js b/packages/admin-ui/src/pages/Transactions/helper.js index 4861896d..47146c3a 100644 --- a/packages/admin-ui/src/pages/Transactions/helper.js +++ b/packages/admin-ui/src/pages/Transactions/helper.js @@ -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 } diff --git a/packages/admin-ui/src/utils/customer.js b/packages/admin-ui/src/utils/customer.js index 5e3ca761..31778a15 100644 --- a/packages/admin-ui/src/utils/customer.js +++ b/packages/admin-ui/src/utils/customer.js @@ -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 } diff --git a/packages/admin-ui/src/utils/materialReactTableOpts.js b/packages/admin-ui/src/utils/materialReactTableOpts.js index c60960a1..f950e865 100644 --- a/packages/admin-ui/src/utils/materialReactTableOpts.js +++ b/packages/admin-ui/src/utils/materialReactTableOpts.js @@ -1,4 +1,5 @@ const defaultMaterialTableOpts = { + enableKeyboardShortcuts: false, enableGlobalFilter: false, paginationDisplayMode: 'pages', enableColumnActions: false, diff --git a/packages/server/lib/new-admin/graphql/resolvers/customer.resolver.js b/packages/server/lib/new-admin/graphql/resolvers/customer.resolver.js index c574b272..96e48a3a 100644 --- a/packages/server/lib/new-admin/graphql/resolvers/customer.resolver.js +++ b/packages/server/lib/new-admin/graphql/resolvers/customer.resolver.js @@ -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) => { diff --git a/packages/server/lib/new-admin/graphql/resolvers/transaction.resolver.js b/packages/server/lib/new-admin/graphql/resolvers/transaction.resolver.js index d5c3b9df..8a03786f 100644 --- a/packages/server/lib/new-admin/graphql/resolvers/transaction.resolver.js +++ b/packages/server/lib/new-admin/graphql/resolvers/transaction.resolver.js @@ -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, [ diff --git a/packages/server/lib/new-admin/graphql/types/customer.type.js b/packages/server/lib/new-admin/graphql/types/customer.type.js index f6d16bf2..79a337f5 100644 --- a/packages/server/lib/new-admin/graphql/types/customer.type.js +++ b/packages/server/lib/new-admin/graphql/types/customer.type.js @@ -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 { diff --git a/packages/server/lib/new-admin/graphql/types/transaction.type.js b/packages/server/lib/new-admin/graphql/types/transaction.type.js index 40193c75..d30dc978 100644 --- a/packages/server/lib/new-admin/graphql/types/transaction.type.js +++ b/packages/server/lib/new-admin/graphql/types/transaction.type.js @@ -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 diff --git a/packages/server/lib/new-admin/services/transactions.js b/packages/server/lib/new-admin/services/transactions.js index 4afbd6bc..46e43d9f 100644 --- a/packages/server/lib/new-admin/services/transactions.js +++ b/packages/server/lib/new-admin/services/transactions.js @@ -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, diff --git a/packages/server/lib/plugins/wallet/mock-wallet/mock-wallet.js b/packages/server/lib/plugins/wallet/mock-wallet/mock-wallet.js index 5dfac8d5..a42d74bc 100644 --- a/packages/server/lib/plugins/wallet/mock-wallet/mock-wallet.js +++ b/packages/server/lib/plugins/wallet/mock-wallet/mock-wallet.js @@ -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, diff --git a/packages/server/lib/routes/customerRoutes.js b/packages/server/lib/routes/customerRoutes.js index b7490fd0..653a2139 100644 --- a/packages/server/lib/routes/customerRoutes.js +++ b/packages/server/lib/routes/customerRoutes.js @@ -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, {})) diff --git a/packages/server/lib/tx.js b/packages/server/lib/tx.js index 5d1de2f0..7faaf2be 100644 --- a/packages/server/lib/tx.js +++ b/packages/server/lib/tx.js @@ -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 } diff --git a/packages/typesafe-db/package.json b/packages/typesafe-db/package.json index 9d992c37..3cc85b69 100644 --- a/packages/typesafe-db/package.json +++ b/packages/typesafe-db/package.json @@ -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}" + } + } } } diff --git a/packages/typesafe-db/src/customers.ts b/packages/typesafe-db/src/customers.ts index 104a02b8..38144cad 100644 --- a/packages/typesafe-db/src/customers.ts +++ b/packages/typesafe-db/src/customers.ts @@ -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 -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 { 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('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('NOW', [])).as('isSuspended'), + eb('cst.suspendedUntil', '>', fn('NOW', [])).as('isSuspended'), fn('GREATEST', [ val(0), fn('date_part', [ val('day'), - eb('c.suspendedUntil', '-', fn('NOW', [])), + eb('cst.suspendedUntil', '-', fn('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 { + 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 } diff --git a/packages/typesafe-db/src/index.ts b/packages/typesafe-db/src/index.ts index 55586a80..33251695 100644 --- a/packages/typesafe-db/src/index.ts +++ b/packages/typesafe-db/src/index.ts @@ -1 +1,2 @@ export * as customers from './customers.js' +export * as transactions from './transactions.js' diff --git a/packages/typesafe-db/src/interpolled-query-logger.ts b/packages/typesafe-db/src/interpolled-query-logger.ts new file mode 100644 index 00000000..a4c6f6d7 --- /dev/null +++ b/packages/typesafe-db/src/interpolled-query-logger.ts @@ -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 +} diff --git a/packages/typesafe-db/src/transactions.ts b/packages/typesafe-db/src/transactions.ts new file mode 100644 index 00000000..b7cb7742 --- /dev/null +++ b/packages/typesafe-db/src/transactions.ts @@ -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`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`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, +} diff --git a/packages/typesafe-db/src/types/manual.types.d.ts b/packages/typesafe-db/src/types/manual.types.d.ts new file mode 100644 index 00000000..307c5eea --- /dev/null +++ b/packages/typesafe-db/src/types/manual.types.d.ts @@ -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 +export type CustomerWithEditedDataEB = ExpressionBuilder< + DB & { cst: Customers } & { cstED: EditedCustomerData }, + 'cst' | 'cstED' +> +export type CashInEB = ExpressionBuilder +export type CashInWithBatchEB = ExpressionBuilder< + DB & { txIn: CashInTxs } & { + txInB: TransactionBatches + }, + 'txIn' | 'txInB' +> + +export type CashOutEB = ExpressionBuilder + +export type DevicesAndUnpairedDevicesEB = ExpressionBuilder< + DB & { d: Nullable } & { + ud: Nullable + }, + 'd' | 'ud' +> + +export type GenericEB = ExpressionBuilder diff --git a/packages/typesafe-db/src/types/types.d.ts b/packages/typesafe-db/src/types/types.d.ts index caaf0434..061f60b6 100644 --- a/packages/typesafe-db/src/types/types.d.ts +++ b/packages/typesafe-db/src/types/types.d.ts @@ -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 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3a655f0d..bcdbbd7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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