Files
Artista-Filmcamp/src/lib/vouchers.ts
T
Nick Szaunig 59bb5a38a3
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s
first commit
2026-09-27 06:06:56 +02:00

122 lines
4.5 KiB
TypeScript

/**
* 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<Voucher, "active" | "validFrom" | "validUntil" | "boundEmail">;
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<number> {
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<VoucherEvaluation> {
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<VoucherRejection, string> = {
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",
};