From 896a646729991f095e55fe74443ce532c95ceac8 Mon Sep 17 00:00:00 2001 From: dnnyorji Date: Mon, 29 Jun 2026 07:53:53 +0100 Subject: [PATCH 1/2] feat: invoice payment functionality --- jest.config.ts | 34 ++++++------- prisma.config.ts | 10 ++-- prisma/schema.prisma | 14 +++++- src/controllers/pay.controllers.ts | 51 +++++++++++++++++++ src/routes/index.ts | 2 + src/routes/pay.routes.ts | 12 +++++ src/services/pay.services.ts | 78 ++++++++++++++++++++++++++++++ 7 files changed, 178 insertions(+), 23 deletions(-) create mode 100644 src/controllers/pay.controllers.ts create mode 100644 src/routes/pay.routes.ts create mode 100644 src/services/pay.services.ts diff --git a/jest.config.ts b/jest.config.ts index 7073e68..78295b3 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,23 +1,23 @@ import type { JestConfigWithTsJest } from 'ts-jest'; const jestConfig: JestConfigWithTsJest = { - preset: 'ts-jest/presets/default-esm', - testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, - transform: { - '^.+\\.tsx?$': [ - 'ts-jest', - { - useESM: true, - }, - ], - }, - modulePathIgnorePatterns: ['/dist/'], - roots: ['/tests/'], - setupFiles: ['/tests/jest.setup.ts'], + preset: 'ts-jest/presets/default-esm', + testEnvironment: 'node', + extensionsToTreatAsEsm: ['.ts'], + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + useESM: true, + }, + ], + }, + modulePathIgnorePatterns: ['/dist/'], + roots: ['/tests/'], + setupFiles: ['/tests/jest.setup.ts'], }; export default jestConfig; diff --git a/prisma.config.ts b/prisma.config.ts index 831a20f..20471ec 100644 --- a/prisma.config.ts +++ b/prisma.config.ts @@ -1,14 +1,14 @@ // This file was generated by Prisma, and assumes you have installed the following: // npm install --save-dev prisma dotenv -import "dotenv/config"; -import { defineConfig } from "prisma/config"; +import 'dotenv/config'; +import { defineConfig } from 'prisma/config'; export default defineConfig({ - schema: "prisma/schema.prisma", + schema: 'prisma/schema.prisma', migrations: { - path: "prisma/migrations", + path: 'prisma/migrations', }, datasource: { - url: process.env["DATABASE_URL"], + url: process.env['DATABASE_URL'], }, }); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e9c38f4..c3eb0d5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,7 +124,8 @@ model Invoice { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - bridgePayments BridgePayment[] + bridgePayments BridgePayment[] + paymentConfirmations PaymentConfirmation[] // Enables the composite FK on BridgePayment that enforces invoiceId+merchantId consistency. @@unique([id, merchantId]) @@ -229,3 +230,14 @@ model BridgePayment { memo String? createdAt DateTime @default(now()) } + +model PaymentConfirmation { + id String @id @default(uuid()) + invoiceId String + merchantId String + // Composite FK: DB rejects a payment confirmation whose merchantId differs from its invoice's merchantId. + invoice Invoice @relation(fields: [invoiceId, merchantId], references: [id, merchantId]) + payerAddress String + txHash String? + createdAt DateTime @default(now()) +} diff --git a/src/controllers/pay.controllers.ts b/src/controllers/pay.controllers.ts new file mode 100644 index 0000000..200bf7d --- /dev/null +++ b/src/controllers/pay.controllers.ts @@ -0,0 +1,51 @@ +import { Request, Response } from 'express'; +import { resolveInvoiceBySlug, confirmPayment } from '../services/pay.services.js'; +import { AppError } from '../utils/errors.js'; + +export const resolveInvoiceController = async (req: Request, res: Response): Promise => { + try { + const { slug } = req.params; + const invoice = await resolveInvoiceBySlug(slug); + res.status(200).json(invoice); + } catch (error) { + if (error instanceof AppError) { + if (error.statusCode === 410 && error.message === 'expired') { + res.status(410).json({ reason: 'expired' }); + return; + } + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; + +export const confirmPaymentController = async (req: Request, res: Response): Promise => { + try { + const { slug } = req.params; + const { payerAddress, txHash } = req.body; + + if (!payerAddress || typeof payerAddress !== 'string') { + res.status(400).json({ error: 'payerAddress is required and must be a string' }); + return; + } + + if (txHash && typeof txHash !== 'string') { + res.status(400).json({ error: 'txHash must be a string if provided' }); + return; + } + + await confirmPayment(slug, payerAddress, txHash); + res.status(202).json({ message: 'Payment confirmation received' }); + } catch (error) { + if (error instanceof AppError) { + if (error.statusCode === 410 && error.message === 'expired') { + res.status(410).json({ reason: 'expired' }); + return; + } + res.status(error.statusCode).json({ error: error.message }); + return; + } + res.status(500).json({ error: 'Internal Server Error' }); + } +}; diff --git a/src/routes/index.ts b/src/routes/index.ts index 7268883..0bccaa4 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -1,6 +1,7 @@ import merchantRoutes from './merchant.routes.js'; import authRoutes from './auth.routes.js'; import invoiceRoutes from './invoice.routes.js'; +import payRoutes from './pay.routes.js'; import { Router } from 'express'; const router = Router(); @@ -8,5 +9,6 @@ const router = Router(); router.use('/merchants', merchantRoutes); router.use('/auth', authRoutes); router.use('/invoices', invoiceRoutes); +router.use('/pay', payRoutes); export default router; diff --git a/src/routes/pay.routes.ts b/src/routes/pay.routes.ts new file mode 100644 index 0000000..95b1b99 --- /dev/null +++ b/src/routes/pay.routes.ts @@ -0,0 +1,12 @@ +import { Router } from 'express'; +import { + resolveInvoiceController, + confirmPaymentController, +} from '../controllers/pay.controllers.js'; + +const router = Router(); + +router.get('/:slug', resolveInvoiceController); +router.post('/:slug/confirm', confirmPaymentController); + +export default router; diff --git a/src/services/pay.services.ts b/src/services/pay.services.ts new file mode 100644 index 0000000..eeb8209 --- /dev/null +++ b/src/services/pay.services.ts @@ -0,0 +1,78 @@ +import prisma from '../config/prisma.js'; +import { AppError } from '../utils/errors.js'; +import type { InvoiceStatus as PrismaInvoiceStatus } from '@prisma/client'; + +const InvoiceStatus = { + DRAFT: 'DRAFT', + PENDING: 'PENDING', + PAID: 'PAID', + CANCELLED: 'CANCELLED', + REFUNDED: 'REFUNDED', +} as const satisfies Record; + +export const resolveInvoiceBySlug = async (slug: string) => { + const invoice = await prisma.invoice.findUnique({ + where: { paymentSlug: slug }, + include: { merchant: true }, + }); + + if (!invoice) { + throw new AppError(404, 'Invoice not found'); + } + + if ( + invoice.status === InvoiceStatus.CANCELLED || + invoice.status === InvoiceStatus.PAID || + invoice.status === InvoiceStatus.REFUNDED + ) { + throw new AppError(410, 'Invoice is no longer available'); + } + + if (invoice.expiresAt && invoice.expiresAt < new Date()) { + throw new AppError(410, 'expired'); + } + + return { + slug: invoice.paymentSlug, + description: invoice.description, + amount: invoice.amount.toString(), + token: invoice.token, + status: invoice.status, + merchantName: invoice.merchant.businessName, + expiresAt: invoice.expiresAt, + pricingMode: invoice.pricingMode, + }; +}; + +export const confirmPayment = async (slug: string, payerAddress: string, txHash?: string) => { + const invoice = await prisma.invoice.findUnique({ + where: { paymentSlug: slug }, + }); + + if (!invoice) { + throw new AppError(404, 'Invoice not found'); + } + + if ( + invoice.status === InvoiceStatus.CANCELLED || + invoice.status === InvoiceStatus.PAID || + invoice.status === InvoiceStatus.REFUNDED + ) { + throw new AppError(410, 'Invoice is no longer available'); + } + + if (invoice.expiresAt && invoice.expiresAt < new Date()) { + throw new AppError(410, 'expired'); + } + + const confirmation = await prisma.paymentConfirmation.create({ + data: { + invoiceId: invoice.id, + merchantId: invoice.merchantId, + payerAddress, + txHash: txHash || null, + }, + }); + + return confirmation; +}; From 57c9673aeb8144fa793d0a54b44667851f121608 Mon Sep 17 00:00:00 2001 From: dnnyorji Date: Mon, 29 Jun 2026 08:07:18 +0100 Subject: [PATCH 2/2] address reviews --- prisma/schema.prisma | 7 +-- src/controllers/pay.controllers.ts | 2 +- src/services/pay.services.ts | 72 +++++++++++++++++++----------- 3 files changed, 51 insertions(+), 30 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c3eb0d5..29a0ffc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -237,7 +237,8 @@ model PaymentConfirmation { merchantId String // Composite FK: DB rejects a payment confirmation whose merchantId differs from its invoice's merchantId. invoice Invoice @relation(fields: [invoiceId, merchantId], references: [id, merchantId]) - payerAddress String - txHash String? - createdAt DateTime @default(now()) + payerAddress String + txHash String? + idempotencyKey String @unique + createdAt DateTime @default(now()) } diff --git a/src/controllers/pay.controllers.ts b/src/controllers/pay.controllers.ts index 200bf7d..8557df8 100644 --- a/src/controllers/pay.controllers.ts +++ b/src/controllers/pay.controllers.ts @@ -30,7 +30,7 @@ export const confirmPaymentController = async (req: Request, res: Response): Pro return; } - if (txHash && typeof txHash !== 'string') { + if (txHash !== undefined && typeof txHash !== 'string') { res.status(400).json({ error: 'txHash must be a string if provided' }); return; } diff --git a/src/services/pay.services.ts b/src/services/pay.services.ts index eeb8209..c1333f0 100644 --- a/src/services/pay.services.ts +++ b/src/services/pay.services.ts @@ -13,7 +13,20 @@ const InvoiceStatus = { export const resolveInvoiceBySlug = async (slug: string) => { const invoice = await prisma.invoice.findUnique({ where: { paymentSlug: slug }, - include: { merchant: true }, + select: { + paymentSlug: true, + description: true, + amount: true, + token: true, + status: true, + expiresAt: true, + pricingMode: true, + merchant: { + select: { + businessName: true, + }, + }, + }, }); if (!invoice) { @@ -45,34 +58,41 @@ export const resolveInvoiceBySlug = async (slug: string) => { }; export const confirmPayment = async (slug: string, payerAddress: string, txHash?: string) => { - const invoice = await prisma.invoice.findUnique({ - where: { paymentSlug: slug }, - }); + return await prisma.$transaction(async tx => { + const invoice = await tx.invoice.findUnique({ + where: { paymentSlug: slug }, + }); - if (!invoice) { - throw new AppError(404, 'Invoice not found'); - } + if (!invoice) { + throw new AppError(404, 'Invoice not found'); + } - if ( - invoice.status === InvoiceStatus.CANCELLED || - invoice.status === InvoiceStatus.PAID || - invoice.status === InvoiceStatus.REFUNDED - ) { - throw new AppError(410, 'Invoice is no longer available'); - } + if ( + invoice.status === InvoiceStatus.CANCELLED || + invoice.status === InvoiceStatus.PAID || + invoice.status === InvoiceStatus.REFUNDED + ) { + throw new AppError(410, 'Invoice is no longer available'); + } - if (invoice.expiresAt && invoice.expiresAt < new Date()) { - throw new AppError(410, 'expired'); - } + if (invoice.expiresAt && invoice.expiresAt < new Date()) { + throw new AppError(410, 'expired'); + } - const confirmation = await prisma.paymentConfirmation.create({ - data: { - invoiceId: invoice.id, - merchantId: invoice.merchantId, - payerAddress, - txHash: txHash || null, - }, - }); + const idempotencyKey = `${invoice.id}-${payerAddress}-${txHash || 'none'}`; - return confirmation; + const confirmation = await tx.paymentConfirmation.upsert({ + where: { idempotencyKey }, + update: {}, + create: { + invoiceId: invoice.id, + merchantId: invoice.merchantId, + payerAddress, + txHash: txHash || null, + idempotencyKey, + }, + }); + + return confirmation; + }); };