mirror of
https://github.com/Since-3/Kuma.git
synced 2026-10-12 21:41:17 +00:00
182 lines
6.4 KiB
TypeScript
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
|
|
);
|
|
}
|