Kuma/src/modules/products/lib/pass-webhook.ts
2026-10-02 14:30:54 +02:00

182 lines
6.4 KiB
TypeScript

import type Stripe from "stripe";
import { Prisma } from "@/src/generated/prisma/client";
import { prisma } from "@/src/lib/prisma";
import { stripe, onAccount } from "@/src/lib/stripe";
import { recordStripeFee } from "@/src/lib/stripe-fee";
import { refundStripePayment } from "@/src/lib/stripe-refund";
import { billingAddressFromCheckout } from "@/src/lib/billing-address";
import { sendMail } from "@/src/lib/mail/resend";
import { generatePassConfirmationEmail } from "@/src/lib/mail/templates/pass-confirmation";
import { toEmailBranding } from "@/src/modules/settings/lib/to-email-branding";
import { notifyBusinessManager } from "@/src/modules/notifications/lib/create-notification";
import { validUntilFrom } from "./pass-rules";
/** Markiert Checkout-Sessions für einen Kartenkauf (siehe api/stripe/checkout/pass). */
export const PASS_CHECKOUT_KIND = "pass";
export function isPassCheckout(session: Stripe.Checkout.Session): boolean {
return session.metadata?.kind === PASS_CHECKOUT_KIND;
}
async function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T | null> {
return Promise.race([
promise,
new Promise<null>((resolve) => setTimeout(() => resolve(null), ms)),
]);
}
/**
* Legt die gekaufte Karte an, sobald Stripe die Zahlung meldet - analog zur
* Kursbuchung nie auf der Success-Page. Idempotent über stripeSessionId: eine
* erneute Zustellung derselben Session legt keine zweite Karte an.
*
* Verzögerte Zahlungsarten (SEPA) sind bei checkout.session.completed noch
* "unpaid" - dann passiert nichts, die Karte entsteht mit
* checkout.session.async_payment_succeeded.
*/
export async function handlePassCheckoutCompleted(
session: Stripe.Checkout.Session,
stripeAccountId: string | null
): Promise<void> {
if (session.payment_status !== "paid") {
console.info(
`Kartenkauf ${session.id} ist noch nicht bezahlt (${session.payment_status}) - ` +
`warte auf checkout.session.async_payment_succeeded.`
);
return;
}
const existing = await prisma.customerPass.findUnique({
where: { stripeSessionId: session.id },
select: { id: true },
});
if (existing) return;
const productId = session.metadata?.productId ?? "";
const userId = session.metadata?.userId ?? "";
const paymentIntentId = (session.payment_intent as string | null) ?? null;
const amountPaidCents = session.amount_total ?? 0;
const [product, user] = await Promise.all([
prisma.product.findUnique({
where: { id: productId },
select: {
id: true,
name: true,
credits: true,
validityMonths: true,
sports: true,
businessId: true,
business: { select: { name: true, branding: true } },
},
}),
prisma.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true },
}),
]);
// Produkt in der Zwischenzeit gelöscht oder Konto weg: Es gibt nichts, woran
// die Karte hängen könnte. Das Geld geht vollständig zurück, Kumasy gibt die
// Gebühr an das Studio zurück - der Fehler liegt nicht beim Studio.
if (!product || !user) {
console.error(
`Kartenkauf ${session.id}: ${!product ? `Produkt ${productId}` : `User ${userId}`} existiert nicht. ` +
`Zahlung wird erstattet.`
);
if (paymentIntentId) {
const refund = await refundStripePayment({
paymentIntentId,
stripeAccountId,
refundApplicationFee: true,
});
if (!refund.success) {
console.error(
`Erstattung für Kartenkauf ${session.id} fehlgeschlagen: ${refund.error}. ` +
`Manuelle Erstattung im Stripe Dashboard nötig.`
);
}
}
return;
}
// Ein Fehler hier wird weitergeworfen: Mit 0 als Ersatzwert stünde der Kauf mit
// falscher Gebühr in der DB, und eine erneute Zustellung änderte ihn nicht mehr.
const platformFeeCents = paymentIntentId
? ((
await stripe.paymentIntents.retrieve(paymentIntentId, undefined, onAccount(stripeAccountId))
).application_fee_amount ?? 0)
: 0;
const billingAddress = billingAddressFromCheckout(session.customer_details);
const now = new Date();
const validUntil = validUntilFrom(now, product.validityMonths);
let passId: string;
try {
const pass = await prisma.customerPass.create({
data: {
productId: product.id,
businessId: product.businessId,
userId: user.id,
email: user.email.toLowerCase(),
name: product.name,
sports: product.sports,
creditsTotal: product.credits,
creditsRemaining: product.credits,
validUntil,
source: "purchase",
priceCents: amountPaidCents - platformFeeCents,
stripeAccountId,
stripeSessionId: session.id,
stripePaymentIntentId: paymentIntentId,
amountPaid: amountPaidCents,
platformFee: platformFeeCents,
...(billingAddress ? { billingAddress } : {}),
},
select: { id: true },
});
passId = pass.id;
} catch (error) {
// Zwei Zustellungen gleichzeitig: die andere hat die Karte schon angelegt.
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") return;
throw error;
}
if (paymentIntentId && stripeAccountId) {
await recordStripeFee(paymentIntentId, stripeAccountId).catch((err) =>
console.error(`Stripe-Gebühr für Kartenkauf ${passId} konnte nicht gelesen werden:`, err)
);
}
const baseUrl = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const { subject, html, text } = generatePassConfirmationEmail({
reason: "purchase",
passName: product.name,
businessName: product.business.name,
credits: product.credits,
validUntil,
amountPaidCents,
hasAccount: true,
myCardsUrl: `${baseUrl}/courses/myCards`,
registerUrl: `${baseUrl}/register/user`,
companyName: process.env.COMPANY_NAME,
branding: toEmailBranding(product.business),
});
await withTimeout(
sendMail({ to: user.email, subject, html, text }).catch((err) =>
console.error("Kartenkauf-Mail fehlgeschlagen:", err)
),
2000
);
await withTimeout(
notifyBusinessManager(product.businessId, {
type: "pass.sold",
title: `Karte verkauft: ${product.name}`,
body: `${user.name?.trim() || user.email} hat eine ${product.name} gekauft.`,
linkUrl: `/kunden/${user.id}`,
}).catch((err) => console.error("Kartenkauf-Benachrichtigung fehlgeschlagen:", err)),
2000
);
}