mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
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:
commit
6359004609
18 changed files with 976 additions and 126 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -16,6 +16,7 @@
|
|||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
STRIPE_INTEGRATION.md
|
||||
|
||||
# production
|
||||
/build
|
||||
|
|
|
|||
28
package-lock.json
generated
28
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
7
src/app/(main)/settings/payments/page.tsx
Normal file
7
src/app/(main)/settings/payments/page.tsx
Normal 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 />;
|
||||
}
|
||||
137
src/app/api/stripe/checkout/course/route.ts
Normal file
137
src/app/api/stripe/checkout/course/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
22
src/app/api/stripe/connect/refresh/route.ts
Normal file
22
src/app/api/stripe/connect/refresh/route.ts
Normal 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);
|
||||
}
|
||||
18
src/app/api/stripe/connect/return/route.ts
Normal file
18
src/app/api/stripe/connect/return/route.ts
Normal 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));
|
||||
}
|
||||
181
src/app/api/stripe/webhook/route.ts
Normal file
181
src/app/api/stripe/webhook/route.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
39
src/app/courses/book/[courseId]/cancel/page.tsx
Normal file
39
src/app/courses/book/[courseId]/cancel/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/app/courses/book/[courseId]/success/page.tsx
Normal file
45
src/app/courses/book/[courseId]/success/page.tsx
Normal 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
43
src/lib/stripe.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
195
src/modules/settings/actions/stripe-connect-actions.ts
Normal file
195
src/modules/settings/actions/stripe-connect-actions.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
191
src/modules/settings/ui/views/settings-payments-view.tsx
Normal file
191
src/modules/settings/ui/views/settings-payments-view.tsx
Normal 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 "Stripe-Konto verbinden" - 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue