feat:UX and fix drinkkaart payment logic

This commit is contained in:
2026-03-04 14:22:42 +01:00
parent 79869a9a21
commit f9d79c3879
26 changed files with 1432 additions and 283 deletions

View File

@@ -163,7 +163,7 @@ export const drinkkaartRouter = {
product_options: {
name: "Drinkkaart Opladen",
description: `Drinkkaart top-up — ${formatCents(input.amountCents)}`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/drinkkaart?topup=success`,
redirect_url: `${serverEnv.BETTER_AUTH_URL}/account?topup=success`,
},
checkout_data: {
email: session.user.email,

View File

@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "@kk/db";
import { adminRequest, registration } from "@kk/db/schema";
import { user } from "@kk/db/schema/auth";
import { drinkkaart, drinkkaartTopup } from "@kk/db/schema/drinkkaart";
import { env } from "@kk/env/server";
import type { RouterClient } from "@orpc/server";
import { and, count, desc, eq, gte, isNull, like, lte, sum } from "drizzle-orm";
@@ -12,6 +13,7 @@ import {
sendUpdateEmail,
} from "../email";
import { adminProcedure, protectedProcedure, publicProcedure } from "../index";
import { generateQrSecret } from "../lib/drinkkaart-utils";
import { drinkkaartRouter } from "./drinkkaart";
// ---------------------------------------------------------------------------
@@ -44,6 +46,151 @@ function parseGuestsJson(raw: string | null): Array<{
}
}
/**
* Credits a watcher's drinkCardValue to their drinkkaart account.
*
* Safe to call multiple times — the `drinkkaartCreditedAt IS NULL` guard and the
* `lemonsqueezy_order_id` UNIQUE constraint together prevent double-crediting even
* if called concurrently (webhook + signup racing each other).
*
* Returns a status string describing the outcome.
*/
export async function creditRegistrationToAccount(
email: string,
userId: string,
): Promise<{
credited: boolean;
amountCents: number;
status:
| "credited"
| "already_credited"
| "no_paid_registration"
| "zero_value";
}> {
// Find the most recent paid watcher registration for this email that hasn't
// been credited yet and has a non-zero drink card value.
const rows = await db
.select()
.from(registration)
.where(
and(
eq(registration.email, email),
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "paid"),
isNull(registration.cancelledAt),
isNull(registration.drinkkaartCreditedAt),
),
)
.orderBy(desc(registration.createdAt))
.limit(1);
const reg = rows[0];
if (!reg) {
// Either no paid registration exists, or it was already credited.
// Distinguish between the two for better logging.
const credited = await db
.select({ id: registration.id })
.from(registration)
.where(
and(
eq(registration.email, email),
eq(registration.registrationType, "watcher"),
eq(registration.paymentStatus, "paid"),
isNull(registration.cancelledAt),
),
)
.limit(1)
.then((r) => r[0]);
return {
credited: false,
amountCents: 0,
status: credited ? "already_credited" : "no_paid_registration",
};
}
const drinkCardValueEuros = reg.drinkCardValue ?? 0;
if (drinkCardValueEuros === 0) {
return { credited: false, amountCents: 0, status: "zero_value" };
}
const amountCents = drinkCardValueEuros * 100;
// Get or create the drinkkaart for this user.
let card = await db
.select()
.from(drinkkaart)
.where(eq(drinkkaart.userId, userId))
.limit(1)
.then((r) => r[0]);
if (!card) {
const now = new Date();
card = {
id: randomUUID(),
userId,
balance: 0,
version: 0,
qrSecret: generateQrSecret(),
createdAt: now,
updatedAt: now,
};
await db.insert(drinkkaart).values(card);
}
const balanceBefore = card.balance;
const balanceAfter = balanceBefore + amountCents;
// Optimistic-lock update — if concurrent, the second caller will see
// drinkkaartCreditedAt IS NOT NULL and return "already_credited" above.
const updateResult = await db
.update(drinkkaart)
.set({
balance: balanceAfter,
version: card.version + 1,
updatedAt: new Date(),
})
.where(
and(eq(drinkkaart.id, card.id), eq(drinkkaart.version, card.version)),
);
if (updateResult.rowsAffected === 0) {
// Lost the optimistic lock race — the other caller will handle it.
return { credited: false, amountCents: 0, status: "already_credited" };
}
// Record the topup. Re-use the registration's lemonsqueezyOrderId so the
// topup row is clearly linked to the original payment. We prefix with
// "reg_" to distinguish from direct drinkkaart top-up orders if needed.
const topupOrderId = reg.lemonsqueezyOrderId
? `reg_${reg.lemonsqueezyOrderId}`
: null;
await db.insert(drinkkaartTopup).values({
id: randomUUID(),
drinkkaartId: card.id,
userId,
amountCents,
balanceBefore,
balanceAfter,
type: "payment",
lemonsqueezyOrderId: topupOrderId,
lemonsqueezyCustomerId: reg.lemonsqueezyCustomerId ?? null,
adminId: null,
reason: "Drinkkaart bij registratie",
paidAt: reg.paidAt ?? new Date(),
});
// Mark the registration as credited — prevents any future double-credit.
await db
.update(registration)
.set({ drinkkaartCreditedAt: new Date() })
.where(eq(registration.id, reg.id));
return { credited: true, amountCents, status: "credited" };
}
/** Fetches a single registration by management token, throws if not found or cancelled. */
async function getActiveRegistration(token: string) {
const rows = await db
@@ -425,6 +572,39 @@ export const appRouter = {
};
}),
// ---------------------------------------------------------------------------
// User account
// ---------------------------------------------------------------------------
claimRegistrationCredit: protectedProcedure.handler(async ({ context }) => {
const result = await creditRegistrationToAccount(
context.session.user.email,
context.session.user.id,
);
return result;
}),
getMyRegistration: protectedProcedure.handler(async ({ context }) => {
const email = context.session.user.email;
const rows = await db
.select()
.from(registration)
.where(
and(eq(registration.email, email), isNull(registration.cancelledAt)),
)
.orderBy(desc(registration.createdAt))
.limit(1);
const row = rows[0];
if (!row) return null;
return {
...row,
guests: parseGuestsJson(row.guests),
};
}),
// ---------------------------------------------------------------------------
// Admin access requests
// ---------------------------------------------------------------------------
@@ -555,7 +735,12 @@ export const appRouter = {
// ---------------------------------------------------------------------------
getCheckoutUrl: publicProcedure
.input(z.object({ token: z.string().uuid() }))
.input(
z.object({
token: z.string().uuid(),
redirectUrl: z.string().optional(),
}),
)
.handler(async ({ input }) => {
if (
!env.LEMON_SQUEEZY_API_KEY ||
@@ -629,7 +814,9 @@ export const appRouter = {
product_options: {
name: "Kunstenkamp Evenement",
description: productDescription,
redirect_url: `${env.CORS_ORIGIN}/manage/${input.token}`,
redirect_url:
input.redirectUrl ??
`${env.CORS_ORIGIN}/manage/${input.token}`,
},
checkout_data: {
email: row.email,

View File

@@ -0,0 +1,2 @@
-- Migration: Track when a watcher's registration fee has been credited to their drinkkaart
ALTER TABLE `registration` ADD COLUMN `drinkkaart_credited_at` integer;

View File

@@ -32,6 +32,12 @@ export const registration = sqliteTable(
lemonsqueezyOrderId: text("lemonsqueezy_order_id"),
lemonsqueezyCustomerId: text("lemonsqueezy_customer_id"),
paidAt: integer("paid_at", { mode: "timestamp_ms" }),
// Set when the drinkCardValue has been credited to the user's drinkkaart.
// Null means not yet credited (either unpaid, account doesn't exist yet, or
// the registration is a performer). Used to prevent double-crediting.
drinkkaartCreditedAt: integer("drinkkaart_credited_at", {
mode: "timestamp_ms",
}),
createdAt: integer("created_at", { mode: "timestamp_ms" })
.default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`)
.notNull(),