Skip to content
Open
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
4 changes: 4 additions & 0 deletions backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import { escrowRouter } from './routes/escrow.js';
import { multisigRouter } from './routes/multisig.js';
import { fiatPaymentsRouter } from './routes/fiat-payments.js';
import { paymentLinksRouter } from './routes/payment-links.js';
import { checkoutRouter } from './routes/checkout.js';
import { taxRouter } from './routes/tax.js';
import { projectsRouter } from './routes/projects.js';
import { graphQLRouter, graphQLWsRouter } from './graphql/gateway.js';
Expand Down Expand Up @@ -292,6 +293,9 @@ app.use('/api/v1/fiat-payments', fiatPaymentsRouter);
// Merchant dynamic payment links
app.use('/api/v1/payment-links', paymentLinksRouter);

// Hosted checkout pages for direct payments
app.use('/api/v1/checkout', checkoutRouter);

// Merchant tax report generation (summary, 1099-K, VAT, nexus, CSV export)
app.use('/api/v1/tax', taxRouter);

Expand Down
85 changes: 85 additions & 0 deletions backend/src/routes/checkout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { Router } from 'express';
import { AppError, asyncHandler } from '../middleware/errorHandler.js';
import { validate } from '../middleware/validate.js';
import { checkoutService } from '../services/checkout.js';
import {
createCheckoutSessionSchema,
updatePaymentMethodSchema,
processPaymentSchema,
} from '../schemas/checkout.js';

export const checkoutRouter = Router();

// Create new checkout session (merchant auth / standard request)
checkoutRouter.post(
'/sessions',
validate(createCheckoutSessionSchema),
asyncHandler(async (req, res) => {
const session = checkoutService.create(req.body);
res.status(201).json({
data: session,
checkoutUrl: `https://pay.agenticpay.com/checkout/${session.id}`,
});
})
);

// Get session details (public endpoint used by checkout client)
checkoutRouter.get(
'/sessions/:id',
asyncHandler(async (req, res) => {
const session = checkoutService.getById(req.params.id);
if (!session) {
throw new AppError(404, 'Checkout session not found', 'NOT_FOUND');
}
res.json({ data: session });
})
);

// Select payment method for a session
checkoutRouter.post(
'/sessions/:id/payment-method',
validate(updatePaymentMethodSchema),
asyncHandler(async (req, res) => {
const session = checkoutService.updatePaymentMethod(req.params.id, req.body.method);
res.json({ data: session });
})
);

// Lock exchange rate for crypto method
checkoutRouter.post(
'/sessions/:id/lock-rate',
asyncHandler(async (req, res) => {
const session = checkoutService.lockExchangeRate(req.params.id);
res.json({ data: session });
})
);

// Process / execute payment
checkoutRouter.post(
'/sessions/:id/pay',
validate(processPaymentSchema),
asyncHandler(async (req, res) => {
const session = await checkoutService.processPayment(req.params.id, req.body);
res.json({ data: session });
})
);

// Download receipt for a completed session
checkoutRouter.get(
'/sessions/:id/receipt',
asyncHandler(async (req, res) => {
const receiptHtml = checkoutService.generateReceipt(req.params.id);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="receipt_${req.params.id}.html"`);
res.send(receiptHtml);
})
);

// Fetch active exchange rates
checkoutRouter.get(
'/exchange-rates',
asyncHandler(async (req, res) => {
const rates = checkoutService.getExchangeRates();
res.json({ data: rates });
})
);
86 changes: 86 additions & 0 deletions backend/src/routes/payment-links.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { renderHostedCheckoutPage } from './payment-links.js';
import type { PaymentLinkRecord } from '../services/payment-links.js';

function makeLink(overrides: Partial<PaymentLinkRecord> = {}): PaymentLinkRecord {
return {
id: 'link_1',
merchantId: 'merchant_1',
slug: 'safeSlug12345678',
amount: 49.99,
currency: 'USD',
description: 'Secure checkout link',
expiresAt: '2030-01-01T00:00:00.000Z',
recurrence: 'one_time',
tags: [],
requiresPassword: false,
maxUses: null,
isActive: true,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
analytics: {
views: 0,
completions: 0,
bySource: {},
lastViewedAt: null,
lastCompletedAt: null,
},
...overrides,
};
}

describe('renderHostedCheckoutPage', () => {
it('escapes merchant-controlled text in the hosted checkout', () => {
const html = renderHostedCheckoutPage(
makeLink({
description: '<script>alert("owned")</script>',
brand: {
brandName: '<img src=x onerror=alert(1)>',
accentColor: '#0052FF',
redirectUrl: 'https://merchant.example/thanks',
},
})
);

expect(html).not.toContain('<script>alert("owned")</script>');
expect(html).not.toContain('<img src=x onerror=alert(1)>');
expect(html).toContain('&lt;script&gt;alert(&quot;owned&quot;)&lt;/script&gt;');
expect(html).toContain('&lt;img src=x onerror=alert(1)&gt;');
});

it('renders a password unlock form before showing the completion action', () => {
const lockedHtml = renderHostedCheckoutPage(
makeLink({
requiresPassword: true,
}),
{ source: 'qr' }
);
const unlockedHtml = renderHostedCheckoutPage(
makeLink({
requiresPassword: true,
}),
{ source: 'qr', password: 'open-sesame' }
);

expect(lockedHtml).toContain('Payment password');
expect(lockedHtml).toContain('Unlock checkout');
expect(lockedHtml).not.toContain('Complete payment');
expect(unlockedHtml).toContain('Complete payment');
});

it('keeps the completion action hidden after a bad password attempt', () => {
const html = renderHostedCheckoutPage(
makeLink({
requiresPassword: true,
}),
{
source: 'qr',
password: 'wrong-password',
passwordError: 'That password did not match this payment link.',
}
);

expect(html).toContain('That password did not match this payment link.');
expect(html).not.toContain('Complete payment');
});
});
Loading