import { randomUUID } from "node:crypto"; import { sendPaymentConfirmationEmail } from "@kk/api/email"; import { creditRegistrationToAccount } from "@kk/api/routers/index"; import { db } from "@kk/db"; import { drinkkaart, drinkkaartTopup, registration } from "@kk/db/schema"; import { user } from "@kk/db/schema/auth"; import { env } from "@kk/env/server"; import { createFileRoute } from "@tanstack/react-router"; import { and, eq } from "drizzle-orm"; // Mollie payment object (relevant fields only) interface MolliePayment { id: string; status: string; amount: { value: string; currency: string }; customerId?: string; metadata?: { registration_token?: string; type?: string; drinkkaartId?: string; userId?: string; }; } async function fetchMolliePayment(paymentId: string): Promise { const response = await fetch( `https://api.mollie.com/v2/payments/${paymentId}`, { headers: { Authorization: `Bearer ${env.MOLLIE_API_KEY}`, }, }, ); if (!response.ok) { throw new Error( `Failed to fetch Mollie payment ${paymentId}: ${response.status}`, ); } return response.json() as Promise; } async function handleWebhook({ request }: { request: Request }) { if (!env.MOLLIE_API_KEY) { console.error("MOLLIE_API_KEY not configured"); return new Response("Payment provider not configured", { status: 500 }); } // Mollie sends application/x-www-form-urlencoded with a single "id" field let paymentId: string | null = null; try { const body = await request.text(); const params = new URLSearchParams(body); paymentId = params.get("id"); } catch { return new Response("Invalid request body", { status: 400 }); } if (!paymentId) { return new Response("Missing payment id", { status: 400 }); } // Fetch-to-verify: retrieve the actual payment from Mollie to confirm its // status. A malicious webhook cannot fake a paid status this way. let payment: MolliePayment; try { payment = await fetchMolliePayment(paymentId); } catch (err) { console.error("Failed to fetch Mollie payment:", err); return new Response("Failed to fetch payment", { status: 500 }); } // Only process paid payments if (payment.status !== "paid") { return new Response("Payment status ignored", { status: 200 }); } const metadata = payment.metadata; try { // ------------------------------------------------------------------------- // Branch: Drinkkaart top-up // ------------------------------------------------------------------------- if (metadata?.type === "drinkkaart_topup") { const { drinkkaartId, userId } = metadata; if (!drinkkaartId || !userId) { console.error( "Missing drinkkaartId or userId in drinkkaart_topup payment metadata", ); return new Response("Missing drinkkaart data", { status: 400 }); } // Amount in cents — Mollie returns e.g. "10.00"; parse to integer cents const amountCents = Math.round( Number.parseFloat(payment.amount.value) * 100, ); // Idempotency: skip if already processed const existing = await db .select({ id: drinkkaartTopup.id }) .from(drinkkaartTopup) .where(eq(drinkkaartTopup.molliePaymentId, payment.id)) .limit(1) .then((r) => r[0]); if (existing) { console.log( `Drinkkaart topup already processed for payment ${payment.id}`, ); return new Response("OK", { status: 200 }); } const card = await db .select() .from(drinkkaart) .where(eq(drinkkaart.id, drinkkaartId)) .limit(1) .then((r) => r[0]); if (!card) { console.error(`Drinkkaart not found: ${drinkkaartId}`); return new Response("Not found", { status: 404 }); } const balanceBefore = card.balance; const balanceAfter = balanceBefore + amountCents; // Optimistic lock update const result = await db .update(drinkkaart) .set({ balance: balanceAfter, version: card.version + 1, updatedAt: new Date(), }) .where( and( eq(drinkkaart.id, drinkkaartId), eq(drinkkaart.version, card.version), ), ); if (result.rowsAffected === 0) { // Return 500 so Mollie retries; idempotency check prevents double-credit console.error( `Drinkkaart optimistic lock conflict for ${drinkkaartId}`, ); return new Response("Conflict — please retry", { status: 500 }); } await db.insert(drinkkaartTopup).values({ id: randomUUID(), drinkkaartId, userId, amountCents, balanceBefore, balanceAfter, type: "payment", molliePaymentId: payment.id, adminId: null, reason: null, paidAt: new Date(), }); console.log( `Drinkkaart topup successful: drinkkaart=${drinkkaartId}, amount=${amountCents}c, payment=${payment.id}`, ); return new Response("OK", { status: 200 }); } // ------------------------------------------------------------------------- // Branch: Registration payment // ------------------------------------------------------------------------- const registrationToken = metadata?.registration_token; if (!registrationToken) { console.error("No registration token in payment metadata"); return new Response("Missing registration token", { status: 400 }); } // Fetch the registration row const regRow = await db .select() .from(registration) .where(eq(registration.managementToken, registrationToken)) .limit(1) .then((r) => r[0]); if (!regRow) { console.error(`Registration not found for token ${registrationToken}`); return new Response("Registration not found", { status: 404 }); } // Mark the registration as paid await db .update(registration) .set({ paymentStatus: "paid", paymentAmount: 0, molliePaymentId: payment.id, paidAt: new Date(), }) .where(eq(registration.managementToken, registrationToken)); console.log( `Payment successful for registration ${registrationToken}, payment ${payment.id}`, ); // Send payment confirmation email (fire-and-forget; don't block webhook) sendPaymentConfirmationEmail({ to: regRow.email, firstName: regRow.firstName, managementToken: regRow.managementToken ?? registrationToken, drinkCardValue: regRow.drinkCardValue ?? undefined, giftAmount: regRow.giftAmount ?? undefined, }).catch((err) => console.error( `Failed to send payment confirmation email for ${regRow.email}:`, err, ), ); // If this is a watcher with a drink card value, try to credit their // drinkkaart immediately — but only if they already have an account. if ( regRow.registrationType === "watcher" && (regRow.drinkCardValue ?? 0) > 0 ) { const accountUser = await db .select({ id: user.id }) .from(user) .where(eq(user.email, regRow.email)) .limit(1) .then((r) => r[0]); if (accountUser) { const creditResult = await creditRegistrationToAccount( regRow.email, accountUser.id, ).catch((err) => { console.error( `Failed to credit drinkkaart for ${regRow.email} after registration payment:`, err, ); return null; }); if (creditResult?.credited) { console.log( `Drinkkaart credited ${creditResult.amountCents}c for user ${accountUser.id} (registration ${registrationToken})`, ); } else if (creditResult) { console.log( `Drinkkaart credit skipped for ${regRow.email}: ${creditResult.status}`, ); } } else { console.log( `No account for ${regRow.email} — drinkkaart credit deferred until signup`, ); } } return new Response("OK", { status: 200 }); } catch (error) { console.error("Webhook processing error:", error); return new Response("Internal error", { status: 500 }); } } export const Route = createFileRoute("/api/webhook/mollie")({ server: { handlers: { POST: handleWebhook, }, }, });