feat: transactions table

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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