From b9e5c4458869f21227b8964baa362f9db9cbaeb2 Mon Sep 17 00:00:00 2001 From: zias Date: Tue, 3 Mar 2026 12:33:44 +0100 Subject: [PATCH] feat:payments --- apps/web/package.json | 1 + .../homepage/EventRegistrationForm.tsx | 47 ++++---- apps/web/src/routeTree.gen.ts | 21 ++++ .../src/routes/api/webhook/lemonsqueezy.ts | 108 +++++++++++++++++ apps/web/src/routes/manage.$token.tsx | 68 +++++++++++ bun.lock | 4 + packages/api/package.json | 1 + packages/api/src/routers/index.ts | 113 ++++++++++++++++++ packages/db/src/schema/registrations.ts | 8 ++ packages/env/src/server.ts | 7 +- 10 files changed, 352 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/routes/api/webhook/lemonsqueezy.ts diff --git a/apps/web/package.json b/apps/web/package.json index f592c65..26e129f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,7 @@ "@base-ui/react": "^1.0.0", "@kk/api": "workspace:*", "@kk/auth": "workspace:*", + "@kk/db": "workspace:*", "@kk/env": "workspace:*", "@libsql/client": "catalog:", "@orpc/client": "catalog:", diff --git a/apps/web/src/components/homepage/EventRegistrationForm.tsx b/apps/web/src/components/homepage/EventRegistrationForm.tsx index d461f17..47da6a5 100644 --- a/apps/web/src/components/homepage/EventRegistrationForm.tsx +++ b/apps/web/src/components/homepage/EventRegistrationForm.tsx @@ -76,33 +76,30 @@ export default function EventRegistrationForm() { const submitMutation = useMutation({ ...orpc.submitRegistration.mutationOptions(), - onSuccess: (data) => { - if (data.managementToken) { + onSuccess: async (data, variables) => { + if (!data.managementToken) return; + + // If it's a performer, show success immediately + if (variables.registrationType === "performer") { setSuccessToken(data.managementToken); + return; + } + + // For watchers, create a checkout and redirect + try { + const checkoutResult = await orpc.createCheckout.call({ + token: data.managementToken, + }); + + if (checkoutResult.checkoutUrl) { + window.location.href = checkoutResult.checkoutUrl; + } else { + toast.error("Kon betalingspagina niet laden"); + } + } catch (error) { + console.error("Checkout error:", error); + toast.error("Er is iets misgegaan bij het aanmaken van de betaling"); } - setPerformerData({ - firstName: "", - lastName: "", - email: "", - phone: "", - artForm: "", - experience: "", - isOver16: false, - extraQuestions: "", - }); - setWatcherData({ - firstName: "", - lastName: "", - email: "", - phone: "", - extraQuestions: "", - }); - setGuests([]); - setGuestErrors([]); - setPerformerErrors({}); - setWatcherErrors({}); - setPerformerTouched({}); - setWatcherTouched({}); }, onError: (error) => { toast.error(`Er is iets misgegaan: ${error.message}`); diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index a0db2ae..4bf033d 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as ContactRouteImport } from './routes/contact' import { Route as AdminRouteImport } from './routes/admin' import { Route as IndexRouteImport } from './routes/index' import { Route as ManageTokenRouteImport } from './routes/manage.$token' +import { Route as ApiWebhookLemonsqueezyRouteImport } from './routes/api/webhook/lemonsqueezy' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc/$' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' @@ -54,6 +55,11 @@ const ManageTokenRoute = ManageTokenRouteImport.update({ path: '/manage/$token', getParentRoute: () => rootRouteImport, } as any) +const ApiWebhookLemonsqueezyRoute = ApiWebhookLemonsqueezyRouteImport.update({ + id: '/api/webhook/lemonsqueezy', + path: '/api/webhook/lemonsqueezy', + getParentRoute: () => rootRouteImport, +} as any) const ApiRpcSplatRoute = ApiRpcSplatRouteImport.update({ id: '/api/rpc/$', path: '/api/rpc/$', @@ -75,6 +81,7 @@ export interface FileRoutesByFullPath { '/manage/$token': typeof ManageTokenRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute + '/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -86,6 +93,7 @@ export interface FileRoutesByTo { '/manage/$token': typeof ManageTokenRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute + '/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -98,6 +106,7 @@ export interface FileRoutesById { '/manage/$token': typeof ManageTokenRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/rpc/$': typeof ApiRpcSplatRoute + '/api/webhook/lemonsqueezy': typeof ApiWebhookLemonsqueezyRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -111,6 +120,7 @@ export interface FileRouteTypes { | '/manage/$token' | '/api/auth/$' | '/api/rpc/$' + | '/api/webhook/lemonsqueezy' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -122,6 +132,7 @@ export interface FileRouteTypes { | '/manage/$token' | '/api/auth/$' | '/api/rpc/$' + | '/api/webhook/lemonsqueezy' id: | '__root__' | '/' @@ -133,6 +144,7 @@ export interface FileRouteTypes { | '/manage/$token' | '/api/auth/$' | '/api/rpc/$' + | '/api/webhook/lemonsqueezy' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -145,6 +157,7 @@ export interface RootRouteChildren { ManageTokenRoute: typeof ManageTokenRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiRpcSplatRoute: typeof ApiRpcSplatRoute + ApiWebhookLemonsqueezyRoute: typeof ApiWebhookLemonsqueezyRoute } declare module '@tanstack/react-router' { @@ -198,6 +211,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ManageTokenRouteImport parentRoute: typeof rootRouteImport } + '/api/webhook/lemonsqueezy': { + id: '/api/webhook/lemonsqueezy' + path: '/api/webhook/lemonsqueezy' + fullPath: '/api/webhook/lemonsqueezy' + preLoaderRoute: typeof ApiWebhookLemonsqueezyRouteImport + parentRoute: typeof rootRouteImport + } '/api/rpc/$': { id: '/api/rpc/$' path: '/api/rpc/$' @@ -225,6 +245,7 @@ const rootRouteChildren: RootRouteChildren = { ManageTokenRoute: ManageTokenRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, ApiRpcSplatRoute: ApiRpcSplatRoute, + ApiWebhookLemonsqueezyRoute: ApiWebhookLemonsqueezyRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/web/src/routes/api/webhook/lemonsqueezy.ts b/apps/web/src/routes/api/webhook/lemonsqueezy.ts new file mode 100644 index 0000000..e3af5b3 --- /dev/null +++ b/apps/web/src/routes/api/webhook/lemonsqueezy.ts @@ -0,0 +1,108 @@ +import { createHmac } from "node:crypto"; +import { db } from "@kk/db"; +import { registration } from "@kk/db/schema"; +import { env } from "@kk/env/server"; +import { createFileRoute } from "@tanstack/react-router"; +import { eq } from "drizzle-orm"; + +// Webhook payload types +interface LemonSqueezyWebhookPayload { + meta: { + event_name: string; + custom_data?: { + registration_token?: string; + }; + }; + data: { + id: string; + type: string; + attributes: { + customer_id: number; + order_number: number; + status: string; + }; + }; +} + +function verifyWebhookSignature( + payload: string, + signature: string, + secret: string, +): boolean { + const hmac = createHmac("sha256", secret); + hmac.update(payload); + const digest = hmac.digest("hex"); + return signature === digest; +} + +async function handleWebhook({ request }: { request: Request }) { + // Get the raw body as text for signature verification + const payload = await request.text(); + const signature = request.headers.get("X-Signature"); + + if (!signature) { + return new Response("Missing signature", { status: 401 }); + } + + if (!env.LEMON_SQUEEZY_WEBHOOK_SECRET) { + console.error("LEMON_SQUEEZY_WEBHOOK_SECRET not configured"); + return new Response("Webhook secret not configured", { status: 500 }); + } + + // Verify the signature + if ( + !verifyWebhookSignature( + payload, + signature, + env.LEMON_SQUEEZY_WEBHOOK_SECRET, + ) + ) { + return new Response("Invalid signature", { status: 401 }); + } + + try { + const event: LemonSqueezyWebhookPayload = JSON.parse(payload); + + // Only handle order_created events + if (event.meta.event_name !== "order_created") { + return new Response("Event ignored", { status: 200 }); + } + + const registrationToken = event.meta.custom_data?.registration_token; + if (!registrationToken) { + console.error("No registration token in webhook payload"); + return new Response("Missing registration token", { status: 400 }); + } + + const orderId = event.data.id; + const customerId = String(event.data.attributes.customer_id); + + // Update registration in database + await db + .update(registration) + .set({ + paymentStatus: "paid", + lemonsqueezyOrderId: orderId, + lemonsqueezyCustomerId: customerId, + paidAt: new Date(), + }) + .where(eq(registration.managementToken, registrationToken)); + + console.log( + `Payment successful for registration ${registrationToken}, order ${orderId}`, + ); + + 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/lemonsqueezy")({ + server: { + handlers: { + POST: handleWebhook, + }, + }, +}); diff --git a/apps/web/src/routes/manage.$token.tsx b/apps/web/src/routes/manage.$token.tsx index c83d5cc..439ffad 100644 --- a/apps/web/src/routes/manage.$token.tsx +++ b/apps/web/src/routes/manage.$token.tsx @@ -78,6 +78,22 @@ function ManageRegistrationPage() { }, }); + const checkoutMutation = useMutation({ + ...orpc.createCheckout.mutationOptions(), + onSuccess: (data) => { + if (data.checkoutUrl) { + window.location.href = data.checkoutUrl; + } + }, + onError: (err) => { + toast.error(`Betaling starten mislukt: ${err.message}`); + }, + }); + + const handlePay = () => { + checkoutMutation.mutate({ token }); + }; + const handleEdit = () => { if (data) { setFormData({ @@ -631,6 +647,47 @@ function ManageRegistrationPage() { )} + {/* Payment status badge for watchers */} + {!isPerformer && ( +
+ {data.paymentStatus === "paid" ? ( +
+ + + + Betaling ontvangen +
+ ) : ( +
+ + + + Betaling in afwachting +
+ )} +
+ )} +
@@ -708,6 +765,17 @@ function ManageRegistrationPage() {
+ {/* Pay button for watchers who haven't paid */} + {!isPerformer && data.paymentStatus !== "paid" && ( + + )}