feat:drinkkaart
This commit is contained in:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
159
packages/api/src/lib/drinkkaart-email.ts
Normal file
159
packages/api/src/lib/drinkkaart-email.ts
Normal 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;">− ${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`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
120
packages/api/src/lib/drinkkaart-utils.ts
Normal file
120
packages/api/src/lib/drinkkaart-utils.ts
Normal 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);
|
||||
}
|
||||
742
packages/api/src/routers/drinkkaart.ts
Normal file
742
packages/api/src/routers/drinkkaart.ts
Normal 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;
|
||||
}),
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
56
packages/db/src/migrations/0004_drinkkaart.sql
Normal file
56
packages/db/src/migrations/0004_drinkkaart.sql
Normal 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`);
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
69
packages/db/src/schema/drinkkaart.ts
Normal file
69
packages/db/src/schema/drinkkaart.ts
Normal 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>;
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./admin-requests";
|
||||
export * from "./auth";
|
||||
export * from "./drinkkaart";
|
||||
export * from "./registrations";
|
||||
|
||||
1
packages/env/src/server.ts
vendored
1
packages/env/src/server.ts
vendored
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user