/** * Stammmodel-Codes (Briefing Abschnitt 6). * * Oeffentlich gibt es nur eine einzige, neutrale Ablehnung — ob ein Code * unbekannt, abgelaufen oder ausgeschoepft ist, erfaehrt nur der * Adminbereich. So laesst sich die Existenz gueltiger Codes nicht * durchprobieren. */ import type { Voucher } from "@/generated/prisma/client"; import type { Db, Tx } from "./db"; export const PUBLIC_VOUCHER_REJECTION = "Dieser Gutscheincode kann leider nicht angewendet werden."; export type VoucherRejection = | "MALFORMED" | "UNKNOWN" | "INACTIVE" | "NOT_YET_VALID" | "EXPIRED" | "EMAIL_MISMATCH" | "EXHAUSTED" | "QUOTA_EXHAUSTED"; /** Grossbuchstaben, ohne Leerzeichen; null bei offensichtlich ungueltiger Form. */ export function normalizeVoucherCode(input: string): string | null { const code = input.replace(/\s+/g, "").toUpperCase(); return /^[A-Z0-9-]{4,40}$/.test(code) ? code : null; } export function normalizeEmail(email: string): string { return email.trim().toLowerCase(); } type StaticVoucherFields = Pick; export function checkVoucherStatic( voucher: StaticVoucherFields, ctx: { now: Date; email: string | null }, ): VoucherRejection | null { if (!voucher.active) return "INACTIVE"; if (voucher.validFrom && ctx.now < voucher.validFrom) return "NOT_YET_VALID"; if (voucher.validUntil && ctx.now >= voucher.validUntil) return "EXPIRED"; if (voucher.boundEmail) { // Ohne E-Mail (reine Preisvorschau) laesst sich die Bindung nicht // pruefen — die verbindliche Pruefung passiert beim Checkout. if (ctx.email !== null && normalizeEmail(ctx.email) !== normalizeEmail(voucher.boundEmail)) { return "EMAIL_MISMATCH"; } } return null; } /** * Zaehlt belegte Einloesungen: abgeschlossene plus reservierte, deren * Buchung ihren Platz noch haelt. Ohne voucherId: Gesamtkontingent ueber * alle Stammmodel-Codes. */ export async function countUsedRedemptions( db: Db | Tx, params: { livemode: boolean; now: Date; voucherId?: string }, ): Promise { const rows = await db.$queryRaw<{ n: number }[]>` SELECT count(*)::int AS n FROM "VoucherRedemption" r JOIN "SeatReservation" s ON s."bookingId" = r."bookingId" WHERE r."livemode" = ${params.livemode} AND (${params.voucherId ?? null}::text IS NULL OR r."voucherId" = ${params.voucherId ?? null}) AND ( r."status" = 'COMPLETED' OR (r."status" = 'RESERVED' AND ( s."status" IN ('HELD', 'CONVERTED') OR (s."status" = 'ACTIVE' AND s."expiresAt" > ${params.now}) )) )`; return rows[0]?.n ?? 0; } export type VoucherEvaluation = | { ok: true; voucher: Voucher } | { ok: false; reason: VoucherRejection }; /** * Vollstaendige Pruefung eines Codes inklusive Einloeselimit und * Gesamtkontingent. Mit lock=true wird die Gutscheinzeile gesperrt (nur * innerhalb einer Transaktion, die bereits die Camp-Zeile haelt). */ export async function evaluateVoucher( db: Db | Tx, params: { code: string; email: string | null; now: Date; livemode: boolean; quota: number; lock: boolean }, ): Promise { const code = normalizeVoucherCode(params.code); if (!code) return { ok: false, reason: "MALFORMED" }; if (params.lock) { await db.$queryRaw`SELECT "id" FROM "Voucher" WHERE "code" = ${code} FOR UPDATE`; } const voucher = await db.voucher.findUnique({ where: { code } }); if (!voucher) return { ok: false, reason: "UNKNOWN" }; const rejection = checkVoucherStatic(voucher, { now: params.now, email: params.email }); if (rejection) return { ok: false, reason: rejection }; const used = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now, voucherId: voucher.id }); if (used >= voucher.maxRedemptions) return { ok: false, reason: "EXHAUSTED" }; const quotaUsed = await countUsedRedemptions(db, { livemode: params.livemode, now: params.now }); if (quotaUsed >= params.quota) return { ok: false, reason: "QUOTA_EXHAUSTED" }; return { ok: true, voucher }; } export const VOUCHER_REJECTION_ADMIN_TEXT: Record = { MALFORMED: "Format ungültig", UNKNOWN: "Unbekannter Code", INACTIVE: "Code ist deaktiviert", NOT_YET_VALID: "Gültigkeit hat noch nicht begonnen", EXPIRED: "Gültigkeit abgelaufen", EMAIL_MISMATCH: "An andere E-Mail-Adresse gebunden", EXHAUSTED: "Maximale Einlösungen erreicht", QUOTA_EXHAUSTED: "Gesamtkontingent für Stammmodel-Buchungen erreicht", };