|
|
|
|
@@ -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,
|
|
|
|
|
|