fix pipeline and see comments

This commit is contained in:
Rajpreet0 2026-05-25 21:09:51 +02:00
parent 6752f8768a
commit ff9e290b56
3 changed files with 61 additions and 26 deletions

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";
@ -25,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 });
@ -108,9 +108,13 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
where: { courseId_userId: { courseId, userId } },
});
// Idempotenz: dieser Stripe-Event wurde schon verarbeitet.
if (existing?.paymentStatus === "paid") {
return "already_paid" as const;
// 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({
@ -172,16 +176,30 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
return "booked" as const;
});
if (outcome === "capacity_exceeded" && paymentIntentId) {
// "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}).`
);
await refundPayment(paymentIntentId);
await prisma.courseBooking.update({
where: { courseId_userId: { courseId, userId } },
data: { paymentStatus: "refunded" },
});
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");
@ -194,15 +212,17 @@ async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
* damit das Geld komplett zurückfließt – auch der bereits transferierte Anteil
* beim Connected Account).
*/
async function refundPayment(paymentIntentId: string) {
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;
}
}

View file

@ -20,21 +20,36 @@ export const stripe = new Proxy({} as Stripe, {
},
});
if (!process.env.STRIPE_WEBHOOK_SECRET) {
throw new Error("STRIPE_WEBHOOK_SECRET ist nicht gesetzt");
/**
* 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;
}
export const STRIPE_WEBHOOK_SECRET: string = process.env.STRIPE_WEBHOOK_SECRET;
// Plattform-Fee in Prozent (z.B. 10 = 10%)
const rawPlatformFeePercent = Number(process.env.STRIPE_PLATFORM_FEE_PERCENT ?? "5");
if (
!Number.isFinite(rawPlatformFeePercent) ||
rawPlatformFeePercent < 0 ||
rawPlatformFeePercent > 100
) {
throw new Error("STRIPE_PLATFORM_FEE_PERCENT muss zwischen 0 und 100 liegen");
/**
* 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;
}
export const PLATFORM_FEE_PERCENT = rawPlatformFeePercent;
/**
* Berechnet die Plattform-Fee in Cents.
@ -43,7 +58,7 @@ export const PLATFORM_FEE_PERCENT = rawPlatformFeePercent;
*/
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

@ -62,7 +62,7 @@ const SettingsPaymentsView = () => {
try {
const result = await createStripeExpressDashboardLink(businessId);
if (result.success && result.url) {
window.open(result.url, "_blank");
window.open(result.url, "_blank", "noopener,noreferrer");
} else {
toast.error(result.error || "Fehler beim Öffnen des Dashboards");
}