mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
fix pipeline and see comments
This commit is contained in:
parent
6752f8768a
commit
ff9e290b56
3 changed files with 61 additions and 26 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue