feat:payments

This commit is contained in:
2026-03-03 12:33:44 +01:00
parent f6c2bad9df
commit b9e5c44588
10 changed files with 352 additions and 26 deletions

View File

@@ -14,6 +14,7 @@
"@kk/auth": "workspace:*",
"@kk/db": "workspace:*",
"@kk/env": "workspace:*",
"@lemonsqueezy/lemonsqueezy.js": "^4.00.0",
"@orpc/client": "catalog:",
"@orpc/openapi": "catalog:",
"@orpc/server": "catalog:",

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { env } from "@kk/env/server";
import type { RouterClient } from "@orpc/server";
import { and, count, desc, eq, gte, isNull, like, lte } from "drizzle-orm";
import { z } from "zod";
@@ -512,6 +513,118 @@ export const appRouter = {
return { success: true, message: "Admin toegang geweigerd" };
}),
// Lemon Squeezy Checkout Procedure
createCheckout: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.handler(async ({ input }) => {
if (
!env.LEMON_SQUEEZY_API_KEY ||
!env.LEMON_SQUEEZY_STORE_ID ||
!env.LEMON_SQUEEZY_VARIANT_ID
) {
throw new Error("Lemon Squeezy is niet geconfigureerd");
}
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.managementToken, input.token),
isNull(registration.cancelledAt),
),
)
.limit(1);
const row = rows[0];
if (!row) throw new Error("Inschrijving niet gevonden");
if (row.paymentStatus === "paid")
throw new Error("Betaling is al voltooid");
const isPerformer = row.registrationType === "performer";
if (isPerformer) {
throw new Error("Artiesten hoeven niet te betalen");
}
// Calculate price: €5 + €2 per guest (in cents)
const guests: Array<{ firstName: string; lastName: string }> = row.guests
? JSON.parse(row.guests)
: [];
const amountInCents = 500 + guests.length * 200;
// Create checkout via Lemon Squeezy API
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
{
method: "POST",
headers: {
Accept: "application/vnd.api+json",
"Content-Type": "application/vnd.api+json",
Authorization: `Bearer ${env.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: amountInCents,
product_options: {
name: "Kunstenkamp Evenement",
description: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
},
checkout_data: {
email: row.email,
name: `${row.firstName} ${row.lastName}`,
custom: {
registration_token: input.token,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: env.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: env.LEMON_SQUEEZY_VARIANT_ID,
},
},
},
},
}),
},
);
if (!response.ok) {
const errorData = await response.json();
console.error("Lemon Squeezy checkout error:", errorData);
throw new Error("Kon checkout niet aanmaken");
}
const checkoutData = (await response.json()) as {
data?: {
attributes?: {
url?: string;
};
};
};
const checkoutUrl = checkoutData.data?.attributes?.url;
if (!checkoutUrl) {
throw new Error("Geen checkout URL ontvangen");
}
return { checkoutUrl };
}),
};
export type AppRouter = typeof appRouter;

View File

@@ -25,6 +25,12 @@ export const registration = sqliteTable(
extraQuestions: text("extra_questions"),
managementToken: text("management_token").unique(),
cancelledAt: integer("cancelled_at", { mode: "timestamp_ms" }),
// Payment fields
paymentStatus: text("payment_status").notNull().default("pending"),
paymentAmount: integer("payment_amount").default(0),
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),
@@ -35,5 +41,7 @@ export const registration = sqliteTable(
index("registration_artForm_idx").on(table.artForm),
index("registration_createdAt_idx").on(table.createdAt),
index("registration_managementToken_idx").on(table.managementToken),
index("registration_paymentStatus_idx").on(table.paymentStatus),
index("registration_lemonsqueezyOrderId_idx").on(table.lemonsqueezyOrderId),
],
);

View File

@@ -5,7 +5,8 @@ import { config } from "dotenv";
import { z } from "zod";
// Only load .env file in development (not in Cloudflare Workers)
if (process.env.NODE_ENV !== "production") {
// Skip if vars are already loaded (e.g., by Vite dev server)
if (process.env.NODE_ENV !== "production" && !process.env.DATABASE_URL) {
const __dirname = dirname(fileURLToPath(import.meta.url));
config({ path: resolve(__dirname, "../.env") });
}
@@ -24,6 +25,10 @@ export const env = createEnv({
NODE_ENV: z
.enum(["development", "production", "test"])
.default("development"),
LEMON_SQUEEZY_API_KEY: z.string().min(1).optional(),
LEMON_SQUEEZY_STORE_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,