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
|
// Webhook nach der Zahlung gelöscht (dann belegt die Buchung den Platz) oder bei
|
||||||
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
|
// abgelaufener Session freigegeben. Abgelaufene Holds (expiresAt < jetzt) zählen
|
||||||
// nicht und werden beim nächsten Reservieren aufgeräumt.
|
// nicht und werden beim nächsten Reservieren aufgeräumt.
|
||||||
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs gibt es höchstens
|
// holderKey = "user:<id>" oder "guest:<email>": pro Person und Kurs läuft höchstens
|
||||||
// einen Hold - wer zurück geht und einen anderen Platz wählt, gibt den alten frei.
|
// 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 {
|
model CourseSpotHold {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
courseId String
|
courseId String
|
||||||
spotId String
|
spotId String
|
||||||
holderKey String
|
holderKey String
|
||||||
expiresAt DateTime
|
expiresAt DateTime
|
||||||
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
|
stripeSessionId String? // Checkout-Session, für die der Platz gehalten wird. null, solange sie angelegt wird.
|
||||||
createdAt DateTime @default(now())
|
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])
|
@@unique([courseId, spotId])
|
||||||
@@index([courseId])
|
@@index([courseId])
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,9 @@ import {
|
||||||
calculateDiscountedPriceCents,
|
calculateDiscountedPriceCents,
|
||||||
} from "@/src/modules/settings/lib/discount-validation";
|
} from "@/src/modules/settings/lib/discount-validation";
|
||||||
import {
|
import {
|
||||||
|
attachSessionToHold,
|
||||||
getCourseSpots,
|
getCourseSpots,
|
||||||
|
getOpenCheckoutHolds,
|
||||||
holdSpot,
|
holdSpot,
|
||||||
releaseSpotHold,
|
releaseSpotHold,
|
||||||
spotHolderKey,
|
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.
|
// Simple in-memory rate limiter: max 10 checkout attempts per IP per minute.
|
||||||
// Best-effort only — in multi-instance/serverless deployments each instance has
|
// 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
|
// 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;
|
reservationToken = reservation.reservationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Platzwahl: erst nach dem Rabattcode, damit ein abgelehnter Code keinen
|
const holderKey = spotHolderKey({
|
||||||
// Platz blockiert. Kann der Platz nicht gehalten werden, wird die eben
|
userId: isGuest ? null : userData!.id,
|
||||||
// reservierte Rabattcode-Nutzung wieder freigegeben.
|
guestEmail: isGuest ? guestEmail : null,
|
||||||
const spots = await getCourseSpots(prisma, course);
|
});
|
||||||
let spotHoldId: string | null = null;
|
let spotHoldId: string | null = null;
|
||||||
let spotLabel: string | null = null;
|
let spotLabel: string | null = null;
|
||||||
if (spots) {
|
|
||||||
const spot = spots.find((s) => s.id === spotId);
|
// Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten.
|
||||||
const rejectSpot = async (error: string, status: number) => {
|
// Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst
|
||||||
if (reservationToken) await releaseDiscountReservation(prisma, reservationToken);
|
// blieben sie blockiert, ohne dass je eine Buchung zustande kommt - eine
|
||||||
return NextResponse.json({ error, spotUnavailable: true }, { status });
|
// nie angelegte Session meldet auch kein checkout.session.expired.
|
||||||
};
|
const releaseReservations = async () => {
|
||||||
if (!spot) {
|
if (reservationToken) {
|
||||||
return rejectSpot(
|
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
||||||
spotId
|
console.error(
|
||||||
? "Diesen Platz gibt es nicht mehr. Bitte wählen Sie einen anderen."
|
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagenem ` +
|
||||||
: "Bitte wählen Sie einen Platz aus.",
|
`Checkout-Start nicht freigegeben werden:`,
|
||||||
400
|
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;
|
let session;
|
||||||
try {
|
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(
|
session = await createBrandedCheckoutSession(
|
||||||
{
|
{
|
||||||
mode: "payment",
|
mode: "payment",
|
||||||
|
|
@ -428,30 +494,18 @@ export async function POST(request: NextRequest) {
|
||||||
brandingSettings,
|
brandingSettings,
|
||||||
course.business.stripeAccountId
|
course.business.stripeAccountId
|
||||||
);
|
);
|
||||||
} catch (stripeError) {
|
} catch (error) {
|
||||||
// Reservierung wurde bereits vor der Session-Erstellung angelegt (siehe
|
await releaseReservations();
|
||||||
// oben) - schlägt die Session-Erstellung selbst fehl, muss der belegte
|
throw error;
|
||||||
// Slot wieder freigegeben werden, sonst bliebe er dauerhaft blockiert,
|
}
|
||||||
// ohne dass je eine Buchung zustande kommt.
|
|
||||||
if (reservationToken) {
|
// Damit ein späterer Checkout derselben Person diese Session beenden kann
|
||||||
await releaseDiscountReservation(prisma, reservationToken).catch((releaseError) => {
|
// (siehe endOpenCheckouts). Scheitert das, bleibt der Platz trotzdem bis
|
||||||
console.error(
|
// zum Ablauf gehalten - kein Grund, die fertige Session zu verwerfen.
|
||||||
`Rabattcode-Reservierung ${reservationToken} konnte nach fehlgeschlagener ` +
|
if (spotHoldId && session.id) {
|
||||||
`Stripe-Session nicht freigegeben werden:`,
|
await attachSessionToHold(prisma, spotHoldId, session.id).catch((err) =>
|
||||||
releaseError
|
console.error(`Session konnte nicht an Platz-Hold ${spotHoldId} gehängt werden:`, err)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
if (spotHoldId) {
|
|
||||||
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
|
|
||||||
console.error(
|
|
||||||
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagener Stripe-Session nicht ` +
|
|
||||||
`freigegeben werden:`,
|
|
||||||
releaseError
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
throw stripeError;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NextResponse.json({
|
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
|
* Teilnehmer zugelassen sein, als es Plätze gibt - sonst bekäme der letzte
|
||||||
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
|
* Kunde zwar eine Zahlung abgebucht, aber keinen Platz. Gibt eine
|
||||||
* Fehlermeldung zurück oder null, wenn alles passt.
|
* 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: {
|
async function validateSpotBooking(
|
||||||
spotBooking?: boolean;
|
data: { spotBooking?: boolean; room?: string; maxParticipants?: number },
|
||||||
room?: string;
|
managerId: string
|
||||||
maxParticipants?: number;
|
): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
|
||||||
}): Promise<{ error: string; field: "room" | "maxParticipants" } | null> {
|
|
||||||
if (!data.spotBooking) return null;
|
if (!data.spotBooking) return null;
|
||||||
if (!data.room) {
|
if (!data.room) {
|
||||||
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
|
return { error: "Für die Platzwahl braucht der Kurs einen Raum", field: "room" };
|
||||||
}
|
}
|
||||||
const room = await prisma.room.findUnique({
|
const room = await prisma.room.findFirst({
|
||||||
where: { id: data.room },
|
where: { id: data.room, createdBy: managerId },
|
||||||
select: { floorPlan: true },
|
select: { floorPlan: true },
|
||||||
});
|
});
|
||||||
const spotCount = bookableSpots(parseFloorPlan(room?.floorPlan)).length;
|
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 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) {
|
if (spotError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
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
|
// Schritt 6: Kurs erstellen — bei Dauerauftrag Template + Instanzen, sonst einzelner Kurs
|
||||||
const commonFields = {
|
const commonFields = {
|
||||||
name: validatedData.name || "",
|
name: validatedData.name || "",
|
||||||
|
|
@ -504,7 +506,7 @@ export async function updateCourse(
|
||||||
|
|
||||||
const validatedData = validation.data;
|
const validatedData = validation.data;
|
||||||
|
|
||||||
const spotError = await validateSpotBooking(validatedData);
|
const spotError = await validateSpotBooking(validatedData, managerId);
|
||||||
if (spotError) {
|
if (spotError) {
|
||||||
return {
|
return {
|
||||||
success: false,
|
success: false,
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,8 @@ import {
|
||||||
* Ablauf:
|
* Ablauf:
|
||||||
* 1. Checkout-Start: holdSpot() hält den gewählten Platz für die Dauer der
|
* 1. Checkout-Start: holdSpot() hält den gewählten Platz für die Dauer der
|
||||||
* Stripe-Session frei (CourseSpotHold), damit kein zweiter Kunde mit
|
* 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.
|
* 2. Webhook nach der Zahlung: assignSpot() schreibt den Platz an die Buchung.
|
||||||
* Verbindlich ist nur, was bezahlt ist - war der Wunschplatz inzwischen
|
* Verbindlich ist nur, was bezahlt ist - war der Wunschplatz inzwischen
|
||||||
* doch vergeben (Hold abgelaufen, SEPA-Zahlung kam Tage später), bekommt
|
* 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
|
* Plätze, die gerade jemand im Checkout hält. exceptHolderKey blendet die
|
||||||
* eigene Reservierung aus - wer den Checkout abbricht und zurückkommt, soll
|
* eigenen offenen Checkouts aus - wer den Checkout abbricht und zurückkommt,
|
||||||
* seinen Platz nicht als belegt sehen.
|
* 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(
|
export async function getHeldSpotIds(
|
||||||
db: PrismaOrTx,
|
db: PrismaOrTx,
|
||||||
|
|
@ -82,7 +84,9 @@ export async function getHeldSpotIds(
|
||||||
where: {
|
where: {
|
||||||
courseId,
|
courseId,
|
||||||
expiresAt: { gt: new Date() },
|
expiresAt: { gt: new Date() },
|
||||||
...(opts.exceptHolderKey ? { holderKey: { not: opts.exceptHolderKey } } : {}),
|
...(opts.exceptHolderKey
|
||||||
|
? { NOT: { holderKey: opts.exceptHolderKey, status: "checkout" } }
|
||||||
|
: {}),
|
||||||
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
|
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
|
||||||
},
|
},
|
||||||
select: { spotId: true },
|
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
|
* Hält einen Platz für den Checkout frei. Die Eindeutigkeit erzwingt
|
||||||
* @@unique([courseId, spotId]): fordern zwei Kunden gleichzeitig denselben
|
* @@unique([courseId, spotId]): fordern zwei Kunden gleichzeitig denselben
|
||||||
* Platz an, gewinnt der erste Insert, der zweite bekommt P2002.
|
* 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(
|
export async function holdSpot(
|
||||||
db: typeof prismaClient,
|
db: typeof prismaClient,
|
||||||
|
|
@ -103,11 +112,9 @@ export async function holdSpot(
|
||||||
): Promise<HoldSpotResult> {
|
): Promise<HoldSpotResult> {
|
||||||
const { courseId, spotId, holderKey, expiresAt } = params;
|
const { courseId, spotId, holderKey, expiresAt } = params;
|
||||||
|
|
||||||
// Abgelaufene Holds wegräumen (sonst blockiert ihr Unique-Key den Platz)
|
// 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.
|
|
||||||
await db.courseSpotHold.deleteMany({
|
await db.courseSpotHold.deleteMany({
|
||||||
where: { courseId, OR: [{ expiresAt: { lte: new Date() } }, { holderKey }] },
|
where: { courseId, expiresAt: { lte: new Date() } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const booked = await db.courseBooking.findFirst({
|
const booked = await db.courseBooking.findFirst({
|
||||||
|
|
@ -129,22 +136,57 @@ export async function holdSpot(
|
||||||
return { held: true, holdId: hold.id };
|
return { held: true, holdId: hold.id };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && "code" in error && (error as { code: string }).code === "P2002") {
|
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 {
|
return {
|
||||||
held: false,
|
held: false,
|
||||||
reason:
|
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;
|
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> {
|
export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise<void> {
|
||||||
await db.courseSpotHold.deleteMany({ where: { id: holdId } });
|
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> {
|
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 };
|
export type AssignedSpot = { spotId: string; spotLabel: string; changed: boolean };
|
||||||
|
|
|
||||||
|
|
@ -18,14 +18,24 @@ vi.mock("@/src/lib/prisma", () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/src/lib/stripe", () => ({
|
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),
|
getPlatformFeeCents: vi.fn().mockReturnValue(50),
|
||||||
eurosToCents: vi.fn().mockReturnValue(2900),
|
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.
|
// Kurse ohne Platzwahl liefern null - so bleiben alle übrigen Tests unberührt.
|
||||||
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
|
vi.mock("@/src/modules/courses/lib/course-spots", () => ({
|
||||||
getCourseSpots: vi.fn().mockResolvedValue(null),
|
getCourseSpots: vi.fn().mockResolvedValue(null),
|
||||||
|
getOpenCheckoutHolds: vi.fn().mockResolvedValue([]),
|
||||||
|
attachSessionToHold: vi.fn().mockResolvedValue(undefined),
|
||||||
holdSpot: vi.fn(),
|
holdSpot: vi.fn(),
|
||||||
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
|
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
|
||||||
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
|
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 { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
|
||||||
import { prisma } from "@/src/lib/prisma";
|
import { prisma } from "@/src/lib/prisma";
|
||||||
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
|
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;
|
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(getCourseSpots).mockResolvedValue([reformer("spot-1", "1"), reformer("spot-2", "2")]);
|
||||||
vi.mocked(holdSpot).mockReset();
|
vi.mocked(holdSpot).mockReset();
|
||||||
vi.mocked(releaseSpotHold).mockClear();
|
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(() => {
|
afterEach(() => {
|
||||||
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
vi.mocked(getCourseSpots).mockResolvedValue(null);
|
||||||
|
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("verlangt einen Platz, bevor es zu Stripe geht", async () => {
|
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(args.metadata.spotId).toBe("");
|
||||||
expect(holdSpot).not.toHaveBeenCalled();
|
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();
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||||
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
|
const call = vi.mocked(prisma.courseSpotHold.updateMany).mock.calls[0][0] as {
|
||||||
where: { id: string };
|
where: { id: string };
|
||||||
data: { expiresAt: Date };
|
data: { expiresAt: Date; status: string };
|
||||||
};
|
};
|
||||||
expect(call.where).toEqual({ id: "hold-1" });
|
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;
|
const days = (call.data.expiresAt.getTime() - Date.now()) / 86_400_000;
|
||||||
expect(days).toBeGreaterThan(13);
|
expect(days).toBeGreaterThan(13);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import {
|
import {
|
||||||
assignSpot,
|
assignSpot,
|
||||||
|
extendSpotHold,
|
||||||
getCourseSpots,
|
getCourseSpots,
|
||||||
|
getHeldSpotIds,
|
||||||
holdSpot,
|
holdSpot,
|
||||||
spotHolderKey,
|
spotHolderKey,
|
||||||
} from "@/src/modules/courses/lib/course-spots";
|
} from "@/src/modules/courses/lib/course-spots";
|
||||||
|
|
@ -10,7 +12,13 @@ import type { FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
|
||||||
const db = {
|
const db = {
|
||||||
room: { findUnique: vi.fn() },
|
room: { findUnique: vi.fn() },
|
||||||
courseBooking: { findMany: vi.fn(), findFirst: 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 => ({
|
const spot = (id: string, label: string): FloorPlanItem => ({
|
||||||
|
|
@ -122,13 +130,12 @@ describe("holdSpot", () => {
|
||||||
expiresAt: new Date(Date.now() + 30 * 60_000),
|
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);
|
const result = await holdSpot(db as never, params);
|
||||||
|
|
||||||
expect(result).toEqual({ held: true, holdId: "hold-1" });
|
expect(result).toEqual({ held: true, holdId: "hold-1" });
|
||||||
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
|
const where = db.courseSpotHold.deleteMany.mock.calls[0][0].where;
|
||||||
expect(where.courseId).toBe("c1");
|
expect(where).toEqual({ courseId: "c1", expiresAt: { lte: expect.any(Date) } });
|
||||||
expect(where.OR).toContainEqual({ holderKey: "user:u1" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hält keinen Platz, der schon bezahlt ist", async () => {
|
it("hält keinen Platz, der schon bezahlt ist", async () => {
|
||||||
|
|
@ -142,8 +149,40 @@ describe("holdSpot", () => {
|
||||||
db.courseSpotHold.create.mockRejectedValue(
|
db.courseSpotHold.create.mockRejectedValue(
|
||||||
Object.assign(new Error("Unique constraint failed"), { code: "P2002" })
|
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);
|
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