Merge pull request #98 from Since-3/feature/add-stripe-checkout

added stripe checkout with stripe express onboarding for businesses
This commit is contained in:
Rajpreet0 2026-05-15 02:27:02 +02:00 • committed by GitHub
commit 6359004609
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 976 additions and 126 deletions

1
.gitignore vendored
View file

@ -16,6 +16,7 @@
# next.js
/.next/
/out/
STRIPE_INTEGRATION.md
# production
/build

28
package-lock.json generated
View file

@ -20,6 +20,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@stripe/stripe-js": "^9.5.0",
"@supabase/ssr": "^0.7.0",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/extension-underline": "^3.13.0",
@ -42,6 +43,7 @@
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14",
"sonner": "^2.0.7",
"stripe": "^22.1.1",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"
@ -7019,6 +7021,15 @@
"integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==",
"license": "MIT"
},
"node_modules/@stripe/stripe-js": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-9.5.0.tgz",
"integrity": "sha512-dTQWkJRw5lhcQipPuw6qZRBK2zY5eWWZ1Srw9mSjhIXSLdsNYO3uaIV+YRMkI0/tB/D7yQdHYStrcZrbeHI5Jg==",
"license": "MIT",
"engines": {
"node": ">=12.16"
}
},
"node_modules/@supabase/ssr": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.7.0.tgz",
@ -15106,6 +15117,23 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/stripe": {
"version": "22.1.1",
"resolved": "https://registry.npmjs.org/stripe/-/stripe-22.1.1.tgz",
"integrity": "sha512-cmodIYP27tBkJ8G7DuGgWw0PFuemlFZbuF3Wwr1TrjFjUa3T7NIgCe6TVwX8BO2ynu+xtTuDGfHafNDCPt9lXA==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@types/node": ">=18"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/strnum": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.2.tgz",

View file

@ -12,7 +12,8 @@
"prettier:fix": "prettier --write .",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"stripe:listen": "stripe listen --forward-to localhost:3000/api/stripe/webhook"
},
"dependencies": {
"@prisma/client": "^6.19.0",
@ -27,6 +28,7 @@
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tooltip": "^1.2.8",
"@stripe/stripe-js": "^9.5.0",
"@supabase/ssr": "^0.7.0",
"@tiptap/extension-placeholder": "^3.13.0",
"@tiptap/extension-underline": "^3.13.0",
@ -49,6 +51,7 @@
"react-icons": "^5.5.0",
"react-phone-number-input": "^3.4.14",
"sonner": "^2.0.7",
"stripe": "^22.1.1",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12",
"zustand": "^5.0.8"

View file

@ -99,6 +99,8 @@ model Business {
subscription Json?
slug String? @unique
isPublic Boolean @default(false)
stripeAccountId String? @unique // Stripe Connect Account ID
stripeAccountStatus String? // "pending" | "active" | "restricted"
manager Manager @relation(fields: [managerId], references: [id])
courses Course[]
createdAt DateTime @default(now())
@ -152,6 +154,11 @@ model CourseBooking {
id String @id @default(uuid())
courseId String
userId String
paymentStatus String @default("pending") // "pending" | "paid" | "failed" | "refunded"
stripeSessionId String? @unique
stripePaymentIntentId String? @unique
amountPaid Float? // tatsächlich gezahlter Betrag in EUR
platformFee Float? // unsere Service-Fee in EUR
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())

View file

@ -0,0 +1,7 @@
import { requireManager } from "@/src/lib/auth/getUser";
import SettingsPaymentsView from "@/src/modules/settings/ui/views/settings-payments-view";
export default async function SettingsPaymentsPage() {
await requireManager();
return <SettingsPaymentsView />;
}

View file

@ -0,0 +1,137 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/src/lib/prisma";
import { stripe, calculatePlatformFeeCents, eurosToCents } from "@/src/lib/stripe";
import { getUserData } from "@/src/lib/auth/getUser";
/**
* Erstellt eine Stripe Checkout Session für eine Kurs-Buchung.
* Geld geht direkt an das Business-Connect-Account (minus Plattform-Fee).
*
* Body: { courseId: string }
*/
export async function POST(request: NextRequest) {
try {
const userData = await getUserData();
if (!userData) {
return NextResponse.json(
{ error: "Sie müssen angemeldet sein, um einen Kurs zu buchen" },
{ status: 401 }
);
}
const body = (await request.json()) as { courseId?: string };
const { courseId } = body;
if (!courseId) {
return NextResponse.json({ error: "courseId fehlt" }, { status: 400 });
}
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
_count: { select: { bookings: true } },
business: {
select: {
id: true,
name: true,
stripeAccountId: true,
stripeAccountStatus: true,
},
},
},
});
if (!course) {
return NextResponse.json({ error: "Kurs nicht gefunden" }, { status: 404 });
}
if (course.status !== "published") {
return NextResponse.json({ error: "Dieser Kurs ist nicht verfügbar" }, { status: 400 });
}
if (course._count.bookings >= course.maxParticipants) {
return NextResponse.json({ error: "Dieser Kurs ist bereits ausgebucht" }, { status: 400 });
}
if (!course.business) {
return NextResponse.json(
{ error: "Dieser Kurs hat keinen zugeordneten Anbieter" },
{ status: 400 }
);
}
if (!course.business.stripeAccountId || course.business.stripeAccountStatus !== "active") {
return NextResponse.json(
{
error: "Dieser Anbieter hat noch keine Zahlung eingerichtet. Bitte später versuchen.",
},
{ status: 400 }
);
}
const existingBooking = await prisma.courseBooking.findUnique({
where: {
courseId_userId: { courseId, userId: userData.id },
},
});
if (existingBooking && existingBooking.paymentStatus === "paid") {
return NextResponse.json(
{ error: "Sie haben sich bereits für diesen Kurs angemeldet" },
{ status: 400 }
);
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const priceCents = eurosToCents(course.price);
const platformFeeCents = calculatePlatformFeeCents(course.price);
const session = await stripe.checkout.sessions.create({
mode: "payment",
payment_method_types: ["card"],
line_items: [
{
price_data: {
currency: "eur",
product_data: {
name: course.name,
description: `${course.business.name} - ${new Date(course.date).toLocaleDateString("de-DE")} ${course.timeFrom}`,
},
unit_amount: priceCents,
},
quantity: 1,
},
],
payment_intent_data: {
application_fee_amount: platformFeeCents,
transfer_data: {
destination: course.business.stripeAccountId,
},
metadata: {
courseId: course.id,
userId: userData.id,
businessId: course.business.id,
},
},
metadata: {
courseId: course.id,
userId: userData.id,
businessId: course.business.id,
},
customer_email: userData.email,
success_url: `${baseUrl}/courses/book/${course.id}/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${baseUrl}/courses/book/${course.id}/cancel`,
});
return NextResponse.json({ url: session.url });
} catch (error) {
console.error("Error creating checkout session:", error);
return NextResponse.json(
{
error:
error instanceof Error ? error.message : "Fehler beim Erstellen der Checkout-Session",
},
{ status: 500 }
);
}
}

View file

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { createConnectOnboardingLink } from "@/src/modules/settings/actions/stripe-connect-actions";
/**
* Refresh-URL für Stripe Connect Onboarding.
* Wird aufgerufen wenn der Onboarding-Link abgelaufen ist - wir generieren einen neuen.
*/
export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get("businessId");
if (!businessId) {
return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url));
}
const result = await createConnectOnboardingLink(businessId);
if (!result.success || !result.url) {
return NextResponse.redirect(new URL("/settings/payments?error=refresh-failed", request.url));
}
return NextResponse.redirect(result.url);
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { syncConnectAccountStatus } from "@/src/modules/settings/actions/stripe-connect-actions";
/**
* Return-URL nach erfolgreichem (oder abgeschlossenem) Stripe Connect Onboarding.
* Stripe leitet den Manager hierher zurück. Wir synchronisieren dann den Account-Status.
*/
export async function GET(request: NextRequest) {
const businessId = request.nextUrl.searchParams.get("businessId");
if (!businessId) {
return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url));
}
await syncConnectAccountStatus(businessId);
return NextResponse.redirect(new URL("/settings/payments?onboarding=success", request.url));
}

View file

@ -0,0 +1,181 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe, STRIPE_WEBHOOK_SECRET } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
import { revalidatePath } from "next/cache";
/**
* Stripe Webhook Handler.
*
* WICHTIG: Erst hier wird die CourseBooking endgültig in der DB als bezahlt markiert.
* Niemals den Booking-Status auf der Success-Page-Seite setzen (kann umgangen werden).
*
* Die folgenden Events werden verarbeitet:
* - checkout.session.completed → Booking als "paid" markieren (oder erstellen)
* - checkout.session.expired → Pending-Booking aufräumen
* - account.updated → Stripe Connect Account-Status synchronisieren
*/
export async function POST(request: NextRequest) {
if (!STRIPE_WEBHOOK_SECRET) {
console.error("STRIPE_WEBHOOK_SECRET ist nicht gesetzt");
return NextResponse.json({ error: "Webhook nicht konfiguriert" }, { status: 500 });
}
const signature = request.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "Signatur fehlt" }, { status: 400 });
}
const rawBody = await request.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET);
} catch (err) {
console.error("Webhook-Signatur ungültig:", err);
return NextResponse.json({ error: "Ungültige Signatur" }, { status: 400 });
}
try {
switch (event.type) {
case "checkout.session.completed": {
await handleCheckoutCompleted(event.data.object);
break;
}
case "checkout.session.expired": {
await handleCheckoutExpired(event.data.object);
break;
}
case "account.updated": {
await handleAccountUpdated(event.data.object);
break;
}
default:
// andere Events werden ignoriert
break;
}
return NextResponse.json({ received: true });
} catch (error) {
console.error(`Fehler bei Webhook-Event ${event.type}:`, error);
return NextResponse.json({ error: "Webhook-Verarbeitung fehlgeschlagen" }, { status: 500 });
}
}
/**
* Verarbeitet eine erfolgreiche Checkout-Session.
* Erstellt die CourseBooking (falls nicht vorhanden) und setzt sie auf "paid".
*/
async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
const courseId = session.metadata?.courseId;
const userId = session.metadata?.userId;
if (!courseId || !userId) {
console.error("checkout.session.completed: courseId oder userId fehlt in Metadata");
return;
}
if (session.payment_status !== "paid") {
console.warn(
`Session ${session.id} ist nicht 'paid', aktueller Status: ${session.payment_status}`
);
return;
}
const amountTotal = session.amount_total ?? 0;
const amountPaidEur = amountTotal / 100;
const applicationFee = session.payment_intent
? await getApplicationFeeFromPaymentIntent(session.payment_intent as string)
: 0;
const platformFeeEur = applicationFee / 100;
// Idempotent: existiert Buchung schon, update sie; sonst erstelle sie
await prisma.courseBooking.upsert({
where: {
courseId_userId: { courseId, userId },
},
update: {
paymentStatus: "paid",
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string | null,
amountPaid: amountPaidEur,
platformFee: platformFeeEur,
},
create: {
courseId,
userId,
paymentStatus: "paid",
stripeSessionId: session.id,
stripePaymentIntentId: session.payment_intent as string | null,
amountPaid: amountPaidEur,
platformFee: platformFeeEur,
},
});
revalidatePath("/courses");
revalidatePath(`/courses/book/${courseId}`);
revalidatePath("/courses/myCourses");
}
/**
* Holt die application_fee_amount vom PaymentIntent.
* Wird gebraucht weil das Feld nicht direkt in der Session steht.
*/
async function getApplicationFeeFromPaymentIntent(paymentIntentId: string): Promise<number> {
try {
const intent = await stripe.paymentIntents.retrieve(paymentIntentId);
return intent.application_fee_amount ?? 0;
} catch (err) {
console.error("Fehler beim Abrufen des PaymentIntent:", err);
return 0;
}
}
/**
* Verarbeitet eine abgelaufene Checkout-Session.
* Setzt eventuell vorhandene Pending-Buchung auf "failed".
*/
async function handleCheckoutExpired(session: Stripe.Checkout.Session) {
const courseId = session.metadata?.courseId;
const userId = session.metadata?.userId;
if (!courseId || !userId) return;
const existing = await prisma.courseBooking.findUnique({
where: { courseId_userId: { courseId, userId } },
});
if (existing && existing.paymentStatus === "pending") {
await prisma.courseBooking.update({
where: { courseId_userId: { courseId, userId } },
data: { paymentStatus: "failed" },
});
}
}
/**
* Synchronisiert den Stripe Connect Account-Status mit der DB.
* Wird ausgelöst wenn z.B. das Onboarding abgeschlossen wird.
*/
async function handleAccountUpdated(account: Stripe.Account) {
const business = await prisma.business.findUnique({
where: { stripeAccountId: account.id },
});
if (!business) {
console.warn(`Kein Business für Stripe-Account ${account.id} gefunden`);
return;
}
let status: "pending" | "active" | "restricted" = "pending";
if (account.charges_enabled && account.payouts_enabled) {
status = "active";
} else if (account.requirements?.disabled_reason) {
status = "restricted";
}
await prisma.business.update({
where: { id: business.id },
data: { stripeAccountStatus: status },
});
}

View file

@ -0,0 +1,39 @@
import Link from "next/link";
import { Button } from "@/src/components/ui/button";
import { XCircle } from "lucide-react";
interface CancelPageProps {
params: Promise<{ courseId: string }>;
}
export default async function CourseBookingCancelPage({ params }: CancelPageProps) {
const { courseId } = await params;
return (
<div className="max-w-2xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<div className="flex justify-center mb-4">
<div className="bg-amber-50 rounded-full p-4">
<XCircle className="text-amber-600" size={48} />
</div>
</div>
<h1 className="text-2xl font-bold mb-2">Buchung abgebrochen</h1>
<p className="text-gray-600 mb-6">
Die Zahlung wurde abgebrochen. Es wurde nichts berechnet.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href={`/courses/book/${courseId}`}>
<Button className="w-full sm:w-auto">Erneut versuchen</Button>
</Link>
<Link href="/">
<Button variant="outline" className="w-full sm:w-auto">
Zur Startseite
</Button>
</Link>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import Link from "next/link";
import { Button } from "@/src/components/ui/button";
import { CheckCircle2 } from "lucide-react";
interface SuccessPageProps {
params: Promise<{ courseId: string }>;
searchParams: Promise<{ session_id?: string }>;
}
export default async function CourseBookingSuccessPage({ params }: SuccessPageProps) {
const { courseId } = await params;
return (
<div className="max-w-2xl mx-auto p-6">
<div className="bg-white rounded-lg shadow-md p-8 text-center">
<div className="flex justify-center mb-4">
<div className="bg-green-50 rounded-full p-4">
<CheckCircle2 className="text-green-600" size={48} />
</div>
</div>
<h1 className="text-2xl font-bold mb-2">Zahlung erfolgreich!</h1>
<p className="text-gray-600 mb-6">
Vielen Dank für Ihre Buchung. Sie erhalten in Kürze eine Bestätigungs-E-Mail.
</p>
<p className="text-sm text-gray-500 mb-8">
Ihre Buchung wird gerade verarbeitet. Falls Sie den Kurs nicht in Ihrer Übersicht sehen,
warten Sie bitte einen Moment und laden die Seite neu.
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Link href="/courses/myCourses">
<Button className="w-full sm:w-auto">Meine Kurse ansehen</Button>
</Link>
<Link href={`/courses/book/${courseId}`}>
<Button variant="outline" className="w-full sm:w-auto">
Zurück zum Kurs
</Button>
</Link>
</div>
</div>
</div>
);
}

43
src/lib/stripe.ts Normal file
View file

@ -0,0 +1,43 @@
import Stripe from "stripe";
let _stripe: Stripe | null = null;
function getStripeClient(): Stripe {
if (_stripe) return _stripe;
if (!process.env.STRIPE_SECRET_KEY) {
throw new Error("STRIPE_SECRET_KEY ist nicht gesetzt");
}
_stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: "2026-04-22.dahlia",
typescript: true,
});
return _stripe;
}
export const stripe = new Proxy({} as Stripe, {
get(_target, prop) {
return Reflect.get(getStripeClient(), prop, getStripeClient());
},
});
export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? "";
// Plattform-Fee in Prozent (z.B. 10 = 10%)
export const PLATFORM_FEE_PERCENT = Number(process.env.STRIPE_PLATFORM_FEE_PERCENT ?? "5");
/**
* Berechnet die Plattform-Fee in Cents.
* @param amountEuros - Kurs-Preis in EUR
* @returns Fee-Betrag in Cents (für Stripe API)
*/
export function calculatePlatformFeeCents(amountEuros: number): number {
const amountCents = Math.round(amountEuros * 100);
return Math.round((amountCents * PLATFORM_FEE_PERCENT) / 100);
}
/**
* Konvertiert EUR zu Cents für Stripe API
*/
export function eurosToCents(euros: number): number {
return Math.round(euros * 100);
}

View file

@ -9,108 +9,6 @@
import { prisma } from "@/src/lib/prisma";
import { getUserData } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
/**
* Bucht einen User in einen Kurs ein
*
* Diese Funktion überprüft:
* - Ob der Kurs existiert und veröffentlicht ist
* - Ob noch Plätze verfügbar sind
* - Ob der User bereits gebucht hat
* - Ob der User angemeldet ist
*
* @param courseId - Die eindeutige ID des Kurses
* @returns Ein Objekt mit success-Flag und Nachricht oder Fehler
*/
export async function bookCourse(courseId: string) {
try {
// Schritt 1: Aktuellen Benutzer abrufen
const userData = await getUserData();
// Schritt 2: Überprüfen, ob Benutzer angemeldet ist
if (!userData) {
return {
success: false,
error: "Sie müssen angemeldet sein, um einen Kurs zu buchen",
};
}
// Schritt 3: Kurs mit Buchungszähler abrufen
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
_count: {
select: { bookings: true },
},
},
});
// Schritt 4: Überprüfen, ob Kurs existiert
if (!course) {
return {
success: false,
error: "Kurs nicht gefunden",
};
}
// Schritt 5: Überprüfen, ob Kurs veröffentlicht ist
if (course.status !== "published") {
return {
success: false,
error: "Dieser Kurs ist nicht verfügbar",
};
}
// Schritt 6: Überprüfen, ob noch Plätze verfügbar sind
if (course._count.bookings >= course.maxParticipants) {
return {
success: false,
error: "Dieser Kurs ist bereits ausgebucht",
};
}
// Schritt 7: Überprüfen, ob User bereits gebucht hat
const existingBooking = await prisma.courseBooking.findUnique({
where: {
courseId_userId: {
courseId,
userId: userData.id,
},
},
});
if (existingBooking) {
return {
success: false,
error: "Sie haben sich bereits für diesen Kurs angemeldet",
};
}
// Schritt 8: Buchung erstellen
await prisma.courseBooking.create({
data: {
courseId,
userId: userData.id,
},
});
// Schritt 9: Cache invalidieren
revalidatePath("/courses");
revalidatePath(`/courses/book/${courseId}`);
return {
success: true,
message: "Sie wurden erfolgreich für den Kurs angemeldet",
};
} catch (error) {
console.error("Error booking course:", error);
return {
success: false,
error: "Ein Fehler ist bei der Buchung aufgetreten",
};
}
}
/**
* Ruft einen Kurs mit Buchungsinformationen ab (öffentlich)
@ -190,7 +88,7 @@ export async function checkUserBookingStatus(courseId: string) {
return {
success: true,
isBooked: !!booking,
isBooked: booking?.paymentStatus === "paid",
};
} catch (error) {
console.error("Error checking booking status:", error);

View file

@ -8,7 +8,7 @@ import { Dialog, DialogContent, DialogTitle } from "@/src/components/ui/dialog";
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/src/components/ui/tooltip";
import DOMPurify from "dompurify";
import { bookCourse, checkUserBookingStatus } from "../../actions/booking-actions";
import { checkUserBookingStatus } from "../../actions/booking-actions";
import { toast } from "sonner";
const levelConfig: Record<string, { label: string; bg: string; text: string }> = {
@ -138,18 +138,29 @@ const BookingDialog = ({
const handleBooking = async () => {
setIsSubmitting(true);
try {
const result = await bookCourse(course.id);
if (result.success) {
toast.success(result.message);
setIsBooked(true);
} else if (result.error?.includes("angemeldet sein")) {
const response = await fetch("/api/stripe/checkout/course", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId: course.id }),
});
const data = (await response.json()) as { url?: string; error?: string };
if (response.status === 401) {
window.open("/login", "_blank");
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
setIsSubmitting(false);
return;
}
if (!response.ok || !data.url) {
toast.error(data.error || "Ein Fehler ist aufgetreten");
setIsSubmitting(false);
return;
}
window.location.assign(data.url);
} catch {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
} finally {
setIsSubmitting(false);
}
};

View file

@ -1,9 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/src/components/ui/button";
import { bookCourse, checkUserBookingStatus } from "../../actions/booking-actions";
import { checkUserBookingStatus } from "../../actions/booking-actions";
import { toast } from "sonner";
interface CourseBookingViewProps {
@ -18,11 +17,11 @@ interface CourseBookingViewProps {
maxParticipants: number;
currentParticipants: number;
room: string;
price: number;
};
}
const CourseBookingView = ({ course }: CourseBookingViewProps) => {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isAlreadyBooked, setIsAlreadyBooked] = useState(false);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
@ -38,6 +37,11 @@ const CourseBookingView = ({ course }: CourseBookingViewProps) => {
day: "numeric",
});
const formattedPrice = new Intl.NumberFormat("de-DE", {
style: "currency",
currency: "EUR",
}).format(course.price);
// Check if user already booked this course
useEffect(() => {
const checkStatus = async () => {
@ -54,19 +58,24 @@ const CourseBookingView = ({ course }: CourseBookingViewProps) => {
setIsSubmitting(true);
try {
const result = await bookCourse(course.id);
const response = await fetch("/api/stripe/checkout/course", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ courseId: course.id }),
});
if (result.success) {
toast.success(result.message);
setIsAlreadyBooked(true);
router.refresh();
} else {
toast.error(result.error || "Ein Fehler ist aufgetreten");
const data = (await response.json()) as { url?: string; error?: string };
if (!response.ok || !data.url) {
toast.error(data.error || "Fehler bei der Buchung");
setIsSubmitting(false);
return;
}
window.location.assign(data.url);
} catch (error) {
toast.error("Ein unerwarteter Fehler ist aufgetreten");
console.error(error);
} finally {
setIsSubmitting(false);
}
};
@ -108,6 +117,11 @@ const CourseBookingView = ({ course }: CourseBookingViewProps) => {
</p>
</div>
<div>
<h2 className="font-semibold text-gray-700">Preis</h2>
<p className="text-2xl font-bold text-gray-900">{formattedPrice}</p>
</div>
<div>
<h2 className="font-semibold text-gray-700 mb-2">Was Sie mitbringen sollten</h2>
<div
@ -128,7 +142,11 @@ const CourseBookingView = ({ course }: CourseBookingViewProps) => {
</Button>
) : (
<Button onClick={handleBooking} disabled={isSubmitting || isFull} className="flex-1">
{isSubmitting ? "Wird gebucht..." : isFull ? "Ausgebucht" : "Jetzt anmelden"}
{isSubmitting
? "Wird weitergeleitet..."
: isFull
? "Ausgebucht"
: `Jetzt buchen - ${formattedPrice}`}
</Button>
)}
</div>

View file

@ -0,0 +1,195 @@
/**
* Stripe Connect Actions - Server-seitige Aktionen für Business-Onboarding bei Stripe
*
* Diese Datei enthält alle Server Actions die nötig sind, damit ein Business
* über Stripe Connect Express Zahlungen empfangen kann.
*/
"use server";
import { prisma } from "@/src/lib/prisma";
import { stripe } from "@/src/lib/stripe";
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import { revalidatePath } from "next/cache";
/**
* Erstellt einen Stripe Connect Express Account für ein Business und liefert
* einen Onboarding-Link zurück. Wenn der Account schon existiert wird nur
* ein neuer Onboarding-Link generiert.
*
* @param businessId - Die ID des Business
* @returns Onboarding-URL oder Fehler
*/
export async function createConnectOnboardingLink(businessId: string) {
try {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
return { success: false, error: "Nur Manager können Stripe-Konten verbinden" };
}
const business = await prisma.business.findUnique({
where: { id: businessId },
});
if (!business) {
return { success: false, error: "Business nicht gefunden" };
}
if (business.managerId !== userData.id) {
return { success: false, error: "Keine Berechtigung für dieses Business" };
}
let stripeAccountId = business.stripeAccountId;
if (!stripeAccountId) {
const account = await stripe.accounts.create({
type: "express",
country: "DE",
email: business.email,
capabilities: {
card_payments: { requested: true },
transfers: { requested: true },
},
business_type: "company",
business_profile: {
name: business.name,
mcc: "7991", // Sports and Recreation
},
metadata: {
businessId: business.id,
managerId: userData.id,
},
});
stripeAccountId = account.id;
await prisma.business.update({
where: { id: businessId },
data: {
stripeAccountId,
stripeAccountStatus: "pending",
},
});
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const accountLink = await stripe.accountLinks.create({
account: stripeAccountId,
refresh_url: `${baseUrl}/api/stripe/connect/refresh?businessId=${businessId}`,
return_url: `${baseUrl}/api/stripe/connect/return?businessId=${businessId}`,
type: "account_onboarding",
});
return { success: true, url: accountLink.url };
} catch (error) {
console.error("Error creating Connect onboarding link:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Fehler beim Erstellen des Onboarding-Links",
};
}
}
/**
* Prüft den Status eines Stripe Connect Accounts und aktualisiert ihn in der DB.
* Wird typischerweise nach dem Onboarding-Return aufgerufen.
*
* @param businessId - Die ID des Business
*/
export async function syncConnectAccountStatus(businessId: string) {
try {
const business = await prisma.business.findUnique({
where: { id: businessId },
});
if (!business?.stripeAccountId) {
return { success: false, error: "Business hat kein Stripe-Konto" };
}
const account = await stripe.accounts.retrieve(business.stripeAccountId);
let status: "pending" | "active" | "restricted" = "pending";
if (account.charges_enabled && account.payouts_enabled) {
status = "active";
} else if (account.requirements?.disabled_reason) {
status = "restricted";
}
await prisma.business.update({
where: { id: businessId },
data: { stripeAccountStatus: status },
});
revalidatePath("/settings");
revalidatePath("/settings/payments");
return { success: true, status };
} catch (error) {
console.error("Error syncing Connect account status:", error);
return { success: false, error: "Fehler beim Synchronisieren des Status" };
}
}
/**
* Liefert alle Businesses eines Managers mit ihrem Stripe-Status.
*/
export async function getBusinessesWithStripeStatus() {
try {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
return { success: false, error: "Nur für Manager verfügbar", businesses: [] };
}
const businesses = await prisma.business.findMany({
where: { managerId: userData.id },
select: {
id: true,
name: true,
stripeAccountId: true,
stripeAccountStatus: true,
},
});
return { success: true, businesses };
} catch (error) {
console.error("Error fetching businesses with stripe status:", error);
return { success: false, error: "Fehler beim Laden", businesses: [] };
}
}
/**
* Erstellt einen Login-Link für das Stripe Express Dashboard.
* So kann der Manager seinen Auszahlungs-Account einsehen.
*
* @param businessId - Die ID des Business
*/
export async function createStripeExpressDashboardLink(businessId: string) {
try {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
return { success: false, error: "Nur Manager können das Dashboard öffnen" };
}
const business = await prisma.business.findUnique({
where: { id: businessId },
});
if (!business || business.managerId !== userData.id) {
return { success: false, error: "Business nicht gefunden" };
}
if (!business.stripeAccountId) {
return { success: false, error: "Kein Stripe-Konto verbunden" };
}
const loginLink = await stripe.accounts.createLoginLink(business.stripeAccountId);
return { success: true, url: loginLink.url };
} catch (error) {
console.error("Error creating Stripe dashboard link:", error);
return { success: false, error: "Fehler beim Erstellen des Dashboard-Links" };
}
}

View file

@ -1,6 +1,6 @@
import { requireAuthWithData, isManager } from "@/src/lib/auth/getUser";
import SettingsMenuItem from "../components/SettingsMenuItem";
import { LucideIcon, PanelTop, Settings, User } from "lucide-react";
import { CreditCard, LucideIcon, PanelTop, Settings, User } from "lucide-react";
type SettingsMenuItem = {
icon: LucideIcon;
@ -22,6 +22,12 @@ const settingsMenuTitles: SettingsMenuItem[] = [
href: "/settings/public-page",
description: "Verwalte deine öffentliche Kursseite",
},
{
icon: CreditCard,
name: "Zahlungen",
href: "/settings/payments",
description: "Verbinde dein Stripe-Konto um Zahlungen zu empfangen",
},
];
const SettingsOverviewView = async () => {

View file

@ -0,0 +1,191 @@
"use client";
import { useEffect, useState } from "react";
import { Button } from "@/src/components/ui/button";
import {
createConnectOnboardingLink,
createStripeExpressDashboardLink,
getBusinessesWithStripeStatus,
} from "@/src/modules/settings/actions/stripe-connect-actions";
import { toast } from "sonner";
import { CheckCircle2, AlertCircle, CreditCard, ExternalLink, Loader2 } from "lucide-react";
type BusinessWithStatus = {
id: string;
name: string;
stripeAccountId: string | null;
stripeAccountStatus: string | null;
};
const SettingsPaymentsView = () => {
const [businesses, setBusinesses] = useState<BusinessWithStatus[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [pendingId, setPendingId] = useState<string | null>(null);
useEffect(() => {
const loadBusinesses = async () => {
setIsLoading(true);
const result = await getBusinessesWithStripeStatus();
if (result.success) {
setBusinesses(result.businesses);
} else {
toast.error(result.error || "Fehler beim Laden");
}
setIsLoading(false);
};
void loadBusinesses();
}, []);
const handleConnect = async (businessId: string) => {
setPendingId(businessId);
const result = await createConnectOnboardingLink(businessId);
if (result.success && result.url) {
window.location.assign(result.url);
} else {
toast.error(result.error || "Fehler beim Verbinden");
setPendingId(null);
}
};
const handleOpenDashboard = async (businessId: string) => {
setPendingId(businessId);
const result = await createStripeExpressDashboardLink(businessId);
if (result.success && result.url) {
window.open(result.url, "_blank");
} else {
toast.error(result.error || "Fehler beim Öffnen des Dashboards");
}
setPendingId(null);
};
const renderStatusBadge = (status: string | null) => {
if (status === "active") {
return (
<span className="inline-flex items-center gap-1 text-xs text-green-700 bg-green-50 px-2 py-1 rounded-md">
<CheckCircle2 size={12} />
Aktiv - bereit für Zahlungen
</span>
);
}
if (status === "pending") {
return (
<span className="inline-flex items-center gap-1 text-xs text-amber-700 bg-amber-50 px-2 py-1 rounded-md">
<AlertCircle size={12} />
Onboarding nicht abgeschlossen
</span>
);
}
if (status === "restricted") {
return (
<span className="inline-flex items-center gap-1 text-xs text-red-700 bg-red-50 px-2 py-1 rounded-md">
<AlertCircle size={12} />
Eingeschränkt - bitte prüfen
</span>
);
}
return (
<span className="inline-flex items-center gap-1 text-xs text-gray-600 bg-gray-50 px-2 py-1 rounded-md">
Nicht verbunden
</span>
);
};
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="animate-spin text-gray-400" />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl flex items-center gap-2 font-bold">
<CreditCard /> Zahlungen
</h1>
<p className="text-sm text-gray-500 mt-1">
Verbinde dein Stripe-Konto, damit deine Kunden für Kurse bezahlen können. Stripe übernimmt
die sichere Zahlungsabwicklung und zahlt das Geld direkt an dich aus.
</p>
</div>
{businesses.length === 0 ? (
<div className="border border-gray-200 rounded-xl p-6 bg-white text-center">
<p className="text-sm text-gray-500">Du hast noch keine Businesses angelegt.</p>
</div>
) : (
<div className="space-y-3">
{businesses.map((business) => {
const isConnected = business.stripeAccountId !== null;
const isActive = business.stripeAccountStatus === "active";
const isPending = pendingId === business.id;
return (
<div
key={business.id}
className="border border-gray-200 rounded-xl p-5 bg-white space-y-3"
>
<div className="flex items-start justify-between gap-3">
<div>
<p className="font-medium text-gray-900">{business.name}</p>
<div className="mt-2">{renderStatusBadge(business.stripeAccountStatus)}</div>
</div>
</div>
<div className="flex flex-wrap gap-2 pt-2">
{!isConnected || !isActive ? (
<Button
onClick={() => handleConnect(business.id)}
disabled={isPending}
size="sm"
>
{isPending ? (
<>
<Loader2 className="animate-spin" size={14} />
Weiterleitung...
</>
) : isConnected ? (
"Onboarding fortsetzen"
) : (
"Stripe-Konto verbinden"
)}
</Button>
) : (
<Button
variant="outline"
onClick={() => handleOpenDashboard(business.id)}
disabled={isPending}
size="sm"
>
{isPending ? (
<Loader2 className="animate-spin" size={14} />
) : (
<>
Stripe Dashboard öffnen
<ExternalLink size={14} />
</>
)}
</Button>
)}
</div>
</div>
);
})}
</div>
)}
<div className="border border-blue-100 bg-blue-50/50 rounded-xl p-4 text-xs text-gray-600">
<p className="font-medium text-gray-900 mb-1">So funktioniert es:</p>
<ul className="list-disc list-inside space-y-1">
<li>Klicke auf &quot;Stripe-Konto verbinden&quot; - du wirst zu Stripe weitergeleitet</li>
<li>Gib deine Geschäftsdaten und Bankverbindung bei Stripe ein</li>
<li>Nach Abschluss kannst du Zahlungen aus Kurs-Buchungen empfangen</li>
<li>Auszahlungen erfolgen automatisch auf dein hinterlegtes Bankkonto</li>
</ul>
</div>
</div>
);
};
export default SettingsPaymentsView;