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 type { EmailMessage } from "../email-queue"; 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.MOLLIE_API_KEY) { throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Mollie is niet geconfigureerd", }); } const card = await getOrCreateDrinkkaart(session.user.id); // Mollie amounts must be formatted as "10.00" (string, 2 decimal places) const amountValue = (input.amountCents / 100).toFixed(2); const response = await fetch("https://api.mollie.com/v2/payments", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${serverEnv.MOLLIE_API_KEY}`, }, body: JSON.stringify({ amount: { value: amountValue, currency: "EUR" }, description: `Drinkkaart Opladen — ${formatCents(input.amountCents)}`, redirectUrl: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`, webhookUrl: `${serverEnv.BETTER_AUTH_URL}/api/webhook/mollie`, locale: "nl_NL", metadata: { type: "drinkkaart_topup", drinkkaartId: card.id, userId: session.user.id, }, }), }); if (!response.ok) { const errorData = await response.json(); console.error("Mollie Drinkkaart checkout error:", errorData); throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Kon checkout niet aanmaken", }); } const data = (await response.json()) as { _links?: { checkout?: { href?: string } }; }; const checkoutUrl = data._links?.checkout?.href; 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 (via queue when available) 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) { const deductionMsg: EmailMessage = { type: "deduction", to: cardUser.email, firstName: cardUser.name.split(" ")[0] ?? cardUser.name, amountCents: input.amountCents, newBalanceCents: balanceAfter, }; if (context.emailQueue) { await context.emailQueue.send(deductionMsg); } else { await sendDeductionEmail(deductionMsg).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`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; }), };