mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 19:51:17 +00:00
added multi-booking
This commit is contained in:
parent
55f0163af5
commit
1cd905be78
36 changed files with 1718 additions and 579 deletions
|
|
@ -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");
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 })[]
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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." };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
|
|
|||
98
src/modules/courses/lib/seat-cancellation.ts
Normal file
98
src/modules/courses/lib/seat-cancellation.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
39
src/modules/courses/lib/seat-counts.ts
Normal file
39
src/modules/courses/lib/seat-counts.ts
Normal 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]));
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
});
|
||||
|
|
|
|||
64
tests/courses/seat-cancellation.test.ts
Normal file
64
tests/courses/seat-cancellation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue