mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 20:51:18 +00:00
see comments
This commit is contained in:
parent
a8db32bf24
commit
bc06a983de
8 changed files with 394 additions and 141 deletions
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "CourseSpotHold" ADD COLUMN "status" TEXT NOT NULL DEFAULT 'checkout',
|
||||
ADD COLUMN "stripeSessionId" TEXT;
|
||||
|
|
@ -257,16 +257,19 @@ model CourseBooking {
|
|||
// Webhook nach der Zahlung gelöscht (dann belegt die Buchung den Platz) oder bei
|
||||
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
|
||||
// nicht und werden beim nächsten Reservieren aufgeräumt.
|
||||
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs gibt es höchstens
|
||||
// einen Hold - wer zurück geht und einen anderen Platz wählt, gibt den alten frei.
|
||||
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
|
||||
// ein offener Checkout - wer zurück geht und neu wählt, beendet den alten (siehe
|
||||
// Checkout-Route). Holds abgeschlossener Checkouts bleiben dabei unangetastet.
|
||||
model CourseSpotHold {
|
||||
id String @id @default(uuid())
|
||||
courseId String
|
||||
spotId String
|
||||
holderKey String
|
||||
expiresAt DateTime
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
courseId String
|
||||
spotId String
|
||||
holderKey String
|
||||
expiresAt DateTime
|
||||
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
|
||||
status String @default("checkout") // "checkout" (Session offen) | "awaiting_payment" (Checkout abgeschlossen, SEPA-Zahlung unterwegs)
|
||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([courseId, spotId])
|
||||
@@index([courseId])
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import {
|
|||
calculateDiscountedPriceCents,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
import {
|
||||
attachSessionToHold,
|
||||
getCourseSpots,
|
||||
getOpenCheckoutHolds,
|
||||
holdSpot,
|
||||
releaseSpotHold,
|
||||
spotHolderKey,
|
||||
|
|
@ -48,6 +50,40 @@ async function createBrandedCheckoutSession(
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pro Person und Kurs läuft höchstens ein offener Checkout mit Platz. Startet
|
||||
* jemand neu (zurück aus dem Checkout, zweiter Tab), wird die alte Session bei
|
||||
* Stripe beendet und ihr Platz freigegeben - so kann sie auch nicht später
|
||||
* noch bezahlt werden.
|
||||
*
|
||||
* Freigegeben wird nur, was Stripe nachweislich als offen meldet. Ist die alte
|
||||
* Session schon abgeschlossen (bezahlt, Webhook noch unterwegs), behält sie
|
||||
* ihren Platz. Im Zweifel (Stripe nicht erreichbar) bleibt der Hold stehen und
|
||||
* läuft von selbst ab.
|
||||
*/
|
||||
async function endOpenCheckouts(courseId: string, holderKey: string, stripeAccountId: string) {
|
||||
const options = { stripeAccount: stripeAccountId };
|
||||
for (const hold of await getOpenCheckoutHolds(prisma, courseId, holderKey)) {
|
||||
// Ohne Session-ID wurde die Session nie fertig angelegt - nichts zu beenden.
|
||||
if (!hold.stripeSessionId) {
|
||||
await releaseSpotHold(prisma, hold.id);
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const old = await stripe.checkout.sessions.retrieve(hold.stripeSessionId, {}, options);
|
||||
if (old.status === "complete") continue;
|
||||
if (old.status === "open") await stripe.checkout.sessions.expire(old.id, {}, options);
|
||||
await releaseSpotHold(prisma, hold.id);
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`Alte Checkout-Session ${hold.stripeSessionId} konnte nicht beendet werden, ` +
|
||||
`Platz-Hold ${hold.id} bleibt bis zum Ablauf bestehen:`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
||||
// its own Map, so a determined attacker can bypass it. The main purpose is to
|
||||
|
|
@ -291,89 +327,119 @@ export async function POST(request: NextRequest) {
|
|||
reservationToken = reservation.reservationToken;
|
||||
}
|
||||
|
||||
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
|
||||
// Platz blockiert. Kann der Platz nicht gehalten werden, wird die eben
|
||||
// reservierte Rabattcode-Nutzung wieder freigegeben.
|
||||
const spots = await getCourseSpots(prisma, course);
|
||||
const holderKey = spotHolderKey({
|
||||
userId: isGuest ? null : userData!.id,
|
||||
guestEmail: isGuest ? guestEmail : null,
|
||||
});
|
||||
let spotHoldId: string | null = null;
|
||||
let spotLabel: string | null = null;
|
||||
if (spots) {
|
||||
const spot = spots.find((s) => s.id === spotId);
|
||||
const rejectSpot = async (error: string, status: number) => {
|
||||
if (reservationToken) await releaseDiscountReservation(prisma, reservationToken);
|
||||
return NextResponse.json({ error, spotUnavailable: true }, { status });
|
||||
};
|
||||
if (!spot) {
|
||||
return rejectSpot(
|
||||
spotId
|
||||
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
|
||||
: "Bitte wählen Sie einen Platz aus.",
|
||||
400
|
||||
);
|
||||
|
||||
// Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten.
|
||||
// Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst
|
||||
// blieben sie blockiert, ohne dass je eine Buchung zustande kommt - eine
|
||||
// nie angelegte Session meldet auch kein checkout.session.expired.
|
||||
const releaseReservations = async () => {
|
||||
if (reservationToken) {
|
||||
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||
console.error(
|
||||
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagenem ` +
|
||||
`Checkout-Start nicht freigegeben werden:`,
|
||||
releaseError
|
||||
);
|
||||
});
|
||||
}
|
||||
if (spotHoldId) {
|
||||
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
|
||||
console.error(
|
||||
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagenem Checkout-Start nicht ` +
|
||||
`freigegeben werden:`,
|
||||
releaseError
|
||||
);
|
||||
});
|
||||
}
|
||||
const hold = await holdSpot(prisma, {
|
||||
courseId: course.id,
|
||||
spotId: spot.id,
|
||||
holderKey: spotHolderKey({
|
||||
userId: isGuest ? null : userData!.id,
|
||||
guestEmail: isGuest ? guestEmail : null,
|
||||
}),
|
||||
// Etwas länger als die Session, damit ein Kunde, der in der letzten
|
||||
// Minute bezahlt, seinen Platz bis zum Webhook behält.
|
||||
expiresAt: new Date(Date.now() + (SPOT_HOLD_MINUTES + 5) * 60_000),
|
||||
});
|
||||
if (!hold.held) return rejectSpot(hold.reason, 409);
|
||||
spotHoldId = hold.holdId;
|
||||
spotLabel = spotDisplayName(spot);
|
||||
}
|
||||
|
||||
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
|
||||
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
|
||||
// ist damit priceCents + platformFeeCents; application_fee_amount führt
|
||||
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
|
||||
// nie die Gebühr.
|
||||
const platformFeeCents = getPlatformFeeCents();
|
||||
|
||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||
|
||||
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
|
||||
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
|
||||
// Zahlung sichtbar ist.
|
||||
const metadata = {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
|
||||
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
|
||||
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
|
||||
// "obendrauf" verbucht werden.
|
||||
platformFeeModel: "on_top",
|
||||
spotId: spotHoldId ? spotId! : "",
|
||||
spotHoldId: spotHoldId ?? "",
|
||||
};
|
||||
|
||||
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
|
||||
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
|
||||
// die Kumasy-Farben.
|
||||
const studioBranding = toEmailBranding(course.business);
|
||||
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
|
||||
display_name: course.business.name,
|
||||
background_color: studioBranding.primaryColor,
|
||||
button_color: studioBranding.accentColor,
|
||||
border_style: "rounded",
|
||||
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
|
||||
? { logo: { type: "url", url: studioBranding.logoUrl } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
let session;
|
||||
try {
|
||||
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
|
||||
// Platz blockiert.
|
||||
const spots = await getCourseSpots(prisma, course);
|
||||
if (spots) {
|
||||
const spot = spots.find((s) => s.id === spotId);
|
||||
if (!spot) {
|
||||
await releaseReservations();
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: spotId
|
||||
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
|
||||
: "Bitte wählen Sie einen Platz aus.",
|
||||
spotUnavailable: true,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
await endOpenCheckouts(course.id, holderKey, course.business.stripeAccountId);
|
||||
const hold = await holdSpot(prisma, {
|
||||
courseId: course.id,
|
||||
spotId: spot.id,
|
||||
holderKey,
|
||||
// Etwas länger als die Session, damit ein Kunde, der in der letzten
|
||||
// Minute bezahlt, seinen Platz bis zum Webhook behält.
|
||||
expiresAt: new Date(Date.now() + (SPOT_HOLD_MINUTES + 5) * 60_000),
|
||||
});
|
||||
if (!hold.held) {
|
||||
await releaseReservations();
|
||||
return NextResponse.json({ error: hold.reason, spotUnavailable: true }, { status: 409 });
|
||||
}
|
||||
spotHoldId = hold.holdId;
|
||||
spotLabel = spotDisplayName(spot);
|
||||
}
|
||||
|
||||
// Fixe Servicegebühr, die der Kunde ZUSÄTZLICH zum Kurspreis zahlt und die
|
||||
// als eigene Checkout-Position sichtbar ist. Der Gesamtbetrag der Charge
|
||||
// ist damit priceCents + platformFeeCents; application_fee_amount führt
|
||||
// exakt die Gebühr an Kumasy ab. Rabattcodes mindern nur den Kurspreis,
|
||||
// nie die Gebühr.
|
||||
const platformFeeCents = getPlatformFeeCents();
|
||||
|
||||
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
|
||||
|
||||
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
|
||||
// an Session UND PaymentIntent, damit es auch im Stripe-Dashboard an der
|
||||
// Zahlung sichtbar ist.
|
||||
const metadata = {
|
||||
courseId: course.id,
|
||||
userId: isGuest ? "" : userData!.id,
|
||||
businessId: course.business.id,
|
||||
isGuest: isGuest ? "true" : "false",
|
||||
guestEmail: isGuest ? customerEmail : "",
|
||||
guestName: isGuest ? (guestName?.trim() ?? "") : "",
|
||||
discountCodeId: appliedDiscountCodeId ?? "",
|
||||
discountAmountCents: String(discountAmountCents),
|
||||
discountReservationToken: reservationToken ?? "",
|
||||
// Sagt dem Webhook, dass die Gebühr obendrauf kam. Sessions aus der Zeit
|
||||
// der prozentualen Gebühr haben diesen Schlüssel nicht - eine solche
|
||||
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
|
||||
// "obendrauf" verbucht werden.
|
||||
platformFeeModel: "on_top",
|
||||
spotId: spotHoldId ? spotId! : "",
|
||||
spotHoldId: spotHoldId ?? "",
|
||||
};
|
||||
|
||||
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
|
||||
// und Logo, wie die öffentliche Seite davor. Ohne eigenes Branding greifen
|
||||
// die Kumasy-Farben.
|
||||
const studioBranding = toEmailBranding(course.business);
|
||||
const brandingSettings: Stripe.Checkout.SessionCreateParams.BrandingSettings = {
|
||||
display_name: course.business.name,
|
||||
background_color: studioBranding.primaryColor,
|
||||
button_color: studioBranding.accentColor,
|
||||
border_style: "rounded",
|
||||
...(studioBranding.logoUrl && isAllowedLogoUrl(studioBranding.logoUrl)
|
||||
? { logo: { type: "url", url: studioBranding.logoUrl } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
session = await createBrandedCheckoutSession(
|
||||
{
|
||||
mode: "payment",
|
||||
|
|
@ -428,30 +494,18 @@ export async function POST(request: NextRequest) {
|
|||
brandingSettings,
|
||||
course.business.stripeAccountId
|
||||
);
|
||||
} catch (stripeError) {
|
||||
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
|
||||
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
|
||||
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert,
|
||||
// ohne dass je eine Buchung zustande kommt.
|
||||
if (reservationToken) {
|
||||
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||
console.error(
|
||||
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
|
||||
`Stripe-Session nicht freigegeben werden:`,
|
||||
releaseError
|
||||
);
|
||||
});
|
||||
}
|
||||
if (spotHoldId) {
|
||||
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
|
||||
console.error(
|
||||
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagener Stripe-Session nicht ` +
|
||||
`freigegeben werden:`,
|
||||
releaseError
|
||||
);
|
||||
});
|
||||
}
|
||||
throw stripeError;
|
||||
} catch (error) {
|
||||
await releaseReservations();
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Damit ein späterer Checkout derselben Person diese Session beenden kann
|
||||
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
|
||||
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
|
||||
if (spotHoldId && session.id) {
|
||||
await attachSessionToHold(prisma, spotHoldId, session.id).catch((err) =>
|
||||
console.error(`Session konnte nicht an Platz-Hold ${spotHoldId} gehängt werden:`, err)
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
|
|
|
|||
|
|
@ -29,18 +29,20 @@ type StandingOrderScope = "this" | "this_and_following" | "all";
|
|||
* Teilnehmer zugelassen sein, als es Plätze gibt - sonst bekäme der letzte
|
||||
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
|
||||
* Fehlermeldung zurück oder null, wenn alles passt.
|
||||
*
|
||||
* Liest nur Räume des eigenen Managers - sonst ließe sich mit einer fremden
|
||||
* Raum-ID dessen Platzanzahl auslesen.
|
||||
*/
|
||||
async function validateSpotBooking(data: {
|
||||
spotBooking?: boolean;
|
||||
room?: string;
|
||||
maxParticipants?: number;
|
||||
}): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
|
||||
async function validateSpotBooking(
|
||||
data: { spotBooking?: boolean; room?: string; maxParticipants?: number },
|
||||
managerId: string
|
||||
): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
|
||||
if (!data.spotBooking) return null;
|
||||
if (!data.room) {
|
||||
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
|
||||
}
|
||||
const room = await prisma.room.findUnique({
|
||||
where: { id: data.room },
|
||||
const room = await prisma.room.findFirst({
|
||||
where: { id: data.room, createdBy: managerId },
|
||||
select: { floorPlan: true },
|
||||
});
|
||||
const spotCount = bookableSpots(parseFloorPlan(room?.floorPlan)).length;
|
||||
|
|
@ -106,7 +108,12 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
|||
|
||||
const validatedData = validation.data;
|
||||
|
||||
const spotError = await validateSpotBooking(validatedData);
|
||||
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
|
||||
const creatorId = getEffectiveManagerId(userData);
|
||||
if (!creatorId)
|
||||
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
|
||||
|
||||
const spotError = await validateSpotBooking(validatedData, creatorId);
|
||||
if (spotError) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
@ -115,11 +122,6 @@ export async function createCourse(data: CourseFormData, status: "draft" | "publ
|
|||
};
|
||||
}
|
||||
|
||||
// Schritt 5: Manager-ID ermitteln (Employee erbt createdBy vom eigenen Datensatz)
|
||||
const creatorId = getEffectiveManagerId(userData);
|
||||
if (!creatorId)
|
||||
return { success: false, error: "Mitarbeiter-Account ist keinem Manager zugeordnet" };
|
||||
|
||||
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
|
||||
const commonFields = {
|
||||
name: validatedData.name || "",
|
||||
|
|
@ -504,7 +506,7 @@ export async function updateCourse(
|
|||
|
||||
const validatedData = validation.data;
|
||||
|
||||
const spotError = await validateSpotBooking(validatedData);
|
||||
const spotError = await validateSpotBooking(validatedData, managerId);
|
||||
if (spotError) {
|
||||
return {
|
||||
success: false,
|
||||
|
|
|
|||
|
|
@ -12,7 +12,8 @@ import {
|
|||
* Ablauf:
|
||||
* 1. Checkout-Start: holdSpot() hält den gewählten Platz für die Dauer der
|
||||
* Stripe-Session frei (CourseSpotHold), damit kein zweiter Kunde mit
|
||||
* demselben Platz zur Kasse geht.
|
||||
* demselben Platz zur Kasse geht. Startet dieselbe Person erneut, beendet
|
||||
* die Checkout-Route vorher ihre alte, noch offene Session.
|
||||
* 2. Webhook nach der Zahlung: assignSpot() schreibt den Platz an die Buchung.
|
||||
* Verbindlich ist nur, was bezahlt ist - war der Wunschplatz inzwischen
|
||||
* doch vergeben (Hold abgelaufen, SEPA-Zahlung kam Tage später), bekommt
|
||||
|
|
@ -70,8 +71,9 @@ export async function getBookedSpotIds(db: PrismaOrTx, courseId: string): Promis
|
|||
|
||||
/**
|
||||
* Plätze, die gerade jemand im Checkout hält. exceptHolderKey blendet die
|
||||
* eigene Reservierung aus - wer den Checkout abbricht und zurückkommt, soll
|
||||
* seinen Platz nicht als belegt sehen.
|
||||
* eigenen offenen Checkouts aus - wer den Checkout abbricht und zurückkommt,
|
||||
* soll seinen Platz nicht als belegt sehen (der nächste Checkout ersetzt ihn).
|
||||
* Ein eigener abgeschlossener Checkout (SEPA unterwegs) bleibt belegt.
|
||||
*/
|
||||
export async function getHeldSpotIds(
|
||||
db: PrismaOrTx,
|
||||
|
|
@ -82,7 +84,9 @@ export async function getHeldSpotIds(
|
|||
where: {
|
||||
courseId,
|
||||
expiresAt: { gt: new Date() },
|
||||
...(opts.exceptHolderKey ? { holderKey: { not: opts.exceptHolderKey } } : {}),
|
||||
...(opts.exceptHolderKey
|
||||
? { NOT: { holderKey: opts.exceptHolderKey, status: "checkout" } }
|
||||
: {}),
|
||||
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
|
||||
},
|
||||
select: { spotId: true },
|
||||
|
|
@ -96,6 +100,11 @@ export type HoldSpotResult = { held: true; holdId: string } | { held: false; rea
|
|||
* Hält einen Platz für den Checkout frei. Die Eindeutigkeit erzwingt
|
||||
* @@unique([courseId, spotId]): fordern zwei Kunden gleichzeitig denselben
|
||||
* Platz an, gewinnt der erste Insert, der zweite bekommt P2002.
|
||||
*
|
||||
* Räumt nur abgelaufene Holds weg. Holds derselben Person aus früheren
|
||||
* Checkouts gibt die Checkout-Route vorher frei, und zwar nur, wenn deren
|
||||
* Session nachweislich noch offen war - ein bereits bezahlter oder per SEPA
|
||||
* abgeschlossener Checkout behält seinen Platz.
|
||||
*/
|
||||
export async function holdSpot(
|
||||
db: typeof prismaClient,
|
||||
|
|
@ -103,11 +112,9 @@ export async function holdSpot(
|
|||
): Promise<HoldSpotResult> {
|
||||
const { courseId, spotId, holderKey, expiresAt } = params;
|
||||
|
||||
// Abgelaufene Holds wegräumen (sonst blockiert ihr Unique-Key den Platz)
|
||||
// und den eigenen alten Hold für diesen Kurs freigeben - wer aus dem
|
||||
// Checkout zurückkommt und umwählt, hält danach nur den neuen Platz.
|
||||
// Abgelaufene Holds wegräumen - sonst blockiert ihr Unique-Key den Platz.
|
||||
await db.courseSpotHold.deleteMany({
|
||||
where: { courseId, OR: [{ expiresAt: { lte: new Date() } }, { holderKey }] },
|
||||
where: { courseId, expiresAt: { lte: new Date() } },
|
||||
});
|
||||
|
||||
const booked = await db.courseBooking.findFirst({
|
||||
|
|
@ -129,22 +136,57 @@ export async function holdSpot(
|
|||
return { held: true, holdId: hold.id };
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") {
|
||||
const existing = await db.courseSpotHold.findUnique({
|
||||
where: { courseId_spotId: { courseId, spotId } },
|
||||
select: { holderKey: true },
|
||||
});
|
||||
return {
|
||||
held: false,
|
||||
reason:
|
||||
"Dieser Platz wird gerade von jemand anderem gebucht. Bitte wählen Sie einen anderen.",
|
||||
existing?.holderKey === holderKey
|
||||
? "Für diesen Platz läuft bereits Ihre Zahlung. Bitte warten Sie die Bestätigung ab."
|
||||
: "Dieser Platz wird gerade von jemand anderem gebucht. Bitte wählen Sie einen anderen.",
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Offene Checkouts einer Person für diesen Kurs - die Checkout-Route ersetzt sie. */
|
||||
export async function getOpenCheckoutHolds(
|
||||
db: PrismaOrTx,
|
||||
courseId: string,
|
||||
holderKey: string
|
||||
): Promise<{ id: string; stripeSessionId: string | null }[]> {
|
||||
return db.courseSpotHold.findMany({
|
||||
where: { courseId, holderKey, status: "checkout" },
|
||||
select: { id: true, stripeSessionId: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Verknüpft den Hold mit seiner Session, sobald Stripe sie angelegt hat. */
|
||||
export async function attachSessionToHold(
|
||||
db: PrismaOrTx,
|
||||
holdId: string,
|
||||
stripeSessionId: string
|
||||
): Promise<void> {
|
||||
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } });
|
||||
}
|
||||
|
||||
export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise<void> {
|
||||
await db.courseSpotHold.deleteMany({ where: { id: holdId } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout abgeschlossen, Zahlung unterwegs (SEPA): Platz länger halten und
|
||||
* als abgeschlossen markieren, damit ein neuer Checkout derselben Person ihn
|
||||
* nicht als "alten offenen Checkout" freigibt.
|
||||
*/
|
||||
export async function extendSpotHold(db: PrismaOrTx, holdId: string, until: Date): Promise<void> {
|
||||
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { expiresAt: until } });
|
||||
await db.courseSpotHold.updateMany({
|
||||
where: { id: holdId },
|
||||
data: { expiresAt: until, status: "awaiting_payment" },
|
||||
});
|
||||
}
|
||||
|
||||
export type AssignedSpot = { spotId: string; spotLabel: string; changed: boolean };
|
||||
|
|
|
|||
|
|
@ -18,14 +18,24 @@ vi.mock("@/src/lib/prisma", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/src/lib/stripe", () => ({
|
||||
stripe: { checkout: { sessions: { create: vi.fn() } } },
|
||||
stripe: { checkout: { sessions: { create: vi.fn(), retrieve: vi.fn(), expire: vi.fn() } } },
|
||||
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
||||
eurosToCents: vi.fn().mockReturnValue(2900),
|
||||
}));
|
||||
|
||||
// Rabattcodes nur für den Test, dass ein Fehler bei der Platzwahl die
|
||||
// Reservierung wieder freigibt.
|
||||
vi.mock("@/src/modules/settings/lib/discount-validation", () => ({
|
||||
reserveDiscountCode: vi.fn(),
|
||||
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
|
||||
calculateDiscountedPriceCents: () => ({ discountedPriceCents: 2000, discountAmountCents: 900 }),
|
||||
}));
|
||||
|
||||
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
|
||||
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
|
||||
getCourseSpots: vi.fn().mockResolvedValue(null),
|
||||
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
|
||||
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
|
||||
holdSpot: vi.fn(),
|
||||
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
|
||||
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
|
||||
|
|
@ -37,7 +47,17 @@ import { getUserData, isUser } from "@/src/lib/auth/getUser";
|
|||
import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||
import { prisma } from "@/src/lib/prisma";
|
||||
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
||||
import { getCourseSpots, holdSpot, releaseSpotHold } from "@/src/modules/courses/lib/course-spots";
|
||||
import {
|
||||
attachSessionToHold,
|
||||
getCourseSpots,
|
||||
getOpenCheckoutHolds,
|
||||
holdSpot,
|
||||
releaseSpotHold,
|
||||
} from "@/src/modules/courses/lib/course-spots";
|
||||
import {
|
||||
reserveDiscountCode,
|
||||
releaseDiscountReservation,
|
||||
} from "@/src/modules/settings/lib/discount-validation";
|
||||
|
||||
let ipCounter = 0;
|
||||
|
||||
|
|
@ -259,10 +279,16 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
|
|||
vi.mocked(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
|
||||
vi.mocked(holdSpot).mockReset();
|
||||
vi.mocked(releaseSpotHold).mockClear();
|
||||
vi.mocked(attachSessionToHold).mockClear();
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
|
||||
vi.mocked(stripe.checkout.sessions.expire).mockReset();
|
||||
vi.mocked(releaseDiscountReservation).mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
|
||||
|
|
@ -336,4 +362,86 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
|
|||
expect(args.metadata.spotId).toBe("");
|
||||
expect(holdSpot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("gibt den Rabattcode frei, wenn die Platzwahl mit einem Fehler abbricht", async () => {
|
||||
vi.mocked(reserveDiscountCode).mockResolvedValue({
|
||||
reserved: true,
|
||||
reservationToken: "tok-1",
|
||||
discountCode: { id: "code-1", type: "percent", value: 30 },
|
||||
} as never);
|
||||
vi.mocked(holdSpot).mockRejectedValue(new Error("DB weg"));
|
||||
const consoleError = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({ courseId: "course-1", spotId: "spot-1", discountCode: "PILATES30" })
|
||||
);
|
||||
|
||||
expect(res.status).toBe(500);
|
||||
expect(releaseDiscountReservation).toHaveBeenCalledWith(prisma, "tok-1");
|
||||
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
|
||||
it("hängt die neue Session an den Hold", async () => {
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
vi.mocked(stripe.checkout.sessions.create).mockResolvedValueOnce({
|
||||
id: "cs_neu",
|
||||
url: "https://checkout.stripe.com/neu",
|
||||
} as never);
|
||||
|
||||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
|
||||
|
||||
expect(attachSessionToHold).toHaveBeenCalledWith(prisma, "hold-1", "cs_neu");
|
||||
});
|
||||
|
||||
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||
]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||||
id: "cs_alt",
|
||||
status: "open",
|
||||
} as never);
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
|
||||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(stripe.checkout.sessions.expire).toHaveBeenCalledWith(
|
||||
"cs_alt",
|
||||
{},
|
||||
{ stripeAccount: "acct_123" }
|
||||
);
|
||||
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-alt");
|
||||
});
|
||||
|
||||
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||
]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockResolvedValue({
|
||||
id: "cs_alt",
|
||||
status: "complete",
|
||||
} as never);
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
|
||||
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
|
||||
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||||
});
|
||||
|
||||
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
|
||||
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([
|
||||
{ id: "hold-alt", stripeSessionId: "cs_alt" },
|
||||
]);
|
||||
vi.mocked(stripe.checkout.sessions.retrieve).mockRejectedValue(new Error("Timeout"));
|
||||
vi.mocked(holdSpot).mockResolvedValue({ held: true, holdId: "hold-1" });
|
||||
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
|
||||
consoleWarn.mockRestore();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -389,9 +389,11 @@ describe("Stripe-Webhook – Platzwahl", () => {
|
|||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
|
||||
where: { id: string };
|
||||
data: { expiresAt: Date };
|
||||
data: { expiresAt: Date; status: string };
|
||||
};
|
||||
expect(call.where).toEqual({ id: "hold-1" });
|
||||
// Ein neuer Checkout derselben Person darf diesen Platz nicht freigeben.
|
||||
expect(call.data.status).toBe("awaiting_payment");
|
||||
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
|
||||
expect(days).toBeGreaterThan(13);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import {
|
||||
assignSpot,
|
||||
extendSpotHold,
|
||||
getCourseSpots,
|
||||
getHeldSpotIds,
|
||||
holdSpot,
|
||||
spotHolderKey,
|
||||
} from "@/src/modules/courses/lib/course-spots";
|
||||
|
|
@ -10,7 +12,13 @@ import type { FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
|
|||
const db = {
|
||||
room: { findUnique: vi.fn() },
|
||||
courseBooking: { findMany: vi.fn(), findFirst: vi.fn() },
|
||||
courseSpotHold: { findMany: vi.fn(), deleteMany: vi.fn(), create: vi.fn() },
|
||||
courseSpotHold: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
create: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const spot = (id: string, label: string): FloorPlanItem => ({
|
||||
|
|
@ -122,13 +130,12 @@ describe("holdSpot", () => {
|
|||
expiresAt: new Date(Date.now() + 30 * 60_000),
|
||||
};
|
||||
|
||||
it("räumt abgelaufene und eigene alte Holds weg, bevor es hält", async () => {
|
||||
it("räumt nur abgelaufene Holds weg - eigene Holds anderer Checkouts bleiben", async () => {
|
||||
const result = await holdSpot(db as never, params);
|
||||
|
||||
expect(result).toEqual({ held: true, holdId: "hold-1" });
|
||||
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
|
||||
expect(where.courseId).toBe("c1");
|
||||
expect(where.OR).toContainEqual({ holderKey: "user:u1" });
|
||||
expect(where).toEqual({ courseId: "c1", expiresAt: { lte: expect.any(Date) } });
|
||||
});
|
||||
|
||||
it("hält keinen Platz, der schon bezahlt ist", async () => {
|
||||
|
|
@ -142,8 +149,40 @@ describe("holdSpot", () => {
|
|||
db.courseSpotHold.create.mockRejectedValue(
|
||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
|
||||
);
|
||||
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:jemand-anderes" });
|
||||
const result = await holdSpot(db as never, params);
|
||||
expect(result).toMatchObject({ held: false });
|
||||
expect(result).toMatchObject({
|
||||
held: false,
|
||||
reason: expect.stringContaining("jemand anderem"),
|
||||
});
|
||||
});
|
||||
|
||||
it("sagt der Person, dass ihre eigene Zahlung für den Platz noch läuft", async () => {
|
||||
db.courseSpotHold.create.mockRejectedValue(
|
||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
|
||||
);
|
||||
db.courseSpotHold.findUnique.mockResolvedValue({ holderKey: "user:u1" });
|
||||
const result = await holdSpot(db as never, params);
|
||||
expect(result).toMatchObject({ held: false, reason: expect.stringContaining("Ihre Zahlung") });
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hold-Status", () => {
|
||||
it("markiert einen verlängerten Hold als abgeschlossenen Checkout", async () => {
|
||||
const until = new Date(Date.now() + 14 * 86_400_000);
|
||||
await extendSpotHold(db as never, "hold-1", until);
|
||||
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: "hold-1" },
|
||||
data: { expiresAt: until, status: "awaiting_payment" },
|
||||
});
|
||||
});
|
||||
|
||||
it("blendet für die Person nur ihre offenen Checkouts aus", async () => {
|
||||
await getHeldSpotIds(db as never, "c1", { exceptHolderKey: "user:u1" });
|
||||
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.NOT).toEqual({
|
||||
holderKey: "user:u1",
|
||||
status: "checkout",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue