Merge pull request #172 from Since-3/feature/multi-booking

added multi-booking
This commit is contained in:
Rajpreet0 2026-10-02 01:34:50 +02:00 • committed by GitHub
commit 86330af16e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 1718 additions and 579 deletions

View file

@ -0,0 +1,24 @@
-- Mehrfachbuchung: eine Buchung kann mehrere Plätze umfassen.
-- AlterTable
ALTER TABLE "CourseBooking" ADD COLUMN "refundedFeeAmount" INTEGER,
ADD COLUMN "seats" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "seatsPurchased" INTEGER NOT NULL DEFAULT 1,
ADD COLUMN "spotIds" TEXT[] DEFAULT ARRAY[]::TEXT[],
ADD COLUMN "spotLabels" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- Bestehende Platzwahl-Buchungen übernehmen (ein Platz pro Buchung)
UPDATE "CourseBooking"
SET "spotIds" = ARRAY["spotId"],
"spotLabels" = ARRAY[COALESCE("spotLabel", '')]
WHERE "spotId" IS NOT NULL;
-- DropIndex
DROP INDEX "CourseBooking_spotId_idx";
-- AlterTable
ALTER TABLE "CourseBooking" DROP COLUMN "spotId",
DROP COLUMN "spotLabel";
-- CreateIndex
CREATE INDEX "CourseBooking_spotIds_idx" ON "CourseBooking" USING GIN ("spotIds");

View file

@ -228,14 +228,21 @@ model CourseBooking {
platformFeeOnTop Boolean @default(false) // true = Gebühr wurde dem Kunden ZUSÄTZLICH berechnet, amountPaid enthält sie. false = Altbuchung, bei der die prozentuale Fee aus dem Kurspreis einbehalten wurde.
invoiceNumber String? @unique // Rechnungsnummer, wird lazy beim ersten PDF-Download vergeben
invoiceIssuedAt DateTime? // Zeitpunkt der Rechnungsnummer-Vergabe, damit das Rechnungsdatum bei jedem Download gleich bleibt
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents
refundedAmount Int? // tatsächlich erstatteter Betrag in Cents, kumuliert über alle Teilstornos. Bei "paid" > 0 = einzelne Plätze wurden storniert.
refundedFeeAmount Int? // Anteil von refundedAmount, der Servicegebühr war (nur bei Kursabsage). Kursanteil der Erstattung = refundedAmount - refundedFeeAmount.
refundedAt DateTime? // Zeitpunkt der erfolgreichen Stripe-Rückerstattung
cancelledBy String? // "user" | "manager" | "system" (system = automatischer Überbuchungs-Refund)
cancellationReason String?
discountCodeId String? // Gesetzt, sobald bei der Buchung ein gültiger Rabattcode eingelöst wurde
discountAmount Int? // Tatsächlich gewährter Rabatt in Cents (Snapshot, unabhängig von späteren Code-Änderungen)
spotId String? // Gerät aus dem Raum-Grundriss (Room.floorPlan.items[].id), nur bei Kursen mit Platzwahl. Belegt ist ein Platz, solange die Buchung "paid" ist.
spotLabel String? // Anzeigename des Platzes als Momentaufnahme, z.B. "Reformer 3" - bleibt lesbar, auch wenn der Grundriss sich später ändert
// Mehrfachbuchung: eine Buchung = eine Zahlung, kann aber mehrere Plätze umfassen
// (eingeloggte Kunden buchen für Begleitpersonen mit). Belegte Plätze eines Kurses
// = Summe von seats über alle "paid"-Buchungen (siehe seat-counts.ts), nicht die
// Anzahl der Buchungen.
seats Int @default(1) // aktuell gehaltene Plätze - sinkt, wenn einzelne Plätze storniert werden
seatsPurchased Int @default(1) // ursprünglich bezahlte Plätze - Bezugsgröße für den Betrag pro Platz
spotIds String[] @default([]) // Geräte aus dem Raum-Grundriss (Room.floorPlan.items[].id), nur bei Kursen mit Platzwahl, ein Eintrag pro Platz. Belegt, solange die Buchung "paid" ist.
spotLabels String[] @default([]) // Anzeigenamen als Momentaufnahme, z.B. "Reformer 3", parallel zu spotIds - bleiben lesbar, auch wenn der Grundriss sich ändert
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
guestLead GuestLead? @relation(fields: [guestLeadId], references: [id], onDelete: SetNull)
@ -248,7 +255,7 @@ model CourseBooking {
@@index([userId])
@@index([guestLeadId])
@@index([discountCodeId])
@@index([spotId])
@@index([spotIds], type: Gin)
}
// Hält einen Platz frei, während der Kunde im Stripe-Checkout bezahlt - analog

View file

@ -12,15 +12,16 @@ import {
calculateDiscountedPriceCents,
} from "@/src/modules/settings/lib/discount-validation";
import {
attachSessionToHold,
attachSessionToHolds,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHold,
releaseSpotHolds,
spotHolderKey,
SPOT_HOLD_MINUTES,
} from "@/src/modules/courses/lib/course-spots";
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
import { spotDisplayName, type FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
/**
* Erstellt die Checkout-Session im Branding des Studios. Lehnt Stripe das
@ -63,21 +64,30 @@ async function createBrandedCheckoutSession(
*/
async function endOpenCheckouts(courseId: string, holderKey: string, stripeAccountId: string) {
const options = { stripeAccount: stripeAccountId };
// Eine Mehrfachbuchung hält mehrere Plätze mit derselben Session.
const holdsBySession = new Map<string | null, string[]>();
for (const hold of await getOpenCheckoutHolds(prisma, courseId, holderKey)) {
const ids = holdsBySession.get(hold.stripeSessionId) ?? [];
ids.push(hold.id);
holdsBySession.set(hold.stripeSessionId, ids);
}
for (const [sessionId, holdIds] of holdsBySession) {
// Ohne Session-ID wurde die Session nie fertig angelegt - nichts zu beenden.
if (!hold.stripeSessionId) {
await releaseSpotHold(prisma, hold.id);
if (!sessionId) {
await releaseSpotHolds(prisma, holdIds);
continue;
}
try {
const old = await stripe.checkout.sessions.retrieve(hold.stripeSessionId, {}, options);
const old = await stripe.checkout.sessions.retrieve(sessionId, {}, options);
if (old.status === "complete") continue;
if (old.status === "open") await stripe.checkout.sessions.expire(old.id, {}, options);
await releaseSpotHold(prisma, hold.id);
await releaseSpotHolds(prisma, holdIds);
} catch (error) {
console.warn(
`Alte Checkout-Session ${hold.stripeSessionId} konnte nicht beendet werden, ` +
`Platz-Hold ${hold.id} bleibt bis zum Ablauf bestehen:`,
`Alte Checkout-Session ${sessionId} konnte nicht beendet werden, ` +
`${holdIds.length} Platz-Hold(s) bleiben bis zum Ablauf bestehen:`,
error
);
}
@ -123,8 +133,10 @@ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
*
* Auth-Flow → Body: { courseId: string }
* Guest-Flow → Body: { courseId: string; guestEmail: string; guestName?: string }
* Kurse mit Platzwahl brauchen zusätzlich { spotId } - der Platz wird für die
* Dauer der Session freigehalten (siehe course-spots.ts).
* Eingeloggte Kunden können mit { seats } mehrere Plätze auf einmal buchen
* (Begleitpersonen) - eine Zahlung, eine Buchung. Kurse mit Platzwahl brauchen
* zusätzlich { spotIds } mit genau so vielen Plätzen; sie werden für die Dauer
* der Session freigehalten (siehe course-spots.ts).
*/
export async function POST(request: NextRequest) {
try {
@ -148,17 +160,40 @@ export async function POST(request: NextRequest) {
guestEmail?: string;
guestName?: string;
discountCode?: string;
seats?: number;
spotIds?: string[];
/** Alter Client vor der Mehrfachbuchung: ein einzelner Platz. */
spotId?: string;
};
const { courseId, guestEmail, guestName, discountCode, spotId } = body;
const { courseId, guestEmail, guestName, discountCode } = body;
const seats = body.seats ?? 1;
const requestedSpotIds = [
...new Set(Array.isArray(body.spotIds) ? body.spotIds : body.spotId ? [body.spotId] : []),
];
if (!courseId) {
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
}
if (!Number.isInteger(seats) || seats < 1 || seats > MAX_SEATS_PER_BOOKING) {
return NextResponse.json(
{ error: `Bitte zwischen 1 und ${MAX_SEATS_PER_BOOKING} Plätzen wählen.` },
{ status: 400 }
);
}
// Determine flow: authenticated user or guest
const isGuest = !userData;
// Mehrfachbuchung nur mit Konto: Gäste haben keine "Meine Kurse"-Ansicht,
// in der sie einzelne Plätze wieder stornieren könnten.
if (isGuest && seats > 1) {
return NextResponse.json(
{ error: "Mehrere Plätze können nur angemeldete Kunden buchen." },
{ status: 400 }
);
}
if (!isGuest) {
// Nur normale User (role: "user") können Kurse buchen.
// Manager/Employee haben keine Einträge in der User-Tabelle, daher würde
@ -200,8 +235,6 @@ export async function POST(request: NextRequest) {
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
// Nur bezahlte Buchungen belegen einen Platz (failed/refunded zählen nicht).
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: {
select: {
id: true,
@ -224,9 +257,21 @@ export async function POST(request: NextRequest) {
// Grober Vor-Filter: spart unnötige Stripe-Sessions wenn offensichtlich voll.
// Die verbindliche Kapazitätsprüfung passiert atomar im Webhook (siehe #101).
if (course._count.bookings >= course.maxParticipants) {
const freeSeats = course.maxParticipants - (await paidSeatCount(prisma, course.id));
if (freeSeats <= 0) {
return NextResponse.json({ error: "Dieser Kurs ist bereits ausgebucht" }, { status: 400 });
}
if (seats > freeSeats) {
return NextResponse.json(
{
error:
freeSeats === 1
? "Es ist nur noch 1 Platz frei."
: `Es sind nur noch ${freeSeats} Plätze frei.`,
},
{ status: 400 }
);
}
if (!course.business) {
return NextResponse.json(
@ -278,7 +323,10 @@ export async function POST(request: NextRequest) {
// (verschiedene User bei knappem Kontingent, oder ein User mit mehreren
// offenen Sessions) alle denselben letzten Slot rabattiert bekommen
// hätten, weil die Verbuchung vorher erst nach der Zahlung passierte.
let priceCents = originalPriceCents;
// Rabatt gilt auf die ganze Buchung: Prozent-Codes auf alle Plätze, ein
// fester Betrag einmal pro Buchung (der Code ist ja auch nur 1x einlösbar).
const undiscountedCents = originalPriceCents * seats;
let courseTotalCents = undiscountedCents;
let discountAmountCents = 0;
let appliedDiscountCodeId: string | null = null;
let reservationToken: string | null = null;
@ -303,7 +351,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: reservation.reason }, { status: 400 });
}
const result = calculateDiscountedPriceCents(originalPriceCents, reservation.discountCode);
const result = calculateDiscountedPriceCents(undiscountedCents, reservation.discountCode);
// Ein auf 0 reduzierter Preis würde eine Stripe Checkout Session ohne
// PaymentIntent erzeugen (kostenlose Bestellung). Der Webhook lehnt
// aktuell jede Session ab, deren payment_status nicht "paid" ist - eine
@ -321,7 +369,7 @@ export async function POST(request: NextRequest) {
{ status: 400 }
);
}
priceCents = result.discountedPriceCents;
courseTotalCents = result.discountedPriceCents;
discountAmountCents = result.discountAmountCents;
appliedDiscountCodeId = reservation.discountCode.id;
reservationToken = reservation.reservationToken;
@ -331,8 +379,8 @@ export async function POST(request: NextRequest) {
userId: isGuest ? null : userData!.id,
guestEmail: isGuest ? guestEmail : null,
});
let spotHoldId: string | null = null;
let spotLabel: string | null = null;
const spotHoldIds: string[] = [];
let spotLabels: string[] = [];
// Ab hier ist ggf. ein Rabattcode reserviert und gleich ein Platz gehalten.
// Jeder Fehler bis zur fertigen Session muss beides wieder freigeben, sonst
@ -348,11 +396,11 @@ export async function POST(request: NextRequest) {
);
});
}
if (spotHoldId) {
await releaseSpotHold(prisma, spotHoldId).catch((releaseError) => {
if (spotHoldIds.length > 0) {
await releaseSpotHolds(prisma, spotHoldIds).catch((releaseError) => {
console.error(
`Platz-Hold ${spotHoldId} konnte nach fehlgeschlagenem Checkout-Start nicht ` +
`freigegeben werden:`,
`Platz-Holds ${spotHoldIds.join(", ")} konnten nach fehlgeschlagenem ` +
`Checkout-Start nicht freigegeben werden:`,
releaseError
);
});
@ -365,43 +413,61 @@ export async function POST(request: NextRequest) {
// Platz blockiert.
const spots = await getCourseSpots(prisma, course);
if (spots) {
const spot = spots.find((s) => s.id === spotId);
if (!spot) {
const chosen = requestedSpotIds
.map((id) => spots.find((s) => s.id === id))
.filter((s): s is FloorPlanItem => s !== undefined);
if (chosen.length !== requestedSpotIds.length || chosen.length !== seats) {
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.",
error:
requestedSpotIds.length === 0
? "Bitte wählen Sie einen Platz aus."
: chosen.length !== requestedSpotIds.length
? "Einen der gewählten Plätze gibt es nicht mehr. Bitte wählen Sie neu."
: "Bitte wählen Sie so viele Plätze, wie Sie buchen möchten.",
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 });
for (const spot of chosen) {
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, seine Plätze 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: `${spotDisplayName(spot)}: ${hold.reason}`, spotUnavailable: true },
{ status: 409 }
);
}
spotHoldIds.push(hold.holdId);
}
spotHoldId = hold.holdId;
spotLabel = spotDisplayName(spot);
spotLabels = chosen.map(spotDisplayName);
}
// 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.
// Fixe Servicegebühr pro Platz, die der Kunde ZUSÄTZLICH zum Kurspreis
// zahlt und die als eigene Checkout-Position sichtbar ist. Der
// Gesamtbetrag der Charge ist damit courseTotalCents + platformFeeCents ×
// seats; application_fee_amount führt exakt die Gebühr an Kumasy ab.
// Rabattcodes mindern nur den Kurspreis, nie die Gebühr.
const platformFeeCents = getPlatformFeeCents();
// Stripe zeigt "3 × 29,00 €", solange sich der Betrag glatt auf die
// Plätze verteilt. Ein fester Rabatt, der nicht aufgeht, steht als eine
// Position mit der Platzanzahl im Namen.
const courseLine =
courseTotalCents % seats === 0
? { unitAmount: courseTotalCents / seats, quantity: seats }
: { unitAmount: courseTotalCents, quantity: 1 };
const customerEmail = isGuest ? guestEmail!.trim().toLowerCase() : userData!.email;
// Alles, was der Webhook später braucht, um die Buchung anzulegen. Liegt
@ -422,8 +488,11 @@ export async function POST(request: NextRequest) {
// Session, die erst nach einem Deploy bezahlt wird, darf nicht als
// "obendrauf" verbucht werden.
platformFeeModel: "on_top",
spotId: spotHoldId ? spotId! : "",
spotHoldId: spotHoldId ?? "",
seats: String(seats),
// Kommagetrennt: Stripe-Metadaten sind reine Strings (max. 500 Zeichen,
// reicht für 10 UUIDs).
spotIds: spotHoldIds.length > 0 ? requestedSpotIds.join(",") : "",
spotHoldIds: spotHoldIds.join(","),
};
// Der Kunde bucht beim Studio - der Checkout trägt deshalb dessen Farben
@ -451,12 +520,19 @@ export async function POST(request: NextRequest) {
price_data: {
currency: "eur",
product_data: {
name: course.name,
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}${spotLabel ? ` · Platz: ${spotLabel}` : ""}`,
name:
courseLine.quantity === seats
? course.name
: `${course.name} (${seats} Plätze)`,
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}${
spotLabels.length > 0
? ` · ${spotLabels.length === 1 ? "Platz" : "Plätze"}: ${spotLabels.join(", ")}`
: ""
}`,
},
unit_amount: priceCents,
unit_amount: courseLine.unitAmount,
},
quantity: 1,
quantity: courseLine.quantity,
},
// Bei STRIPE_PLATFORM_FEE_CENTS=0 entfällt die Position ganz, statt
// dem Kunden eine 0-€-Zeile im Checkout zu zeigen.
@ -471,13 +547,14 @@ export async function POST(request: NextRequest) {
},
unit_amount: platformFeeCents,
},
quantity: 1,
// Servicegebühr pro Platz
quantity: seats,
},
]
: []),
],
payment_intent_data: {
...(platformFeeCents > 0 ? { application_fee_amount: platformFeeCents } : {}),
...(platformFeeCents > 0 ? { application_fee_amount: platformFeeCents * seats } : {}),
metadata,
},
metadata,
@ -485,7 +562,7 @@ export async function POST(request: NextRequest) {
// Mit Platzwahl verfällt die Session, wenn der Platz-Hold endet -
// sonst könnte jemand nach Stunden einen längst freigegebenen Platz
// bezahlen. Ohne Platzwahl bleibt Stripes Standard (24 Stunden).
...(spotHoldId
...(spotHoldIds.length > 0
? { expires_at: Math.floor(Date.now() / 1000) + SPOT_HOLD_MINUTES * 60 }
: {}),
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
@ -502,9 +579,9 @@ export async function POST(request: NextRequest) {
// 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)
if (spotHoldIds.length > 0 && session.id) {
await attachSessionToHolds(prisma, spotHoldIds, session.id).catch((err) =>
console.error(`Session konnte nicht an die Platz-Holds gehängt werden:`, err)
);
}

View file

@ -20,13 +20,14 @@ import {
releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation";
import {
assignSpot,
extendSpotHold,
assignSpots,
extendSpotHolds,
getCourseSpots,
releaseSpotHold,
releaseSpotHolds,
DELAYED_PAYMENT_HOLD_DAYS,
type AssignedSpot,
} from "@/src/modules/courses/lib/course-spots";
import { MAX_SEATS_PER_BOOKING, paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
/**
* Sucht einen registrierten User anhand der E-Mail, case-insensitive.
@ -197,12 +198,9 @@ async function handleCheckoutCompleted(
return;
}
// Platzwahl (siehe course-spots.ts): Wunschplatz und der Hold, der ihn
// seit dem Checkout-Start freihält. Leer bei Kursen ohne Platzwahl.
const spotRequest = {
requestedSpotId: session.metadata?.spotId || null,
holdId: session.metadata?.spotHoldId || null,
};
// Anzahl Plätze, Wunschplätze und die Holds, die sie seit dem Checkout-Start
// freihalten (siehe course-spots.ts). Plätze/Holds leer ohne Platzwahl.
const seatRequest = seatRequestFromMetadata(session.metadata);
if (session.payment_status !== "paid") {
// Verzögerte Zahlungsart wie SEPA-Lastschrift: Der Kunde hat den Checkout
@ -213,13 +211,11 @@ async function handleCheckoutCompleted(
`Session ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` +
`warte auf checkout.session.async_payment_succeeded.`
);
if (spotRequest.holdId) {
await extendSpotHold(
prisma,
spotRequest.holdId,
new Date(Date.now() + DELAYED_PAYMENT_HOLD_DAYS * 24 * 60 * 60_000)
);
}
await extendSpotHolds(
prisma,
seatRequest.holdIds,
new Date(Date.now() + DELAYED_PAYMENT_HOLD_DAYS * 24 * 60 * 60_000)
);
return;
}
@ -301,7 +297,7 @@ async function handleCheckoutCompleted(
userId: registeredUser.id,
session,
payment,
spotRequest,
seatRequest,
discountCodeId,
discountAmountCents,
discountReservationToken,
@ -313,7 +309,7 @@ async function handleCheckoutCompleted(
guestName: guestName || undefined,
session,
payment,
spotRequest,
seatRequest,
});
} else {
const userId = session.metadata?.userId;
@ -352,7 +348,7 @@ async function handleCheckoutCompleted(
userId: registeredUser.id,
session,
payment,
spotRequest,
seatRequest,
discountCodeId,
discountAmountCents,
discountReservationToken,
@ -364,7 +360,7 @@ async function handleCheckoutCompleted(
guestName: session.metadata?.guestName || undefined,
session,
payment,
spotRequest,
seatRequest,
});
} else {
console.error(
@ -395,7 +391,7 @@ async function handleCheckoutCompleted(
userId,
session,
payment,
spotRequest,
seatRequest,
discountCodeId,
discountAmountCents,
discountReservationToken,
@ -430,14 +426,12 @@ async function handleCheckoutCompleted(
if (!outcome) throw new Error("runCapacityTransaction lieferte kein Ergebnis");
// Ab hier hält die Buchung den Platz (oder es gibt keine) - der Hold aus dem
// Checkout-Start hat seinen Zweck erfüllt. Ein Fehler beim Aufräumen ist
// harmlos: der Hold läuft ohnehin ab.
if (spotRequest.holdId) {
await releaseSpotHold(prisma, spotRequest.holdId).catch((err) =>
console.error(`Platz-Hold ${spotRequest.holdId} konnte nicht freigegeben werden:`, err)
);
}
// Ab hier hält die Buchung ihre Plätze (oder es gibt keine) - die Holds aus
// dem Checkout-Start haben ihren Zweck erfüllt. Ein Fehler beim Aufräumen
// ist harmlos: die Holds laufen ohnehin ab.
await releaseSpotHolds(prisma, seatRequest.holdIds).catch((err) =>
console.error(`Platz-Holds ${seatRequest.holdIds.join(", ")} nicht freigegeben:`, err)
);
const needsRefund = outcome.result === "capacity_exceeded" || outcome.result === "already_failed";
@ -507,7 +501,10 @@ async function handleCheckoutCompleted(
if (recipientEmail) {
if (outcome.result === "booked") {
await withTimeout(
sendBookingConfirmation(recipientEmail, courseId, amountPaidCents, outcome.spot ?? null),
sendBookingConfirmation(recipientEmail, courseId, amountPaidCents, {
seats: seatRequest.seats,
spots: outcome.spots ?? [],
}),
2000
);
} else if (needsRefund && refundSucceeded) {
@ -536,7 +533,7 @@ async function handleCheckoutCompleted(
session.customer_details?.email ||
"Ein Kunde";
await withTimeout(
notifyManagerOfBooking(courseId, customerLabel).catch((err) =>
notifyManagerOfBooking(courseId, customerLabel, seatRequest.seats).catch((err) =>
console.error("Buchungs-Benachrichtigung fehlgeschlagen:", err)
),
2000
@ -569,15 +566,47 @@ type CapacityOutcome = {
| "course_not_found"
| "duplicate_payment";
bookingId?: string;
/** Zugewiesener Platz bei Kursen mit Platzwahl (nur bei "booked"). */
spot?: AssignedSpot | null;
/** Zugewiesene Plätze bei Kursen mit Platzwahl (nur bei "booked"). */
spots?: AssignedSpot[];
};
type SpotRequest = { requestedSpotId: string | null; holdId: string | null };
type SeatRequest = { seats: number; requestedSpotIds: string[]; holdIds: string[] };
/** Spalten für den Platz einer Buchung - explizit null, damit ein Upsert alte Werte überschreibt. */
function spotColumns(spot: AssignedSpot | null) {
return { spotId: spot?.spotId ?? null, spotLabel: spot?.spotLabel ?? null };
const NO_SEAT_REQUEST: SeatRequest = { seats: 1, requestedSpotIds: [], holdIds: [] };
/**
* Liest Platzanzahl, Wunschplätze und Holds aus den Session-Metadaten.
* Sessions von vor der Mehrfachbuchung tragen einen einzelnen spotId/spotHoldId
* statt der kommagetrennten Listen, ganz alte gar nichts (= 1 Platz).
*/
function seatRequestFromMetadata(metadata: Stripe.Metadata | null): SeatRequest {
const list = (plural?: string, singular?: string) =>
(plural || singular || "").split(",").filter(Boolean);
const seats = Number(metadata?.seats);
return {
seats: Number.isInteger(seats) && seats >= 1 && seats <= MAX_SEATS_PER_BOOKING ? seats : 1,
requestedSpotIds: list(metadata?.spotIds, metadata?.spotId),
holdIds: list(metadata?.spotHoldIds, metadata?.spotHoldId),
};
}
/**
* Platz-Spalten einer Buchung. Beim Upsert auf eine alte, stornierte Zeile
* desselben Kunden setzt das auch die Storno-Felder zurück - sonst würden
* Erstattungen der alten Zahlung der neuen zugerechnet.
*/
function seatColumns(seats: number, spots: AssignedSpot[]) {
return {
seats,
seatsPurchased: seats,
spotIds: spots.map((s) => s.spotId),
spotLabels: spots.map((s) => s.spotLabel),
refundedAmount: null,
refundedFeeAmount: null,
refundedAt: null,
cancelledBy: null,
cancellationReason: null,
};
}
// Status, die eine bereits abschließend verarbeitete Buchung markieren - bei
@ -610,7 +639,7 @@ async function runCapacityTransactionUser(params: {
userId: string;
session: Stripe.Checkout.Session;
payment: SessionPayment;
spotRequest?: SpotRequest;
seatRequest?: SeatRequest;
discountCodeId?: string | null;
discountAmountCents?: number;
discountReservationToken?: string | null;
@ -621,7 +650,7 @@ async function runCapacityTransactionUser(params: {
userId,
session,
payment,
spotRequest = { requestedSpotId: null, holdId: null },
seatRequest = NO_SEAT_REQUEST,
discountCodeId = null,
discountAmountCents = 0,
discountReservationToken = null,
@ -692,31 +721,22 @@ async function runCapacityTransactionUser(params: {
if (!course) return { result: "course_not_found" as const };
const paidCount = await tx.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
const capacity = await checkSeatCapacity(tx, courseId, course, seatRequest);
// Bei Platzwahl ist der Kurs auch dann voll, wenn kein Platz mehr frei
// ist - etwa weil der Grundriss nach dem Checkout-Start verkleinert wurde.
const spots = await getCourseSpots(tx, course);
const spot =
spots && paidCount < course.maxParticipants
? await assignSpot(tx, { courseId, spots, ...spotRequest })
: null;
if (paidCount >= course.maxParticipants || (spots && !spot)) {
if (!capacity.fits) {
const booking = await tx.courseBooking.upsert({
where: { courseId_userId: { courseId, userId } },
update: {
paymentStatus: "failed",
...paymentColumns(session.id, payment),
...spotColumns(null),
...seatColumns(seatRequest.seats, []),
},
create: {
courseId,
userId,
paymentStatus: "failed",
...paymentColumns(session.id, payment),
...seatColumns(seatRequest.seats, []),
},
});
return { result: "capacity_exceeded" as const, bookingId: booking.id };
@ -727,7 +747,7 @@ async function runCapacityTransactionUser(params: {
update: {
paymentStatus: "paid",
...paymentColumns(session.id, payment),
...spotColumns(spot),
...seatColumns(seatRequest.seats, capacity.spots),
discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null,
},
@ -736,7 +756,7 @@ async function runCapacityTransactionUser(params: {
userId,
paymentStatus: "paid",
...paymentColumns(session.id, payment),
...spotColumns(spot),
...seatColumns(seatRequest.seats, capacity.spots),
discountCodeId,
discountAmount: discountCodeId ? discountAmountCents : null,
},
@ -758,12 +778,40 @@ async function runCapacityTransactionUser(params: {
});
}
return { result: "booked" as const, bookingId: booking.id, spot };
return { result: "booked" as const, bookingId: booking.id, spots: capacity.spots };
},
{ isolationLevel: "Serializable" }
);
}
/**
* Passen die Plätze dieser Zahlung noch in den Kurs? Belegt ist die Summe der
* Plätze aller bezahlten Buchungen. Bei Platzwahl ist der Kurs auch dann voll,
* wenn nicht mehr genug Geräte frei sind - etwa weil der Grundriss nach dem
* Checkout-Start verkleinert wurde.
*/
async function checkSeatCapacity(
tx: Parameters<Parameters<typeof prisma.$transaction>[0]>[0],
courseId: string,
course: { maxParticipants: number; spotBooking: boolean; room: string },
request: SeatRequest
): Promise<{ fits: false } | { fits: true; spots: AssignedSpot[] }> {
const bookedSeats = await paidSeatCount(tx, courseId);
if (bookedSeats + request.seats > course.maxParticipants) return { fits: false };
const spots = await getCourseSpots(tx, course);
if (!spots) return { fits: true, spots: [] };
const assigned = await assignSpots(tx, {
courseId,
spots,
count: request.seats,
requestedSpotIds: request.requestedSpotIds,
holdIds: request.holdIds,
});
return assigned ? { fits: true, spots: assigned } : { fits: false };
}
/**
* Atomare Kapazitätsprüfung für Gäste.
* Erstellt einen GuestLead und eine CourseBooking ohne userId.
@ -775,7 +823,7 @@ async function runCapacityTransactionGuest(params: {
guestName?: string;
session: Stripe.Checkout.Session;
payment: SessionPayment;
spotRequest?: SpotRequest;
seatRequest?: SeatRequest;
}): Promise<CapacityOutcome> {
const {
courseId,
@ -783,7 +831,7 @@ async function runCapacityTransactionGuest(params: {
guestName,
session,
payment,
spotRequest = { requestedSpotId: null, holdId: null },
seatRequest = NO_SEAT_REQUEST,
} = params;
return prisma.$transaction(
@ -816,20 +864,12 @@ async function runCapacityTransactionGuest(params: {
if (!course) return { result: "course_not_found" as const };
const paidCount = await tx.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
const spots = await getCourseSpots(tx, course);
const spot =
spots && paidCount < course.maxParticipants
? await assignSpot(tx, { courseId, spots, ...spotRequest })
: null;
const capacity = await checkSeatCapacity(tx, courseId, course, seatRequest);
// GuestLead erst nach Kapazitätsprüfung anlegen — verhindert Phantom-Einträge
// bei Überbuchung. upsert by email: ein Gast bekommt genau einen Lead-Eintrag,
// auch bei mehreren Buchungen (@@unique auf email im Schema).
if (paidCount >= course.maxParticipants || (spots && !spot)) {
if (!capacity.fits) {
const guestLead = await tx.guestLead.upsert({
where: { email: guestEmail },
update: {},
@ -857,10 +897,10 @@ async function runCapacityTransactionGuest(params: {
guestLeadId: guestLead.id,
paymentStatus: "paid",
...paymentColumns(session.id, payment),
...spotColumns(spot),
...seatColumns(seatRequest.seats, capacity.spots),
},
});
return { result: "booked" as const, bookingId: booking.id, spot };
return { result: "booked" as const, bookingId: booking.id, spots: capacity.spots };
},
{ isolationLevel: "Serializable" }
);
@ -880,7 +920,7 @@ async function sendBookingConfirmation(
recipientEmail: string,
courseId: string,
amountPaidCents: number,
spot: AssignedSpot | null
booked: { seats: number; spots: AssignedSpot[] }
) {
try {
const course = await prisma.course.findUnique({
@ -905,8 +945,9 @@ async function sendBookingConfirmation(
businessName: course.business.name,
businessAddress: course.business.address,
amountPaidCents,
spotLabel: spot?.spotLabel ?? null,
spotChanged: spot?.changed ?? false,
seats: booked.seats,
spotLabels: booked.spots.map((s) => s.spotLabel),
spotChanged: booked.spots.some((s) => s.changed),
myCoursesUrl: `${baseUrl}/courses/myCourses`,
coverImageUrl: course.coverImage,
companyName: process.env.COMPANY_NAME,
@ -945,7 +986,11 @@ async function removeFromWaitlistIfPresent(courseId: string, bookingId: string):
* falls diese Buchung den Kurs gefüllt hat. Wirft nie (notifyBusinessManager
* kapselt alle Fehler), der Aufrufer hängt zusätzlich ein .catch daran.
*/
async function notifyManagerOfBooking(courseId: string, customerLabel: string): Promise<void> {
async function notifyManagerOfBooking(
courseId: string,
customerLabel: string,
seats: number
): Promise<void> {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: { name: true, date: true, businessId: true, maxParticipants: true },
@ -962,13 +1007,14 @@ async function notifyManagerOfBooking(courseId: string, customerLabel: string):
await notifyBusinessManager(course.businessId, {
type: "booking.created",
title: `Neue Buchung: ${course.name}`,
body: `${customerLabel} hat sich für den ${courseDate} angemeldet.`,
body:
seats > 1
? `${customerLabel} hat ${seats} Plätze für den ${courseDate} gebucht.`
: `${customerLabel} hat sich für den ${courseDate} angemeldet.`,
linkUrl,
});
const paidCount = await prisma.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
const paidCount = await paidSeatCount(prisma, courseId);
if (paidCount >= course.maxParticipants) {
await notifyBusinessManager(course.businessId, {
type: "course.full",
@ -1045,14 +1091,12 @@ async function handleCheckoutUnpaid(session: Stripe.Checkout.Session) {
const courseId = session.metadata?.courseId;
const isGuest = session.metadata?.isGuest === "true";
const discountReservationToken = session.metadata?.discountReservationToken || null;
const spotHoldId = session.metadata?.spotHoldId || null;
const { holdIds } = seatRequestFromMetadata(session.metadata);
// Nie bezahlt - der Platz wird wieder frei.
if (spotHoldId) {
await releaseSpotHold(prisma, spotHoldId).catch((error) =>
console.error(`Platz-Hold ${spotHoldId} konnte nicht freigegeben werden:`, error)
);
}
// Nie bezahlt - die Plätze werden wieder frei.
await releaseSpotHolds(prisma, holdIds).catch((error) =>
console.error(`Platz-Holds ${holdIds.join(", ")} nicht freigegeben:`, error)
);
// Gibt einen beim Checkout-Start reservierten Rabattcode-Slot wieder frei
// (siehe reserveDiscountCode), da diese Session nie bezahlt wurde. Läuft

View file

@ -87,11 +87,18 @@ export default async function CourseBookingSuccessPage({ params, searchParams }:
<span>{course.business.address}</span>
</div>
)}
{result.success && result.spotLabel && (
{result.success && (result.seats > 1 || result.spotLabels.length > 0) && (
<div className="flex items-center gap-2.5">
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
<span>
Ihr Platz: <b>{result.spotLabel}</b>
{result.seats > 1 && `${result.seats} Plätze`}
{result.seats > 1 && result.spotLabels.length > 0 && ": "}
{result.spotLabels.length > 0 && (
<>
{result.seats === 1 && "Ihr Platz: "}
<b>{result.spotLabels.join(", ")}</b>
</>
)}
</span>
</div>
)}

View file

@ -1,57 +0,0 @@
"use client";
// TEMPORÄR - nur für die visuelle Prüfung, wird wieder gelöscht.
import { useState } from "react";
import FloorPlanEditor from "@/src/modules/rooms/ui/components/floor-plan/FloorPlanEditor";
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
import type { FloorPlan, FloorPlanItem } from "@/src/modules/rooms/lib/floor-plan";
const r = (n: number, x: number, y: number): FloorPlanItem => ({
id: `r${n}`,
type: "reformer",
x,
y,
rotation: 0,
label: String(n),
});
const initial: FloorPlan = {
width: 8,
depth: 6,
items: [
{ id: "mirror", type: "mirror", x: 4, y: 0.06, rotation: 0, label: "" },
{ id: "trainer", type: "trainer", x: 4, y: 0.7, rotation: 0, label: "" },
{ id: "door", type: "door", x: 7.2, y: 5.9, rotation: 180, label: "" },
r(1, 1.0, 2.5),
r(2, 2.0, 2.5),
r(3, 3.0, 2.5),
r(4, 4.0, 2.5),
r(5, 5.0, 2.5),
r(6, 6.0, 2.5),
{ id: "c1", type: "chair", x: 7.4, y: 1.5, rotation: 90, label: "1" },
{ id: "m1", type: "mat", x: 1.5, y: 5.0, rotation: 90, label: "1" },
{ id: "b1", type: "barrel", x: 4.5, y: 5.0, rotation: 90, label: "1" },
{ id: "t1", type: "tower", x: 7.4, y: 3.8, rotation: 0, label: "1" },
],
};
export default function Page() {
const [plan, setPlan] = useState(initial);
const [sel, setSel] = useState<string | null>("r3");
return (
<div className="p-6 flex flex-col gap-10 max-w-5xl mx-auto">
<section className="border border-white/60 bg-white/60 rounded-2xl p-5">
<h2 className="font-semibold mb-4">Editor</h2>
<FloorPlanEditor value={plan} onChange={setPlan} />
</section>
<section className="border rounded-2xl p-5 bg-white max-w-3xl">
<h2 className="font-semibold mb-4">Platzwahl</h2>
<SpotPicker
floorPlan={plan}
takenSpotIds={["r1", "r5", "c1"]}
selectedSpotId={sel}
onSelect={setSel}
accentColor="#0f766e"
/>
</section>
</div>
);
}

View file

@ -61,10 +61,23 @@ export function payoutAmountCents(booking: BookingPayout): number {
return (booking.amountPaid ?? 0) - (booking.platformFee ?? 0) - (booking.stripeFee ?? 0);
}
/**
* Kursanteil einer Erstattung: refundedAmount ohne die Servicegebühr, die bei
* einer Kursabsage mit zurückgeht. Für Rechnung und Zahlungen-Tab, die nur
* den Kurspreis ausweisen.
*/
export function refundedCourseCents(booking: {
refundedAmount?: number | null;
refundedFeeAmount?: number | null;
}): number {
return (booking.refundedAmount ?? 0) - (booking.refundedFeeAmount ?? 0);
}
/**
* Studio-Umsatz über mehrere Buchungen: Kursanteil abzüglich bereits
* erstatteter Beträge. Für "paid"-Zeilen ist refundedAmount null, die Formel
* gilt also unverändert auch dort.
* erstatteter Beträge. refundedAmount ist bei "paid"-Zeilen null oder - nach
* dem Storno einzelner Plätze einer Mehrfachbuchung - die bereits erstattete
* Summe; die Formel gilt in beiden Fällen.
*/
export function sumCourseRevenueCents(
bookings: (BookingAmounts & { refundedAmount?: number | null })[]

View file

@ -18,9 +18,11 @@ interface BookingConfirmationEmailParams {
businessName: string;
businessAddress?: string;
amountPaidCents: number;
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
spotLabel?: string | null;
/** true = der Wunschplatz war bei Zahlungseingang vergeben, spotLabel ist ein anderer. */
/** Anzahl gebuchter Plätze - mehr als 1 bei einer Buchung mit Begleitpersonen. */
seats?: number;
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
spotLabels?: string[];
/** true = mindestens ein Wunschplatz war bei Zahlungseingang vergeben und wurde ersetzt. */
spotChanged?: boolean;
myCoursesUrl: string;
coverImageUrl?: string | null;
@ -53,7 +55,8 @@ export function generateBookingConfirmationEmail({
businessName,
businessAddress,
amountPaidCents,
spotLabel,
seats = 1,
spotLabels = [],
spotChanged = false,
myCoursesUrl,
coverImageUrl,
@ -66,9 +69,13 @@ export function generateBookingConfirmationEmail({
const safeBusinessAddress = businessAddress ? escapeHtml(businessAddress) : "";
const safeTimeFrom = escapeHtml(timeFrom);
const safeTimeTo = escapeHtml(timeTo);
const safeSpotLabel = spotLabel ? escapeHtml(spotLabel) : "";
const spotList = spotLabels.join(", ");
const safeSpotList = escapeHtml(spotList);
const spotTitle = spotLabels.length === 1 ? "Ihr Platz" : "Ihre Plätze";
const spotChangedNote = spotChanged
? "Ihr Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen diesen Platz reserviert."
? spotLabels.length === 1
? "Ihr Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen diesen Platz reserviert."
: "Ein Wunschplatz war bei Zahlungseingang leider schon vergeben – wir haben Ihnen stattdessen einen anderen freien Platz reserviert."
: "";
const safeMyCoursesUrl = sanitizeUrl(myCoursesUrl);
const safeCoverImageUrl = coverImageUrl ? sanitizeUrl(coverImageUrl) : null;
@ -105,11 +112,12 @@ export function generateBookingConfirmationEmail({
{ label: "Anbieter", valueHtml: safeBusinessName },
{ label: "Termin", valueHtml: `${formattedDate}<br />${safeTimeFrom} – ${safeTimeTo} Uhr` },
...(safeBusinessAddress ? [{ label: "Ort", valueHtml: safeBusinessAddress }] : []),
...(safeSpotLabel
...(seats > 1 ? [{ label: "Plätze", valueHtml: `${seats} Plätze` }] : []),
...(safeSpotList
? [
{
label: "Ihr Platz",
valueHtml: `<strong>${safeSpotLabel}</strong>${
label: spotTitle,
valueHtml: `<strong>${safeSpotList}</strong>${
spotChangedNote
? `<br /><span style="color: #666666; font-size: 13px;">${spotChangedNote}</span>`
: ""
@ -160,7 +168,7 @@ vielen Dank für Ihre Buchung. Ihre Zahlung wurde erfolgreich verarbeitet.
Kurs: ${courseName}
Anbieter: ${businessName}
Termin: ${formattedDate}, ${timeFrom} – ${timeTo} Uhr
${businessAddress ? `Ort: ${businessAddress}\n` : ""}${spotLabel ? `Ihr Platz: ${spotLabel}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}Bezahlt: ${formattedPrice}
${businessAddress ? `Ort: ${businessAddress}\n` : ""}${seats > 1 ? `Plätze: ${seats}\n` : ""}${spotList ? `${spotTitle}: ${spotList}\n` : ""}${spotChangedNote ? `${spotChangedNote}\n` : ""}Bezahlt: ${formattedPrice}
Wir wünschen Ihnen viel Spaß im Kurs!

View file

@ -28,6 +28,8 @@ interface BookingRefundEmailParams {
refundPercent: number;
reason: BookingRefundReason;
originalAmountCents?: number; // nur relevant/angezeigt wenn refundPercent < 100
/** Nur bei Mehrfachbuchungen: wie viele Plätze storniert wurden und wie viele bleiben. */
seats?: { cancelled: number; remaining: number };
companyName?: string;
branding?: EmailBranding;
}
@ -84,6 +86,7 @@ export function generateBookingRefundEmail({
refundPercent,
reason,
originalAmountCents,
seats,
companyName = "Ihr Unternehmen",
branding = KUMASY_BRANDING,
}: BookingRefundEmailParams): { subject: string; html: string; text: string } {
@ -101,8 +104,18 @@ export function generateBookingRefundEmail({
hasRefund ? `Rückerstattung – ${courseName}` : `Stornierung bestätigt – ${courseName}`
);
const introHtml = REASON_COPY[reason].intro(safeCourseName, safeBusinessName);
const introText = REASON_COPY_TEXT[reason](courseName, businessName);
// Teilstorno einer Mehrfachbuchung: sagen, was storniert wurde und was bleibt.
const seatsLine =
seats && seats.remaining > 0
? `Storniert ${seats.cancelled === 1 ? "wurde 1 Platz" : `wurden ${seats.cancelled} Plätze`}, ${
seats.remaining === 1 ? "1 Platz bleibt" : `${seats.remaining} Plätze bleiben`
} für Sie gebucht.`
: "";
const introHtml =
REASON_COPY[reason].intro(safeCourseName, safeBusinessName) +
(seatsLine ? ` ${seatsLine}` : "");
const introText =
REASON_COPY_TEXT[reason](courseName, businessName) + (seatsLine ? ` ${seatsLine}` : "");
// "Ihre Zahlung wurde erstattet"-Satz nur, wenn tatsächlich Geld bewegt wird -
// bei refundPercent === 0 (Stornierung ohne Rückerstattung, z.B. weil die

View file

@ -11,6 +11,7 @@ import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { unstable_cache, revalidatePath, revalidateTag } from "next/cache";
import { executeBookingCancellation } from "./cancel-booking-shared";
import type { SeatSelection } from "../lib/seat-cancellation";
import { combineDateAndTime } from "@/src/lib/course-time";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
@ -21,6 +22,7 @@ import {
} from "@/src/modules/settings/lib/discount-validation";
import { bookableSpots, parseFloorPlan, type FloorPlan } from "@/src/modules/rooms/lib/floor-plan";
import { getBookedSpotIds, getHeldSpotIds, spotHolderKey } from "../lib/course-spots";
import { MAX_SEATS_PER_BOOKING, paidSeatCounts } from "../lib/seat-counts";
export type UserBooking = {
bookingId: string;
@ -45,8 +47,10 @@ export type UserBooking = {
trainers: string[];
trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string;
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
spotLabel: string | null;
/** Plätze dieser Buchung - mehr als 1, wenn für Begleitpersonen mitgebucht wurde. */
seats: number;
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
spotLabels: string[];
isPast: boolean;
businessName: string;
/** Kontaktdaten des Standorts für den Detail-Dialog (Ticket: Email + Ort anzeigen). */
@ -72,7 +76,8 @@ export async function getUserBookings(): Promise<
select: {
id: true,
paymentStatus: true,
spotLabel: true,
seats: true,
spotLabels: true,
course: {
select: {
id: true,
@ -87,7 +92,6 @@ export async function getUserBookings(): Promise<
maxParticipants: true,
coverImage: true,
description: true,
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: {
select: { name: true, address: true, email: true, branding: true },
},
@ -97,6 +101,11 @@ export async function getUserBookings(): Promise<
orderBy: { createdAt: "desc" },
});
const seatCounts = await paidSeatCounts(
prisma,
bookingsRaw.map((b) => b.course.id)
);
// Collect all room and trainer IDs
const roomIds = [
...new Set(bookingsRaw.map((b) => b.course.room).filter(Boolean)),
@ -156,14 +165,15 @@ export async function getUserBookings(): Promise<
level: b.course.level,
price: b.course.price,
maxParticipants: b.course.maxParticipants,
currentParticipants: b.course._count.bookings,
currentParticipants: seatCounts.get(b.course.id) ?? 0,
coverImage: b.course.coverImage ?? null,
description: b.course.description,
trainerProfiles: b.course.trainers.map((tid) => trainerProfilesMap[tid]).filter(Boolean),
trainers: b.course.trainers,
trainersMap,
paymentStatus: b.paymentStatus,
spotLabel: b.spotLabel,
seats: b.seats,
spotLabels: b.spotLabels,
isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName,
businessAddress: b.course.business?.address ?? null,
@ -189,7 +199,8 @@ export async function getUserBookings(): Promise<
}
export async function cancelUserBooking(
bookingId: string
bookingId: string,
seatSelection?: SeatSelection
): Promise<{ success: true } | { success: false; error: string }> {
const userData = await getUserData();
if (!userData || !isUser(userData)) {
@ -209,7 +220,7 @@ export async function cancelUserBooking(
return { success: false, error: "Buchung nicht gefunden." };
}
const result = await executeBookingCancellation(bookingId, "user");
const result = await executeBookingCancellation(bookingId, "user", { seatSelection });
if (!result.success) return result;
revalidatePath("/courses/myCourses");
@ -259,7 +270,10 @@ export async function checkUserBookingStatus(courseId: string) {
return {
success: true,
isLoggedIn: true,
// Nur Kunden-Accounts dürfen buchen - und nur sie bekommen den Platz-Zähler.
canBookMultiple: isUser(userData),
isBooked: booking?.paymentStatus === "paid",
bookedSeats: booking?.paymentStatus === "paid" ? booking.seats : 0,
isOnWaitlist: waitlistEntry !== null,
};
} catch (error) {
@ -321,23 +335,25 @@ export async function getCourseSummaryForConfirmation(courseId: string, sessionI
// die Anzeige auf den regulären Kurspreis zurück.
let paidPrice = course.price;
let discountAmount: number | null = null;
let spotLabel: string | null = null;
let seats = 1;
let spotLabels: string[] = [];
if (sessionId) {
// findFirst statt findUnique mit stripeSessionId allein: eine URL, die
// courseId von Kurs B mit der session_id von Kurs A kombiniert, würde
// sonst Kurs B mit den Zahlungsdaten von Kurs A anzeigen.
const booking = await prisma.courseBooking.findFirst({
where: { stripeSessionId: sessionId, courseId },
select: { amountPaid: true, discountAmount: true, spotLabel: true },
select: { amountPaid: true, discountAmount: true, seats: true, spotLabels: true },
});
if (booking?.amountPaid !== null && booking?.amountPaid !== undefined) {
paidPrice = booking.amountPaid / 100;
discountAmount = booking.discountAmount !== null ? booking.discountAmount / 100 : null;
}
spotLabel = booking?.spotLabel ?? null;
seats = booking?.seats ?? 1;
spotLabels = booking?.spotLabels ?? [];
}
return { success: true as const, course, paidPrice, discountAmount, spotLabel };
return { success: true as const, course, paidPrice, discountAmount, seats, spotLabels };
} catch (error) {
console.error("Error fetching course summary:", error);
return { success: false as const, error: "Fehler beim Laden des Kurses" };
@ -348,8 +364,8 @@ export type CourseSpotMap = {
floorPlan: FloorPlan;
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout gehalten. */
takenSpotIds: string[];
/** Platz der eigenen bezahlten Buchung des eingeloggten Kunden. */
mySpotId: string | null;
/** Plätze der eigenen bezahlten Buchung des eingeloggten Kunden. */
mySpotIds: string[];
};
/**
@ -386,14 +402,14 @@ export async function getCourseSpotMap(courseId: string): Promise<CourseSpotMap
userId
? prisma.courseBooking.findFirst({
where: { courseId, userId, paymentStatus: "paid" },
select: { spotId: true },
select: { spotIds: true },
})
: null,
]);
const mySpotId = myBooking?.spotId ?? null;
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => id !== mySpotId);
return { floorPlan, takenSpotIds, mySpotId };
const mySpotIds = myBooking?.spotIds ?? [];
const takenSpotIds = [...new Set([...booked, ...held])].filter((id) => !mySpotIds.includes(id));
return { floorPlan, takenSpotIds, mySpotIds };
} catch (error) {
console.error("Error fetching course spot map:", error);
return null;
@ -459,11 +475,12 @@ async function _fetchCoursesForBusiness(businessId: string, options: { from: Dat
status: "published",
date: { gte: options.from, lte: options.to },
},
include: {
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
},
orderBy: { date: "asc" },
});
const seatCounts = await paidSeatCounts(
prisma,
courses.map((c) => c.id)
);
const trainerIds = [...new Set(courses.flatMap((c) => c.trainers))];
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))] as string[];
@ -490,7 +507,7 @@ async function _fetchCoursesForBusiness(businessId: string, options: { from: Dat
success: true,
courses: courses.map((c) => ({
...c,
currentParticipants: c._count.bookings,
currentParticipants: seatCounts.get(c.id) ?? 0,
roomName: c.room ? (roomMap[c.room] ?? null) : null,
trainerProfiles: c.trainers.map((id) => trainerMap[id]).filter(Boolean) as {
id: string;
@ -510,9 +527,12 @@ async function _fetchCoursesForBusiness(businessId: string, options: { from: Dat
// Preis VOR dem Stripe-Checkout sieht. Legt keine Redemption an und erhöht
// keinen Counter (siehe redeemDiscountCode) - die Checkout-Route führt exakt
// dieselbe Validierung erneut aus, bevor die Session erstellt wird.
// Rechnet wie der Checkout auf den Gesamtpreis aller Plätze: discountedPrice
// ist der Kurspreis für `seats` Plätze nach Rabatt.
export async function previewDiscountCode(
courseId: string,
code: string
code: string,
seats = 1
): Promise<
{ valid: true; discountedPrice: number; discountAmount: number } | { valid: false; error: string }
> {
@ -537,9 +557,11 @@ export async function previewDiscountCode(
return { valid: false, error: validation.reason };
}
const originalPriceCents = eurosToCents(course.price);
const seatCount = Number.isInteger(seats)
? Math.min(Math.max(seats, 1), MAX_SEATS_PER_BOOKING)
: 1;
const { discountedPriceCents, discountAmountCents } = calculateDiscountedPriceCents(
originalPriceCents,
eurosToCents(course.price) * seatCount,
validation.discountCode
);

View file

@ -4,12 +4,23 @@ import { sendMail } from "@/src/lib/mail/resend";
import { generateBookingRefundEmail } from "@/src/lib/mail/templates/booking-refund";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
import { courseAmountCents } from "@/src/lib/booking-amounts";
import {
resolveSeatCancellation,
seatShareCents,
type SeatSelection,
} from "../lib/seat-cancellation";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyCourseWaitlist } from "./notify-waitlist";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
export type CancelBookingResult =
| { success: true; refundedAmountCents: number; refundPercent: number }
| {
success: true;
refundedAmountCents: number;
refundPercent: number;
/** Plätze, die nach dem Storno noch gebucht sind (0 = Buchung ganz storniert). */
remainingSeats: number;
}
| { success: false; error: string };
/**
@ -52,7 +63,8 @@ async function sendCancellationEmail(
originalAmountCents: number,
refundPercent: number,
cancelledBy: "user" | "manager",
reasonOverride?: "course_cancelled"
reasonOverride?: "course_cancelled",
seats?: { cancelled: number; remaining: number }
): Promise<void> {
if (!booking.course.business) return;
@ -78,6 +90,7 @@ async function sendCancellationEmail(
reason:
reasonOverride ?? (cancelledBy === "manager" ? "cancelled_by_manager" : "cancelled_by_user"),
originalAmountCents,
seats,
companyName: process.env.COMPANY_NAME,
branding: toEmailBranding(booking.course.business),
});
@ -92,6 +105,11 @@ async function sendCancellationEmail(
* sich nur in Auth/Berechtigungsprüfung (die der jeweilige Aufrufer VOR diesem
* Aufruf erledigt) und im cancelledBy-Wert.
*
* Mehrfachbuchungen: `seatSelection` storniert nur einen Teil der Plätze. Die
* Buchung bleibt dann "paid" mit weniger seats, die Erstattung für die
* stornierten Plätze wird in refundedAmount aufsummiert. Erst wenn der letzte
* Platz storniert wird, wechselt der Status wie bisher.
*
* `forceFullRefund` wird von der Kurs-Stornierung (executeCourseCancellation)
* gesetzt: wenn der GANZE Kurs ausfällt, trifft den Kunden keine Schuld, daher
* gilt weder die zeitbasierte Stornierungs-Policy noch die
@ -101,7 +119,11 @@ async function sendCancellationEmail(
export async function executeBookingCancellation(
bookingId: string,
cancelledBy: "user" | "manager",
options?: { forceFullRefund?: boolean; cancellationReason?: string }
options?: {
forceFullRefund?: boolean;
cancellationReason?: string;
seatSelection?: SeatSelection;
}
): Promise<CancelBookingResult> {
const booking = await prisma.courseBooking.findUnique({
where: { id: bookingId },
@ -114,6 +136,13 @@ export async function executeBookingCancellation(
stripeAccountId: true,
userId: true,
guestLeadId: true,
seats: true,
seatsPurchased: true,
spotIds: true,
spotLabels: true,
refundedAmount: true,
refundedFeeAmount: true,
refundedAt: true,
course: {
select: {
id: true,
@ -137,11 +166,19 @@ export async function executeBookingCancellation(
const forceFullRefund = options?.forceFullRefund ?? false;
// Stornoquote und Erstattungsbetrag beziehen sich auf den KURSANTEIL. Die
// Servicegebühr hat der Kunde zusätzlich gezahlt; sie ist Leistung der
// Plattform und wird bei einer regulären Stornierung einbehalten. Nur wenn
// der Kurs komplett ausfällt (forceFullRefund), geht auch sie zurück.
// Eine Kursabsage storniert immer alle Plätze.
const resolved = resolveSeatCancellation(booking, forceFullRefund ? {} : options?.seatSelection);
if (!resolved.ok) return { success: false, error: resolved.error };
const { count, cancelsAll } = resolved.value;
const seatsBefore = booking.seats;
// Stornoquote und Erstattungsbetrag beziehen sich auf den KURSANTEIL der
// stornierten Plätze. Die Servicegebühr hat der Kunde zusätzlich gezahlt;
// sie ist Leistung der Plattform und wird bei einer regulären Stornierung
// einbehalten. Nur wenn der Kurs komplett ausfällt (forceFullRefund), geht
// auch sie zurück.
const courseAmount = courseAmountCents(booking);
const share = seatShareCents(booking, count);
if (!forceFullRefund) {
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
@ -161,10 +198,14 @@ export async function executeBookingCancellation(
? // Kursabsage durch das Studio: den Kunden trifft keine Schuld, er bekommt
// den vollen gezahlten Betrag inklusive Servicegebühr zurück - finanziert
// aus der Erstattungssumme, nicht über refund_application_fee.
{ cancellationAllowed: true, refundPercent: 100, refundAmountCents: booking.amountPaid }
{
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: share.courseCents + share.feeCents,
}
: await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: courseAmount,
amountPaidCents: share.courseCents,
courseStart: combineDateAndTime(booking.course.date, booking.course.timeFrom),
now: new Date(),
});
@ -183,11 +224,12 @@ export async function executeBookingCancellation(
// (Doppelklick, erneuter Server-Action-Aufruf, o.ä.) können beide den obigen
// paymentStatus === "paid"-Read sehen, aber nur einer gewinnt dieses
// updateMany - der andere bricht sofort ab, bevor überhaupt ein Stripe-Call
// stattfindet. Der Idempotency-Key an Stripe (unten) ist eine zweite,
// unabhängige Absicherung für den Fall, dass trotzdem zwei echte
// refunds.create-Aufrufe rausgehen (z.B. bei einem Retry nach Timeout).
// stattfindet. Die Bindung an seats verhindert, dass zwei Teilstornos auf
// demselben Platzstand rechnen. Der Idempotency-Key an Stripe (unten) ist
// eine zweite, unabhängige Absicherung für den Fall, dass trotzdem zwei
// echte refunds.create-Aufrufe rausgehen (z.B. bei einem Retry nach Timeout).
const claimed = await prisma.courseBooking.updateMany({
where: { id: bookingId, paymentStatus: "paid" },
where: { id: bookingId, paymentStatus: "paid", seats: seatsBefore },
data: { paymentStatus: "refunding" },
});
if (claimed.count !== 1) {
@ -195,8 +237,8 @@ export async function executeBookingCancellation(
}
// Stripe lehnt einen Refund mit amount: 0 ab ("must be greater than or equal
// to 1") - bei 0% Rückerstattung wird die Buchung storniert, ohne Stripe
// überhaupt zu kontaktieren.
// to 1") - bei 0% Rückerstattung wird storniert, ohne Stripe überhaupt zu
// kontaktieren.
let amountRefundedCents = 0;
if (refundAmountCents > 0) {
// Die Erstattung geht vom Guthaben des Studios ab (Direct Charge) bzw. wird
@ -208,10 +250,13 @@ export async function executeBookingCancellation(
// undefined hieße "komplette Charge" - und die enthält seit der fixen
// Servicegebühr mehr als den Kursanteil. Deshalb wird der Betrag bei
// jeder regulären Stornierung explizit gesetzt und nur bei der
// Kursabsage das Voll-Erstattungs-Verhalten genutzt.
// Kursabsage einer unangetasteten Buchung das Voll-Erstattungs-Verhalten
// genutzt.
amountCents:
forceFullRefund && refundAmountCents >= booking.amountPaid ? undefined : refundAmountCents,
idempotencyKey: `refund-${bookingId}`,
// Pro Platzstand eindeutig: ein späterer Teilstorno derselben Buchung ist
// eine neue Erstattung, ein Retry desselben Schritts nicht.
idempotencyKey: `refund-${bookingId}-${seatsBefore}`,
// Die Servicegebühr bleibt bei Kumasy - auch bei einer Kursabsage. Sie
// zurückzugeben würde den Kunden nicht besserstellen (der bekommt seinen
// vollen Betrag über die Erstattungssumme), sondern sie nur an das Studio
@ -232,40 +277,67 @@ export async function executeBookingCancellation(
amountRefundedCents = refundResult.amountRefundedCents;
}
// 0% Rückerstattung storniert die Buchung trotzdem, aber ohne Geldbewegung -
// dafür gibt es einen eigenen Status, damit weder die Rechnungs-/Metrik-Logik
// noch die Storno-Mail fälschlich "erstattet" suggerieren (siehe §"cancelled").
// "Vollständig erstattet" heißt: alles, was in diesem Fall erstattungsfähig
// war, ist zurück - bei regulärer Stornierung der Kursanteil, bei einer
// Kursabsage der komplette gezahlte Betrag.
const fullyRefundableCents = forceFullRefund ? booking.amountPaid : courseAmount;
const newPaymentStatus =
amountRefundedCents >= fullyRefundableCents
? "refunded"
: amountRefundedCents > 0
? "partially_refunded"
: "cancelled";
// Erstattungen werden über alle Teilstornos aufsummiert. Den Gebührenanteil
// gesondert festhalten, damit Rechnung und Zahlungen-Tab den Kursanteil der
// Erstattung kennen.
const totalRefunded = (booking.refundedAmount ?? 0) + amountRefundedCents;
const totalRefundedFee =
(booking.refundedFeeAmount ?? 0) +
(forceFullRefund ? Math.min(share.feeCents, amountRefundedCents) : 0);
const refundColumns = {
refundedAmount: totalRefunded > 0 ? totalRefunded : null,
refundedFeeAmount: totalRefundedFee > 0 ? totalRefundedFee : null,
refundedAt: amountRefundedCents > 0 ? new Date() : booking.refundedAt,
};
await prisma.courseBooking.update({
where: { id: bookingId },
data: {
paymentStatus: newPaymentStatus,
refundedAmount: amountRefundedCents,
refundedAt: new Date(),
cancelledBy,
...(options?.cancellationReason ? { cancellationReason: options.cancellationReason } : {}),
},
});
if (cancelsAll) {
// 0% Rückerstattung storniert die Buchung trotzdem, aber ohne Geldbewegung -
// dafür gibt es einen eigenen Status, damit weder die Rechnungs-/Metrik-
// Logik noch die Storno-Mail fälschlich "erstattet" suggerieren. "Vollständig
// erstattet" heißt: vom Kursanteil ist nichts beim Studio geblieben - auch
// nicht aus früheren Teilstornos.
const newPaymentStatus =
totalRefunded - totalRefundedFee >= courseAmount
? "refunded"
: totalRefunded > 0
? "partially_refunded"
: "cancelled";
await prisma.courseBooking.update({
where: { id: bookingId },
data: {
paymentStatus: newPaymentStatus,
...refundColumns,
cancelledBy,
...(options?.cancellationReason ? { cancellationReason: options.cancellationReason } : {}),
},
});
} else {
// Teilstorno: die Buchung bleibt aktiv, nur mit weniger Plätzen.
await prisma.courseBooking.update({
where: { id: bookingId },
data: {
paymentStatus: "paid",
seats: seatsBefore - count,
spotIds: resolved.value.keptSpotIds,
spotLabels: resolved.value.keptSpotLabels,
...refundColumns,
},
});
}
const remainingSeats = cancelsAll ? 0 : seatsBefore - count;
const emailPromise = sendCancellationEmail(
booking,
amountRefundedCents,
// Bezugsgröße der Stornoquote in der Mail - der Kursanteil, nicht die
// Charge inklusive Gebühr.
courseAmount,
// Bezugsgröße der Stornoquote in der Mail - der Kursanteil der stornierten
// Plätze, nicht die Charge inklusive Gebühr.
share.courseCents,
refundPercent,
cancelledBy,
forceFullRefund ? "course_cancelled" : undefined
forceFullRefund ? "course_cancelled" : undefined,
seatsBefore > 1 ? { cancelled: count, remaining: remainingSeats } : undefined
).catch((err) => console.error("Storno-Mail fehlgeschlagen:", err));
await withTimeout(emailPromise, 2000);
@ -291,7 +363,9 @@ export async function executeBookingCancellation(
await notifyBusinessManager(booking.course.businessId, {
type: "booking.cancelled",
title: `Stornierung: ${booking.course.name}`,
body: `${customerLabel} hat die Buchung für den ${courseDate} storniert.`,
body: cancelsAll
? `${customerLabel} hat die Buchung für den ${courseDate} storniert.`
: `${customerLabel} hat ${count} von ${seatsBefore} Plätzen für den ${courseDate} storniert.`,
linkUrl: `/courses/edit/${booking.course.id}`,
});
})().catch((err) => console.error("Storno-Benachrichtigung fehlgeschlagen:", err));
@ -322,5 +396,6 @@ export async function executeBookingCancellation(
success: true,
refundedAmountCents: amountRefundedCents,
refundPercent,
remainingSeats,
};
}

View file

@ -3,13 +3,24 @@
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser, isManager } from "@/src/lib/auth/getUser";
import { combineDateAndTime, calculateRefundAmountCents } from "./cancellation-policy";
import { courseAmountCents } from "@/src/lib/booking-amounts";
import {
resolveSeatCancellation,
seatShareCents,
type SeatSelection,
} from "../lib/seat-cancellation";
export type CancellationPreview = {
cancellationAllowed: boolean;
refundPercent: number;
refundAmountCents: number;
/** Kursanteil der stornierten Plätze - Bezugsgröße der Erstattung. */
amountPaidCents: number;
/** Aktuell gebuchte Plätze - mehr als 1 = der Dialog bietet Teilstorno an. */
seats: number;
/** Gebuchte Geräte bei Kursen mit Platzwahl, zur Auswahl im Dialog. */
spots: { id: string; label: string }[];
/** Wie viele Plätze die Vorschau storniert. */
seatsToCancel: number;
};
/**
@ -24,7 +35,8 @@ export type CancellationPreview = {
* verwendet.
*/
export async function previewBookingCancellation(
bookingId: string
bookingId: string,
seatSelection?: SeatSelection
): Promise<{ success: true; preview: CancellationPreview } | { success: false; error: string }> {
const userData = await getUserData();
if (!userData) {
@ -38,6 +50,10 @@ export async function previewBookingCancellation(
amountPaid: true,
platformFee: true,
platformFeeOnTop: true,
seats: true,
seatsPurchased: true,
spotIds: true,
spotLabels: true,
course: { select: { date: true, timeFrom: true, businessId: true } },
},
});
@ -56,17 +72,20 @@ export async function previewBookingCancellation(
return { success: false, error: "Keine Berechtigung für diese Buchung." };
}
const resolved = resolveSeatCancellation(booking, seatSelection);
if (!resolved.ok) return { success: false, error: resolved.error };
const courseStart = combineDateAndTime(booking.course.date, booking.course.timeFrom);
// Identische Bezugsgröße wie in executeBookingCancellation: der Kursanteil
// ohne Servicegebühr. Sonst würde der Dialog eine höhere Erstattung
// ankündigen, als die Stornierung anschließend auszahlt.
const courseAmount = courseAmountCents(booking);
// der stornierten Plätze ohne Servicegebühr. Sonst würde der Dialog eine
// höhere Erstattung ankündigen, als die Stornierung anschließend auszahlt.
const { courseCents } = seatShareCents(booking, resolved.value.count);
const { cancellationAllowed, refundPercent, refundAmountCents } =
await calculateRefundAmountCents({
businessId: booking.course.businessId,
amountPaidCents: courseAmount,
amountPaidCents: courseCents,
courseStart,
now: new Date(),
});
@ -77,7 +96,10 @@ export async function previewBookingCancellation(
cancellationAllowed,
refundPercent,
refundAmountCents,
amountPaidCents: courseAmount,
amountPaidCents: courseCents,
seats: booking.seats,
spots: booking.spotIds.map((id, i) => ({ id, label: booking.spotLabels[i] ?? "" })),
seatsToCancel: resolved.value.count,
},
};
}

View file

@ -21,6 +21,7 @@ import {
type FrequencyType,
} from "../utils/generate-instances";
import { bookableSpots, parseFloorPlan } from "@/src/modules/rooms/lib/floor-plan";
import { paidSeatCounts } from "../lib/seat-counts";
type StandingOrderScope = "this" | "this_and_following" | "all";
@ -335,18 +336,24 @@ export async function getMyCourses(options?: { dateFrom?: Date; dateTo?: Date })
}
// Step 4: Fetch courses created by this manager with optional date filtering
const courses = await prisma.course.findMany({
const rows = await prisma.course.findMany({
where: whereClause,
include: {
_count: {
select: { bookings: { where: { paymentStatus: "paid" } } },
},
},
orderBy: {
date: "asc", // Sort by course date ascending (oldest first)
},
});
// _count.bookings bleibt die Form, die die Kursliste erwartet, zählt aber
// belegte PLÄTZE - eine Mehrfachbuchung belegt mehrere.
const seatCounts = await paidSeatCounts(
prisma,
rows.map((c) => c.id)
);
const courses = rows.map((c) => ({
...c,
_count: { bookings: seatCounts.get(c.id) ?? 0 },
}));
return {
success: true,
courses,

View file

@ -19,6 +19,7 @@
import { prisma } from "@/src/lib/prisma";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
import { Prisma } from "@/src/generated/prisma/client";
import { paidSeatCount } from "../lib/seat-counts";
export type JoinWaitlistResult =
| { success: true; alreadyOnWaitlist: boolean }
@ -28,25 +29,21 @@ export type JoinWaitlistResult =
* Prüft, ob ein Kurs überhaupt wartelisten-fähig ist: muss existieren,
* veröffentlicht sein und aktuell voll sein (bezahlte Buchungen >=
* maxParticipants) - dieselbe "voll"-Definition, die überall sonst im
* Code verwendet wird (paymentStatus: "paid").
* Code verwendet wird (Summe der Plätze aller "paid"-Buchungen).
*/
async function assertCourseIsFullAndJoinable(
courseId: string
): Promise<{ ok: true } | { ok: false; error: string }> {
const course = await prisma.course.findUnique({
where: { id: courseId },
select: {
status: true,
maxParticipants: true,
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
},
select: { status: true, maxParticipants: true },
});
if (!course) return { ok: false, error: "Kurs nicht gefunden." };
if (course.status !== "published") {
return { ok: false, error: "Dieser Kurs ist nicht verfügbar." };
}
if (course._count.bookings < course.maxParticipants) {
if ((await paidSeatCount(prisma, courseId)) < course.maxParticipants) {
return { ok: false, error: "Dieser Kurs ist nicht ausgebucht." };
}

View file

@ -10,19 +10,19 @@ import {
* Platzwahl bei Kursen (z.B. Reformer im Pilates-Studio).
*
* 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. 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
* der Kunde den nächsten freien Platz statt einer Erstattung.
* 3. Danach oder bei abgelaufener Session: releaseSpotHold().
* 1. Checkout-Start: holdSpot() hält jeden gewählten Platz für die Dauer der
* Stripe-Session frei (ein CourseSpotHold pro Platz), damit kein zweiter
* Kunde mit demselben Platz zur Kasse geht. Startet dieselbe Person erneut,
* beendet die Checkout-Route vorher ihre alte, noch offene Session.
* 2. Webhook nach der Zahlung: assignSpots() schreibt die Plätze an die
* Buchung. Verbindlich ist nur, was bezahlt ist - war ein Wunschplatz
* inzwischen doch vergeben (Hold abgelaufen, SEPA-Zahlung kam Tage später),
* bekommt der Kunde einen anderen freien Platz statt einer Erstattung.
* 3. Danach oder bei abgelaufener Session: releaseSpotHolds().
*
* Belegt ist ein Platz durch eine "paid"-Buchung mit dieser spotId - dieselbe
* Definition wie bei der Teilnehmerzahl. Stornos geben den Platz damit von
* selbst frei, ohne dass jemand spotId zurücksetzen muss.
* Belegt ist ein Platz durch eine "paid"-Buchung, deren spotIds ihn enthalten -
* dieselbe Definition wie bei der Teilnehmerzahl. Stornos geben die Plätze
* damit von selbst frei; ein Teilstorno nimmt die stornierten aus spotIds.
*/
type PrismaOrTx =
@ -60,13 +60,13 @@ export async function getCourseSpots(
return spots.length > 0 ? spots : null;
}
/** Plätze mit bezahlter Buchung. */
/** Plätze mit bezahlter Buchung - eine Mehrfachbuchung belegt mehrere. */
export async function getBookedSpotIds(db: PrismaOrTx, courseId: string): Promise<Set<string>> {
const rows = await db.courseBooking.findMany({
where: { courseId, paymentStatus: "paid", spotId: { not: null } },
select: { spotId: true },
where: { courseId, paymentStatus: "paid", NOT: { spotIds: { isEmpty: true } } },
select: { spotIds: true },
});
return new Set(rows.map((r) => r.spotId!));
return new Set(rows.flatMap((r) => r.spotIds));
}
/**
@ -78,7 +78,7 @@ export async function getBookedSpotIds(db: PrismaOrTx, courseId: string): Promis
export async function getHeldSpotIds(
db: PrismaOrTx,
courseId: string,
opts: { exceptHolderKey?: string; exceptHoldId?: string } = {}
opts: { exceptHolderKey?: string; exceptHoldIds?: string[] } = {}
): Promise<Set<string>> {
const rows = await db.courseSpotHold.findMany({
where: {
@ -87,7 +87,7 @@ export async function getHeldSpotIds(
...(opts.exceptHolderKey
? { NOT: { holderKey: opts.exceptHolderKey, status: "checkout" } }
: {}),
...(opts.exceptHoldId ? { id: { not: opts.exceptHoldId } } : {}),
...(opts.exceptHoldIds?.length ? { id: { notIn: opts.exceptHoldIds } } : {}),
},
select: { spotId: true },
});
@ -118,7 +118,7 @@ export async function holdSpot(
});
const booked = await db.courseBooking.findFirst({
where: { courseId, spotId, paymentStatus: "paid" },
where: { courseId, spotIds: { has: spotId }, paymentStatus: "paid" },
select: { id: true },
});
if (booked) {
@ -164,27 +164,37 @@ export async function getOpenCheckoutHolds(
});
}
/** Verknüpft den Hold mit seiner Session, sobald Stripe sie angelegt hat. */
export async function attachSessionToHold(
/** Verknüpft die Holds einer Buchung mit ihrer Session, sobald Stripe sie angelegt hat. */
export async function attachSessionToHolds(
db: PrismaOrTx,
holdId: string,
holdIds: string[],
stripeSessionId: string
): Promise<void> {
await db.courseSpotHold.updateMany({ where: { id: holdId }, data: { stripeSessionId } });
if (holdIds.length === 0) return;
await db.courseSpotHold.updateMany({
where: { id: { in: holdIds } },
data: { stripeSessionId },
});
}
export async function releaseSpotHold(db: PrismaOrTx, holdId: string): Promise<void> {
await db.courseSpotHold.deleteMany({ where: { id: holdId } });
export async function releaseSpotHolds(db: PrismaOrTx, holdIds: string[]): Promise<void> {
if (holdIds.length === 0) return;
await db.courseSpotHold.deleteMany({ where: { id: { in: holdIds } } });
}
/**
* Checkout abgeschlossen, Zahlung unterwegs (SEPA): Platz länger halten und
* als abgeschlossen markieren, damit ein neuer Checkout derselben Person ihn
* Checkout abgeschlossen, Zahlung unterwegs (SEPA): Plätze länger halten und
* als abgeschlossen markieren, damit ein neuer Checkout derselben Person sie
* nicht als "alten offenen Checkout" freigibt.
*/
export async function extendSpotHold(db: PrismaOrTx, holdId: string, until: Date): Promise<void> {
export async function extendSpotHolds(
db: PrismaOrTx,
holdIds: string[],
until: Date
): Promise<void> {
if (holdIds.length === 0) return;
await db.courseSpotHold.updateMany({
where: { id: holdId },
where: { id: { in: holdIds } },
data: { expiresAt: until, status: "awaiting_payment" },
});
}
@ -192,35 +202,44 @@ export async function extendSpotHold(db: PrismaOrTx, holdId: string, until: Date
export type AssignedSpot = { spotId: string; spotLabel: string; changed: boolean };
/**
* Bestimmt im Webhook (innerhalb der Kapazitäts-Transaktion) den Platz einer
* bezahlten Buchung. Wunschplatz, wenn er noch frei ist - sonst der erste freie
* Platz, den niemand gerade im Checkout hält. null = kein Platz mehr frei, die
* Buchung ist dann wie bei einem vollen Kurs abzulehnen.
* Bestimmt im Webhook (innerhalb der Kapazitäts-Transaktion) die Plätze einer
* bezahlten Buchung. Wunschplätze, soweit sie noch frei sind - fehlende werden
* mit freien Plätzen aufgefüllt, die niemand gerade im Checkout hält (notfalls
* auch mit gehaltenen: bezahlt schlägt Checkout). null = nicht genug Plätze
* frei, die Buchung ist dann wie bei einem vollen Kurs abzulehnen.
*/
export async function assignSpot(
export async function assignSpots(
tx: PrismaOrTx,
params: {
courseId: string;
spots: FloorPlanItem[];
requestedSpotId: string | null;
holdId: string | null;
count: number;
requestedSpotIds: string[];
holdIds: string[];
}
): Promise<AssignedSpot | null> {
const { courseId, spots, requestedSpotId, holdId } = params;
): Promise<AssignedSpot[] | null> {
const { courseId, spots, count, requestedSpotIds, holdIds } = params;
const booked = await getBookedSpotIds(tx, courseId);
const free = spots.filter((s) => !booked.has(s.id));
if (free.length === 0) return null;
if (free.length < count) return null;
let chosen = requestedSpotId ? free.find((s) => s.id === requestedSpotId) : undefined;
if (!chosen) {
const held = await getHeldSpotIds(tx, courseId, holdId ? { exceptHoldId: holdId } : {});
chosen = free.find((s) => !held.has(s.id)) ?? free[0];
const requested = new Set(requestedSpotIds);
const chosen = free.filter((s) => requested.has(s.id)).slice(0, count);
if (chosen.length < count) {
const held = await getHeldSpotIds(tx, courseId, { exceptHoldIds: holdIds });
const rest = free.filter((s) => !chosen.includes(s));
const unheldFirst = [
...rest.filter((s) => !held.has(s.id)),
...rest.filter((s) => held.has(s.id)),
];
chosen.push(...unheldFirst.slice(0, count - chosen.length));
}
return {
spotId: chosen.id,
spotLabel: spotDisplayName(chosen),
changed: requestedSpotId !== null && chosen.id !== requestedSpotId,
};
return chosen.map((s) => ({
spotId: s.id,
spotLabel: spotDisplayName(s),
changed: requestedSpotIds.length > 0 && !requested.has(s.id),
}));
}

View file

@ -0,0 +1,98 @@
import { courseAmountCents, type BookingAmounts } from "@/src/lib/booking-amounts";
/**
* Storno einzelner Plätze einer Mehrfachbuchung.
*
* Eine Buchung mit 3 Plätzen ist eine Zahlung. Wird ein Platz storniert, geht
* nur dessen Anteil zurück: Kursanteil / seatsPurchased, bei einer Kursabsage
* zusätzlich der Gebührenanteil. Der Rundungsrest (z.B. ein fester Rabatt, der
* sich nicht glatt teilt) bleibt bei den letzten Plätzen - so ergibt die Summe
* aller Teilstornos genau den gezahlten Betrag.
*
* Vorschau (cancellation-preview-actions) und Storno (cancel-booking-shared)
* rechnen beide hierüber, damit der Dialog ankündigt, was später ausgezahlt wird.
*/
/** Welche Plätze storniert werden. Leer = alle verbleibenden. */
export type SeatSelection = {
/** Anzahl bei Kursen ohne Platzwahl. */
seats?: number;
/** Bei Platzwahl: die Geräte, die frei werden. */
spotIds?: string[];
};
type SeatBooking = {
seats: number;
seatsPurchased: number;
spotIds: string[];
spotLabels: string[];
};
export type ResolvedSeatCancellation = {
/** Anzahl stornierter Plätze. */
count: number;
/** true = es bleibt kein Platz übrig, die Buchung ist danach storniert. */
cancelsAll: boolean;
/** Plätze im Grundriss, die nach dem Storno noch gebucht sind. */
keptSpotIds: string[];
keptSpotLabels: string[];
/** Anzeigenamen der freiwerdenden Plätze. */
releasedSpotLabels: string[];
};
export function resolveSeatCancellation(
booking: SeatBooking,
selection: SeatSelection = {}
): { ok: true; value: ResolvedSeatCancellation } | { ok: false; error: string } {
const spots = booking.spotIds.map((id, i) => ({ id, label: booking.spotLabels[i] ?? "" }));
let count: number;
let released: typeof spots;
if (spots.length > 0 && selection.spotIds) {
const ids = new Set(selection.spotIds);
released = spots.filter((spot) => ids.has(spot.id));
if (released.length === 0 || released.length !== ids.size) {
return { ok: false, error: "Bitte wählen Sie Plätze aus dieser Buchung." };
}
count = released.length;
} else {
count = selection.seats ?? booking.seats;
if (!Number.isInteger(count) || count < 1 || count > booking.seats) {
return { ok: false, error: `Es können 1 bis ${booking.seats} Plätze storniert werden.` };
}
// Ohne Auswahl die zuletzt gebuchten Geräte freigeben.
released = spots.slice(spots.length - count);
}
const kept = spots.filter((spot) => !released.includes(spot));
return {
ok: true,
value: {
count,
cancelsAll: count >= booking.seats,
keptSpotIds: kept.map((s) => s.id),
keptSpotLabels: kept.map((s) => s.label),
releasedSpotLabels: released.map((s) => s.label),
},
};
}
/**
* Kurs- und Gebührenanteil der stornierten Plätze, in Cents. Der Gebührenanteil
* ist nur bei "Gebühr obendrauf" > 0 und wird nur bei einer Kursabsage erstattet.
*/
export function seatShareCents(
booking: BookingAmounts & Pick<SeatBooking, "seats" | "seatsPurchased">,
count: number
): { courseCents: number; feeCents: number } {
const purchased = Math.max(booking.seatsPurchased, 1);
const alreadyCancelled = purchased - booking.seats;
const share = (total: number) => {
const perSeat = Math.floor(total / purchased);
return count >= booking.seats ? total - perSeat * alreadyCancelled : perSeat * count;
};
return {
courseCents: share(courseAmountCents(booking)),
feeCents: share(booking.platformFeeOnTop ? (booking.platformFee ?? 0) : 0),
};
}

View file

@ -0,0 +1,39 @@
import type { prisma as prismaClient } from "@/src/lib/prisma";
/**
* Belegte Plätze eines Kurses.
*
* Seit der Mehrfachbuchung kann eine Buchung mehrere Plätze umfassen - die
* Auslastung ist deshalb die SUMME von seats über alle bezahlten Buchungen,
* nicht ihre Anzahl. Jede Kapazitätsprüfung und jede "x/y Teilnehmer"-Anzeige
* muss hierüber laufen, sonst wird überbucht.
*/
type PrismaOrTx =
| typeof prismaClient
| Parameters<Parameters<typeof prismaClient.$transaction>[0]>[0];
/** Höchstzahl Plätze pro Buchung - für Kunde, Checkout und Webhook gleich. */
export const MAX_SEATS_PER_BOOKING = 10;
export async function paidSeatCount(db: PrismaOrTx, courseId: string): Promise<number> {
const result = await db.courseBooking.aggregate({
where: { courseId, paymentStatus: "paid" },
_sum: { seats: true },
});
return result._sum.seats ?? 0;
}
/** Belegte Plätze für mehrere Kurse auf einmal. Kurse ohne Buchung fehlen in der Map. */
export async function paidSeatCounts(
db: PrismaOrTx,
courseIds: string[]
): Promise<Map<string, number>> {
if (courseIds.length === 0) return new Map();
const rows = await db.courseBooking.groupBy({
by: ["courseId"],
where: { courseId: { in: courseIds }, paymentStatus: "paid" },
_sum: { seats: true },
});
return new Map(rows.map((r) => [r.courseId, r._sum.seats ?? 0]));
}

View file

@ -3,7 +3,19 @@
import { useState, useEffect, useRef, useSyncExternalStore } from "react";
import Image from "next/image";
import Link from "next/link";
import { Clock, MapPin, Users, Mail, Phone, DoorOpen, Info, X, LayoutGrid } from "lucide-react";
import {
Clock,
MapPin,
Users,
Mail,
Phone,
DoorOpen,
Info,
X,
LayoutGrid,
Minus,
Plus,
} from "lucide-react";
import { Button } from "@/src/components/ui/button";
import { Dialog, DialogClose, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
@ -21,6 +33,7 @@ import { COURSE_LEVELS } from "../../lib/course-levels";
import { formatEuro } from "./course-schedule-shared";
import { SpotPicker } from "@/src/modules/rooms/ui/components/floor-plan/SpotPicker";
import { spotDisplayName } from "@/src/modules/rooms/lib/floor-plan";
import { MAX_SEATS_PER_BOOKING } from "../../lib/seat-counts";
// useSyncExternalStore: liefert `false` auf dem Server (server snapshot) und
// `true` im Browser. Damit haben Server- und initialer Client-Render denselben
@ -87,8 +100,10 @@ export interface PublicCourse {
trainerProfiles?: TrainerProfile[];
/** Kunden wählen beim Buchen einen Platz im Grundriss des Raums. */
spotBooking?: boolean;
/** Gebuchter Platz - nur in der Ansicht einer bestehenden Buchung. */
spotLabel?: string | null;
/** Nur in der Ansicht einer bestehenden Buchung: gebuchte Plätze ... */
seats?: number;
/** ... und bei Platzwahl ihre Geräte, z.B. ["Reformer 3", "Reformer 4"]. */
spotLabels?: string[];
}
const TrainerAvatar = ({ trainer, size }: { trainer: TrainerProfile; size: "sm" | "lg" }) => {
@ -172,15 +187,22 @@ export const BookingDialog = ({
const [guestName, setGuestName] = useState("");
const [discountInput, setDiscountInput] = useState("");
const [isCheckingDiscount, setIsCheckingDiscount] = useState(false);
// discountedPrice gilt für `seats` Plätze - ändert sich die Platzzahl, wird
// der Rabatt neu berechnet (siehe Effekt unten).
const [appliedDiscount, setAppliedDiscount] = useState<{
code: string;
seats: number;
discountedPrice: number;
} | null>(null);
// Mehrfachbuchung: nur für eingeloggte Kunden (Gäste buchen genau einen Platz).
const [canBookMultiple, setCanBookMultiple] = useState(false);
const [bookedSeats, setBookedSeats] = useState(0);
const [seatCount, setSeatCount] = useState(1);
// Platzwahl: Grundriss + Belegung, live geladen beim Öffnen. Bleibt null bei
// Kursen ohne Platzwahl - dann läuft die Buchung wie bisher.
const [spotMap, setSpotMap] = useState<CourseSpotMap | null>(null);
const [isLoadingSpots, setIsLoadingSpots] = useState(false);
const [selectedSpotId, setSelectedSpotId] = useState<string | null>(null);
const [selectedSpotIds, setSelectedSpotIds] = useState<string[]>([]);
const [highlightPicker, setHighlightPicker] = useState(false);
const spotPickerRef = useRef<HTMLDivElement>(null);
@ -197,11 +219,7 @@ export const BookingDialog = ({
year: "numeric",
});
// Preisangabenverordnung: Der Kunde muss vor dem Checkout den Endpreis
// sehen, also inklusive der Servicegebühr - sonst taucht sie erst bei
// Stripe auf.
const serviceFee = serviceFeeCents / 100;
const formattedPrice = formatEuro(course.price + serviceFee);
// Booking-Status nur laden wenn keine Admin-Ansicht
useEffect(() => {
@ -215,6 +233,8 @@ export const BookingDialog = ({
if (!active) return;
setIsBooked(result.success ? result.isBooked : false);
setIsOnWaitlist(result.isOnWaitlist);
setCanBookMultiple("canBookMultiple" in result && result.canBookMultiple === true);
setBookedSeats("bookedSeats" in result ? (result.bookedSeats ?? 0) : 0);
// Gäste sehen das Gast-Formular sofort, statt erst nach einem
// gescheiterten Buchungsversuch.
if (result.isLoggedIn === false) setShowGuestForm(true);
@ -243,13 +263,11 @@ export const BookingDialog = ({
const map = await getCourseSpotMap(course.id);
if (!active) return;
setSpotMap(map);
// Eine Auswahl aus einem früheren Öffnen gilt nur, solange der Platz frei ist.
setSelectedSpotId((prev) =>
prev &&
map?.floorPlan.items.some((i) => i.id === prev) &&
!map.takenSpotIds.includes(prev)
? prev
: null
// Eine Auswahl aus einem früheren Öffnen gilt nur, solange die Plätze frei sind.
setSelectedSpotIds((prev) =>
prev.filter(
(id) => map?.floorPlan.items.some((i) => i.id === id) && !map.takenSpotIds.includes(id)
)
);
} catch {
if (active) setSpotMap(null);
@ -265,13 +283,68 @@ export const BookingDialog = ({
}, [open, course.id, course.spotBooking, isAdminView]);
const spotMode = !isAdminView && spotMap !== null;
const selectedSpot = spotMode
? (spotMap.floorPlan.items.find((i) => i.id === selectedSpotId) ?? null)
: null;
const mySpot = spotMode
? (spotMap.floorPlan.items.find((i) => i.id === spotMap.mySpotId) ?? null)
: null;
const needsSpot = spotMode && !selectedSpot;
const selectedSpots = spotMode
? spotMap.floorPlan.items.filter((i) => selectedSpotIds.includes(i.id))
: [];
const mySpots = spotMode
? spotMap.floorPlan.items.filter((i) => spotMap.mySpotIds.includes(i.id))
: [];
const needsSpot = spotMode && selectedSpots.length === 0;
// Wie viele Plätze sich höchstens auf einmal buchen lassen: so viele wie frei
// sind, gedeckelt - und für Gäste genau einer.
const freeSeats = Math.max(0, safeMax - safeCurrent);
const maxSeats = canBookMultiple ? Math.max(1, Math.min(MAX_SEATS_PER_BOOKING, freeSeats)) : 1;
// Bei Platzwahl ergibt sich die Anzahl aus den gewählten Geräten.
const seats = spotMode ? Math.max(1, selectedSpots.length) : Math.min(seatCount, maxSeats);
const toggleSpot = (spotId: string) => {
if (selectedSpotIds.includes(spotId)) {
setSelectedSpotIds(selectedSpotIds.filter((id) => id !== spotId));
} else if (maxSeats <= 1) {
setSelectedSpotIds([spotId]);
} else if (selectedSpotIds.length >= maxSeats) {
toast.info(`Sie können höchstens ${maxSeats} Plätze auf einmal buchen.`);
} else {
setSelectedSpotIds([...selectedSpotIds, spotId]);
}
};
// Preisangabenverordnung: Der Kunde muss vor dem Checkout den Endpreis
// sehen, also inklusive der Servicegebühr (pro Platz) - sonst taucht sie
// erst bei Stripe auf.
const coursePrice = appliedDiscount ? appliedDiscount.discountedPrice : course.price * seats;
const formattedPrice = formatEuro(course.price * seats + serviceFee * seats);
const discountOutdated = appliedDiscount !== null && appliedDiscount.seats !== seats;
// Rabatt auf die neue Platzzahl umrechnen - ein fester Betrag gilt pro
// Buchung, ein Prozent-Rabatt auf alle Plätze.
useEffect(() => {
if (!appliedDiscount || appliedDiscount.seats === seats) return;
let active = true;
const refresh = async () => {
try {
const result = await previewDiscountCode(course.id, appliedDiscount.code, seats);
if (!active) return;
if (result.valid) {
setAppliedDiscount({
code: appliedDiscount.code,
seats,
discountedPrice: result.discountedPrice,
});
} else {
toast.error(result.error);
setAppliedDiscount(null);
}
} catch {
if (active) setAppliedDiscount(null);
}
};
void refresh();
return () => {
active = false;
};
}, [appliedDiscount, seats, course.id]);
const showSpotPicker = () => {
spotPickerRef.current?.scrollIntoView({ behavior: "smooth", block: "center" });
@ -294,13 +367,13 @@ export const BookingDialog = ({
if (!code) return;
setIsCheckingDiscount(true);
try {
const result = await previewDiscountCode(course.id, code);
const result = await previewDiscountCode(course.id, code, seats);
if (!result.valid) {
toast.error(result.error);
setAppliedDiscount(null);
return;
}
setAppliedDiscount({ code, discountedPrice: result.discountedPrice });
setAppliedDiscount({ code, seats, discountedPrice: result.discountedPrice });
toast.success("Rabattcode angewendet.");
} catch {
toast.error("Rabattcode konnte nicht geprüft werden.");
@ -313,9 +386,9 @@ export const BookingDialog = ({
const handleBooking = async (opts?: { asGuest: boolean }) => {
setIsSubmitting(true);
try {
const body: Record<string, string> = { courseId: course.id };
const body: Record<string, string | number | string[]> = { courseId: course.id, seats };
if (appliedDiscount) body.discountCode = appliedDiscount.code;
if (selectedSpot) body.spotId = selectedSpot.id;
if (spotMode) body.spotIds = selectedSpots.map((s) => s.id);
if (opts?.asGuest) {
const normalizedEmail = guestEmail.trim().toLowerCase();
if (!EMAIL_RE.test(normalizedEmail)) {
@ -362,7 +435,7 @@ export const BookingDialog = ({
// damit der Kunde direkt einen anderen wählen kann.
if (data.spotUnavailable) {
toast.error(data.error || "Dieser Platz ist nicht mehr frei.");
setSelectedSpotId(null);
setSelectedSpotIds([]);
setSpotMap(await getCourseSpotMap(course.id));
setIsSubmitting(false);
showSpotPicker();
@ -488,11 +561,20 @@ export const BookingDialog = ({
</div>
)}
{isAdminView && course.spotLabel && (
{isAdminView && ((course.seats ?? 1) > 1 || (course.spotLabels?.length ?? 0) > 0) && (
<div className="flex items-center gap-2.5">
<LayoutGrid size={15} className="text-gray-400 shrink-0" />
<span>
<b>Platz:</b> {course.spotLabel}
{(course.seats ?? 1) > 1 ? (
<>
<b>Plätze:</b> {course.seats}
{course.spotLabels?.length ? ` · ${course.spotLabels.join(", ")}` : ""}
</>
) : (
<>
<b>Platz:</b> {course.spotLabels?.join(", ")}
</>
)}
</span>
</div>
)}
@ -532,7 +614,7 @@ export const BookingDialog = ({
</div>
)}
{spotMode && (isBooked ? mySpot : !isFull) && (
{spotMode && (isBooked ? mySpots.length > 0 : !isFull) && (
<div
ref={spotPickerRef}
className={`flex flex-col gap-3 rounded-xl border p-3 sm:p-4 transition-colors ${
@ -543,7 +625,18 @@ export const BookingDialog = ({
<LayoutGrid size={15} className="text-gray-400 shrink-0 mt-0.5" />
<div>
{isBooked ? (
<b>Ihr Platz: {spotDisplayName(mySpot!)}</b>
<b>
{mySpots.length === 1 ? "Ihr Platz" : "Ihre Plätze"}:{" "}
{mySpots.map(spotDisplayName).join(", ")}
</b>
) : maxSeats > 1 ? (
<>
<b>Plätze wählen</b>
<p className="text-gray-500 mt-0.5">
Tippen Sie auf ein freies Gerät, für Begleitpersonen auf weitere. Die
Plätze sind für Sie reserviert, sobald die Zahlung durch ist.
</p>
</>
) : (
<>
<b>Platz wählen</b>
@ -558,9 +651,9 @@ export const BookingDialog = ({
<SpotPicker
floorPlan={spotMap.floorPlan}
takenSpotIds={spotMap.takenSpotIds}
selectedSpotId={selectedSpotId}
mySpotId={spotMap.mySpotId}
onSelect={isBooked ? undefined : setSelectedSpotId}
selectedSpotIds={selectedSpotIds}
mySpotIds={spotMap.mySpotIds}
onToggle={isBooked ? undefined : toggleSpot}
accentColor={accentColor}
/>
</div>
@ -584,6 +677,46 @@ export const BookingDialog = ({
{/* Sticky Footer */}
<div className="shrink-0 border-t border-gray-200 bg-white px-4 sm:px-6 py-3 sm:py-4 flex flex-col gap-3">
{/* Mehrfachbuchung ohne Platzwahl: Anzahl per Zähler. Mit Platzwahl
ergibt sie sich aus den gewählten Geräten. */}
{!isAdminView &&
!isCheckingStatus &&
!isFull &&
!isBooked &&
!spotMode &&
maxSeats > 1 && (
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">Plätze</span>
<span className="text-xs text-gray-500">Für Begleitpersonen mitbuchen</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setSeatCount(Math.max(1, seats - 1))}
disabled={seats <= 1}
aria-label="Einen Platz weniger"
>
<Minus size={14} />
</Button>
<span className="w-6 text-center font-semibold tabular-nums" aria-live="polite">
{seats}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => setSeatCount(Math.min(maxSeats, seats + 1))}
disabled={seats >= maxSeats}
aria-label="Einen Platz mehr"
>
<Plus size={14} />
</Button>
</div>
</div>
)}
{/* Rabattcodes sind nur für eingeloggte User verfügbar (siehe
validateDiscountCode) - im Gast-Formular macht das Feld daher
keinen Sinn und würde nur einen ungültigen Code suggerieren. */}
@ -617,23 +750,28 @@ export const BookingDialog = ({
<span className="line-through text-gray-400 font-normal mr-2">
{formattedPrice}
</span>
Preis: {formatEuro(appliedDiscount.discountedPrice + serviceFee)}
Preis: {formatEuro(coursePrice + serviceFee * seats)}
</>
) : (
`Preis: ${formattedPrice}`
)}
</span>
{serviceFee > 0 && (
{(serviceFee > 0 || seats > 1) && (
<span className="text-xs text-gray-500">
{formatEuro(appliedDiscount ? appliedDiscount.discountedPrice : course.price)}{" "}
Kurs + {formatEuro(serviceFee)} Servicegebühr
{seats > 1 && !appliedDiscount
? `${seats} × ${formatEuro(course.price)}`
: formatEuro(coursePrice)}{" "}
Kurs
{serviceFee > 0 &&
` + ${seats > 1 ? `${seats} × ` : ""}${formatEuro(serviceFee)} Servicegebühr`}
</span>
)}
{spotMode && !isBooked && !isFull && (
<span className="text-xs text-gray-700 mt-0.5">
{selectedSpot ? (
{selectedSpots.length > 0 ? (
<>
Ihr Platz: <b>{spotDisplayName(selectedSpot)}</b>
{selectedSpots.length === 1 ? "Ihr Platz" : "Ihre Plätze"}:{" "}
<b>{selectedSpots.map(spotDisplayName).join(", ")}</b>
</>
) : (
"Noch kein Platz gewählt"
@ -684,7 +822,7 @@ export const BookingDialog = ({
</Button>
) : isBooked ? (
<Button disabled className="flex-1 sm:flex-initial">
✓ Angemeldet
✓ Angemeldet{bookedSeats > 1 ? ` (${bookedSeats} Plätze)` : ""}
</Button>
) : isFull ? (
isOnWaitlist ? (
@ -706,11 +844,19 @@ export const BookingDialog = ({
// "Buchen"-Button daneben wäre nur verwirrend.
<Button
onClick={() => (needsSpot ? showSpotPicker() : handleBooking())}
disabled={isSubmitting}
disabled={isSubmitting || discountOutdated}
className="flex-1 sm:flex-initial"
style={accentButtonStyle}
>
{isSubmitting ? "Wird gebucht…" : needsSpot ? "Platz wählen" : "Buchen"}
{isSubmitting
? "Wird gebucht…"
: needsSpot
? seats > 1 || maxSeats > 1
? "Plätze wählen"
: "Platz wählen"
: seats > 1
? `${seats} Plätze buchen`
: "Buchen"}
</Button>
)}
</>

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import { AlertTriangle, Minus, Plus } from "lucide-react";
import { Button } from "@/src/components/ui/button";
import {
Dialog,
@ -17,15 +17,25 @@ import {
previewBookingCancellation,
type CancellationPreview,
} from "@/src/modules/courses/actions/cancellation-preview-actions";
import type { SeatSelection } from "@/src/modules/courses/lib/seat-cancellation";
type CancelResult = { success: true } | { success: false; error: string };
export type CancelBookingCopyVariant = "user" | "manager";
const VARIANT_COPY: Record<
CancelBookingCopyVariant,
{ description: string; fullRefund: string; partialRefund: (percent: string) => string }
{
description: string;
/** Teilstorno mehrerer Plätze: "werden storniert." */
descriptionPlural: string;
fullRefund: string;
partialRefund: (percent: string) => string;
}
> = {
user: {
description: "wird storniert.",
descriptionPlural: "werden storniert.",
fullRefund: "Du erhältst den vollen Betrag zurück.",
partialRefund: (percent) =>
`Da du kurzfristig stornierst, erhältst du gemäß den Stornierungsbedingungen nur ${percent} % zurück:`,
@ -33,6 +43,8 @@ const VARIANT_COPY: Record<
manager: {
description:
"wird storniert und die Zahlung über Stripe erstattet. Dieser Vorgang kann nicht rückgängig gemacht werden.",
descriptionPlural:
"werden storniert und anteilig über Stripe erstattet. Dieser Vorgang kann nicht rückgängig gemacht werden.",
fullRefund: "Der Kunde erhält den vollen Betrag zurück.",
partialRefund: (percent) =>
`Gemäß den Stornierungsbedingungen erhält der Kunde nur ${percent} % zurück:`,
@ -45,8 +57,10 @@ interface CancelBookingConfirmDialogProps {
bookingId: string;
courseName: string;
variant: CancelBookingCopyVariant;
onConfirm: () => Promise<{ success: true } | { success: false; error: string }>;
onSuccess: () => void;
/** Bekommt die gewählten Plätze - leer = alle. */
onConfirm: (selection: SeatSelection) => Promise<CancelResult>;
/** cancelledAll = die Buchung ist danach ganz storniert (nicht nur einzelne Plätze). */
onSuccess: (info: { cancelledAll: boolean }) => void;
}
/**
@ -68,21 +82,24 @@ function CancelBookingDialogBody({
bookingId: string;
courseName: string;
variant: CancelBookingCopyVariant;
onConfirm: () => Promise<{ success: true } | { success: false; error: string }>;
onConfirm: (selection: SeatSelection) => Promise<CancelResult>;
onClose: () => void;
onSuccess: () => void;
onSuccess: (info: { cancelledAll: boolean }) => void;
}) {
const [preview, setPreview] = useState<CancellationPreview | null>(null);
const [loadingPreview, setLoadingPreview] = useState(true);
const [isPending, setIsPending] = useState(false);
// Teilstorno einer Mehrfachbuchung. Leer = alle Plätze (Standard).
const [selection, setSelection] = useState<SeatSelection>({});
const copy = VARIANT_COPY[variant];
const selectionKey = JSON.stringify(selection);
useEffect(() => {
let cancelled = false;
(async () => {
try {
const result = await previewBookingCancellation(bookingId);
const result = await previewBookingCancellation(bookingId, JSON.parse(selectionKey));
if (cancelled) return;
if (result.success) {
setPreview(result.preview);
@ -103,14 +120,20 @@ function CancelBookingDialogBody({
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [bookingId]);
}, [bookingId, selectionKey]);
// Ladezustand im Handler setzen statt im Effekt (react-hooks/set-state-in-effect).
const changeSelection = (next: SeatSelection) => {
setLoadingPreview(true);
setSelection(next);
};
const handleConfirm = async () => {
setIsPending(true);
try {
const result = await onConfirm();
const result = await onConfirm(selection);
if (result.success) {
onSuccess();
onSuccess({ cancelledAll: preview ? preview.seatsToCancel >= preview.seats : true });
} else {
toast.error(result.error);
}
@ -123,19 +146,101 @@ function CancelBookingDialogBody({
const isPartialRefund = preview !== null && preview.refundPercent < 100;
const cancellationBlocked = preview !== null && !preview.cancellationAllowed;
const multiSeat = preview !== null && preview.seats > 1;
const toCancel = preview?.seatsToCancel ?? 0;
const cancelsAll = preview === null || toCancel >= preview.seats;
const chosenSpotIds = preview ? (selection.spotIds ?? preview.spots.map((spot) => spot.id)) : [];
const toggleSpot = (spotId: string) => {
const next = chosenSpotIds.includes(spotId)
? chosenSpotIds.filter((id) => id !== spotId)
: [...chosenSpotIds, spotId];
// Mindestens ein Platz bleibt gewählt - sonst gäbe es nichts zu stornieren.
if (next.length > 0) changeSelection({ spotIds: next });
};
return (
<>
<DialogHeader>
<DialogTitle>Buchung stornieren?</DialogTitle>
<DialogTitle>{multiSeat ? "Plätze stornieren?" : "Buchung stornieren?"}</DialogTitle>
{!cancellationBlocked && (
<DialogDescription>
Die Buchung für <strong>{courseName}</strong> {copy.description}
{multiSeat && !cancelsAll ? (
<>
{toCancel} von {preview!.seats} Plätzen für <strong>{courseName}</strong>{" "}
{toCancel === 1 ? copy.description : copy.descriptionPlural}
</>
) : (
<>
Die Buchung für <strong>{courseName}</strong> {copy.description}
</>
)}
</DialogDescription>
)}
</DialogHeader>
{loadingPreview ? (
{/* Auswahl, welche Plätze storniert werden - nur bei Mehrfachbuchungen */}
{multiSeat && !cancellationBlocked && (
<div className="flex flex-col gap-2 rounded-lg border border-gray-200 p-3">
{preview!.spots.length > 0 ? (
<>
<p className="text-sm font-medium text-gray-900">Welche Plätze?</p>
<div className="flex flex-wrap gap-2">
{preview!.spots.map((spot) => {
const checked = chosenSpotIds.includes(spot.id);
return (
<label
key={spot.id}
className={`flex cursor-pointer items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm ${
checked ? "border-gray-900 bg-gray-50" : "border-gray-200"
}`}
>
<input
type="checkbox"
checked={checked}
onChange={() => toggleSpot(spot.id)}
disabled={isPending}
/>
{spot.label}
</label>
);
})}
</div>
</>
) : (
<div className="flex items-center justify-between gap-3">
<p className="text-sm font-medium text-gray-900">Wie viele Plätze?</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => changeSelection({ seats: toCancel - 1 })}
disabled={isPending || toCancel <= 1}
aria-label="Einen Platz weniger stornieren"
>
<Minus size={14} />
</Button>
<span className="w-12 text-center text-sm tabular-nums" aria-live="polite">
{toCancel} von {preview!.seats}
</span>
<Button
variant="outline"
size="icon"
className="size-8"
onClick={() => changeSelection({ seats: toCancel + 1 })}
disabled={isPending || toCancel >= preview!.seats}
aria-label="Einen Platz mehr stornieren"
>
<Plus size={14} />
</Button>
</div>
</div>
)}
</div>
)}
{loadingPreview && !preview ? (
<p className="text-sm text-gray-500">Berechne Rückerstattung…</p>
) : cancellationBlocked ? (
<div className="flex gap-2 text-sm bg-red-50 border border-red-200 rounded-lg p-3 text-red-700">
@ -169,7 +274,11 @@ function CancelBookingDialogBody({
onClick={handleConfirm}
disabled={isPending || loadingPreview}
>
{isPending ? "Wird storniert…" : "Ja, stornieren"}
{isPending
? "Wird storniert…"
: multiSeat && !cancelsAll
? `${toCancel} ${toCancel === 1 ? "Platz" : "Plätze"} stornieren`
: "Ja, stornieren"}
</Button>
)}
</DialogFooter>
@ -208,9 +317,9 @@ const CancelBookingConfirmDialog = ({
variant={variant}
onConfirm={onConfirm}
onClose={() => onOpenChange(false)}
onSuccess={() => {
onSuccess={(info) => {
onOpenChange(false);
onSuccess();
onSuccess(info);
}}
/>
)}

View file

@ -7,6 +7,7 @@ import { MapPin, Clock, Users } from "lucide-react";
import type { UserBooking } from "../../actions/booking-actions";
import { cancelUserBooking } from "../../actions/booking-actions";
import CancelBookingConfirmDialog from "./CancelBookingConfirmDialog";
import type { SeatSelection } from "../../lib/seat-cancellation";
import AbstractTooltip from "@/src/components/layout/AbstractTooltip";
import { BookingDialog } from "@/src/modules/courses/ui/components/BookingDialog";
import type { PublicCourse, BusinessInfo } from "@/src/modules/courses/ui/components/BookingDialog";
@ -50,7 +51,8 @@ function toPublicCourse(b: UserBooking): PublicCourse {
currentParticipants: b.currentParticipants,
description: b.description,
roomName: b.roomName,
spotLabel: b.spotLabel,
seats: b.seats,
spotLabels: b.spotLabels,
coverImage: b.coverImage ?? undefined,
trainerProfiles: b.trainerProfiles,
};
@ -91,10 +93,10 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
const safeCurrent = Math.max(0, booking.currentParticipants);
const safeMax = Math.max(0, booking.maxParticipants);
const handleConfirmCancel = () =>
const handleConfirmCancel = (selection: SeatSelection) =>
new Promise<{ success: true } | { success: false; error: string }>((resolve) => {
startTransition(async () => {
const result = await cancelUserBooking(booking.bookingId);
const result = await cancelUserBooking(booking.bookingId, selection);
resolve(result);
});
});
@ -187,7 +189,9 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
<MapPin size={13} className="shrink-0" />
<span className="truncate">
{booking.roomName}
{booking.spotLabel && ` · ${booking.spotLabel}`}
{booking.seats > 1
? ` · ${booking.seats} Plätze`
: booking.spotLabels[0] && ` · ${booking.spotLabels[0]}`}
</span>
</span>
</div>
@ -237,10 +241,11 @@ const UserBookingCard = ({ booking }: UserBookingCardProps) => {
courseName={booking.courseName}
variant="user"
onConfirm={handleConfirmCancel}
onSuccess={() => {
setCancelled(true);
onSuccess={({ cancelledAll }) => {
// Teilstorno: die Buchung bleibt aktiv, nur mit weniger Plätzen.
if (cancelledAll) setCancelled(true);
router.refresh();
toast.success("Buchung erfolgreich storniert.");
toast.success(cancelledAll ? "Buchung erfolgreich storniert." : "Plätze storniert.");
}}
/>
</>

View file

@ -1,6 +1,7 @@
"use server";
import { prisma } from "@/src/lib/prisma";
import { paidSeatCounts } from "@/src/modules/courses/lib/seat-counts";
import {
requireManager,
getUserData,
@ -107,16 +108,20 @@ export async function getDashboardStats(): Promise<
date: { gte: todayStart, lte: todayEnd },
},
}),
prisma.courseBooking.count({
where: {
course: {
createdBy: managerId,
isStandingOrder: false,
date: { gte: todayStart, lte: todayEnd },
// Teilnehmer = gebuchte Plätze, eine Mehrfachbuchung zählt mehrfach.
prisma.courseBooking
.aggregate({
where: {
course: {
createdBy: managerId,
isStandingOrder: false,
date: { gte: todayStart, lte: todayEnd },
},
paymentStatus: "paid",
},
paymentStatus: "paid",
},
}),
_sum: { seats: true },
})
.then((r) => r._sum.seats ?? 0),
prisma.course.count({
where: {
createdBy: managerId,
@ -133,16 +138,19 @@ export async function getDashboardStats(): Promise<
date: { gte: yesterdayStart, lte: yesterdayEnd },
},
}),
prisma.courseBooking.count({
where: {
course: {
createdBy: managerId,
isStandingOrder: false,
date: { gte: yesterdayStart, lte: yesterdayEnd },
prisma.courseBooking
.aggregate({
where: {
course: {
createdBy: managerId,
isStandingOrder: false,
date: { gte: yesterdayStart, lte: yesterdayEnd },
},
paymentStatus: "paid",
},
paymentStatus: "paid",
},
}),
_sum: { seats: true },
})
.then((r) => r._sum.seats ?? 0),
prisma.course.count({
where: {
createdBy: managerId,
@ -334,13 +342,12 @@ export async function getDashboardTodayCourses(): Promise<
status: "published",
date: { gte: todayStart, lte: todayEnd },
},
include: {
_count: {
select: { bookings: { where: { paymentStatus: "paid" } } },
},
},
orderBy: { timeFrom: "asc" },
});
const seatCounts = await paidSeatCounts(
prisma,
courses.map((c) => c.id)
);
const roomIds = [...new Set(courses.map((c) => c.room).filter(Boolean))];
const rooms = roomIds.length
@ -358,7 +365,7 @@ export async function getDashboardTodayCourses(): Promise<
timeTo: c.timeTo,
room: roomNameById.get(c.room) ?? c.room,
sport: c.sport as string[],
participantCount: c._count.bookings,
participantCount: seatCounts.get(c.id) ?? 0,
maxParticipants: c.maxParticipants,
}));
},

View file

@ -3,10 +3,12 @@
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { executeBookingCancellation } from "@/src/modules/courses/actions/cancel-booking-shared";
import type { SeatSelection } from "@/src/modules/courses/lib/seat-cancellation";
import { revalidatePath, revalidateTag } from "next/cache";
export async function cancelKundeBooking(
bookingId: string
bookingId: string,
seatSelection?: SeatSelection
): Promise<{ success: true } | { success: false; error: string }> {
const manager = await requireManager();
const businessIds = manager.businesses.map((b) => b.id);
@ -25,7 +27,7 @@ export async function cancelKundeBooking(
return { success: false, error: "Keine Berechtigung für diese Buchung." };
}
const result = await executeBookingCancellation(bookingId, "manager");
const result = await executeBookingCancellation(bookingId, "manager", { seatSelection });
if (!result.success) return result;
if (booking.userId) {

View file

@ -6,6 +6,7 @@ import { unstable_cache } from "next/cache";
import { combineDateAndTime } from "@/src/lib/course-time";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { KUMASY_BRANDING } from "@/src/lib/mail/templates/tokens";
import { paidSeatCounts } from "@/src/modules/courses/lib/seat-counts";
export type KundeBooking = {
bookingId: string;
@ -32,8 +33,10 @@ export type KundeBooking = {
trainers: string[];
trainersMap: Record<string, { label: string; pbSrc?: string }>;
paymentStatus: string;
/** Gebuchter Platz bei Kursen mit Platzwahl, z.B. "Reformer 3". */
spotLabel: string | null;
/** Plätze dieser Buchung - mehr als 1, wenn für Begleitpersonen mitgebucht wurde. */
seats: number;
/** Gebuchte Plätze bei Kursen mit Platzwahl, z.B. ["Reformer 3", "Reformer 4"]. */
spotLabels: string[];
isPast: boolean;
businessName: string;
businessBranding: { logoUrl: string | null; accentColor: string; accentTextColor: string };
@ -86,7 +89,8 @@ export async function getKundeDetail(
select: {
id: true,
paymentStatus: true,
spotLabel: true,
seats: true,
spotLabels: true,
course: {
select: {
id: true,
@ -102,9 +106,6 @@ export async function getKundeDetail(
maxParticipants: true,
coverImage: true,
description: true,
// Nur bezahlte Buchungen zählen als belegte Plätze - pending/failed/
// refunded/cancelled/partially_refunded blockieren keinen Platz mehr.
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: { select: { name: true, branding: true } },
},
},
@ -158,6 +159,12 @@ export async function getKundeDetail(
};
}
// Belegte Plätze pro Kurs - Summe der Plätze bezahlter Buchungen.
const seatCounts = await paidSeatCounts(
prisma,
userWithBookings.courseBookings.map((b) => b.course.id)
);
const now = new Date();
const bookings: KundeBooking[] = userWithBookings.courseBookings.map((b) => {
@ -174,14 +181,15 @@ export async function getKundeDetail(
level: b.course.level,
price: b.course.price,
maxParticipants: b.course.maxParticipants,
currentParticipants: b.course._count.bookings,
currentParticipants: seatCounts.get(b.course.id) ?? 0,
coverImage: b.course.coverImage ?? null,
description: b.course.description,
trainerProfiles: b.course.trainers.map((tid) => trainerProfilesMap[tid]).filter(Boolean),
trainers: b.course.trainers,
trainersMap,
paymentStatus: b.paymentStatus,
spotLabel: b.spotLabel,
seats: b.seats,
spotLabels: b.spotLabels,
isPast: courseStart < now,
businessName: b.course.business?.name ?? branding.companyName,
businessBranding: {

View file

@ -46,7 +46,8 @@ function toPublicCourse(b: KundeBooking): PublicCourse {
currentParticipants: b.currentParticipants,
description: b.description,
roomName: b.roomName,
spotLabel: b.spotLabel,
seats: b.seats,
spotLabels: b.spotLabels,
coverImage: b.coverImage ?? undefined,
trainerProfiles: b.trainerProfiles,
};
@ -137,7 +138,9 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
<MapPin size={13} className="shrink-0" />
<span className="truncate">
{booking.roomName}
{booking.spotLabel && ` · ${booking.spotLabel}`}
{booking.seats > 1
? ` · ${booking.seats} Plätze`
: booking.spotLabels[0] && ` · ${booking.spotLabels[0]}`}
</span>
</span>
</div>
@ -186,10 +189,10 @@ const KundeBookingCard = ({ booking }: KundeBookingCardProps) => {
bookingId={booking.bookingId}
courseName={booking.courseName}
variant="manager"
onConfirm={() => cancelKundeBooking(booking.bookingId)}
onSuccess={() => {
onConfirm={(selection) => cancelKundeBooking(booking.bookingId, selection)}
onSuccess={({ cancelledAll }) => {
router.refresh();
toast.success("Buchung erfolgreich storniert.");
toast.success(cancelledAll ? "Buchung erfolgreich storniert." : "Plätze storniert.");
}}
/>
</>

View file

@ -347,15 +347,14 @@ export async function updateRoom(roomId: string, data: RoomFormData) {
if (touchedSpots.length > 0) {
const booked = await prisma.courseBooking.findMany({
where: {
spotId: { in: touchedSpots.map((s) => s.id) },
spotIds: { hasSome: touchedSpots.map((s) => s.id) },
paymentStatus: "paid",
course: { room: roomId, date: { gte: todayStart } },
},
select: { spotId: true },
distinct: ["spotId"],
select: { spotIds: true },
});
if (booked.length > 0) {
const bookedIds = new Set(booked.map((b) => b.spotId));
const bookedIds = new Set(booked.flatMap((b) => b.spotIds));
const names = touchedSpots.filter((s) => bookedIds.has(s.id)).map(spotDisplayName);
return {
success: false,

View file

@ -16,11 +16,12 @@ interface SpotPickerProps {
floorPlan: FloorPlan;
/** Bezahlt gebucht oder gerade von jemand anderem im Checkout. */
takenSpotIds: string[];
selectedSpotId: string | null;
/** Platz der eigenen Buchung - wird hervorgehoben, ist aber nicht wählbar. */
mySpotId?: string | null;
/** Fehlt = nur ansehen. */
onSelect?: (spotId: string) => void;
/** Gewählte Plätze - mehrere bei einer Buchung mit Begleitpersonen. */
selectedSpotIds: string[];
/** Plätze der eigenen Buchung - werden hervorgehoben, sind aber nicht wählbar. */
mySpotIds?: string[];
/** Antippen eines freien oder gewählten Platzes. Fehlt = nur ansehen. */
onToggle?: (spotId: string) => void;
/** Akzentfarbe des Studios für den gewählten Platz. */
accentColor?: string;
}
@ -32,9 +33,9 @@ interface SpotPickerProps {
export function SpotPicker({
floorPlan,
takenSpotIds,
selectedSpotId,
mySpotId = null,
onSelect,
selectedSpotIds,
mySpotIds = [],
onToggle,
accentColor = DEFAULT_ACCENT,
}: SpotPickerProps) {
const taken = new Set(takenSpotIds);
@ -52,9 +53,9 @@ export function SpotPicker({
);
}
const isMine = item.id === mySpotId;
const isMine = mySpotIds.includes(item.id);
const isTaken = !isMine && taken.has(item.id);
const isSelected = item.id === selectedSpotId;
const isSelected = selectedSpotIds.includes(item.id);
const tone: EquipmentTone = isMine || isSelected ? "picked" : isTaken ? "taken" : "free";
const name = spotDisplayName(item);
const state = isMine
@ -69,9 +70,9 @@ export function SpotPicker({
<button
key={item.id}
type="button"
disabled={!onSelect || isTaken || isMine}
onClick={() => onSelect?.(item.id)}
aria-pressed={onSelect ? isSelected : undefined}
disabled={!onToggle || isTaken || isMine}
onClick={() => onToggle?.(item.id)}
aria-pressed={onToggle ? isSelected : undefined}
aria-label={`${name}, ${state}`}
title={`${name} – ${state}`}
className={cn(
@ -92,7 +93,7 @@ export function SpotPicker({
<LegendSwatch className="border-slate-500 bg-white">Frei</LegendSwatch>
<LegendSwatch className="border-slate-300 bg-slate-200">Belegt</LegendSwatch>
<LegendSwatch style={{ backgroundColor: accent.color, borderColor: accent.color }}>
{mySpotId ? "Ihr Platz" : "Ihre Wahl"}
{mySpotIds.length > 0 ? "Ihre Buchung" : "Ihre Wahl"}
</LegendSwatch>
</div>
</div>

View file

@ -3,7 +3,7 @@ import type { ManagerData } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
import type { ZahlungenFilters } from "./zahlungen-actions";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { courseAmountCents } from "@/src/lib/booking-amounts";
import { courseAmountCents, refundedCourseCents } from "@/src/lib/booking-amounts";
import { readTaxSettings, type TaxSettings } from "@/src/modules/settings/lib/tax-settings";
import { formatBillingAddressLines, readBillingAddress } from "@/src/lib/billing-address";
@ -41,11 +41,13 @@ export type InvoiceData = {
course: {
name: string;
date: string; // ISO
/** Bezahlte Plätze - "3 × Kursname" bei einer Mehrfachbuchung. */
seats: number;
};
customer: InvoiceCustomer;
amountCents: number;
originalAmountCents: number;
refundedAmountCents: number; // 0, außer bei Teilerstattung
refundedAmountCents: number; // Kursanteil der Erstattung - 0, außer bei Teilerstattung oder Teilstorno einzelner Plätze
};
/**
@ -119,6 +121,8 @@ const invoiceBookingSelect = {
platformFee: true,
platformFeeOnTop: true,
refundedAmount: true,
refundedFeeAmount: true,
seatsPurchased: true,
paymentStatus: true,
course: {
select: {
@ -214,15 +218,18 @@ function toInvoiceData(
course: {
name: booking.course.name,
date: booking.course.date.toISOString(),
seats: booking.seatsPurchased,
},
customer,
// Bei Teilerstattung zeigt die Rechnung den nach Erstattung verbliebenen
// Betrag als Endbetrag, weist aber zusätzlich den ursprünglichen Preis und
// die Erstattung separat aus - sonst wäre für Kunde und Buchhaltung nicht
// nachvollziehbar, warum die Rechnung von der Kursgebühr abweicht.
amountCents: originalAmountCents - (booking.refundedAmount ?? 0),
// nachvollziehbar, warum die Rechnung von der Kursgebühr abweicht. Nur der
// Kursanteil der Erstattung zählt: eine bei der Kursabsage mit erstattete
// Servicegebühr stand nie auf dieser Rechnung.
amountCents: originalAmountCents - refundedCourseCents(booking),
originalAmountCents,
refundedAmountCents: booking.refundedAmount ?? 0,
refundedAmountCents: refundedCourseCents(booking),
};
}

View file

@ -3,7 +3,11 @@
import { prisma } from "@/src/lib/prisma";
import { requireManager } from "@/src/lib/auth/getUser";
import { buildZahlungenWhere } from "../lib/build-zahlungen-where";
import { courseAmountCents, payoutAmountCents } from "@/src/lib/booking-amounts";
import {
courseAmountCents,
payoutAmountCents,
refundedCourseCents,
} from "@/src/lib/booking-amounts";
export type ZahlungStatus =
| "paid"
@ -30,6 +34,8 @@ export type ZahlungRow = {
customerEmail: string;
isGuest: boolean;
courseName: string;
/** Ursprünglich bezahlte Plätze - mehr als 1 bei einer Mehrfachbuchung. */
seats: number;
businessId: string;
businessName: string;
/**
@ -88,6 +94,8 @@ export async function getZahlungen(
stripeFee: true,
stripeAccountId: true,
refundedAmount: true,
refundedFeeAmount: true,
seatsPurchased: true,
invoiceNumber: true,
course: {
select: {
@ -110,15 +118,16 @@ export async function getZahlungen(
const customerEmail = b.user?.email ?? b.guestLead?.email ?? "–";
// Bei Teilerstattung zeigt die Übersicht den Betrag, den das Business
// behält (netto). Voll erstattete / stornierte Zeilen behalten ihren
// Rohbetrag – dort trägt das Status-Badge die Bedeutung.
// Basis ist immer der Kursanteil: die Servicegebühr hat der Kunde an die
// Plattform gezahlt, sie war nie Umsatz des Studios.
// behält (netto) - das gilt auch für eine aktive Mehrfachbuchung, von der
// einzelne Plätze storniert wurden ("paid" mit refundedAmount). Voll
// erstattete / stornierte Zeilen behalten ihren Rohbetrag – dort trägt
// das Status-Badge die Bedeutung. Basis ist immer der Kursanteil: die
// Servicegebühr hat der Kunde an die Plattform gezahlt, sie war nie
// Umsatz des Studios.
const keepsMoney = b.paymentStatus === "paid" || b.paymentStatus === "partially_refunded";
const courseAmount = b.amountPaid !== null ? courseAmountCents(b) : null;
const netAmount =
b.paymentStatus === "partially_refunded" && courseAmount !== null
? courseAmount - (b.refundedAmount ?? 0)
: courseAmount;
keepsMoney && courseAmount !== null ? courseAmount - refundedCourseCents(b) : courseAmount;
// Direct Charges (stripeAccountId gesetzt): die Gebühr zieht Stripe beim
// Studio ab, null heißt "noch nicht abgerechnet". Altbuchungen: die
@ -131,8 +140,7 @@ export async function getZahlungen(
// einer Stornierung ein.
const payout =
b.amountPaid !== null
? payoutAmountCents(b) -
(b.paymentStatus === "partially_refunded" ? (b.refundedAmount ?? 0) : 0)
? payoutAmountCents(b) - (keepsMoney ? (b.refundedAmount ?? 0) : 0)
: null;
return {
@ -142,6 +150,7 @@ export async function getZahlungen(
customerEmail,
isGuest,
courseName: b.course.name,
seats: b.seatsPurchased,
businessId: b.course.business?.id ?? "",
businessName: b.course.business?.name ?? "–",
amountPaid: netAmount,

View file

@ -160,7 +160,9 @@ export function InvoicePdfDocument({ data, includeLogo = true }: InvoicePdfDocum
</View>
<View style={styles.tableRow}>
<View style={styles.colDescription}>
<Text>1 × {course.name}</Text>
<Text>
{course.seats} × {course.name}
</Text>
<Text style={styles.muted}>Leistungsdatum: {formatDate(course.date)}</Text>
</View>
<Text style={styles.colAmount}>{formatPrice(originalAmountCents)}</Text>

View file

@ -152,6 +152,7 @@ const ZahlungenTable = ({
</TableCell>
<TableCell className="hidden md:table-cell text-muted-foreground">
{z.courseName}
{z.seats > 1 && <span className="text-xs"> · {z.seats} Plätze</span>}
</TableCell>
{showBusinessColumn && (
<TableCell className="hidden lg:table-cell text-muted-foreground">

View file

@ -25,19 +25,30 @@ vi.mock("@/src/lib/stripe", () => ({
// 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 }),
vi.mock("@/src/modules/settings/lib/discount-validation", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/src/modules/settings/lib/discount-validation")>();
return {
reserveDiscountCode: vi.fn(),
releaseDiscountReservation: vi.fn().mockResolvedValue(undefined),
// Die echte Rechnung - die Tests prüfen, wie der Rabatt auf mehrere Plätze wirkt.
calculateDiscountedPriceCents: actual.calculateDiscountedPriceCents,
};
});
// Belegte Plätze des Kurses - pro Test überschreibbar.
vi.mock("@/src/modules/courses/lib/seat-counts", () => ({
MAX_SEATS_PER_BOOKING: 10,
paidSeatCount: vi.fn().mockResolvedValue(0),
}));
// 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),
attachSessionToHolds: vi.fn().mockResolvedValue(undefined),
holdSpot: vi.fn(),
releaseSpotHold: vi.fn().mockResolvedValue(undefined),
releaseSpotHolds: vi.fn().mockResolvedValue(undefined),
spotHolderKey: (p: { userId?: string | null; guestEmail?: string | null }) =>
p.userId ? `user:${p.userId}` : `guest:${p.guestEmail}`,
SPOT_HOLD_MINUTES: 30,
@ -48,16 +59,17 @@ import { isManagerOrEmployeeEmail } from "@/src/lib/auth/account-role-by-email";
import { prisma } from "@/src/lib/prisma";
import { stripe, getPlatformFeeCents } from "@/src/lib/stripe";
import {
attachSessionToHold,
attachSessionToHolds,
getCourseSpots,
getOpenCheckoutHolds,
holdSpot,
releaseSpotHold,
releaseSpotHolds,
} from "@/src/modules/courses/lib/course-spots";
import {
reserveDiscountCode,
releaseDiscountReservation,
} from "@/src/modules/settings/lib/discount-validation";
import { paidSeatCount } from "@/src/modules/courses/lib/seat-counts";
let ipCounter = 0;
@ -83,7 +95,6 @@ const publishedCourse = {
date: new Date("2099-01-01"),
timeFrom: "10:00",
maxParticipants: 20,
_count: { bookings: 0 },
business: {
id: "biz-1",
name: "Studio",
@ -278,8 +289,8 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
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(releaseSpotHolds).mockClear();
vi.mocked(attachSessionToHolds).mockClear();
vi.mocked(getOpenCheckoutHolds).mockResolvedValue([]);
vi.mocked(stripe.checkout.sessions.retrieve).mockReset();
vi.mocked(stripe.checkout.sessions.expire).mockReset();
@ -306,8 +317,9 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(409);
// Bei mehreren Plätzen muss klar sein, welcher nicht geklappt hat.
expect(await res.json()).toMatchObject({
error: "Platz gerade vergeben",
error: "Reformer 2: Platz gerade vergeben",
spotUnavailable: true,
});
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
@ -329,7 +341,7 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
metadata: Record<string, string>;
line_items: { price_data: { product_data: { description: string } } }[];
};
expect(args.metadata).toMatchObject({ spotId: "spot-2", spotHoldId: "hold-1" });
expect(args.metadata).toMatchObject({ seats: "1", spotIds: "spot-2", spotHoldIds: "hold-1" });
expect(args.line_items[0].price_data.product_data.description).toContain("Reformer 2");
const inMinutes = (args.expires_at! * 1000 - Date.now()) / 60_000;
expect(inMinutes).toBeGreaterThan(29);
@ -344,7 +356,7 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(res.status).toBe(500);
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-1");
expect(releaseSpotHolds).toHaveBeenCalledWith(prisma, ["hold-1"]);
consoleError.mockRestore();
});
@ -359,7 +371,7 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
metadata: Record<string, string>;
};
expect(args.expires_at).toBeUndefined();
expect(args.metadata.spotId).toBe("");
expect(args.metadata.spotIds).toBe("");
expect(holdSpot).not.toHaveBeenCalled();
});
@ -391,7 +403,7 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
await POST(makeRequest({ courseId: "course-1", spotId: "spot-1" }));
expect(attachSessionToHold).toHaveBeenCalledWith(prisma, "hold-1", "cs_neu");
expect(attachSessionToHolds).toHaveBeenCalledWith(prisma, ["hold-1"], "cs_neu");
});
it("beendet einen alten, noch offenen Checkout derselben Person", async () => {
@ -411,7 +423,7 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
{},
{ stripeAccount: "acct_123" }
);
expect(releaseSpotHold).toHaveBeenCalledWith(prisma, "hold-alt");
expect(releaseSpotHolds).toHaveBeenCalledWith(prisma, ["hold-alt"]);
});
it("lässt den Platz eines schon bezahlten Checkouts stehen, bis der Webhook kommt", async () => {
@ -427,7 +439,7 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(stripe.checkout.sessions.expire).not.toHaveBeenCalled();
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
expect(releaseSpotHolds).not.toHaveBeenCalledWith(prisma, ["hold-alt"]);
});
it("lässt den alten Hold im Zweifel stehen, wenn Stripe nicht antwortet", async () => {
@ -441,7 +453,108 @@ describe("POST /api/stripe/checkout/course – Platzwahl", () => {
const res = await POST(makeRequest({ courseId: "course-1", spotId: "spot-2" }));
expect(res.status).toBe(200);
expect(releaseSpotHold).not.toHaveBeenCalledWith(prisma, "hold-alt");
expect(releaseSpotHolds).not.toHaveBeenCalledWith(prisma, ["hold-alt"]);
consoleWarn.mockRestore();
});
});
describe("POST /api/stripe/checkout/course – Mehrfachbuchung", () => {
type SessionArgs = {
line_items: {
price_data: { unit_amount: number; product_data: { name: string } };
quantity: number;
}[];
payment_intent_data: { application_fee_amount?: number };
metadata: Record<string, string>;
};
const sessionArgs = () =>
vi.mocked(stripe.checkout.sessions.create).mock.calls[0][0] as never as SessionArgs;
beforeEach(() => {
vi.mocked(getUserData).mockResolvedValue({ id: "user-1", email: "kunde@example.com" } as never);
vi.mocked(isUser).mockReturnValue(true);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(null as never);
vi.mocked(paidSeatCount).mockResolvedValue(0);
vi.mocked(reserveDiscountCode).mockReset();
vi.mocked(holdSpot).mockReset();
});
it("berechnet Kurspreis und Servicegebühr pro Platz", async () => {
const res = await POST(makeRequest({ courseId: "course-1", seats: 3 }));
expect(res.status).toBe(200);
const args = sessionArgs();
expect(args.line_items[0]).toMatchObject({ quantity: 3, price_data: { unit_amount: 2900 } });
expect(args.line_items[1]).toMatchObject({ quantity: 3, price_data: { unit_amount: 50 } });
expect(args.payment_intent_data.application_fee_amount).toBe(150);
expect(args.metadata.seats).toBe("3");
});
it("lässt Gäste nur einen Platz buchen", async () => {
vi.mocked(getUserData).mockResolvedValue(null as never);
vi.mocked(isManagerOrEmployeeEmail).mockResolvedValue(false);
const res = await POST(
makeRequest({ courseId: "course-1", guestEmail: "gast@example.com", seats: 2 })
);
expect(res.status).toBe(400);
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lehnt mehr Plätze ab, als frei sind", async () => {
// 20 Plätze, 18 belegt
vi.mocked(paidSeatCount).mockResolvedValue(18);
const res = await POST(makeRequest({ courseId: "course-1", seats: 3 }));
expect(res.status).toBe(400);
expect((await res.json()).error).toContain("2 Plätze frei");
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("lehnt eine ungültige Platzanzahl ab", async () => {
for (const seats of [0, 11, 1.5]) {
const res = await POST(makeRequest({ courseId: "course-1", seats }));
expect(res.status).toBe(400);
}
expect(stripe.checkout.sessions.create).not.toHaveBeenCalled();
});
it("zieht einen festen Rabatt einmal pro Buchung ab", async () => {
vi.mocked(reserveDiscountCode).mockResolvedValue({
reserved: true,
reservationToken: "tok-1",
discountCode: { id: "code-1", type: "fixed", value: 501 },
} as never);
const res = await POST(makeRequest({ courseId: "course-1", seats: 2, discountCode: "FIX5" }));
expect(res.status).toBe(200);
// 2 × 29 € − 5,01 € = 52,99 € - teilt sich nicht in ganze Cent pro Platz,
// also eine Position mit der Platzanzahl im Namen
const course = sessionArgs().line_items[0];
expect(course.quantity).toBe(1);
expect(course.price_data.unit_amount).toBe(5299);
expect(course.price_data.product_data.name).toBe("Yoga (2 Plätze)");
});
it("verlangt bei Platzwahl genau so viele Plätze wie gebucht werden", async () => {
const reformer = (id: string, label: string) => ({
id,
type: "reformer" as const,
x: 1,
y: 1.5,
rotation: 0 as const,
label,
});
vi.mocked(getCourseSpots).mockResolvedValue([reformer("s1", "1"), reformer("s2", "2")]);
const res = await POST(makeRequest({ courseId: "course-1", seats: 2, spotIds: ["s1"] }));
expect(res.status).toBe(400);
expect((await res.json()).spotUnavailable).toBe(true);
expect(holdSpot).not.toHaveBeenCalled();
vi.mocked(getCourseSpots).mockResolvedValue(null);
});
});

View file

@ -18,7 +18,13 @@ vi.mock("@/src/lib/prisma", () => ({
$transaction: vi.fn(),
user: { findUnique: vi.fn(), findFirst: vi.fn() },
course: { findUnique: vi.fn() },
courseBooking: { findUnique: vi.fn(), findFirst: vi.fn(), update: vi.fn(), count: vi.fn() },
courseBooking: {
findUnique: vi.fn(),
findFirst: vi.fn(),
update: vi.fn(),
count: vi.fn(),
aggregate: vi.fn(),
},
courseWaitlistEntry: { deleteMany: vi.fn() },
courseSpotHold: { deleteMany: vi.fn(), updateMany: vi.fn() },
business: { findUnique: vi.fn(), update: vi.fn() },
@ -59,6 +65,7 @@ const tx = {
findFirst: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
aggregate: vi.fn(),
upsert: vi.fn(),
},
course: { findUnique: vi.fn() },
@ -119,6 +126,7 @@ beforeEach(() => {
tx.courseBooking.findUnique.mockResolvedValue(null);
tx.courseBooking.findFirst.mockResolvedValue(null);
tx.courseBooking.count.mockResolvedValue(0);
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 0 } });
tx.courseBooking.upsert.mockResolvedValue({ id: "booking-1" });
tx.course.findUnique.mockResolvedValue({ maxParticipants: 10 });
tx.courseBooking.findMany.mockResolvedValue([]);
@ -143,6 +151,7 @@ beforeEach(() => {
} as never);
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({ userId: "user-1" } as never);
vi.mocked(prisma.courseBooking.count).mockResolvedValue(1 as never);
vi.mocked(prisma.courseBooking.aggregate).mockResolvedValue({ _sum: { seats: 1 } } as never);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_1",
@ -269,7 +278,7 @@ describe("Stripe-Webhook – Buchung", () => {
});
it("erstattet eine Überbuchung auf dem Studio-Account und gibt die Gebühr ans Studio zurück", async () => {
tx.courseBooking.count.mockResolvedValue(10);
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 10 } });
await deliver(
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
@ -340,7 +349,7 @@ describe("Stripe-Webhook – Platzwahl", () => {
sessionEvent(type, {
account: "acct_studio",
paymentStatus,
metadata: { spotId: "spot-1", spotHoldId: "hold-1" },
metadata: { spotIds: "spot-1", spotHoldIds: "hold-1" },
});
it("schreibt den gewählten Platz an die Buchung und gibt den Hold frei", async () => {
@ -348,28 +357,30 @@ describe("Stripe-Webhook – Platzwahl", () => {
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "paid",
spotId: "spot-1",
spotLabel: "Reformer 1",
spotIds: ["spot-1"],
spotLabels: ["Reformer 1"],
});
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ["hold-1"] } },
});
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
});
it("gibt den nächsten freien Platz, wenn der Wunschplatz inzwischen bezahlt ist", async () => {
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }]);
tx.courseBooking.findMany.mockResolvedValue([{ spotIds: ["spot-1"] }]);
await deliver(spotSession(), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "paid",
spotId: "spot-2",
spotLabel: "Reformer 2",
spotIds: ["spot-2"],
spotLabels: ["Reformer 2"],
});
expect(refundStripePayment).not.toHaveBeenCalled();
});
it("erstattet wie bei einem vollen Kurs, wenn kein Platz mehr frei ist", async () => {
tx.courseBooking.count.mockResolvedValue(2);
tx.courseBooking.findMany.mockResolvedValue([{ spotId: "spot-1" }, { spotId: "spot-2" }]);
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 2 } });
tx.courseBooking.findMany.mockResolvedValue([{ spotIds: ["spot-1", "spot-2"] }]);
await deliver(spotSession(), "whsec_connect");
@ -391,7 +402,7 @@ describe("Stripe-Webhook – Platzwahl", () => {
where: { id: string };
data: { expiresAt: Date; status: string };
};
expect(call.where).toEqual({ id: "hold-1" });
expect(call.where).toEqual({ id: { in: ["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;
@ -401,6 +412,102 @@ describe("Stripe-Webhook – Platzwahl", () => {
it("gibt den Platz frei, wenn die Session abläuft", async () => {
await deliver(spotSession("checkout.session.expired", "unpaid"), "whsec_connect");
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({ where: { id: "hold-1" } });
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ["hold-1"] } },
});
});
});
describe("Stripe-Webhook – Mehrfachbuchung", () => {
const multiSession = (seats: string, metadata: Record<string, string> = {}) =>
sessionEvent("checkout.session.completed", {
account: "acct_studio",
metadata: { seats, ...metadata },
});
it("legt eine Buchung mit allen Plätzen an", async () => {
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 4 } });
await deliver(multiSession("3"), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "paid",
seats: 3,
seatsPurchased: 3,
});
expect(refundStripePayment).not.toHaveBeenCalled();
});
it("erstattet, wenn nicht mehr alle Plätze frei sind - kein halbes Buchen", async () => {
// 10 Plätze, 8 belegt: 3 passen nicht mehr
tx.courseBooking.aggregate.mockResolvedValue({ _sum: { seats: 8 } });
await deliver(multiSession("3"), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
paymentStatus: "failed",
});
expect(refundStripePayment).toHaveBeenCalledWith({
paymentIntentId: "pi_1",
stripeAccountId: "acct_studio",
refundApplicationFee: true,
});
});
it("setzt Storno-Felder einer alten Buchung desselben Kunden zurück", async () => {
await deliver(multiSession("2"), "whsec_connect");
expect(tx.courseBooking.upsert.mock.calls[0][0].update).toMatchObject({
paymentStatus: "paid",
seats: 2,
refundedAmount: null,
refundedFeeAmount: null,
cancelledBy: null,
});
});
it("weist bei Platzwahl jedem Platz ein Gerät zu", async () => {
tx.course.findUnique.mockResolvedValue({
maxParticipants: 10,
spotBooking: true,
room: "room-1",
});
tx.room.findUnique.mockResolvedValue({
floorPlan: {
width: 8,
depth: 6,
items: ["1", "2", "3"].map((label, i) => ({
id: `spot-${label}`,
type: "reformer",
x: i + 1,
y: 1.5,
rotation: 0,
label,
})),
},
});
await deliver(
multiSession("2", { spotIds: "spot-1,spot-3", spotHoldIds: "hold-1,hold-3" }),
"whsec_connect"
);
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({
seats: 2,
spotIds: ["spot-1", "spot-3"],
spotLabels: ["Reformer 1", "Reformer 3"],
});
expect(prisma.courseSpotHold.deleteMany).toHaveBeenCalledWith({
where: { id: { in: ["hold-1", "hold-3"] } },
});
});
it("behandelt Sessions ohne Platzanzahl als einen Platz", async () => {
await deliver(
sessionEvent("checkout.session.completed", { account: "acct_studio" }),
"whsec_connect"
);
expect(tx.courseBooking.upsert.mock.calls[0][0].create).toMatchObject({ seats: 1 });
});
});

View file

@ -43,6 +43,13 @@ const booking = {
stripeAccountId: "acct_studio",
userId: "user-1",
guestLeadId: null,
seats: 1,
seatsPurchased: 1,
spotIds: [] as string[],
spotLabels: [] as string[],
refundedAmount: null as number | null,
refundedFeeAmount: null as number | null,
refundedAt: null as Date | null,
course: {
id: "course-1",
name: "Yoga",
@ -150,3 +157,146 @@ describe("executeBookingCancellation – Servicegebühr", () => {
expect(vi.mocked(refundStripePayment).mock.calls[0][0].stripeAccountId).toBeNull();
});
});
describe("executeBookingCancellation – einzelne Plätze einer Mehrfachbuchung", () => {
// 3 Plätze à 29 € + 3 × 0,50 € Servicegebühr = 88,50 €
const threeSeats = {
...booking,
amountPaid: 8850,
platformFee: 150,
seats: 3,
seatsPurchased: 3,
};
const lastUpdate = () => vi.mocked(prisma.courseBooking.update).mock.calls.at(-1)![0].data;
it("storniert einen Platz und lässt die Buchung mit zwei Plätzen aktiv", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(threeSeats as never);
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 1 },
});
expect(result).toMatchObject({ success: true, remainingSeats: 2 });
// Erstattungsbasis ist der Kursanteil EINES Platzes
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(2900);
// Claim an den Platzstand gebunden, Idempotenz pro Schritt
expect(vi.mocked(prisma.courseBooking.updateMany).mock.calls[0][0].where).toMatchObject({
paymentStatus: "paid",
seats: 3,
});
expect(vi.mocked(refundStripePayment).mock.calls[0][0].idempotencyKey).toBe(
"refund-booking-1-3"
);
expect(lastUpdate()).toMatchObject({ paymentStatus: "paid", seats: 2, refundedAmount: 2900 });
expect(lastUpdate().cancelledBy).toBeUndefined();
});
it("gilt als erstattet, wenn auch der letzte Rest voll zurückgeht", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
seats: 2,
refundedAmount: 2900,
} as never);
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 5800,
});
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_3",
amountRefundedCents: 5800,
});
const result = await executeBookingCancellation("booking-1", "user");
expect(result).toMatchObject({ success: true, remainingSeats: 0 });
expect(vi.mocked(calculateRefundAmountCents).mock.calls[0][0].amountPaidCents).toBe(5800);
expect(lastUpdate()).toMatchObject({
paymentStatus: "refunded",
refundedAmount: 8700,
cancelledBy: "user",
});
});
it("bleibt teilerstattet, wenn ein früherer Platz nur anteilig zurückging", async () => {
// Erster Platz zu 50 % storniert (14,50 €), jetzt der Rest zu 100 %
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
seats: 2,
refundedAmount: 1450,
} as never);
vi.mocked(calculateRefundAmountCents).mockResolvedValue({
cancellationAllowed: true,
refundPercent: 100,
refundAmountCents: 5800,
});
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_4",
amountRefundedCents: 5800,
});
await executeBookingCancellation("booking-1", "user");
// Das Studio behält 14,50 € - die Buchung bleibt rechnungsfähig.
expect(lastUpdate()).toMatchObject({
paymentStatus: "partially_refunded",
refundedAmount: 7250,
});
});
it("gibt bei Platzwahl genau die gewählten Geräte frei", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
spotIds: ["r1", "r2", "r3"],
spotLabels: ["Reformer 1", "Reformer 2", "Reformer 3"],
} as never);
await executeBookingCancellation("booking-1", "user", {
seatSelection: { spotIds: ["r2"] },
});
expect(lastUpdate()).toMatchObject({
seats: 2,
spotIds: ["r1", "r3"],
spotLabels: ["Reformer 1", "Reformer 3"],
});
});
it("erstattet bei einer Kursabsage nach einem Teilstorno nur noch den Rest", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue({
...threeSeats,
seats: 2,
refundedAmount: 2900,
} as never);
vi.mocked(refundStripePayment).mockResolvedValue({
success: true,
stripeRefundId: "re_5",
amountRefundedCents: 5900,
});
await executeBookingCancellation("booking-1", "manager", { forceFullRefund: true });
// 2 Plätze Kurs (58 €) + deren Servicegebühr (1 €) - explizit, nicht "alles"
expect(vi.mocked(refundStripePayment).mock.calls[0][0].amountCents).toBe(5900);
expect(lastUpdate()).toMatchObject({
paymentStatus: "refunded",
refundedAmount: 8800,
refundedFeeAmount: 100,
});
});
it("lehnt eine ungültige Auswahl ab, bevor etwas passiert", async () => {
vi.mocked(prisma.courseBooking.findUnique).mockResolvedValue(threeSeats as never);
const result = await executeBookingCancellation("booking-1", "user", {
seatSelection: { seats: 4 },
});
expect(result.success).toBe(false);
expect(prisma.courseBooking.updateMany).not.toHaveBeenCalled();
expect(refundStripePayment).not.toHaveBeenCalled();
});
});

View file

@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
assignSpot,
extendSpotHold,
assignSpots,
extendSpotHolds,
getCourseSpots,
getHeldSpotIds,
holdSpot,
@ -60,65 +60,56 @@ describe("getCourseSpots", () => {
});
});
describe("assignSpot", () => {
it("gibt den Wunschplatz, wenn er frei ist", async () => {
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s2",
holdId: "hold-1",
});
expect(result).toEqual({ spotId: "s2", spotLabel: "Reformer 2", changed: false });
describe("assignSpots", () => {
const assign = (params: { count: number; requestedSpotIds: string[]; holdIds?: string[] }) =>
assignSpots(db as never, { courseId: "c1", spots, holdIds: [], ...params });
it("gibt die Wunschplätze, wenn sie frei sind", async () => {
const result = await assign({ count: 2, requestedSpotIds: ["s3", "s2"], holdIds: ["h"] });
expect(result).toEqual([
{ spotId: "s2", spotLabel: "Reformer 2", changed: false },
{ spotId: "s3", spotLabel: "Reformer 3", changed: false },
]);
});
it("weicht auf einen freien Platz aus, den niemand gerade im Checkout hält", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }]);
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1"] }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: "hold-1",
});
const result = await assign({ count: 1, requestedSpotIds: ["s1"], holdIds: ["hold-1"] });
expect(result).toEqual({ spotId: "s3", spotLabel: "Reformer 3", changed: true });
// Der eigene Hold zählt nicht als "von jemand anderem gehalten".
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.id).toEqual({ not: "hold-1" });
expect(result).toEqual([{ spotId: "s3", spotLabel: "Reformer 3", changed: true }]);
// Die eigenen Holds zählen nicht als "von jemand anderem gehalten".
expect(db.courseSpotHold.findMany.mock.calls[0][0].where.id).toEqual({ notIn: ["hold-1"] });
});
it("behält freie Wunschplätze und füllt nur den fehlenden auf", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1"] }]);
const result = await assign({ count: 2, requestedSpotIds: ["s1", "s2"] });
expect(result?.map((s) => [s.spotId, s.changed])).toEqual([
["s2", false],
["s3", true],
]);
});
it("nimmt notfalls einen gehaltenen Platz - bezahlt schlägt Checkout", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotId: "s1" }, { spotId: "s3" }]);
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1", "s3"] }]);
db.courseSpotHold.findMany.mockResolvedValue([{ spotId: "s2" }]);
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: null,
});
expect(result?.spotId).toBe("s2");
const result = await assign({ count: 1, requestedSpotIds: ["s1"] });
expect(result?.[0].spotId).toBe("s2");
});
it("liefert null, wenn alle Plätze bezahlt sind", async () => {
db.courseBooking.findMany.mockResolvedValue(spots.map((s) => ({ spotId: s.id })));
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: "s1",
holdId: null,
});
expect(result).toBeNull();
it("liefert null, wenn nicht genug Plätze frei sind", async () => {
db.courseBooking.findMany.mockResolvedValue([{ spotIds: ["s1", "s2"] }]);
expect(await assign({ count: 2, requestedSpotIds: ["s3"] })).toBeNull();
});
it("weist bei alten Sessions ohne Wunschplatz den ersten freien zu", async () => {
const result = await assignSpot(db as never, {
courseId: "c1",
spots,
requestedSpotId: null,
holdId: null,
});
expect(result).toEqual({ spotId: "s1", spotLabel: "Reformer 1", changed: false });
it("weist bei alten Sessions ohne Wunschplatz die ersten freien zu", async () => {
const result = await assign({ count: 1, requestedSpotIds: [] });
expect(result).toEqual([{ spotId: "s1", spotLabel: "Reformer 1", changed: false }]);
});
});
@ -170,9 +161,9 @@ describe("holdSpot", () => {
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);
await extendSpotHolds(db as never, ["hold-1"], until);
expect(db.courseSpotHold.updateMany).toHaveBeenCalledWith({
where: { id: "hold-1" },
where: { id: { in: ["hold-1"] } },
data: { expiresAt: until, status: "awaiting_payment" },
});
});

View file

@ -0,0 +1,64 @@
import { describe, it, expect } from "vitest";
import {
resolveSeatCancellation,
seatShareCents,
} from "@/src/modules/courses/lib/seat-cancellation";
describe("seatShareCents", () => {
// 2 Plätze, fester Rabatt von 5,01 €: 52,99 € Kurs + 1,00 € Servicegebühr
const booking = {
amountPaid: 5399,
platformFee: 100,
platformFeeOnTop: true,
seats: 2,
seatsPurchased: 2,
};
it("teilt den Kursanteil auf die Plätze und gibt den Rundungsrest dem letzten", () => {
const first = seatShareCents(booking, 1);
const last = seatShareCents({ ...booking, seats: 1 }, 1);
expect(first).toEqual({ courseCents: 2649, feeCents: 50 });
expect(last).toEqual({ courseCents: 2650, feeCents: 50 });
// In Summe genau, was gezahlt wurde
expect(first.courseCents + last.courseCents).toBe(5299);
});
it("rechnet Altbuchungen ohne aufgeschlagene Gebühr ohne Gebührenanteil", () => {
const legacy = { ...booking, platformFeeOnTop: false, seats: 1, seatsPurchased: 1 };
expect(seatShareCents(legacy, 1)).toEqual({ courseCents: 5399, feeCents: 0 });
});
});
describe("resolveSeatCancellation", () => {
const withSpots = {
seats: 3,
seatsPurchased: 3,
spotIds: ["r1", "r2", "r3"],
spotLabels: ["Reformer 1", "Reformer 2", "Reformer 3"],
};
it("storniert ohne Auswahl alle Plätze", () => {
const result = resolveSeatCancellation(withSpots);
expect(result).toMatchObject({ ok: true, value: { count: 3, cancelsAll: true } });
});
it("gibt bei einer reinen Anzahl die zuletzt gebuchten Geräte frei", () => {
const result = resolveSeatCancellation(withSpots, { seats: 1 });
expect(result).toMatchObject({
ok: true,
value: { count: 1, cancelsAll: false, keptSpotIds: ["r1", "r2"] },
});
});
it("akzeptiert nur Geräte aus dieser Buchung", () => {
expect(resolveSeatCancellation(withSpots, { spotIds: ["r9"] }).ok).toBe(false);
expect(resolveSeatCancellation(withSpots, { spotIds: ["r1", "r9"] }).ok).toBe(false);
});
it("lehnt mehr Plätze ab, als gebucht sind", () => {
const noSpots = { seats: 2, seatsPurchased: 3, spotIds: [], spotLabels: [] };
expect(resolveSeatCancellation(noSpots, { seats: 3 }).ok).toBe(false);
expect(resolveSeatCancellation(noSpots, { seats: 0 }).ok).toBe(false);
});
});

View file

@ -57,7 +57,7 @@ beforeEach(() => {
describe("updateRoom – gebuchte Plätze", () => {
it("verhindert das Entfernen eines Geräts mit kommender Buchung", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotId: "r3" }] as never);
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotIds: ["r3"] }] as never);
const result = await updateRoom("room-1", {
name: "Reformer-Raum",
@ -70,7 +70,7 @@ describe("updateRoom – gebuchte Plätze", () => {
});
it("verhindert das Umnummerieren eines gebuchten Geräts", async () => {
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotId: "r1" }] as never);
vi.mocked(prisma.courseBooking.findMany).mockResolvedValue([{ spotIds: ["r1"] }] as never);
const result = await updateRoom("room-1", {
name: "Reformer-Raum",