Befunde aus dem Sicherheits-Review des vorherigen Commits: - Gerät eine Buchung in "Prüfung nötig" (Rückbuchung, Doppelzahlung ...), wird der Stripe-Plan beendet und offene Rechnungen storniert. Nach Bestätigung legt der Worker einen neuen Plan für die offenen Raten an. Vorher zog Stripe auch bei Buchungen in Prüfung weiter ein. - Vor dem Anlegen wird bei Stripe nach einem laufenden Plan der Buchung gesucht; der Idempotenzschlüssel enthält Generation und Start. Verhindert einen zweiten Plan nach verlorener ID und die Rückgabe des alten, beendeten Plans nach einer Prüfung. - Start bei bereits verstrichener Fälligkeit deterministisch am nächsten 9-Uhr-Termin, letzte Rate bleibt am Stichtag. - Rückbuchungs-Event meldet die betroffene Buchung zurück. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
/**
|
|
* 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<string, string>,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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<string | null> {
|
|
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<string[]> {
|
|
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<string[]> {
|
|
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<string, string>;
|
|
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<string>([
|
|
"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<WebhookResult> {
|
|
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;
|
|
}
|
|
}
|