feat:gifts
This commit is contained in:
@@ -32,11 +32,34 @@ function registrationConfirmationHtml(params: {
|
||||
manageUrl: string;
|
||||
wantsToPerform: boolean;
|
||||
artForm?: string | null;
|
||||
giftAmount?: number;
|
||||
drinkCardValue?: number;
|
||||
}) {
|
||||
const role = params.wantsToPerform
|
||||
? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}`
|
||||
: "Toeschouwer";
|
||||
|
||||
const giftCents = params.giftAmount ?? 0;
|
||||
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
|
||||
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
|
||||
const hasPayment = drinkCardCents > 0 || giftCents > 0;
|
||||
|
||||
const formatEuro = (cents: number) =>
|
||||
`€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
|
||||
|
||||
const paymentSummary = hasPayment
|
||||
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
|
||||
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
|
||||
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
|
||||
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(drinkCardCents + giftCents)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
@@ -74,6 +97,7 @@ function registrationConfirmationHtml(params: {
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
${paymentSummary}
|
||||
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
|
||||
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.
|
||||
</p>
|
||||
@@ -114,11 +138,34 @@ function updateConfirmationHtml(params: {
|
||||
manageUrl: string;
|
||||
wantsToPerform: boolean;
|
||||
artForm?: string | null;
|
||||
giftAmount?: number;
|
||||
drinkCardValue?: number;
|
||||
}) {
|
||||
const role = params.wantsToPerform
|
||||
? `Optreden${params.artForm ? ` — ${params.artForm}` : ""}`
|
||||
: "Toeschouwer";
|
||||
|
||||
const giftCents = params.giftAmount ?? 0;
|
||||
// drinkCardValue is stored in euros (e.g., 5), giftAmount in cents (e.g., 5000)
|
||||
const drinkCardCents = (params.drinkCardValue ?? 0) * 100;
|
||||
const hasPayment = drinkCardCents > 0 || giftCents > 0;
|
||||
|
||||
const formatEuro = (cents: number) =>
|
||||
`€${(cents / 100).toFixed(cents % 100 === 0 ? 0 : 2).replace(".", ",")}`;
|
||||
|
||||
const paymentSummary = hasPayment
|
||||
? `<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:16px 0;">
|
||||
<tr>
|
||||
<td style="padding:20px 24px;">
|
||||
<p style="margin:0 0 12px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Betaling</p>
|
||||
${drinkCardCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Drinkkaart: <span style="color:#ffffff;font-weight:500;">${formatEuro(drinkCardCents)}</span></p>` : ""}
|
||||
${giftCents > 0 ? `<p style="margin:0 0 8px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.5;">Vrijwillige gift: <span style="color:#ffffff;font-weight:500;">${formatEuro(giftCents)}</span></p>` : ""}
|
||||
<p style="margin:16px 0 0;padding-top:12px;border-top:1px solid rgba(255,255,255,0.1);font-size:18px;color:#ffffff;font-weight:600;">Totaal: ${formatEuro(drinkCardCents + giftCents)}</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="nl">
|
||||
<head>
|
||||
@@ -152,6 +199,7 @@ function updateConfirmationHtml(params: {
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
${paymentSummary}
|
||||
<table cellpadding="0" cellspacing="0">
|
||||
<tr>
|
||||
<td style="border-radius:2px;background:#ffffff;">
|
||||
@@ -230,6 +278,8 @@ export async function sendConfirmationEmail(params: {
|
||||
managementToken: string;
|
||||
wantsToPerform: boolean;
|
||||
artForm?: string | null;
|
||||
giftAmount?: number;
|
||||
drinkCardValue?: number;
|
||||
}) {
|
||||
const transport = getTransport();
|
||||
if (!transport) {
|
||||
@@ -246,6 +296,8 @@ export async function sendConfirmationEmail(params: {
|
||||
manageUrl,
|
||||
wantsToPerform: params.wantsToPerform,
|
||||
artForm: params.artForm,
|
||||
giftAmount: params.giftAmount,
|
||||
drinkCardValue: params.drinkCardValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
@@ -256,6 +308,8 @@ export async function sendUpdateEmail(params: {
|
||||
managementToken: string;
|
||||
wantsToPerform: boolean;
|
||||
artForm?: string | null;
|
||||
giftAmount?: number;
|
||||
drinkCardValue?: number;
|
||||
}) {
|
||||
const transport = getTransport();
|
||||
if (!transport) {
|
||||
@@ -272,6 +326,8 @@ export async function sendUpdateEmail(params: {
|
||||
manageUrl,
|
||||
wantsToPerform: params.wantsToPerform,
|
||||
artForm: params.artForm,
|
||||
giftAmount: params.giftAmount,
|
||||
drinkCardValue: params.drinkCardValue,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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 { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
sendCancellationEmail,
|
||||
@@ -84,6 +84,7 @@ const coreRegistrationFields = {
|
||||
isOver16: z.boolean().optional(),
|
||||
guests: z.array(guestSchema).max(9).optional(),
|
||||
extraQuestions: z.string().optional(),
|
||||
giftAmount: z.number().int().min(0).default(0),
|
||||
};
|
||||
|
||||
const submitRegistrationSchema = z.object(coreRegistrationFields);
|
||||
@@ -135,6 +136,7 @@ export const appRouter = {
|
||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
||||
extraQuestions: input.extraQuestions || null,
|
||||
giftAmount: input.giftAmount,
|
||||
managementToken,
|
||||
});
|
||||
|
||||
@@ -144,6 +146,8 @@ export const appRouter = {
|
||||
managementToken,
|
||||
wantsToPerform: isPerformer,
|
||||
artForm: input.artForm,
|
||||
giftAmount: input.giftAmount,
|
||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||
}).catch((err) =>
|
||||
console.error("Failed to send confirmation email:", err),
|
||||
);
|
||||
@@ -175,6 +179,17 @@ export const appRouter = {
|
||||
const isPerformer = input.registrationType === "performer";
|
||||
const guests = isPerformer ? [] : (input.guests ?? []);
|
||||
|
||||
// Validate gift amount cannot be changed after payment
|
||||
const hasPaidGift =
|
||||
(row.paymentStatus === "paid" ||
|
||||
row.paymentStatus === "extra_payment_pending") &&
|
||||
(row.giftAmount ?? 0) > 0;
|
||||
if (hasPaidGift && input.giftAmount !== row.giftAmount) {
|
||||
throw new Error(
|
||||
"Gift kan niet worden aangepast na betaling. Neem contact op met de organisatie.",
|
||||
);
|
||||
}
|
||||
|
||||
// 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";
|
||||
@@ -208,6 +223,7 @@ export const appRouter = {
|
||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||
guests: guests.length > 0 ? JSON.stringify(guests) : null,
|
||||
extraQuestions: input.extraQuestions || null,
|
||||
giftAmount: input.giftAmount,
|
||||
paymentStatus: newPaymentStatus,
|
||||
paymentAmount: newPaymentAmount,
|
||||
})
|
||||
@@ -219,6 +235,8 @@ export const appRouter = {
|
||||
managementToken: input.token,
|
||||
wantsToPerform: isPerformer,
|
||||
artForm: input.artForm,
|
||||
giftAmount: input.giftAmount,
|
||||
drinkCardValue: isPerformer ? 0 : drinkCardEuros(guests.length),
|
||||
}).catch((err) => console.error("Failed to send update email:", err));
|
||||
|
||||
return { success: true };
|
||||
@@ -300,7 +318,7 @@ export const appRouter = {
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
|
||||
const [totalResult, todayResult, artFormResult, typeResult] =
|
||||
const [totalResult, todayResult, artFormResult, typeResult, giftResult] =
|
||||
await Promise.all([
|
||||
db.select({ count: count() }).from(registration),
|
||||
db
|
||||
@@ -319,6 +337,7 @@ export const appRouter = {
|
||||
})
|
||||
.from(registration)
|
||||
.groupBy(registration.registrationType),
|
||||
db.select({ total: sum(registration.giftAmount) }).from(registration),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -332,6 +351,7 @@ export const appRouter = {
|
||||
registrationType: r.registrationType,
|
||||
count: r.count,
|
||||
})),
|
||||
totalGiftRevenue: giftResult[0]?.total ?? 0,
|
||||
};
|
||||
}),
|
||||
|
||||
@@ -352,6 +372,7 @@ export const appRouter = {
|
||||
"Experience",
|
||||
"Is Over 16",
|
||||
"Drink Card Value",
|
||||
"Gift Amount",
|
||||
"Guest Count",
|
||||
"Guests",
|
||||
"Payment Status",
|
||||
@@ -379,6 +400,7 @@ export const appRouter = {
|
||||
r.experience || "",
|
||||
r.isOver16 ? "Yes" : "No",
|
||||
String(r.drinkCardValue ?? 0),
|
||||
String(r.giftAmount ?? 0),
|
||||
String(guests.length),
|
||||
guestSummary,
|
||||
r.paymentStatus === "paid" ? "Paid" : "Pending",
|
||||
@@ -561,22 +583,32 @@ export const appRouter = {
|
||||
row.paymentStatus !== "extra_payment_pending"
|
||||
)
|
||||
throw new Error("Onverwachte betalingsstatus");
|
||||
if (row.registrationType === "performer")
|
||||
throw new Error("Artiesten hoeven niet te betalen");
|
||||
|
||||
const isPerformer = row.registrationType === "performer";
|
||||
const guests = parseGuestsJson(row.guests);
|
||||
const giftTotal = row.giftAmount ?? 0;
|
||||
|
||||
// Performers only pay if they have a gift; watchers pay drink card + gift
|
||||
if (isPerformer && giftTotal === 0) {
|
||||
throw new Error("Artiesten hoeven niet te betalen");
|
||||
}
|
||||
|
||||
// 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.
|
||||
// charge the full drink card price. Always add any gift amount.
|
||||
const isExtraPayment = row.paymentStatus === "extra_payment_pending";
|
||||
const amountInCents = isExtraPayment
|
||||
const drinkCardTotal = isExtraPayment
|
||||
? (row.paymentAmount ?? 0)
|
||||
: drinkCardCents(guests.length);
|
||||
: isPerformer
|
||||
? 0
|
||||
: drinkCardCents(guests.length);
|
||||
const amountInCents = drinkCardTotal + giftTotal;
|
||||
|
||||
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)`;
|
||||
? `Extra bijdrage${giftTotal > 0 ? " + gift" : ""}`
|
||||
: isPerformer
|
||||
? `Vrijwillige gift${drinkCardTotal > 0 ? " + drinkkaart" : ""}`
|
||||
: `Toegangskaart${giftTotal > 0 ? " + gift" : ""}`;
|
||||
|
||||
const response = await fetch(
|
||||
"https://api.lemonsqueezy.com/v1/checkouts",
|
||||
|
||||
Reference in New Issue
Block a user