diff --git a/pages/api/payments/paymentId/index.ts b/pages/api/payments/paymentId/index.ts new file mode 100644 index 00000000..09659fb1 --- /dev/null +++ b/pages/api/payments/paymentId/index.ts @@ -0,0 +1,29 @@ +import { Decimal } from '@prisma/client/runtime/library' +import { generatePaymentId } from 'services/transactionService' +import { parseAddress, parseCreatePaymentIdPOSTRequest } from 'utils/validators' +import { RESPONSE_MESSAGES } from 'constants/index' + +export default async (req: any, res: any): Promise => { + if (req.method === 'POST') { + try { + const values = parseCreatePaymentIdPOSTRequest(req.body) + const address = parseAddress(values.address) + const amount = values.amount as Decimal | undefined + + const paymentId = await generatePaymentId(address, amount) + + res.status(200).json({ paymentId }) + } catch (error: any) { + switch (error.message) { + case RESPONSE_MESSAGES.ADDRESS_NOT_PROVIDED_400.message: + res.status(RESPONSE_MESSAGES.ADDRESS_NOT_PROVIDED_400.statusCode).json(RESPONSE_MESSAGES.ADDRESS_NOT_PROVIDED_400) + break + default: + res.status(500).json({ statusCode: 500, message: error.message }) + } + } + } else { + res.status(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED.statusCode) + .json(RESPONSE_MESSAGES.METHOD_NOT_ALLOWED) + } +} diff --git a/prisma-local/migrations/20250912183436_client_payment/migration.sql b/prisma-local/migrations/20250912183436_client_payment/migration.sql new file mode 100644 index 00000000..c21b5b8a --- /dev/null +++ b/prisma-local/migrations/20250912183436_client_payment/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE `ClientPayment` ( + `paymentId` VARCHAR(191) NOT NULL, + `status` ENUM('PENDING', 'ADDED_TO_MEMPOOL', 'CONFIRMED') NOT NULL, + `addressString` VARCHAR(191) NOT NULL, + `amount` DECIMAL(65, 30) NULL, + `createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updatedAt` DATETIME(3) NOT NULL, + + PRIMARY KEY (`paymentId`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `ClientPayment` ADD CONSTRAINT `ClientPayment_addressString_fkey` FOREIGN KEY (`addressString`) REFERENCES `Address`(`address`) ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma-local/schema.prisma b/prisma-local/schema.prisma index d37a256b..1a2b9e5f 100644 --- a/prisma-local/schema.prisma +++ b/prisma-local/schema.prisma @@ -22,6 +22,7 @@ model Address { syncing Boolean @default(false) paybuttons AddressesOnButtons[] transactions Transaction[] + clientPayments ClientPayment[] @@index([networkId], map: "Address_networkId_fkey") } @@ -271,3 +272,19 @@ model Invoice { @@unique([invoiceNumber, userId], map: "Invoice_invoiceNumber_userId_unique_constraint") @@unique([transactionId, userId], map: "Invoice_transactionId_userId_unique_constraint") } + +enum ClientPaymentStatus { + PENDING + ADDED_TO_MEMPOOL + CONFIRMED +} + +model ClientPayment { + paymentId String @id + status ClientPaymentStatus + addressString String + amount Decimal? + address Address @relation(fields: [addressString], references: [address]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} diff --git a/services/chronikService.ts b/services/chronikService.ts index 6c6937c6..99957074 100644 --- a/services/chronikService.ts +++ b/services/chronikService.ts @@ -11,9 +11,11 @@ import { upsertTransaction, getSimplifiedTransactions, getSimplifiedTrasaction, - connectAllTransactionsToPrices + connectAllTransactionsToPrices, + updatePaymentStatus, + getClientPayment } from './transactionService' -import { Address, Prisma } from '@prisma/client' +import { Address, Prisma, ClientPaymentStatus } from '@prisma/client' import xecaddr from 'xecaddrjs' import { getAddressPrefix, satoshisToUnit } from 'utils/index' import { fetchAddressesArray, fetchAllAddressesForNetworkId, getEarliestUnconfirmedTxTimestampForAddress, getLatestConfirmedTxTimestampForAddress, setSyncing, setSyncingBatch, updateLastSynced, updateManyLastSynced } from './addressService' @@ -551,10 +553,23 @@ export class ChronikBlockchainClient { } } + private async updateClientPaymentStatusToConfirmed (addressesWithTransactions: AddressWithTransaction[]): Promise { + for (const addressWithTransaction of addressesWithTransactions) { + const parsedOpReturn = parseOpReturnData(addressWithTransaction.transaction.opReturn ?? '') + const paymentId = parsedOpReturn.paymentId + const newClientPaymentStatus = 'CONFIRMED' as ClientPaymentStatus + + await updatePaymentStatus(paymentId, newClientPaymentStatus) + } + } + private async processWsMessage (msg: WsMsgClient): Promise { // delete unconfirmed transaction from our database // if they were cancelled and not confirmed if (msg.type === 'Tx') { + const transaction = await this.chronik.tx(msg.txid) + const addressesWithTransactions = await this.getAddressesForTransaction(transaction) + if (msg.msgType === 'TX_REMOVED_FROM_MEMPOOL') { console.log(`${this.CHRONIK_MSG_PREFIX}: [${msg.msgType}] ${msg.txid}`) const transactionsToDelete = await fetchUnconfirmedTransactions(msg.txid) @@ -569,6 +584,7 @@ export class ChronikBlockchainClient { } else if (msg.msgType === 'TX_CONFIRMED') { console.log(`${this.CHRONIK_MSG_PREFIX}: [${msg.msgType}] ${msg.txid}`) this.confirmedTxsHashesFromLastBlock = [...this.confirmedTxsHashesFromLastBlock, msg.txid] + await this.updateClientPaymentStatusToConfirmed(addressesWithTransactions) } else if (msg.msgType === 'TX_ADDED_TO_MEMPOOL') { if (this.isAlreadyBeingProcessed(msg.txid, false)) { return @@ -588,6 +604,17 @@ export class ChronikBlockchainClient { if (created) { // only execute trigger for newly added txs await executeAddressTriggers(broadcastTxData, tx.address.networkId) } + const parsedOpReturn = parseOpReturnData(tx.opReturn) + const paymentId = parsedOpReturn.paymentId + const newClientPaymentStatus = 'ADDED_TO_MEMPOOL' as ClientPaymentStatus + const clientPayment = await getClientPayment(paymentId) + if (clientPayment.amount !== null) { + if (clientPayment.amount === tx.amount) { + await updatePaymentStatus(paymentId, newClientPaymentStatus) + } + } else { + await updatePaymentStatus(paymentId, newClientPaymentStatus) + } } } this.mempoolTxsBeingProcessed -= 1 diff --git a/services/transactionService.ts b/services/transactionService.ts index 044287eb..fd73ee50 100644 --- a/services/transactionService.ts +++ b/services/transactionService.ts @@ -1,6 +1,6 @@ import prisma from 'prisma-local/clientInstance' -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 { 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 { fetchAddressBySubstring, fetchAddressById, fetchAddressesByPaybuttonId, addressExists } from 'services/addressService' import { AllPrices, QuoteValues, fetchPricesForNetworkAndTimestamp, flattenTimestamp } from 'services/priceService' import _ from 'lodash' @@ -9,6 +9,8 @@ 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 = { @@ -974,3 +976,52 @@ export const fetchDistinctPaymentYearsByUser = async (userId: string): Promise y.year) } + +export const generatePaymentId = async (address: string, amount?: Prisma.Decimal): Promise => { + const rawUUID = uuidv4() + const cleanUUID = rawUUID.replace(/-/g, '') + const status = 'PENDING' as ClientPaymentStatus + 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 updatePaymentStatus = async (paymentId: string, status: ClientPaymentStatus): Promise => { + await prisma.clientPayment.update({ + where: { paymentId }, + data: { status } + }) +} + +export const getClientPayment = async (paymentId: string): Promise> => { + return await prisma.clientPayment.findUniqueOrThrow({ + where: { paymentId }, + include: { address: true } + }) +} diff --git a/utils/validators.ts b/utils/validators.ts index 977affba..86b9e1f9 100644 --- a/utils/validators.ts +++ b/utils/validators.ts @@ -566,3 +566,25 @@ export interface CreateInvoicePOSTParameters { customerName: string customerAddress: string } +export interface CreatePaymentIdPOSTParameters { + address?: string + amount?: string +} +export interface CreatePaymentIdInput { + address: string + amount?: string +} + +export const parseCreatePaymentIdPOSTRequest = function (params: CreatePaymentIdPOSTParameters): CreatePaymentIdInput { + if ( + params.address === undefined || + params.address === '' + ) { + throw new Error(RESPONSE_MESSAGES.ADDRESS_NOT_PROVIDED_400.message) + } + + return { + address: params.address, + amount: params.amount === '' ? undefined : params.amount + } +}