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
34 changes: 17 additions & 17 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -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: ['<rootDir>/dist/'],
roots: ['<rootDir>/tests/'],
setupFiles: ['<rootDir>/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: ['<rootDir>/dist/'],
roots: ['<rootDir>/tests/'],
setupFiles: ['<rootDir>/tests/jest.setup.ts'],
};

export default jestConfig;
10 changes: 5 additions & 5 deletions prisma.config.ts
Original file line number Diff line number Diff line change
@@ -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'],
},
});
15 changes: 14 additions & 1 deletion prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -229,3 +230,15 @@ 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?
idempotencyKey String @unique
createdAt DateTime @default(now())
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
51 changes: 51 additions & 0 deletions src/controllers/pay.controllers.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<void> => {
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 !== undefined && typeof txHash !== 'string') {
res.status(400).json({ error: 'txHash must be a string if provided' });
return;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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' });
}
};
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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();

router.use('/merchants', merchantRoutes);
router.use('/auth', authRoutes);
router.use('/invoices', invoiceRoutes);
router.use('/pay', payRoutes);

export default router;
12 changes: 12 additions & 0 deletions src/routes/pay.routes.ts
Original file line number Diff line number Diff line change
@@ -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;
98 changes: 98 additions & 0 deletions src/services/pay.services.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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<string, PrismaInvoiceStatus>;

export const resolveInvoiceBySlug = async (slug: string) => {
const invoice = await prisma.invoice.findUnique({
where: { paymentSlug: slug },
select: {
paymentSlug: true,
description: true,
amount: true,
token: true,
status: true,
expiresAt: true,
pricingMode: true,
merchant: {
select: {
businessName: 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) => {
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.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 idempotencyKey = `${invoice.id}-${payerAddress}-${txHash || 'none'}`;

const confirmation = await tx.paymentConfirmation.upsert({
where: { idempotencyKey },
update: {},
create: {
invoiceId: invoice.id,
merchantId: invoice.merchantId,
payerAddress,
txHash: txHash || null,
idempotencyKey,
},
});

return confirmation;
});
};
Loading