Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,5 @@ export const TRIGGER_LOG_BATCH_SIZE = 200
export const PRICES_CONNECTION_BATCH_SIZE = 1_000
// interactive $transaction timeout in ms (for the single delete + several createMany of prices)
export const PRICES_CONNECTION_TIMEOUT = 30_000

export const CLIENT_PAYMENT_EXPIRATION_TIME = (7) * (24 * 60 * 60 * 1000) // (number of days) * (24 * 60 * 60 * 1000)
20 changes: 18 additions & 2 deletions jobs/initJobs.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { CURRENT_PRICE_REPEAT_DELAY } from 'constants/index'
import { CLIENT_PAYMENT_EXPIRATION_TIME, CURRENT_PRICE_REPEAT_DELAY } from 'constants/index'
import { Queue } from 'bullmq'
import { redisBullMQ } from 'redis/clientInstance'
import EventEmitter from 'events'
import { syncCurrentPricesWorker, syncBlockchainAndPricesWorker } from './workers'
import { syncCurrentPricesWorker, syncBlockchainAndPricesWorker, cleanupClientPaymentsWorker } from './workers'

EventEmitter.defaultMaxListeners = 20

Expand All @@ -24,6 +24,22 @@ const main = async (): Promise<void> => {
const blockchainQueue = new Queue('blockchainSync', { connection: redisBullMQ })
await blockchainQueue.add('syncBlockchainAndPrices', {}, { jobId: 'syncBlockchainAndPrices' })
await syncBlockchainAndPricesWorker(blockchainQueue.name)

const cleanupQueue = new Queue('clientPaymentCleanup', { connection: redisBullMQ })

await cleanupQueue.add(
'cleanupClientPayments',
{},
{
jobId: 'cleanupClientPayments',
removeOnFail: false,
repeat: {
every: CLIENT_PAYMENT_EXPIRATION_TIME
}
}
)

await cleanupClientPaymentsWorker(cleanupQueue.name)
}

void main()
24 changes: 24 additions & 0 deletions jobs/workers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { redisBullMQ } from 'redis/clientInstance'
import { DEFAULT_WORKER_LOCK_DURATION } from 'constants/index'
import { multiBlockchainClient } from 'services/chronikService'
import { connectAllTransactionsToPrices } from 'services/transactionService'
import { cleanupExpiredClientPayments } from 'services/clientPaymentService'

import * as priceService from 'services/priceService'

Expand Down Expand Up @@ -60,3 +61,26 @@ export const syncBlockchainAndPricesWorker = async (queueName: string): Promise<
}
})
}

export const cleanupClientPaymentsWorker = async (queueName: string): Promise<void> => {
const worker = new Worker(
queueName,
async (job) => {
console.log(`[CLIENT_PAYMENT CLEANUP] job ${job.id as string}: running expired payment cleanup...`)
await cleanupExpiredClientPayments()
console.log('[CLIENT_PAYMENT CLEANUP] cleanup finished.')
},
{
connection: redisBullMQ,
lockDuration: DEFAULT_WORKER_LOCK_DURATION
}
)

worker.on('completed', job => {
console.log(`[CLIENT_PAYMENT CLEANUP] job ${job.id as string}: completed successfully`)
})

worker.on('failed', (job, err) => {
console.error(`[CLIENT_PAYMENT CLEANUP] job ${job?.id as string}: FAILED — ${err.message}`)
Copy link

Copilot AI Oct 18, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using as string on a potentially undefined value can cause runtime errors. The job parameter can be null/undefined in failed events.

Suggested change
console.error(`[CLIENT_PAYMENT CLEANUP] job ${job?.id as string}: FAILED — ${err.message}`)
console.error(`[CLIENT_PAYMENT CLEANUP] job ${(job?.id ?? "unknown")}: FAILED — ${err.message}`)

Copilot uses AI. Check for mistakes.
})
}
2 changes: 1 addition & 1 deletion pages/api/payments/paymentId/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Decimal } from '@prisma/client/runtime/library'
import { generatePaymentId } from 'services/transactionService'
import { generatePaymentId } from 'services/clientPaymentService'
import { parseAddress, parseCreatePaymentIdPOSTRequest } from 'utils/validators'
import { RESPONSE_MESSAGES } from 'constants/index'
import { runMiddleware } from 'utils/index'
Expand Down
6 changes: 4 additions & 2 deletions services/chronikService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@ import {
fetchUnconfirmedTransactions,
upsertTransaction,
getSimplifiedTransactions,
getSimplifiedTrasaction,
getSimplifiedTrasaction
} from './transactionService'
import {
updateClientPaymentStatus,
getClientPayment
} from './transactionService'
} from './clientPaymentService'
import { Address, Prisma, ClientPaymentStatus } from '@prisma/client'
import xecaddr from 'xecaddrjs'
import { getAddressPrefix, satoshisToUnit } from 'utils/index'
Expand Down
82 changes: 82 additions & 0 deletions services/clientPaymentService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import prisma from 'prisma-local/clientInstance'
import { v4 as uuidv4 } from 'uuid'
import { multiBlockchainClient } from 'services/chronikService'
import { Prisma, ClientPaymentStatus } from '@prisma/client'
import { NETWORK_IDS_FROM_SLUGS, CLIENT_PAYMENT_EXPIRATION_TIME } from 'constants/index'
import { parseAddress } from 'utils/validators'
import { addressExists } from './addressService'
import moment from 'moment'

export const generatePaymentId = async (address: string, amount?: Prisma.Decimal): Promise<string> => {
const rawUUID = uuidv4()
const cleanUUID = rawUUID.replace(/-/g, '')
const status = 'PENDING' as ClientPaymentStatus
address = parseAddress(address)
const prefix = address.split(':')[0].toLowerCase()
const networkId = NETWORK_IDS_FROM_SLUGS[prefix]
Comment on lines +14 to +16
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Validate networkId before database operation.

If the address prefix is not in NETWORK_IDS_FROM_SLUGS, networkId will be undefined, causing a database constraint violation when creating the clientPayment.

Apply this diff to add validation:

   address = parseAddress(address)
   const prefix = address.split(':')[0].toLowerCase()
   const networkId = NETWORK_IDS_FROM_SLUGS[prefix]
+  if (networkId === undefined) {
+    throw new Error(`Invalid network prefix: ${prefix}`)
+  }
   const isAddressRegistered = await addressExists(address)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
address = parseAddress(address)
const prefix = address.split(':')[0].toLowerCase()
const networkId = NETWORK_IDS_FROM_SLUGS[prefix]
address = parseAddress(address)
const prefix = address.split(':')[0].toLowerCase()
const networkId = NETWORK_IDS_FROM_SLUGS[prefix]
if (networkId === undefined) {
throw new Error(`Invalid network prefix: ${prefix}`)
}
const isAddressRegistered = await addressExists(address)
🤖 Prompt for AI Agents
In services/clientPaymentService.ts around lines 14 to 16, the computed
networkId may be undefined when the address prefix isn't found in
NETWORK_IDS_FROM_SLUGS which will cause a DB constraint violation; after
computing const networkId = NETWORK_IDS_FROM_SLUGS[prefix], validate that
networkId is defined and if not, immediately throw or return a clear validation
error (e.g., BadRequestError or similar used in the codebase) that includes the
invalid prefix/address, and stop further processing so the create clientPayment
DB call never runs with an undefined networkId.

const isAddressRegistered = await addressExists(address)

const clientPayment = await prisma.clientPayment.create({
data: {
address: {
connectOrCreate: {
where: { address },
create: {
address,
networkId
}
}
},
paymentId: cleanUUID,
status,
amount
},
include: {
address: true
}
})

if (!isAddressRegistered) {
void multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address])
}
Comment on lines +39 to +41
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling for fire-and-forget synchronization.

The syncAndSubscribeAddresses call is fire-and-forget. If synchronization fails silently, the payment won't be tracked properly, but the function returns successfully.

Apply this diff to add error handling:

   if (!isAddressRegistered) {
-    void multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address])
+    multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address])
+      .catch(err => console.error(`[CLIENT_PAYMENT] Failed to sync address ${clientPayment.address.address}: ${err.message}`))
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (!isAddressRegistered) {
void multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address])
}
if (!isAddressRegistered) {
multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address])
.catch(err => console.error(`[CLIENT_PAYMENT] Failed to sync address ${clientPayment.address.address}: ${err.message}`))
}
🤖 Prompt for AI Agents
In services/clientPaymentService.ts around lines 39 to 41, the fire-and-forget
call to multiBlockchainClient.syncAndSubscribeAddresses may fail silently;
change it to handle errors by either awaiting the promise inside a try/catch or
attaching a .catch handler that logs the error (and optionally records a metric
or retries). Specifically, replace the void call with an awaited call inside a
try { await
multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address]) } catch
(err) { logger.error("Failed to sync/subscribe address", { address:
clientPayment.address, error: err }) } or, if you must remain non-blocking, call
multiBlockchainClient.syncAndSubscribeAddresses(...).catch(err =>
logger.error(...)) so failures are surfaced and retriable/monitored.


return clientPayment.paymentId
}

export const updateClientPaymentStatus = async (paymentId: string, status: ClientPaymentStatus): Promise<void> => {
await prisma.clientPayment.update({
where: { paymentId },
data: { status }
})
}

export const getClientPayment = async (paymentId: string): Promise<Prisma.ClientPaymentGetPayload<{ include: { address: true } }> | null> => {
return await prisma.clientPayment.findUnique({
where: { paymentId },
include: { address: true }
})
}

export const cleanupExpiredClientPayments = async (): Promise<void> => {
const cutoff = moment.utc().subtract(CLIENT_PAYMENT_EXPIRATION_TIME, 'milliseconds').toDate()

const oldPaymentsUnpaid = await prisma.clientPayment.findMany({
where: {
status: 'PENDING',
createdAt: { lt: cutoff }
},
select: { paymentId: true }
})

if (oldPaymentsUnpaid.length === 0) {
console.log('[CLIENT_PAYMENT CLEANUP] no expired pending payments found.')
return
}

console.log(`[CLIENT_PAYMENT CLEANUP] deleting ${oldPaymentsUnpaid.length} expired pending payments...`)
await prisma.clientPayment.deleteMany({
where: {
paymentId: { in: oldPaymentsUnpaid.map(p => p.paymentId) }
}
})
}
56 changes: 2 additions & 54 deletions services/transactionService.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import prisma from 'prisma-local/clientInstance'
import { Prisma, Transaction, ClientPaymentStatus } from '@prisma/client'
import { RESPONSE_MESSAGES, USD_QUOTE_ID, CAD_QUOTE_ID, N_OF_QUOTES, UPSERT_TRANSACTION_PRICES_ON_DB_TIMEOUT, SupportedQuotesType, NETWORK_IDS, NETWORK_IDS_FROM_SLUGS, PRICES_CONNECTION_BATCH_SIZE, PRICES_CONNECTION_TIMEOUT } from 'constants/index'
import { Prisma, Transaction } from '@prisma/client'
import { RESPONSE_MESSAGES, USD_QUOTE_ID, CAD_QUOTE_ID, N_OF_QUOTES, UPSERT_TRANSACTION_PRICES_ON_DB_TIMEOUT, SupportedQuotesType, NETWORK_IDS, PRICES_CONNECTION_BATCH_SIZE, PRICES_CONNECTION_TIMEOUT } from 'constants/index'
import { fetchAddressBySubstring, fetchAddressById, fetchAddressesByPaybuttonId, addressExists } from 'services/addressService'
import { AllPrices, QuoteValues, fetchPricesForNetworkAndTimestamp, flattenTimestamp } from 'services/priceService'
import _ from 'lodash'
Expand All @@ -9,8 +9,6 @@ import { SimplifiedTransaction } from 'ws-service/types'
import { OpReturnData, parseAddress } from 'utils/validators'
import { generatePaymentFromTxWithInvoices } from 'redis/paymentCache'
import { ButtonDisplayData, Payment } from 'redis/types'
import { v4 as uuidv4 } from 'uuid'
import { multiBlockchainClient } from 'services/chronikService'

export function getTransactionValue (transaction: TransactionWithPrices | TransactionsWithPaybuttonsAndPrices | SimplifiedTransaction): QuoteValues {
const ret: QuoteValues = {
Expand Down Expand Up @@ -976,53 +974,3 @@ export const fetchDistinctPaymentYearsByUser = async (userId: string): Promise<n

return years.map(y => y.year)
}

export const generatePaymentId = async (address: string, amount?: Prisma.Decimal): Promise<string> => {
const rawUUID = uuidv4()
const cleanUUID = rawUUID.replace(/-/g, '')
const status = 'PENDING' as ClientPaymentStatus
address = parseAddress(address)
const prefix = address.split(':')[0].toLowerCase()
const networkId = NETWORK_IDS_FROM_SLUGS[prefix]
const isAddressRegistered = await addressExists(address)

const clientPayment = await prisma.clientPayment.create({
data: {
address: {
connectOrCreate: {
where: { address },
create: {
address,
networkId
}
}
},
paymentId: cleanUUID,
status,
amount
},
include: {
address: true
}
})

if (!isAddressRegistered) {
void multiBlockchainClient.syncAndSubscribeAddresses([clientPayment.address])
}

return clientPayment.paymentId
}

export const updateClientPaymentStatus = async (paymentId: string, status: ClientPaymentStatus): Promise<void> => {
await prisma.clientPayment.update({
where: { paymentId },
data: { status }
})
}

export const getClientPayment = async (paymentId: string): Promise<Prisma.ClientPaymentGetPayload<{ include: { address: true } }> | null> => {
return await prisma.clientPayment.findUnique({
where: { paymentId },
include: { address: true }
})
}
10 changes: 5 additions & 5 deletions tests/unittests/handleUpdateClientPaymentStatus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { ChronikBlockchainClient } from '../../services/chronikService'

process.env.WS_AUTH_KEY = 'test-auth-key'

// Mock the transactionService functions
jest.mock('../../services/transactionService', () => ({
// Mock the clientPayment functions
jest.mock('../../services/clientPaymentService', () => ({
getClientPayment: jest.fn(),
updateClientPaymentStatus: jest.fn()
}))
Expand Down Expand Up @@ -71,9 +71,9 @@ describe('handleUpdateClientPaymentStatus tests', () => {

// Get the mocked functions
// eslint-disable-next-line @typescript-eslint/no-var-requires
const transactionService = require('../../services/transactionService')
mockGetClientPayment = transactionService.getClientPayment
mockUpdateClientPaymentStatus = transactionService.updateClientPaymentStatus
const clientPaymentService = require('../../services/clientPaymentService')
mockGetClientPayment = clientPaymentService.getClientPayment
mockUpdateClientPaymentStatus = clientPaymentService.updateClientPaymentStatus

// Clear all mocks before each test
jest.clearAllMocks()
Expand Down