mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
Merge pull request #108 from Since-3/bug/stripe-payments
addressed issues regarding Stripe Payments
This commit is contained in:
commit
3713f6ef68
10 changed files with 258 additions and 65 deletions
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue