This commit is contained in:
commit
59bb5a38a3
132 files changed
+19435
No files matched your search
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* 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",
|
||||
};
|
||||
Reference in new issue
Block a user