edtech / apps /api /src /routes /payments.ts
CognxSafeTrack
chore: execute Sprint 38 technical debt resolution (Type Safety, Zod validation, Vitest, Mock LLM extracted)
d9879cf
import { FastifyInstance } from 'fastify';
import { stripeService } from '../services/stripe';
import { prisma } from '../services/prisma';
import { z } from 'zod';
// ─── Shared Zod schemas ────────────────────────────────────────────────────────
const checkoutSchema = z.object({
userId: z.string().uuid(),
trackId: z.string().uuid(),
});
// ─── Private routes (require ADMIN_API_KEY) ───────────────────────────────────
export async function paymentRoutes(fastify: FastifyInstance) {
// Create a Checkout Session
fastify.post('/checkout', async (request, reply) => {
const parseResult = checkoutSchema.safeParse(request.body);
if (!parseResult.success) {
return reply.status(400).send({ error: 'Invalid request body', details: parseResult.error.flatten() });
}
const { userId, trackId } = parseResult.data;
try {
// Validate the track exists and is premium
const track = await prisma.track.findUnique({ where: { id: trackId } });
if (!track || !track.isPremium || !track.stripePriceId) {
return reply.status(400).send({ error: 'Invalid or non-premium track' });
}
const user = await prisma.user.findUnique({ where: { id: userId } });
if (!user) {
return reply.status(404).send({ error: 'User not found' });
}
const checkoutUrl = await stripeService.createCheckoutSession(
user.id,
track.id,
track.stripePriceId,
user.phone
);
return { success: true, url: checkoutUrl };
} catch (error) {
fastify.log.error(error);
return reply.status(500).send({ error: 'Failed to create checkout session' });
}
});
}
// ─── Public Stripe webhook (no API key auth β€” secured by Stripe signature) ────
export async function stripeWebhookRoute(fastify: FastifyInstance) {
// Capture raw body buffer for Stripe signature verification
fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
(req as any).rawBody = body;
try {
done(null, JSON.parse(body.toString('utf8')));
} catch (err: unknown) {
done(err as Error, undefined as unknown as Buffer);
}
});
fastify.post('/webhook', async (request, reply) => {
const sig = request.headers['stripe-signature'];
if (!sig || typeof sig !== 'string') {
return reply.status(400).send({ error: 'Missing stripe-signature header' });
}
let event;
try {
const rawBody = (request as any).rawBody as Buffer;
event = stripeService.verifyWebhookSignature(rawBody, sig);
} catch (err: unknown) {
fastify.log.warn(`[Stripe Webhook] Signature verification failed: ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))}`);
return reply.status(400).send(`Webhook Error: ${(err instanceof Error ? (err instanceof Error ? err.message : String(err)) : String(err))}`);
}
// Handle the checkout.session.completed event
if (event.type === 'checkout.session.completed') {
const session = event.data.object as any;
const userId = session.metadata?.userId;
const trackId = session.metadata?.trackId;
const amountTotal = session.amount_total;
if (userId && trackId) {
try {
await prisma.$transaction(async (tx: any) => {
// 1. Record the payment
await tx.payment.create({
data: {
userId,
trackId,
amount: amountTotal,
status: 'COMPLETED',
stripeSessionId: session.id,
currency: session.currency || 'XOF'
}
});
// 2. Create the Enrollment (dedup)
const existingEnrollment = await tx.enrollment.findFirst({
where: { userId, trackId }
});
if (!existingEnrollment) {
await tx.enrollment.create({
data: {
userId,
trackId,
status: 'ACTIVE',
currentDay: 1
}
});
fastify.log.info(`[Stripe Webhook] Enrollment created for User ${userId}, Track ${trackId}`);
}
});
} catch (dbError) {
fastify.log.error(dbError, '[Stripe Webhook] Database error during webhook processing');
// Still return 200 so Stripe doesn't retry endlessly β€” but alert monitoring.
}
}
}
reply.send({ received: true });
});
}