first commit
Docker-Images bauen und veroeffentlichen / build-and-push (push) Failing after 1m32s

This commit is contained in:
Nick Szaunig committed 2026-09-27 06:06:56 +02:00
commit 59bb5a38a3
132 files changed
+19435

No files matched your search

+252
View File
@@ -0,0 +1,252 @@
/**
* 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 { normalizePaymentStatus, type CheckoutSessionInfo, type PaymentGateway } from "./payment-gateway";
import {
applyCheckoutExpired,
applyDispute,
applyFirstPaymentFailed,
applyFirstPaymentProcessing,
applyFirstPaymentSucceeded,
applyInstallmentFailed,
applyInstallmentPaid,
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, installmentId } = session.metadata;
if (!bookingId || (kind !== "booking" && kind !== "installment")) return null;
const booking = await db.booking.findUnique({ where: { id: bookingId }, select: { id: true, stripeCheckoutSessionId: true } });
if (!booking) return null;
// Eine Buchungs-Session muss zur gespeicherten Session passen (oder es ist
// noch keine gespeichert, weil das Speichern nach Stripe scheiterte).
if (kind === "booking" && booking.stripeCheckoutSessionId && booking.stripeCheckoutSessionId !== session.id) return null;
const paid = session.paymentStatus === "paid";
if (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,
};
if (kind === "booking") {
await db.$transaction((tx) => applyFirstPaymentSucceeded(tx, bookingId, info, eventCtx(ctx, actor)));
} else if (installmentId) {
await db.$transaction((tx) => applyInstallmentPaid(tx, installmentId, info, eventCtx(ctx, actor)));
}
return bookingId;
}
if (session.status === "complete") {
if (kind === "booking") {
await db.$transaction((tx) =>
applyFirstPaymentProcessing(tx, bookingId, { paymentIntentId: session.paymentIntentId, customerId: session.customerId }, eventCtx(ctx, actor)),
);
} else if (installmentId) {
await db.installment.updateMany({
where: { id: installmentId, status: { in: ["PENDING", "FAILED", "REQUIRES_ACTION"] } },
data: { status: "PROCESSING", stripePaymentIntentId: session.paymentIntentId, lastAttemptAt: ctx.now },
});
}
}
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, installmentId } = s.metadata;
if (kind === "booking" && bookingId) {
await db.$transaction((tx) => applyFirstPaymentFailed(tx, bookingId, eventCtx(ctx)));
return [bookingId];
}
if (kind === "installment" && installmentId) {
await db.installment.updateMany({
where: { id: installmentId, status: "PROCESSING" },
data: { status: "FAILED", failureCode: "async_payment_failed", checkoutSessionId: null, checkoutExpiresAt: null },
});
return bookingId ? [bookingId] : [];
}
return [];
}
case "checkout.session.expired": {
const s = event.data.object;
const { kind, bookingId, installmentId } = (s.metadata ?? {}) as Record<string, string>;
if (kind === "booking" && bookingId) {
await db.$transaction((tx) => applyCheckoutExpired(tx, bookingId, s.id, eventCtx(ctx)));
return [bookingId];
}
if (kind === "installment" && installmentId) {
await db.installment.updateMany({
where: { id: installmentId, checkoutSessionId: s.id },
data: { checkoutSessionId: null, checkoutExpiresAt: null },
});
}
return [];
}
case "payment_intent.succeeded": {
const pi = event.data.object;
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
const outcome = await db.$transaction((tx) =>
applyInstallmentPaid(
tx,
pi.metadata.installmentId!,
{
paymentIntentId: pi.id,
chargeId: idOf(pi.latest_charge),
paymentMethodId: idOf(pi.payment_method),
customerId: idOf(pi.customer),
amountCents: pi.amount_received,
},
eventCtx(ctx),
),
);
void outcome;
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
}
case "payment_intent.payment_failed": {
const pi = event.data.object;
if (pi.metadata?.kind !== "installment_offsession" || !pi.metadata.installmentId) return [];
const code = pi.last_payment_error?.code ?? null;
await db.$transaction((tx) =>
applyInstallmentFailed(
tx,
pi.metadata.installmentId!,
{
paymentIntentId: pi.id,
failureCode: pi.last_payment_error?.decline_code ?? code,
requiresAction: code === "authentication_required" || pi.status === "requires_action",
},
eventCtx(ctx),
),
);
return pi.metadata.bookingId ? [pi.metadata.bookingId] : [];
}
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) await db.$transaction((tx) => applyDispute(tx, piId, eventCtx(ctx)));
return [];
}
default:
return [];
}
}
const HANDLED = new Set<string>([
"checkout.session.completed",
"checkout.session.async_payment_succeeded",
"checkout.session.async_payment_failed",
"checkout.session.expired",
"payment_intent.succeeded",
"payment_intent.payment_failed",
"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 } });
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;
}
}