diff --git a/apps/web/src/routes/api/webhook/mollie.ts b/apps/web/src/routes/api/webhook/mollie.ts
index 2054e09..198f227 100644
--- a/apps/web/src/routes/api/webhook/mollie.ts
+++ b/apps/web/src/routes/api/webhook/mollie.ts
@@ -1,4 +1,5 @@
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";
@@ -206,6 +207,20 @@ async function handleWebhook({ request }: { request: Request }) {
`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!,
+ 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 (
diff --git a/packages/api/src/email.ts b/packages/api/src/email.ts
index b95b2ea..addce7b 100644
--- a/packages/api/src/email.ts
+++ b/packages/api/src/email.ts
@@ -30,6 +30,7 @@ const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
function registrationConfirmationHtml(params: {
firstName: string;
manageUrl: string;
+ signupUrl: string;
wantsToPerform: boolean;
artForm?: string | null;
giftAmount?: number;
@@ -99,13 +100,26 @@ function registrationConfirmationHtml(params: {
${paymentSummary}
- Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
+ Maak een account aan om je inschrijving te beheren en (voor bezoekers) je Drinkkaart te activeren.
-
-
+
+
+
+ Wil je je gegevens later nog aanpassen of je inschrijving annuleren? Gebruik dan de knop hieronder. De link is uniek voor jou — deel hem niet.
+
+
+
+
+ |
+
Beheer mijn inschrijving
|
@@ -280,6 +294,8 @@ export async function sendConfirmationEmail(params: {
artForm?: string | null;
giftAmount?: number;
drinkCardValue?: number;
+ /** Pre-filled signup URL, e.g. /login?signup=1&email=…&next=/account */
+ signupUrl?: string;
}) {
const transport = getTransport();
if (!transport) {
@@ -287,6 +303,9 @@ export async function sendConfirmationEmail(params: {
return;
}
const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
+ const signupUrl =
+ params.signupUrl ??
+ `${baseUrl}/login?signup=1&email=${encodeURIComponent(params.to)}&next=/account`;
await transport.sendMail({
from,
to: params.to,
@@ -294,6 +313,7 @@ export async function sendConfirmationEmail(params: {
html: registrationConfirmationHtml({
firstName: params.firstName,
manageUrl,
+ signupUrl,
wantsToPerform: params.wantsToPerform,
artForm: params.artForm,
giftAmount: params.giftAmount,
@@ -444,3 +464,220 @@ export async function sendReminderEmail(params: {
html: reminderHtml({ firstName: params.firstName }),
});
}
+
+function paymentReminderHtml(params: {
+ firstName: string;
+ accountUrl: string;
+ manageUrl: string;
+ drinkCardValue?: number;
+ giftAmount?: number;
+}) {
+ const formatEuro = (cents: number) =>
+ `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
+
+ const giftCents = params.giftAmount ?? 0;
+ const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
+ const totalCents = drinkCardCents + giftCents;
+ const totalStr = totalCents > 0 ? formatEuro(totalCents) : "";
+
+ const amountLine = totalStr
+ ? `
+ Je betaling van ${totalStr} staat nog open. Log in op je account om te betalen.
+
`
+ : `
+ Je betaling staat nog open. Log in op je account om te betalen.
+
`;
+
+ return `
+
+
+
+
+ Herinnering: betaling open
+
+
+
+
+
+
+
+ |
+ Kunstenkamp
+ Betaling nog open
+ |
+
+
+ |
+
+ Hoi ${params.firstName},
+
+
+ Je hebt je ingeschreven voor Open Mic Night — vrijdag 18 april 2026, maar we hebben nog geen betaling ontvangen.
+
+ ${amountLine}
+
+
+
+ Je inschrijving beheren? Klik hier
+
+ |
+
+
+ |
+
+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
+
+ |
+
+
+ |
+
+
+
+`;
+}
+
+export async function sendPaymentReminderEmail(params: {
+ to: string;
+ firstName: string;
+ managementToken: string;
+ drinkCardValue?: number;
+ giftAmount?: number;
+}) {
+ const transport = getTransport();
+ if (!transport) {
+ console.warn("SMTP not configured — skipping payment reminder email");
+ return;
+ }
+ const accountUrl = `${baseUrl}/account`;
+ const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
+ await transport.sendMail({
+ from,
+ to: params.to,
+ subject: "Herinnering: betaling open — Open Mic Night",
+ html: paymentReminderHtml({
+ firstName: params.firstName,
+ accountUrl,
+ manageUrl,
+ drinkCardValue: params.drinkCardValue,
+ giftAmount: params.giftAmount,
+ }),
+ });
+}
+
+function paymentConfirmationHtml(params: {
+ firstName: string;
+ manageUrl: string;
+ drinkCardValue?: number;
+ giftAmount?: number;
+}) {
+ const formatEuro = (cents: number) =>
+ `€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
+
+ const giftCents = params.giftAmount ?? 0;
+ const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
+ const totalCents = drinkCardCents + giftCents;
+
+ const paymentSummary =
+ totalCents > 0
+ ? `
+
+ |
+ Betaling ontvangen
+ ${drinkCardCents > 0 ? `Drinkkaart: ${formatEuro(drinkCardCents)} ` : ""}
+ ${giftCents > 0 ? `Vrijwillige gift: ${formatEuro(giftCents)} ` : ""}
+ Totaal: ${formatEuro(totalCents)}
+ |
+
+
`
+ : "";
+
+ return `
+
+
+
+
+ Betaling ontvangen
+
+
+
+
+
+
+
+ |
+ Kunstenkamp
+ Betaling ontvangen!
+ |
+
+
+ |
+
+ Hoi ${params.firstName},
+
+
+ We hebben je betaling voor Open Mic Night — vrijdag 18 april 2026 in goede orde ontvangen. Tot dan!
+
+ ${paymentSummary}
+
+
+ |
+
+
+ |
+
+ Vragen? Mail ons op info@kunstenkamp.be
+ Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
+
+ |
+
+
+ |
+
+
+
+`;
+}
+
+export async function sendPaymentConfirmationEmail(params: {
+ to: string;
+ firstName: string;
+ managementToken: string;
+ drinkCardValue?: number;
+ giftAmount?: number;
+}) {
+ const transport = getTransport();
+ if (!transport) {
+ console.warn("SMTP not configured — skipping payment confirmation email");
+ return;
+ }
+ const manageUrl = `${baseUrl}/manage/${params.managementToken}`;
+ await transport.sendMail({
+ from,
+ to: params.to,
+ subject: "Betaling ontvangen — Open Mic Night",
+ html: paymentConfirmationHtml({
+ firstName: params.firstName,
+ manageUrl,
+ drinkCardValue: params.drinkCardValue,
+ giftAmount: params.giftAmount,
+ }),
+ });
+}
diff --git a/packages/api/src/routers/index.ts b/packages/api/src/routers/index.ts
index a1bcbcb..9b41877 100644
--- a/packages/api/src/routers/index.ts
+++ b/packages/api/src/routers/index.ts
@@ -10,6 +10,7 @@ import { z } from "zod";
import {
sendCancellationEmail,
sendConfirmationEmail,
+ sendPaymentReminderEmail,
sendReminderEmail,
sendUpdateEmail,
} from "../email";
@@ -975,5 +976,40 @@ export async function runSendReminders(): Promise<{
}
}
+ // Payment reminders: watchers with pending payment older than 3 days
+ const threeDaysAgo = now - 3 * 24 * 60 * 60 * 1000;
+ const unpaidWatchers = await db
+ .select()
+ .from(registration)
+ .where(
+ and(
+ eq(registration.registrationType, "watcher"),
+ eq(registration.paymentStatus, "pending"),
+ isNull(registration.paymentReminderSentAt),
+ lte(registration.createdAt, new Date(threeDaysAgo)),
+ ),
+ );
+
+ for (const reg of unpaidWatchers) {
+ if (!reg.managementToken) continue;
+ try {
+ await sendPaymentReminderEmail({
+ to: reg.email,
+ firstName: reg.firstName,
+ managementToken: reg.managementToken,
+ drinkCardValue: reg.drinkCardValue ?? undefined,
+ giftAmount: reg.giftAmount ?? undefined,
+ });
+ await db
+ .update(registration)
+ .set({ paymentReminderSentAt: new Date() })
+ .where(eq(registration.id, reg.id));
+ sent++;
+ } catch (err) {
+ errors.push(`payment-reminder ${reg.email}: ${String(err)}`);
+ console.error(`Failed to send payment reminder to ${reg.email}:`, err);
+ }
+ }
+
return { sent, skipped: false, errors };
}
diff --git a/packages/db/src/migrations/0008_payment_reminder_sent_at.sql b/packages/db/src/migrations/0008_payment_reminder_sent_at.sql
new file mode 100644
index 0000000..6c48542
--- /dev/null
+++ b/packages/db/src/migrations/0008_payment_reminder_sent_at.sql
@@ -0,0 +1,2 @@
+-- Migration: add payment_reminder_sent_at column to registration table
+ALTER TABLE registration ADD COLUMN payment_reminder_sent_at INTEGER;
diff --git a/packages/db/src/schema/registrations.ts b/packages/db/src/schema/registrations.ts
index b6d27bf..0a57991 100644
--- a/packages/db/src/schema/registrations.ts
+++ b/packages/db/src/schema/registrations.ts
@@ -37,6 +37,9 @@ export const registration = sqliteTable(
drinkkaartCreditedAt: integer("drinkkaart_credited_at", {
mode: "timestamp_ms",
}),
+ paymentReminderSentAt: integer("payment_reminder_sent_at", {
+ mode: "timestamp_ms",
+ }),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),