Merge pull request #108 from Since-3/bug/stripe-payments

addressed issues regarding Stripe Payments
This commit is contained in:
Rajpreet0 2026-05-25 21:15:23 +02:00 • committed by GitHub
commit 3713f6ef68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 258 additions and 65 deletions

View file

@ -157,8 +157,8 @@ model CourseBooking {
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
amountPaid Int? // tatsächlich gezahlter Betrag in Cents
platformFee Int? // unsere Service-Fee in Cents
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())

View file

@ -1,7 +1,7 @@
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";
import { getUserData, isUser } from "@/src/lib/auth/getUser";
/**
* Erstellt eine Stripe Checkout Session für eine Kurs-Buchung.
@ -19,6 +19,19 @@ export async function POST(request: NextRequest) {
);
}
// Nur normale User (role: "user") können Kurse buchen.
// Manager/Employee haben keine Einträge in der User-Tabelle, daher würde
// sonst der Foreign-Key-Constraint CourseBooking_userId_fkey im Webhook
// verletzt werden – und das erst NACHDEM das Geld geflossen ist.
if (!isUser(userData)) {
return NextResponse.json(
{
error: "Nur Kunden-Accounts können Kurse buchen. Bitte mit einem Nutzer-Konto anmelden.",
},
{ status: 403 }
);
}
const body = (await request.json()) as { courseId?: string };
const { courseId } = body;
@ -29,7 +42,8 @@ export async function POST(request: NextRequest) {
const course = await prisma.course.findUnique({
where: { id: courseId },
include: {
_count: { select: { bookings: true } },
// Nur bezahlte Buchungen belegen einen Platz (failed/refunded zählen nicht).
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
business: {
select: {
id: true,
@ -49,6 +63,8 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Dieser Kurs ist nicht verfügbar" }, { status: 400 });
}
// 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) {
return NextResponse.json({ error: "Dieser Kurs ist bereits ausgebucht" }, { status: 400 });
}

View file

@ -12,7 +12,10 @@ export async function GET(request: NextRequest) {
return NextResponse.redirect(new URL("/settings/payments?error=missing-business", request.url));
}
await syncConnectAccountStatus(businessId);
const result = await syncConnectAccountStatus(businessId);
if (!result.success) {
return NextResponse.redirect(new URL("/settings/payments?error=sync-failed", request.url));
}
return NextResponse.redirect(new URL("/settings/payments?onboarding=success", request.url));
}

View file

@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { stripe, STRIPE_WEBHOOK_SECRET } from "@/src/lib/stripe";
import { stripe, getWebhookSecret } from "@/src/lib/stripe";
import { prisma } from "@/src/lib/prisma";
import { revalidatePath } from "next/cache";
@ -16,11 +16,6 @@ import { revalidatePath } from "next/cache";
* - 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 });
@ -30,7 +25,7 @@ export async function POST(request: NextRequest) {
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(rawBody, signature, STRIPE_WEBHOOK_SECRET);
event = stripe.webhooks.constructEvent(rawBody, signature, getWebhookSecret());
} catch (err) {
console.error("Webhook-Signatur ungültig:", err);
return NextResponse.json({ error: "Ungültige Signatur" }, { status: 400 });
@ -82,41 +77,155 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
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,
},
// Sicherheitsnetz: User muss in der User-Tabelle existieren, sonst würde
// der Foreign-Key-Constraint die Buchung ablehnen. Wir loggen das und
// geben 200 zurück (kein Stripe-Retry für einen Fehler den ein Retry nicht behebt).
const userExists = await prisma.user.findUnique({
where: { id: userId },
select: { id: true },
});
if (!userExists) {
console.error(
`checkout.session.completed: User ${userId} existiert nicht in der User-Tabelle. ` +
`Session ${session.id} wird ignoriert. Zahlung muss ggf. manuell erstattet werden.`
);
return;
}
// Beträge in Cents (ganzzahlig) speichern – keine Float-Rundungsfehler.
const amountPaidCents = session.amount_total ?? 0;
const paymentIntentId = (session.payment_intent as string | null) ?? null;
const platformFeeCents = paymentIntentId
? await getApplicationFeeFromPaymentIntent(paymentIntentId)
: 0;
// Atomare Kapazitätsprüfung: verhindert dass zwei gleichzeitig zahlende User
// den letzten Platz bekommen. Innerhalb der Transaction wird gezählt und
// erst dann auf "paid" gesetzt.
const outcome = await prisma.$transaction(async (tx) => {
const existing = await tx.courseBooking.findUnique({
where: { courseId_userId: { courseId, userId } },
});
// Idempotenz: Wenn diese Buchung bereits einen Endzustand für DIESE Session
// erreicht hat, ist der Event schon verarbeitet. Erneutes Durchlaufen würde
// sonst einen zweiten Refund auslösen oder "refunded" → "failed" überschreiben.
if (existing && existing.stripeSessionId === session.id) {
if (existing.paymentStatus === "paid") return "already_paid" as const;
if (existing.paymentStatus === "refunded") return "already_refunded" as const;
if (existing.paymentStatus === "failed") return "already_failed" as const;
}
const course = await tx.course.findUnique({
where: { id: courseId },
select: { maxParticipants: true },
});
if (!course) {
return "course_not_found" as const;
}
const paidCount = await tx.courseBooking.count({
where: { courseId, paymentStatus: "paid" },
});
if (paidCount >= course.maxParticipants) {
// Platz ist weg – Buchung als failed markieren, Aufrufer löst Refund aus.
await tx.courseBooking.upsert({
where: { courseId_userId: { courseId, userId } },
update: {
paymentStatus: "failed",
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
},
create: {
courseId,
userId,
paymentStatus: "failed",
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
},
});
return "capacity_exceeded" as const;
}
await tx.courseBooking.upsert({
where: { courseId_userId: { courseId, userId } },
update: {
paymentStatus: "paid",
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
},
create: {
courseId,
userId,
paymentStatus: "paid",
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
},
});
return "booked" as const;
});
// "already_failed" = Buchung wurde bei früherem Versuch wegen voller Kapazität
// auf "failed" gesetzt, aber der Refund ist noch ausstehend (z.B. damals
// fehlgeschlagen). Bei Stripe-Retry erneut versuchen.
const needsRefund = outcome === "capacity_exceeded" || outcome === "already_failed";
if (needsRefund && paymentIntentId) {
console.warn(
`Kurs ${courseId} war voll als Zahlung für User ${userId} ankam. ` +
`Automatische Rückerstattung wird ausgelöst (PaymentIntent ${paymentIntentId}).`
);
const refunded = await refundPayment(paymentIntentId);
if (refunded) {
await prisma.courseBooking.update({
where: { courseId_userId: { courseId, userId } },
data: { paymentStatus: "refunded" },
});
} else {
// Refund fehlgeschlagen: Buchung bleibt "failed" für manuelle Bearbeitung.
// Stripe wiederholt den Webhook, sodass ein erneuter Refund-Versuch erfolgt.
console.error(
`Refund fehlgeschlagen für Kurs ${courseId}, User ${userId}. ` +
`Buchung bleibt "failed" – manuelle Erstattung im Stripe Dashboard nötig.`
);
}
}
revalidatePath("/courses");
revalidatePath(`/courses/book/${courseId}`);
revalidatePath("/courses/myCourses");
}
/**
* Erstattet eine Zahlung vollständig zurück (Reverse Transfer + Application Fee,
* damit das Geld komplett zurückfließt – auch der bereits transferierte Anteil
* beim Connected Account).
*/
async function refundPayment(paymentIntentId: string): Promise<boolean> {
try {
await stripe.refunds.create({
payment_intent: paymentIntentId,
reverse_transfer: true,
refund_application_fee: true,
});
return true;
} catch (err) {
console.error(`Refund für PaymentIntent ${paymentIntentId} fehlgeschlagen:`, err);
return false;
}
}
/**
* Holt die application_fee_amount vom PaymentIntent.
* Wird gebraucht weil das Feld nicht direkt in der Session steht.

View file

@ -20,10 +20,36 @@ export const stripe = new Proxy({} as Stripe, {
},
});
export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET ?? "";
/**
* Liefert das Webhook-Secret und wirft, wenn es fehlt.
* Lazy ausgewertet (nicht auf Modul-Ebene), damit `next build` nicht abbricht,
* wenn die ENV-Variable beim Build (z.B. in CI) noch nicht gesetzt ist.
*/
export function getWebhookSecret(): string {
const secret = process.env.STRIPE_WEBHOOK_SECRET;
if (!secret) {
throw new Error("STRIPE_WEBHOOK_SECRET ist nicht gesetzt");
}
return secret;
}
// Plattform-Fee in Prozent (z.B. 10 = 10%)
export const PLATFORM_FEE_PERCENT = Number(process.env.STRIPE_PLATFORM_FEE_PERCENT ?? "5");
/**
* Liefert die validierte Plattform-Fee in Prozent (Default 5%).
* Lazy ausgewertet, damit ein ungültiger Wert nicht den Build crasht.
*/
export function getPlatformFeePercent(): number {
const raw = process.env.STRIPE_PLATFORM_FEE_PERCENT;
// Leerer/Whitespace-String würde via Number("") still zu 0 werden und so
// die Fee unbemerkt auf 0 setzen. Explizit ablehnen.
if (raw?.trim() === "") {
throw new Error("STRIPE_PLATFORM_FEE_PERCENT darf nicht leer sein");
}
const value = Number(raw ?? "5");
if (!Number.isFinite(value) || value < 0 || value > 100) {
throw new Error("STRIPE_PLATFORM_FEE_PERCENT muss zwischen 0 und 100 liegen");
}
return value;
}
/**
* Berechnet die Plattform-Fee in Cents.
@ -32,7 +58,7 @@ export const PLATFORM_FEE_PERCENT = Number(process.env.STRIPE_PLATFORM_FEE_PERCE
*/
export function calculatePlatformFeeCents(amountEuros: number): number {
const amountCents = Math.round(amountEuros * 100);
return Math.round((amountCents * PLATFORM_FEE_PERCENT) / 100);
return Math.round((amountCents * getPlatformFeePercent()) / 100);
}
/**

View file

@ -25,7 +25,7 @@ export async function getCourseForBooking(courseId: string) {
where: { id: courseId },
include: {
_count: {
select: { bookings: true },
select: { bookings: { where: { paymentStatus: "paid" } } },
},
},
});
@ -138,7 +138,7 @@ export async function getPublishedCoursesForBusiness(
date: { gte: options.from, lte: options.to },
},
include: {
_count: { select: { bookings: true } },
_count: { select: { bookings: { where: { paymentStatus: "paid" } } } },
},
orderBy: { date: "asc" },
});

View file

@ -144,14 +144,19 @@ const BookingDialog = ({
body: JSON.stringify({ courseId: course.id }),
});
const data = (await response.json()) as { url?: string; error?: string };
if (response.status === 401) {
window.open("/login", "_blank");
setIsSubmitting(false);
return;
}
let data: { url?: string; error?: string } = {};
try {
data = (await response.json()) as { url?: string; error?: string };
} catch {
// keep fallback error below
}
if (!response.ok || !data.url) {
toast.error(data.error || "Ein Fehler ist aufgetreten");
setIsSubmitting(false);

View file

@ -64,7 +64,17 @@ const CourseBookingView = ({ course }: CourseBookingViewProps) => {
body: JSON.stringify({ courseId: course.id }),
});
const data = (await response.json()) as { url?: string; error?: string };
if (response.status === 401) {
window.location.assign("/login");
return;
}
let data: { url?: string; error?: string } = {};
try {
data = (await response.json()) as { url?: string; error?: string };
} catch {
// fallback error message below
}
if (!response.ok || !data.url) {
toast.error(data.error || "Fehler bei der Buchung");

View file

@ -99,6 +99,11 @@ export async function createConnectOnboardingLink(businessId: string) {
*/
export async function syncConnectAccountStatus(businessId: string) {
try {
const userData = await requireAuthWithData();
if (!isManager(userData)) {
return { success: false, error: "Nur für Manager verfügbar" };
}
const business = await prisma.business.findUnique({
where: { id: businessId },
});
@ -107,6 +112,10 @@ export async function syncConnectAccountStatus(businessId: string) {
return { success: false, error: "Business hat kein Stripe-Konto" };
}
if (business.managerId !== userData.id) {
return { success: false, error: "Keine Berechtigung für dieses Business" };
}
const account = await stripe.accounts.retrieve(business.stripeAccountId);
let status: "pending" | "active" | "restricted" = "pending";

View file

@ -25,37 +25,52 @@ const SettingsPaymentsView = () => {
useEffect(() => {
const loadBusinesses = async () => {
setIsLoading(true);
const result = await getBusinessesWithStripeStatus();
if (result.success) {
setBusinesses(result.businesses);
} else {
toast.error(result.error || "Fehler beim Laden");
try {
const result = await getBusinessesWithStripeStatus();
if (result.success) {
setBusinesses(result.businesses);
} else {
toast.error(result.error || "Fehler beim Laden");
}
} catch {
toast.error("Fehler beim Laden");
} finally {
setIsLoading(false);
}
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 {
try {
const result = await createConnectOnboardingLink(businessId);
if (result.success && result.url) {
window.location.assign(result.url);
return;
}
toast.error(result.error || "Fehler beim Verbinden");
} catch {
toast.error("Fehler beim Verbinden");
} finally {
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");
try {
const result = await createStripeExpressDashboardLink(businessId);
if (result.success && result.url) {
window.open(result.url, "_blank", "noopener,noreferrer");
} else {
toast.error(result.error || "Fehler beim Öffnen des Dashboards");
}
} catch {
toast.error("Fehler beim Öffnen des Dashboards");
} finally {
setPendingId(null);
}
setPendingId(null);
};
const renderStatusBadge = (status: string | null) => {