Kuma/src/modules/settings/lib/discount-validation.ts
2026-09-13 18:45:46 +02:00

154 lines
6 KiB
TypeScript

import type { prisma as prismaClient } from "@/src/lib/prisma";
// Erlaubt sowohl den globalen `prisma`-Client (Checkout-Route, Vorab-Prüfung)
// als auch den `tx`-Parameter aus prisma.$transaction() (Webhook, Source of
// Truth) - Prisma leitet den Transaktions-Client-Typ nicht separat exportierbar
// ab, daher hier über Parameters<> vom $transaction-Callback selbst.
type PrismaOrTx =
| typeof prismaClient
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
export type DiscountValidationResult =
| { valid: true; discountCode: { id: string; type: string; value: number } }
| { valid: false; reason: string };
// Wird an ZWEI Stellen aufgerufen: (1) beim Checkout-Start für sofortiges
// User-Feedback, (2) erneut INNERHALB der Serializable-Transaktion im
// Stripe-Webhook als Source of Truth. Muss deshalb rein lesend und ohne
// Seiteneffekte bleiben (keine Redemption/Increment hier) - das Verbuchen
// passiert ausschließlich in redeemDiscountCode() nach erfolgreicher Zahlung.
export async function validateDiscountCode(
db: PrismaOrTx,
params: { code: string; businessId: string; courseId: string; userId: string | null }
): Promise<DiscountValidationResult> {
const normalizedCode = params.code.trim().toUpperCase();
if (!normalizedCode) {
return { valid: false, reason: "Kein Rabattcode angegeben." };
}
const discountCode = await db.discountCode.findUnique({
where: { businessId_code: { businessId: params.businessId, code: normalizedCode } },
});
if (!discountCode || !discountCode.isActive) {
return { valid: false, reason: "Dieser Rabattcode ist ungültig." };
}
const now = new Date();
if (discountCode.startsAt && now < discountCode.startsAt) {
return { valid: false, reason: "Dieser Rabattcode ist noch nicht gültig." };
}
if (discountCode.expiresAt && now > discountCode.expiresAt) {
return { valid: false, reason: "Dieser Rabattcode ist abgelaufen." };
}
if (
discountCode.scope === "specific_courses" &&
!discountCode.courseIds.includes(params.courseId)
) {
return { valid: false, reason: "Dieser Rabattcode gilt nicht für diesen Kurs." };
}
if (
discountCode.maxRedemptions !== null &&
discountCode.redeemedCount >= discountCode.maxRedemptions
) {
return { valid: false, reason: "Dieser Rabattcode wurde bereits vollständig eingelöst." };
}
// Rabattcodes sind bewusst nur eingeloggten Usern vorbehalten: Gäste haben
// keine stabile Identität (nur eine unverifizierte E-Mail), wodurch weder
// die 1x-pro-User-Regel noch das Gesamtkontingent zuverlässig durchsetzbar
// wären (siehe #182 - Gast-Redemptions wurden zunächst nicht mitgezählt).
if (!params.userId) {
return { valid: false, reason: "Rabattcodes sind nur für angemeldete Kunden verfügbar." };
}
const existingRedemption = await db.discountRedemption.findUnique({
where: { discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId } },
});
if (existingRedemption) {
return { valid: false, reason: "Du hast diesen Rabattcode bereits verwendet." };
}
return {
valid: true,
discountCode: { id: discountCode.id, type: discountCode.type, value: discountCode.value },
};
}
export function calculateDiscountedPriceCents(
originalPriceCents: number,
discount: { type: string; value: number }
): { discountedPriceCents: number; discountAmountCents: number } {
const rawDiscount =
discount.type === "percent"
? Math.round((originalPriceCents * discount.value) / 100)
: discount.value;
// Rabatt darf den Preis nicht unter 0 drücken (relevant v.a. bei "fixed" auf
// günstige Kurse) - Stripe lehnt unit_amount <= 0 in der Regel ohnehin ab.
const discountAmountCents = Math.min(rawDiscount, originalPriceCents);
const discountedPriceCents = originalPriceCents - discountAmountCents;
return { discountedPriceCents, discountAmountCents };
}
// Wird NUR innerhalb der Serializable-Transaktion im Webhook aufgerufen,
// nachdem die Zahlung bereits bestätigt ist und die CourseBooking-Zeile
// bereits existiert (bookingId erforderlich, siehe DiscountRedemption.bookingId
// @unique). Validiert den Code ein zweites Mal (Kontingent/Zeitfenster/
// 1x-pro-User können sich seit dem Checkout-Start geändert haben) und legt bei
// Erfolg atomar Redemption + Counter-Increment an. Wird der Code zwischenzeitlich
// ungültig, tut diese Funktion nichts (kein Fehler) - die Buchung wurde vom
// Aufrufer bereits unabhängig davon zum (schon berechneten) rabattierten Preis
// bestätigt, nur ohne verbuchte Redemption. Das ist bewusstes Verhalten.
export async function redeemDiscountCode(
tx: PrismaOrTx,
params: {
discountCodeId: string;
businessId: string;
courseId: string;
userId: string | null;
bookingId: string;
}
): Promise<boolean> {
if (!params.userId) return false; // Guests haben keine User-Redemption-Zuordnung, siehe validateDiscountCode
const discountCode = await tx.discountCode.findUnique({
where: { id: params.discountCodeId },
});
if (!discountCode || discountCode.businessId !== params.businessId) return false;
const now = new Date();
const stillValid =
discountCode.isActive &&
(!discountCode.startsAt || now >= discountCode.startsAt) &&
(!discountCode.expiresAt || now <= discountCode.expiresAt) &&
(discountCode.scope !== "specific_courses" ||
discountCode.courseIds.includes(params.courseId)) &&
(discountCode.maxRedemptions === null ||
discountCode.redeemedCount < discountCode.maxRedemptions);
if (!stillValid) return false;
const existingRedemption = await tx.discountRedemption.findUnique({
where: {
discountCodeId_userId: { discountCodeId: discountCode.id, userId: params.userId },
},
});
if (existingRedemption) return false;
await tx.discountCode.update({
where: { id: discountCode.id },
data: { redeemedCount: { increment: 1 } },
});
await tx.discountRedemption.create({
data: {
discountCodeId: discountCode.id,
userId: params.userId,
bookingId: params.bookingId,
},
});
return true;
}