feat:UX and fix drinkkaart payment logic
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user