feat:drinkkaart

This commit is contained in:
2026-03-03 23:52:49 +01:00
parent b8cefd373d
commit 79869a9a21
35 changed files with 3189 additions and 76 deletions

View File

@@ -1,4 +1,5 @@
import { auth } from "@kk/auth";
import { env } from "@kk/env/server";
export async function createContext({ req }: { req: Request }) {
const session = await auth.api.getSession({
@@ -6,6 +7,7 @@ export async function createContext({ req }: { req: Request }) {
});
return {
session,
env,
};
}

View File

@@ -13,6 +13,7 @@ const requireAuth = o.middleware(async ({ context, next }) => {
return next({
context: {
session: context.session,
env: context.env,
},
});
});
@@ -29,6 +30,7 @@ const requireAdmin = o.middleware(async ({ context, next }) => {
return next({
context: {
session: context.session,
env: context.env,
},
});
});

View File

@@ -0,0 +1,159 @@
import { env } from "@kk/env/server";
import nodemailer from "nodemailer";
// Re-use the same SMTP transport strategy as email.ts.
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;
}
_transport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_PORT === 465,
auth: {
user: env.SMTP_USER,
pass: env.SMTP_PASS,
},
});
return _transport;
}
const from = env.SMTP_FROM ?? "Kunstenkamp <info@kunstenkamp.be>";
const baseUrl = env.BETTER_AUTH_URL ?? "https://kunstenkamp.be";
function formatEuro(cents: number): string {
return new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}
function deductionHtml(params: {
firstName: string;
amountCents: number;
newBalanceCents: number;
dateTime: string;
drinkkaartUrl: string;
}) {
return `<!DOCTYPE html>
<html lang="nl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Drinkkaart afschrijving</title>
</head>
<body style="margin:0;padding:0;background:#f4f4f5;font-family:sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f4f4f5;padding:40px 0;">
<tr>
<td align="center">
<table width="600" cellpadding="0" cellspacing="0" style="background:#214e51;border-radius:4px;overflow:hidden;">
<!-- Header -->
<tr>
<td style="padding:40px 48px 32px;">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.5);letter-spacing:0.08em;text-transform:uppercase;">Kunstenkamp</p>
<h1 style="margin:12px 0 0;font-size:28px;color:#ffffff;font-weight:700;">Drinkkaart afschrijving</h1>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:0 48px 32px;">
<p style="margin:0 0 16px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Hoi ${params.firstName},
</p>
<p style="margin:0 0 24px;font-size:16px;color:rgba(255,255,255,0.85);line-height:1.6;">
Er is zojuist een bedrag afgeschreven van je Drinkkaart.
</p>
<!-- Summary -->
<table width="100%" cellpadding="0" cellspacing="0" style="background:rgba(255,255,255,0.08);border-radius:4px;margin:0 0 24px;">
<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;">Afschrijving</p>
<p style="margin:0 0 8px;font-size:22px;color:#ffffff;font-weight:700;">&minus; ${formatEuro(params.amountCents)}</p>
<p style="margin:8px 0 0;font-size:14px;color:rgba(255,255,255,0.55);">${params.dateTime}</p>
</td>
</tr>
<tr>
<td style="padding:0 24px 20px;border-top:1px solid rgba(255,255,255,0.08);">
<p style="margin:12px 0 4px;font-size:12px;color:rgba(255,255,255,0.5);text-transform:uppercase;letter-spacing:0.06em;">Nieuw saldo</p>
<p style="margin:0;font-size:20px;color:#ffffff;font-weight:600;">${formatEuro(params.newBalanceCents)}</p>
</td>
</tr>
</table>
<!-- CTA -->
<table cellpadding="0" cellspacing="0">
<tr>
<td style="border-radius:2px;background:#ffffff;">
<a href="${params.drinkkaartUrl}" style="display:inline-block;padding:14px 32px;font-size:16px;font-weight:600;color:#214e51;text-decoration:none;">
Bekijk mijn Drinkkaart
</a>
</td>
</tr>
</table>
<p style="margin:16px 0 0;font-size:13px;color:rgba(255,255,255,0.4);">
Klopt er iets niet? Neem contact op via <a href="mailto:info@kunstenkamp.be" style="color:rgba(255,255,255,0.6);">info@kunstenkamp.be</a>.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="padding:24px 48px;border-top:1px solid rgba(255,255,255,0.1);">
<p style="margin:0;font-size:13px;color:rgba(255,255,255,0.4);line-height:1.6;">
Kunstenkamp vzw — Een initiatief voor en door kunstenaars.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>`;
}
/**
* Send a deduction notification email to the cardholder.
* Fire-and-forget: errors are logged but not re-thrown.
*/
export async function sendDeductionEmail(params: {
to: string;
firstName: string;
amountCents: number;
newBalanceCents: number;
}): Promise<void> {
const transport = getTransport();
if (!transport) {
console.warn("SMTP not configured — skipping deduction email");
return;
}
const now = new Date();
const dateTime = now.toLocaleString("nl-BE", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const amountFormatted = new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(params.amountCents / 100);
await transport.sendMail({
from,
to: params.to,
subject: `Drinkkaart — ${amountFormatted} afgeschreven`,
html: deductionHtml({
firstName: params.firstName,
amountCents: params.amountCents,
newBalanceCents: params.newBalanceCents,
dateTime,
drinkkaartUrl: `${baseUrl}/drinkkaart`,
}),
});
}

View File

@@ -0,0 +1,120 @@
// QR token utilities for Drinkkaart using Web Crypto API
// Compatible with Cloudflare Workers (no node:crypto dependency).
const QR_TOKEN_TTL_MS = 5 * 60 * 1000; // 5 minutes
interface QrPayload {
drinkkaartId: string;
userId: string;
iat: number;
exp: number;
}
function toBase64Url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
function base64UrlToBuffer(str: string): Uint8Array<ArrayBuffer> {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
// Ensure the underlying buffer is a plain ArrayBuffer (required by crypto.subtle)
return new Uint8Array(bytes.buffer.slice(0));
}
/** Generate a cryptographically secure 32-byte hex secret for a new Drinkkaart. */
export function generateQrSecret(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Sign a QR token for the given drinkkaart / user.
* Returns the compact token string and the expiry date.
*/
export async function signQrToken(
payload: Omit<QrPayload, "iat" | "exp">,
qrSecret: string,
authSecret: string,
): Promise<{ token: string; expiresAt: Date }> {
const now = Date.now();
const exp = now + QR_TOKEN_TTL_MS;
const full: QrPayload = { ...payload, iat: now, exp };
const encodedPayload = btoa(JSON.stringify(full))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
const keyData = new TextEncoder().encode(qrSecret + authSecret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const sigBuffer = await crypto.subtle.sign(
"HMAC",
key,
new TextEncoder().encode(encodedPayload),
);
const sig = toBase64Url(sigBuffer);
return { token: `${encodedPayload}.${sig}`, expiresAt: new Date(exp) };
}
/**
* Verify a QR token. Throws if the signature is invalid or the token is expired.
* Returns the decoded payload.
*/
export async function verifyQrToken(
token: string,
qrSecret: string,
authSecret: string,
): Promise<QrPayload> {
const parts = token.split(".");
if (parts.length !== 2) throw new Error("MALFORMED");
const [encodedPayload, providedSig] = parts as [string, string];
const keyData = new TextEncoder().encode(qrSecret + authSecret);
const key = await crypto.subtle.importKey(
"raw",
keyData,
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"],
);
const sigBytes = base64UrlToBuffer(providedSig);
const valid = await crypto.subtle.verify(
"HMAC",
key,
sigBytes,
new TextEncoder().encode(encodedPayload),
);
if (!valid) throw new Error("INVALID_SIGNATURE");
const payload: QrPayload = JSON.parse(
atob(encodedPayload.replace(/-/g, "+").replace(/_/g, "/")),
);
if (Date.now() > payload.exp) throw new Error("EXPIRED");
return payload;
}
/** Format a cents integer as a Belgian euro string, e.g. 1250 → "€ 12,50". */
export function formatCents(cents: number): string {
return new Intl.NumberFormat("nl-BE", {
style: "currency",
currency: "EUR",
}).format(cents / 100);
}

View File

@@ -0,0 +1,742 @@
import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import {
drinkkaart,
drinkkaartTopup,
drinkkaartTransaction,
} from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { ORPCError } from "@orpc/server";
import { and, desc, eq, gte, lte, sql } from "drizzle-orm";
import { z } from "zod";
import { adminProcedure, protectedProcedure } from "../index";
import { sendDeductionEmail } from "../lib/drinkkaart-email";
import {
formatCents,
generateQrSecret,
signQrToken,
verifyQrToken,
} from "../lib/drinkkaart-utils";
// ---------------------------------------------------------------------------
// Internal helper: get or create the drinkkaart row for a user
// ---------------------------------------------------------------------------
async function getOrCreateDrinkkaart(userId: string) {
const rows = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.userId, userId))
.limit(1);
if (rows[0]) return rows[0];
const now = new Date();
const newCard = {
id: randomUUID(),
userId,
balance: 0,
version: 0,
qrSecret: generateQrSecret(),
createdAt: now,
updatedAt: now,
};
await db.insert(drinkkaart).values(newCard);
return newCard;
}
// ---------------------------------------------------------------------------
// Router procedures
// ---------------------------------------------------------------------------
export const drinkkaartRouter = {
// -------------------------------------------------------------------------
// 4.1 getMyDrinkkaart
// -------------------------------------------------------------------------
getMyDrinkkaart: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
const card = await getOrCreateDrinkkaart(userId);
const [topups, transactions] = await Promise.all([
db
.select()
.from(drinkkaartTopup)
.where(eq(drinkkaartTopup.drinkkaartId, card.id))
.orderBy(desc(drinkkaartTopup.paidAt))
.limit(50),
db
.select()
.from(drinkkaartTransaction)
.where(eq(drinkkaartTransaction.drinkkaartId, card.id))
.orderBy(desc(drinkkaartTransaction.createdAt))
.limit(50),
]);
return {
id: card.id,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
createdAt: card.createdAt,
updatedAt: card.updatedAt,
topups: topups.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amountCents,
balanceBefore: t.balanceBefore,
balanceAfter: t.balanceAfter,
reason: t.reason,
paidAt: t.paidAt,
})),
transactions: transactions.map((t) => ({
id: t.id,
type: t.type,
amountCents: t.amountCents,
balanceBefore: t.balanceBefore,
balanceAfter: t.balanceAfter,
note: t.note,
reversedBy: t.reversedBy,
reverses: t.reverses,
createdAt: t.createdAt,
})),
};
}),
// -------------------------------------------------------------------------
// 4.2 getMyQrToken
// -------------------------------------------------------------------------
getMyQrToken: protectedProcedure.handler(async ({ context }) => {
const userId = context.session.user.id;
const card = await getOrCreateDrinkkaart(userId);
const authSecret = context.env.BETTER_AUTH_SECRET;
const { token, expiresAt } = await signQrToken(
{ drinkkaartId: card.id, userId },
card.qrSecret,
authSecret,
);
return { token, expiresAt };
}),
// -------------------------------------------------------------------------
// 4.3 getTopUpCheckoutUrl
// -------------------------------------------------------------------------
getTopUpCheckoutUrl: protectedProcedure
.input(
z.object({
amountCents: z
.number()
.int()
.min(100, "Minimumbedrag is € 1,00")
.max(50000, "Maximumbedrag is € 500,00"),
}),
)
.handler(async ({ input, context }) => {
const { env: serverEnv, session } = context;
if (
!serverEnv.LEMON_SQUEEZY_API_KEY ||
!serverEnv.LEMON_SQUEEZY_STORE_ID ||
!serverEnv.LEMON_SQUEEZY_DRINKKAART_VARIANT_ID
) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Lemon Squeezy Drinkkaart variant is niet geconfigureerd",
});
}
const card = await getOrCreateDrinkkaart(session.user.id);
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 ${serverEnv.LEMON_SQUEEZY_API_KEY}`,
},
body: JSON.stringify({
data: {
type: "checkouts",
attributes: {
custom_price: input.amountCents,
product_options: {
name: "Drinkkaart Opladen",
description: `Drinkkaart top-up — ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/drinkkaart?topup=success`,
},
checkout_data: {
email: session.user.email,
name: session.user.name,
custom: {
type: "drinkkaart_topup",
drinkkaartId: card.id,
userId: session.user.id,
},
},
checkout_options: {
embed: false,
locale: "nl",
},
},
relationships: {
store: {
data: {
type: "stores",
id: serverEnv.LEMON_SQUEEZY_STORE_ID,
},
},
variant: {
data: {
type: "variants",
id: serverEnv.LEMON_SQUEEZY_DRINKKAART_VARIANT_ID,
},
},
},
},
}),
},
);
if (!response.ok) {
const errorData = await response.json();
console.error("Lemon Squeezy Drinkkaart checkout error:", errorData);
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Kon checkout niet aanmaken",
});
}
const data = (await response.json()) as {
data?: { attributes?: { url?: string } };
};
const checkoutUrl = data.data?.attributes?.url;
if (!checkoutUrl) {
throw new ORPCError("INTERNAL_SERVER_ERROR", {
message: "Geen checkout URL ontvangen",
});
}
return { checkoutUrl };
}),
// -------------------------------------------------------------------------
// 4.4 resolveQrToken (admin)
// -------------------------------------------------------------------------
resolveQrToken: adminProcedure
.input(z.object({ token: z.string().min(1) }))
.handler(async ({ input, context }) => {
let drinkkaartId: string;
let userId: string;
// Decode payload first to get the drinkkaartId for key lookup
const parts = input.token.split(".");
if (parts.length !== 2) {
throw new ORPCError("BAD_REQUEST", {
message: "Ongeldig QR-token formaat",
});
}
let rawPayload: { drinkkaartId: string; userId: string; exp: number };
try {
rawPayload = JSON.parse(
atob((parts[0] as string).replace(/-/g, "+").replace(/_/g, "/")),
);
drinkkaartId = rawPayload.drinkkaartId;
userId = rawPayload.userId;
} catch {
throw new ORPCError("BAD_REQUEST", { message: "Ongeldig QR-token" });
}
// Fetch the card to get the per-user secret
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
// Verify HMAC and expiry
try {
await verifyQrToken(
input.token,
card.qrSecret,
context.env.BETTER_AUTH_SECRET,
);
} catch (err: unknown) {
const msg = (err as Error).message;
if (msg === "EXPIRED") {
throw new ORPCError("GONE", { message: "QR-token is verlopen" });
}
throw new ORPCError("UNAUTHORIZED", { message: "Ongeldig QR-token" });
}
// Fetch user
const cardUser = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, userId))
.limit(1)
.then((r) => r[0]);
if (!cardUser) {
throw new ORPCError("NOT_FOUND", {
message: "Gebruiker niet gevonden",
});
}
return {
drinkkaartId: card.id,
userId: cardUser.id,
userName: cardUser.name,
userEmail: cardUser.email,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
};
}),
// -------------------------------------------------------------------------
// 4.5 deductBalance (admin)
// -------------------------------------------------------------------------
deductBalance: adminProcedure
.input(
z.object({
drinkkaartId: z.string().min(1),
amountCents: z.number().int().min(1),
note: z.string().max(200).optional(),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, input.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
if (card.balance < input.amountCents) {
throw new ORPCError("UNPROCESSABLE_CONTENT", {
message: `Onvoldoende saldo. Huidig saldo: ${formatCents(card.balance)}`,
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore - input.amountCents;
const now = new Date();
// Optimistic lock update
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(
eq(drinkkaart.id, input.drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const transactionId = randomUUID();
await db.insert(drinkkaartTransaction).values({
id: transactionId,
drinkkaartId: card.id,
userId: card.userId,
adminId,
amountCents: input.amountCents,
balanceBefore,
balanceAfter,
type: "deduction",
note: input.note ?? null,
createdAt: now,
});
// Fire-and-forget deduction email
const cardUser = await db
.select({ email: user.email, name: user.name })
.from(user)
.where(eq(user.id, card.userId))
.limit(1)
.then((r) => r[0]);
if (cardUser) {
sendDeductionEmail({
to: cardUser.email,
firstName: cardUser.name.split(" ")[0] ?? cardUser.name,
amountCents: input.amountCents,
newBalanceCents: balanceAfter,
}).catch((err) =>
console.error("Failed to send deduction email:", err),
);
}
return {
transactionId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.6 adminCreditBalance (admin)
// -------------------------------------------------------------------------
adminCreditBalance: adminProcedure
.input(
z.object({
drinkkaartId: z.string().min(1),
amountCents: z.number().int().min(1),
reason: z.string().min(1).max(200),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, input.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + input.amountCents;
const now = new Date();
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(
eq(drinkkaart.id, input.drinkkaartId),
eq(drinkkaart.version, card.version),
),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const topupId = randomUUID();
await db.insert(drinkkaartTopup).values({
id: topupId,
drinkkaartId: card.id,
userId: card.userId,
amountCents: input.amountCents,
balanceBefore,
balanceAfter,
type: "admin_credit",
adminId,
reason: input.reason,
paidAt: now,
});
return {
topupId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.7 reverseTransaction (admin)
// -------------------------------------------------------------------------
reverseTransaction: adminProcedure
.input(
z.object({
transactionId: z.string().min(1),
note: z.string().max(200).optional(),
}),
)
.handler(async ({ input, context }) => {
const adminId = context.session.user.id;
const original = await db
.select()
.from(drinkkaartTransaction)
.where(eq(drinkkaartTransaction.id, input.transactionId))
.limit(1)
.then((r) => r[0]);
if (!original) {
throw new ORPCError("NOT_FOUND", {
message: "Transactie niet gevonden",
});
}
if (original.type !== "deduction") {
throw new ORPCError("BAD_REQUEST", {
message: "Alleen afschrijvingen kunnen worden teruggedraaid",
});
}
if (original.reversedBy) {
throw new ORPCError("CONFLICT", {
message: "Deze transactie is al teruggedraaid",
});
}
const card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.id, original.drinkkaartId))
.limit(1)
.then((r) => r[0]);
if (!card) {
throw new ORPCError("NOT_FOUND", {
message: "Drinkkaart niet gevonden",
});
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + original.amountCents;
const now = new Date();
const result = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: now,
})
.where(
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
);
if (result.rowsAffected === 0) {
throw new ORPCError("CONFLICT", {
message: "Gelijktijdige wijziging gedetecteerd. Probeer opnieuw.",
});
}
const reversalId = randomUUID();
await db.insert(drinkkaartTransaction).values({
id: reversalId,
drinkkaartId: card.id,
userId: card.userId,
adminId,
amountCents: original.amountCents,
balanceBefore,
balanceAfter,
type: "reversal",
reverses: original.id,
note: input.note ?? null,
createdAt: now,
});
// Mark original as reversed
await db
.update(drinkkaartTransaction)
.set({ reversedBy: reversalId })
.where(eq(drinkkaartTransaction.id, original.id));
return {
reversalId,
balanceBefore,
balanceAfter,
balanceFormatted: formatCents(balanceAfter),
};
}),
// -------------------------------------------------------------------------
// 4.8 getTransactionLog (admin) — paginated audit log
// -------------------------------------------------------------------------
getTransactionLog: adminProcedure
.input(
z.object({
page: z.number().int().min(1).default(1),
pageSize: z.number().int().min(1).max(200).default(50),
userId: z.string().optional(),
adminId: z.string().optional(),
type: z.enum(["deduction", "reversal"]).optional(),
dateFrom: z.string().optional(),
dateTo: z.string().optional(),
}),
)
.handler(async ({ input }) => {
// Build conditions
const conditions = [];
if (input.userId) {
conditions.push(eq(drinkkaartTransaction.userId, input.userId));
}
if (input.adminId) {
conditions.push(eq(drinkkaartTransaction.adminId, input.adminId));
}
if (input.type) {
conditions.push(eq(drinkkaartTransaction.type, input.type));
}
if (input.dateFrom) {
conditions.push(
gte(drinkkaartTransaction.createdAt, new Date(input.dateFrom)),
);
}
if (input.dateTo) {
conditions.push(
lte(drinkkaartTransaction.createdAt, new Date(input.dateTo)),
);
}
const where = conditions.length > 0 ? and(...conditions) : undefined;
// Paginated results with user names
const offset = (input.page - 1) * input.pageSize;
const rows = await db
.select({
id: drinkkaartTransaction.id,
type: drinkkaartTransaction.type,
userId: drinkkaartTransaction.userId,
adminId: drinkkaartTransaction.adminId,
amountCents: drinkkaartTransaction.amountCents,
balanceBefore: drinkkaartTransaction.balanceBefore,
balanceAfter: drinkkaartTransaction.balanceAfter,
note: drinkkaartTransaction.note,
reversedBy: drinkkaartTransaction.reversedBy,
reverses: drinkkaartTransaction.reverses,
createdAt: drinkkaartTransaction.createdAt,
})
.from(drinkkaartTransaction)
.where(where)
.orderBy(desc(drinkkaartTransaction.createdAt))
.limit(input.pageSize)
.offset(offset);
// Collect unique user IDs
const userIds = [
...new Set([
...rows.map((r) => r.userId),
...rows.map((r) => r.adminId),
]),
];
const users =
userIds.length > 0
? await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(
userIds.length === 1
? eq(user.id, userIds[0] as string)
: sql`${user.id} IN (${sql.join(
userIds.map((id) => sql`${id}`),
sql`, `,
)})`,
)
: [];
const userMap = new Map(users.map((u) => [u.id, u]));
// Count total
const countResult = await db
.select({ count: sql<number>`count(*)` })
.from(drinkkaartTransaction)
.where(where);
const total = Number(countResult[0]?.count ?? 0);
return {
transactions: rows.map((r) => ({
id: r.id,
type: r.type,
userId: r.userId,
userName: userMap.get(r.userId)?.name ?? "Onbekend",
userEmail: userMap.get(r.userId)?.email ?? "",
adminId: r.adminId,
adminName: userMap.get(r.adminId)?.name ?? "Onbekend",
amountCents: r.amountCents,
balanceBefore: r.balanceBefore,
balanceAfter: r.balanceAfter,
note: r.note,
reversedBy: r.reversedBy,
reverses: r.reverses,
createdAt: r.createdAt,
})),
total,
page: input.page,
pageSize: input.pageSize,
};
}),
// -------------------------------------------------------------------------
// Extra: getDrinkkaartByUserId (admin) — for manual credit user search
// -------------------------------------------------------------------------
getDrinkkaartByUserId: adminProcedure
.input(z.object({ userId: z.string().min(1) }))
.handler(async ({ input }) => {
const cardUser = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(eq(user.id, input.userId))
.limit(1)
.then((r) => r[0]);
if (!cardUser) {
throw new ORPCError("NOT_FOUND", {
message: "Gebruiker niet gevonden",
});
}
const card = await getOrCreateDrinkkaart(input.userId);
return {
drinkkaartId: card.id,
userId: cardUser.id,
userName: cardUser.name,
userEmail: cardUser.email,
balance: card.balance,
balanceFormatted: formatCents(card.balance),
};
}),
// -------------------------------------------------------------------------
// Extra: searchUsers (admin) — for manual credit user search UI
// -------------------------------------------------------------------------
searchUsers: adminProcedure
.input(z.object({ query: z.string().min(1) }))
.handler(async ({ input }) => {
const term = `%${input.query}%`;
const results = await db
.select({ id: user.id, name: user.name, email: user.email })
.from(user)
.where(
sql`lower(${user.name}) LIKE lower(${term}) OR lower(${user.email}) LIKE lower(${term})`,
)
.limit(20);
return results;
}),
};

View File

@@ -12,6 +12,7 @@ import {
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { drinkkaartRouter } from "./drinkkaart";
// ---------------------------------------------------------------------------
// Shared helpers
@@ -110,6 +111,7 @@ const getRegistrationsSchema = z.object({
export const appRouter = {
healthCheck: publicProcedure.handler(() => "OK"),
drinkkaart: drinkkaartRouter,
privateData: protectedProcedure.handler(({ context }) => ({
message: "This is private",

View File

@@ -12,7 +12,11 @@ export const auth = betterAuth({
schema: schema,
}),
trustedOrigins: [env.CORS_ORIGIN],
trustedOrigins: [
env.CORS_ORIGIN,
"http://localhost:3000",
"http://localhost:3001",
],
emailAndPassword: {
enabled: true,
// Use Cloudflare's native scrypt via node:crypto for better performance

View File

@@ -0,0 +1,56 @@
-- Migration: Add Drinkkaart digital balance card tables
CREATE TABLE `drinkkaart` (
`id` text PRIMARY KEY NOT NULL,
`user_id` text NOT NULL,
`balance` integer DEFAULT 0 NOT NULL,
`version` integer DEFAULT 0 NOT NULL,
`qr_secret` text NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL,
UNIQUE(`user_id`)
);
--> statement-breakpoint
CREATE TABLE `drinkkaart_transaction` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`admin_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`reversed_by` text,
`reverses` text,
`note` text,
`created_at` integer NOT NULL
);
--> statement-breakpoint
CREATE TABLE `drinkkaart_topup` (
`id` text PRIMARY KEY NOT NULL,
`drinkkaart_id` text NOT NULL,
`user_id` text NOT NULL,
`amount_cents` integer NOT NULL,
`balance_before` integer NOT NULL,
`balance_after` integer NOT NULL,
`type` text NOT NULL,
`lemonsqueezy_order_id` text,
`lemonsqueezy_customer_id` text,
`admin_id` text,
`reason` text,
`paid_at` integer NOT NULL,
UNIQUE(`lemonsqueezy_order_id`)
);
--> statement-breakpoint
CREATE INDEX `idx_drinkkaart_user_id` ON `drinkkaart` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_drinkkaart_id` ON `drinkkaart_transaction` (`drinkkaart_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_user_id` ON `drinkkaart_transaction` (`user_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_admin_id` ON `drinkkaart_transaction` (`admin_id`);
--> statement-breakpoint
CREATE INDEX `idx_dkt_created_at` ON `drinkkaart_transaction` (`created_at`);
--> statement-breakpoint
CREATE INDEX `idx_dktu_drinkkaart_id` ON `drinkkaart_topup` (`drinkkaart_id`);
--> statement-breakpoint
CREATE INDEX `idx_dktu_user_id` ON `drinkkaart_topup` (`user_id`);

View File

@@ -22,6 +22,13 @@
"when": 1772520000000,
"tag": "0002_registration_type_redesign",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772530000000,
"tag": "0003_add_guests",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,69 @@
import type { InferInsertModel, InferSelectModel } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// ---------------------------------------------------------------------------
// drinkkaart — one row per user account (1:1 with user)
// ---------------------------------------------------------------------------
export const drinkkaart = sqliteTable("drinkkaart", {
id: text("id").primaryKey(),
userId: text("user_id").notNull().unique(),
balance: integer("balance").notNull().default(0), // in cents
version: integer("version").notNull().default(0), // optimistic lock counter
qrSecret: text("qr_secret").notNull(), // CSPRNG 32-byte hex; signs QR tokens
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
// drinkkaart_transaction — immutable audit log of deductions and reversals
// ---------------------------------------------------------------------------
export const drinkkaartTransaction = sqliteTable("drinkkaart_transaction", {
id: text("id").primaryKey(),
drinkkaartId: text("drinkkaart_id").notNull(),
userId: text("user_id").notNull(), // denormalized for fast audit queries
adminId: text("admin_id").notNull(), // FK → user.id (admin who processed it)
amountCents: integer("amount_cents").notNull(),
balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["deduction", "reversal"] }).notNull(),
reversedBy: text("reversed_by"), // nullable; id of the reversal transaction
reverses: text("reverses"), // nullable; id of the original deduction
note: text("note"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
// drinkkaart_topup — immutable log of every credited amount
// ---------------------------------------------------------------------------
export const drinkkaartTopup = sqliteTable("drinkkaart_topup", {
id: text("id").primaryKey(),
drinkkaartId: text("drinkkaart_id").notNull(),
userId: text("user_id").notNull(), // denormalized
amountCents: integer("amount_cents").notNull(),
balanceBefore: integer("balance_before").notNull(),
balanceAfter: integer("balance_after").notNull(),
type: text("type", { enum: ["payment", "admin_credit"] }).notNull(),
lemonsqueezyOrderId: text("lemonsqueezy_order_id").unique(), // nullable; only for type="payment"
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
adminId: text("admin_id"), // nullable; only for type="admin_credit"
reason: text("reason"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }).notNull(),
});
// ---------------------------------------------------------------------------
// Inferred Drizzle types
// ---------------------------------------------------------------------------
export type Drinkkaart = InferSelectModel<typeof drinkkaart>;
export type NewDrinkkaart = InferInsertModel<typeof drinkkaart>;
export type DrinkkaartTransaction = InferSelectModel<
typeof drinkkaartTransaction
>;
export type NewDrinkkaartTransaction = InferInsertModel<
typeof drinkkaartTransaction
>;
export type DrinkkaartTopup = InferSelectModel<typeof drinkkaartTopup>;
export type NewDrinkkaartTopup = InferInsertModel<typeof drinkkaartTopup>;

View File

@@ -1,3 +1,4 @@
export * from "./admin-requests";
export * from "./auth";
export * from "./drinkkaart";
export * from "./registrations";

View File

@@ -28,6 +28,7 @@ export const env = createEnv({
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_DRINKKAART_VARIANT_ID: z.string().min(1).optional(),
LEMON_SQUEEZY_WEBHOOK_SECRET: z.string().min(1).optional(),
},
runtimeEnv: process.env,