/** * Verarbeitung verifizierter Stripe-Events. * * - Idempotenz: Jede Event-ID wird in StripeEvent festgehalten; ein bereits * verarbeitetes Event wird beim erneuten Zustellen uebersprungen. * - Reihenfolge: Die Handler in payment-events.ts sind monoton und sperren * die Buchung — ungeordnete Events (z. B. "expired" nach "completed") * richten keinen Schaden an. * - Wahrheit: Fuer Zahlungen wird der PaymentIntent bei Stripe nachgeladen, * statt dem ggf. veralteten Event-Inhalt zu vertrauen. */ import type Stripe from "stripe"; import { Prisma } from "@/generated/prisma/client"; import { STRIPE_ACTOR, type Actor } from "./audit"; import type { Db } from "./db"; import { ensureInstallmentSchedule, holdSchedulesInReview, INSTALLMENT_PLAN_KIND, settleInstallmentInvoice } from "./installment-billing"; import { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway"; import { applyCheckoutExpired, applyDispute, applyFirstPaymentFailed, applyFirstPaymentProcessing, applyFirstPaymentSucceeded, applyInstallmentInvoiceFailed, applyRefundState, type EventContext, } from "./payment-events"; export type HandlerContext = { now: Date; secret: string; adminEmail: string | null }; export type WebhookResult = { outcome: "processed" | "ignored" | "duplicate"; bookingIds: string[] }; function idOf(value: string | { id: string } | null | undefined): string | null { if (!value) return null; return typeof value === "string" ? value : value.id; } function eventCtx(ctx: HandlerContext, actor: Actor = STRIPE_ACTOR): EventContext { return { now: ctx.now, secret: ctx.secret, adminEmail: ctx.adminEmail, actor }; } export function sessionInfoFromStripe(s: Stripe.Checkout.Session): CheckoutSessionInfo { return { id: s.id, status: (s.status ?? "open") as CheckoutSessionInfo["status"], paymentStatus: normalizePaymentStatus(s.payment_status), paymentIntentId: idOf(s.payment_intent), customerId: idOf(s.customer), url: s.url ?? null, metadata: (s.metadata ?? {}) as Record, }; } /** * Bringt eine abgeschlossene Checkout-Session in die Datenbank. Wird vom * Webhook UND vom Worker (Abgleich bei verlorenem Webhook) genutzt. */ export async function settleCheckoutSession( db: Db, gateway: PaymentGateway, session: CheckoutSessionInfo, ctx: HandlerContext, actor: Actor = STRIPE_ACTOR, ): Promise { const { kind, bookingId } = session.metadata; if (!bookingId || kind !== "booking") return null; const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } }); if (!booking) return null; // Die Session muss zur gespeicherten Session passen (oder es ist noch keine // gespeichert, weil das Speichern nach Stripe scheiterte). if (booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null; if (session.paymentStatus === "paid") { if (!session.paymentIntentId) throw new Error(`Bezahlte Session ${session.id} ohne PaymentIntent.`); const pi = await gateway.retrievePaymentIntent(session.paymentIntentId); const info = { paymentIntentId: pi.id, chargeId: pi.chargeId, paymentMethodId: pi.paymentMethodId, customerId: session.customerId, amountCents: pi.amountReceived, }; await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor))); await startInstallmentSchedule(db, gateway, bookingId, ctx, actor); return bookingId; } if (session.status === "complete") { await db.$transaction((tx) => applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)), ); } return bookingId; } /** * Ratenplan bei Stripe anlegen, sobald die erste Rate bezahlt ist. Ein * Fehler hier macht die verbuchte Zahlung nicht rueckgaengig: der Worker * holt das Anlegen beim naechsten Lauf nach. */ async function startInstallmentSchedule(db: Db, gateway: PaymentGateway, bookingId: string, ctx: HandlerContext, actor: Actor) { try { await ensureInstallmentSchedule(db, gateway, bookingId, ctx, actor); } catch (error) { console.error(`[webhook] Ratenplan fuer Buchung ${bookingId} nicht angelegt: ${error instanceof Error ? error.message : "unbekannt"}`); } } async function handleInstallmentInvoice(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise { const object = event.data.object as Stripe.Invoice; // Wahrheit bei Stripe nachladen statt dem Event-Inhalt zu vertrauen. const invoice = await gateway.retrieveInvoice(object.id!); const { kind, bookingId } = invoice.metadata; if (kind !== INSTALLMENT_PLAN_KIND || !bookingId) return []; if (event.type === "invoice.paid") { await settleInstallmentInvoice(db, gateway, invoice, ctx, STRIPE_ACTOR); return [bookingId]; } if (invoice.status !== "open") return []; await db.$transaction((tx) => applyInstallmentInvoiceFailed( tx, bookingId, invoice, { requiresAction: event.type === "invoice.payment_action_required" }, eventCtx(ctx), ), ); return [bookingId]; } async function dispatch(db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext): Promise { switch (event.type) { case "checkout.session.completed": case "checkout.session.async_payment_succeeded": { const id = await settleCheckoutSession(db, gateway, sessionInfoFromStripe(event.data.object), ctx); return id ? [id] : []; } case "checkout.session.async_payment_failed": { const s = sessionInfoFromStripe(event.data.object); const { kind, bookingId } = s.metadata; if (kind !== "booking" || !bookingId) return []; await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx))); return [bookingId]; } case "checkout.session.expired": { const s = event.data.object; const { kind, bookingId } = (s.metadata ?? {}) as Record; if (kind !== "booking" || !bookingId) return []; await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx))); return [bookingId]; } case "invoice.paid": case "invoice.payment_failed": case "invoice.payment_action_required": return handleInstallmentInvoice(db, gateway, event, ctx); case "charge.refunded": { const charge = event.data.object; const piId = idOf(charge.payment_intent); if (!piId) return []; await db.$transaction((tx) => applyRefundState(tx, { paymentIntentId: piId, amountRefundedCents: charge.amount_refunded }, eventCtx(ctx)), ); return []; } case "refund.created": case "refund.updated": { const refund = event.data.object; await db.refund.updateMany({ where: { stripeRefundId: refund.id }, data: { status: refund.status ?? "unknown" } }); return []; } case "charge.dispute.created": { const piId = idOf(event.data.object.payment_intent); if (!piId) return []; await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx))); const disputed = await db.installment.findUnique({ where: { stripePaymentIntentId: piId }, select: { bookingId: true } }); return disputed ? [disputed.bookingId] : []; } default: return []; } } const HANDLED = new Set([ "checkout.session.completed", "checkout.session.async_payment_succeeded", "checkout.session.async_payment_failed", "checkout.session.expired", "invoice.paid", "invoice.payment_failed", "invoice.payment_action_required", "charge.refunded", "refund.created", "refund.updated", "charge.dispute.created", ]); export async function handleStripeEvent( db: Db, gateway: PaymentGateway, event: Stripe.Event, ctx: HandlerContext, ): Promise { const livemode = gateway.mode === "live"; const relevant = HANDLED.has(event.type) && event.livemode === livemode; try { await db.stripeEvent.create({ data: { id: event.id, type: event.type, livemode: event.livemode, status: relevant ? "PROCESSING" : "IGNORED" }, }); } catch (error) { if (!(error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002")) throw error; const existing = await db.stripeEvent.findUniqueOrThrow({ where: { id: event.id } }); if (existing.status === "PROCESSED" || existing.status === "IGNORED") return { outcome: "duplicate", bookingIds: [] }; // PROCESSING (paralleler/abgebrochener Versuch) oder FAILED: erneut // verarbeiten — die Handler sind idempotent. await db.stripeEvent.update({ where: { id: event.id }, data: { attempts: { increment: 1 } } }); } if (!relevant) return { outcome: "ignored", bookingIds: [] }; try { const bookingIds = await dispatch(db, gateway, event, ctx); await db.stripeEvent.update({ where: { id: event.id }, data: { status: "PROCESSED", processedAt: new Date(), error: null } }); // Geriet eine Buchung in Pruefung (Rueckbuchung, Doppelzahlung ...), // zieht Stripe bis zur Entscheidung nichts weiter ein. Scheitert das // hier, erledigt es der Worker. if (bookingIds.length > 0) { await holdSchedulesInReview(db, gateway, bookingIds, STRIPE_ACTOR).catch((error: unknown) => { console.error(`[webhook] Ratenplan nicht angehalten: ${error instanceof Error ? error.message : "unbekannt"}`); }); } return { outcome: "processed", bookingIds }; } catch (error) { const message = error instanceof Error ? error.message.slice(0, 500) : "unbekannter Fehler"; await db.stripeEvent.update({ where: { id: event.id }, data: { status: "FAILED", error: message } }); throw error; } }