fix:manage edit for extra bezoekers payments

This commit is contained in:
2026-03-03 14:28:59 +01:00
parent 0a1d1db9ec
commit e6f52e6a73
6 changed files with 338 additions and 134 deletions

View File

@@ -1,11 +1,18 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
function createTransport() {
// Singleton transport — created once per module, reused across all email sends.
// Re-creating it on every call causes EventEmitter listener accumulation in
// long-lived Cloudflare Worker processes, triggering memory leak warnings.
let _transport: nodemailer.Transporter | null | undefined;
function getTransport(): nodemailer.Transporter | null {
if (_transport !== undefined) return _transport;
if (!env.SMTP_HOST || !env.SMTP_USER || !env.SMTP_PASS) {
_transport = null;
return null;
}
return nodemailer.createTransport({
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
@@ -14,6 +21,7 @@ function createTransport() {
pass: env.SMTP_PASS,
},
});
return _transport;
}
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
@@ -223,7 +231,7 @@ export async function sendConfirmationEmail(params: {
wantsToPerform: boolean;
artForm?: string | null;
}) {
const transport = createTransport();
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping confirmation email");
return;
@@ -249,7 +257,7 @@ export async function sendUpdateEmail(params: {
wantsToPerform: boolean;
artForm?: string | null;
}) {
const transport = createTransport();
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping update email");
return;
@@ -272,7 +280,7 @@ export async function sendCancellationEmail(params: {
to: string;
firstName: string;
}) {
const transport = createTransport();
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping cancellation email");
return;

View File

@@ -172,11 +172,28 @@ export const appRouter = {
.handler(async ({ input }) => {
const row = await getActiveRegistration(input.token);
// If already paid and switching to a watcher with fewer guests, we
// still allow the update — payment adjustments are handled manually.
const isPerformer = input.registrationType === "performer";
const guests = isPerformer ? [] : (input.guests ?? []);
// Detect whether an already-paid watcher is adding extra guests so we
// can charge them the delta instead of the full amount.
const isPaid = row.paymentStatus === "paid";
const oldGuestCount = isPerformer
? 0
: parseGuestsJson(row.guests).length;
const newGuestCount = guests.length;
const extraGuests =
!isPerformer && isPaid ? newGuestCount - oldGuestCount : 0;
// Determine the new paymentStatus and paymentAmount (delta in cents).
// Only flag extra_payment_pending when the watcher genuinely owes more.
const newPaymentStatus =
isPaid && extraGuests > 0 ? "extra_payment_pending" : row.paymentStatus;
const newPaymentAmount =
newPaymentStatus === "extra_payment_pending"
? extraGuests * 2 * 100 // €2 per extra guest, in cents
: row.paymentAmount;
await db
.update(registration)
.set({
@@ -191,6 +208,8 @@ export const appRouter = {
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
guests: guests.length > 0 ? JSON.stringify(guests) : null,
extraQuestions: input.extraQuestions || null,
paymentStatus: newPaymentStatus,
paymentAmount: newPaymentAmount,
})
.where(eq(registration.managementToken, input.token));
@@ -202,9 +221,6 @@ export const appRouter = {
artForm: input.artForm,
}).catch((err) => console.error("Failed to send update email:", err));
// Satisfy the linter — row is used to confirm the record existed.
void row;
return { success: true };
}),
@@ -540,11 +556,27 @@ export const appRouter = {
if (!row) throw new Error("Inschrijving niet gevonden");
if (row.paymentStatus === "paid")
throw new Error("Betaling is al voltooid");
if (
row.paymentStatus !== "pending" &&
row.paymentStatus !== "extra_payment_pending"
)
throw new Error("Onverwachte betalingsstatus");
if (row.registrationType === "performer")
throw new Error("Artiesten hoeven niet te betalen");
const guests = parseGuestsJson(row.guests);
const amountInCents = drinkCardCents(guests.length);
// For an extra_payment_pending registration, charge only the delta
// (stored in paymentAmount in cents). For a fresh pending registration
// charge the full drink card price.
const isExtraPayment = row.paymentStatus === "extra_payment_pending";
const amountInCents = isExtraPayment
? (row.paymentAmount ?? 0)
: drinkCardCents(guests.length);
const productDescription = isExtraPayment
? `Extra bijdrage voor ${row.paymentAmount != null ? row.paymentAmount / 200 : 0} extra gast(en)`
: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`;
const response = await fetch(
"https://api.lemonsqueezy.com/v1/checkouts",
@@ -562,7 +594,7 @@ export const appRouter = {
custom_price: amountInCents,
product_options: {
name: "Kunstenkamp Evenement",
description: `Toegangskaart voor ${1 + guests.length} perso(o)n(en)`,
description: productDescription,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
},
checkout_data: {

View File

@@ -7,7 +7,7 @@ config({ path: "../env/.env" });
const app = await alchemy("kk");
// Helper function to get required env var
/** Throws at deploy time if a required variable is missing from the environment. */
function getEnvVar(name: string): string {
const value = process.env[name];
if (!value) {
@@ -19,15 +19,22 @@ function getEnvVar(name: string): string {
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
// Core
DATABASE_URL: getEnvVar("DATABASE_URL"),
CORS_ORIGIN: getEnvVar("CORS_ORIGIN"),
BETTER_AUTH_SECRET: getEnvVar("BETTER_AUTH_SECRET"),
BETTER_AUTH_URL: getEnvVar("BETTER_AUTH_URL"),
// Email (SMTP)
SMTP_HOST: getEnvVar("SMTP_HOST"),
SMTP_PORT: getEnvVar("SMTP_PORT"),
SMTP_USER: getEnvVar("SMTP_USER"),
SMTP_PASS: getEnvVar("SMTP_PASS"),
SMTP_FROM: getEnvVar("SMTP_FROM"),
// Payments (Lemon Squeezy)
LEMON_SQUEEZY_API_KEY: getEnvVar("LEMON_SQUEEZY_API_KEY"),
LEMON_SQUEEZY_STORE_ID: getEnvVar("LEMON_SQUEEZY_STORE_ID"),
LEMON_SQUEEZY_VARIANT_ID: getEnvVar("LEMON_SQUEEZY_VARIANT_ID"),
LEMON_SQUEEZY_WEBHOOK_SECRET: getEnvVar("LEMON_SQUEEZY_WEBHOOK_SECRET"),
},
domains: ["kunstenkamp.be", "www.kunstenkamp.be"],
});