import Stripe from "stripe"; import { usePrisma } from "../../utils/prisma"; import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation"; import type { Plan } from "../../utils/plan-features"; import { listMagicRemovalCredentials } from "../../db/devices"; import { sendMagicRemovalEmail } from "../../utils/magic-removal-email"; import { serverSupabaseServiceRole } from "../../utils/useSupabase"; /** * POST /api/stripe/webhook * Stripe Webhook – verarbeitet Subscription-Events. * Aktualisiert profiles.plan + stripe_* Felder + triggert Downgrade-Reconciliation. */ export default defineEventHandler(async (event) => { const config = useRuntimeConfig(); const stripe = new Stripe(config.stripeSecretKey); const body = await readRawBody(event); const sig = getHeader(event, "stripe-signature"); if (!body || !sig) { throw createError({ statusCode: 400, message: "Missing body or signature", }); } let stripeEvent: Stripe.Event; try { stripeEvent = stripe.webhooks.constructEvent( body, sig, config.stripeWebhookSecret, ); } catch (err: any) { throw createError({ statusCode: 400, message: `Webhook Error: ${err.message}`, }); } const db = usePrisma(); switch (stripeEvent.type) { case "checkout.session.completed": { const session = stripeEvent.data.object as Stripe.Checkout.Session; const userId = session.metadata?.user_id || session.client_reference_id; const plan = session.metadata?.plan || "legend"; const newPlan = ( plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free" ) as Plan; if (userId) { const current = await db.profile.findUnique({ where: { id: userId }, select: { plan: true }, }); const fromPlan = (current?.plan ?? "free") as Plan; await db.profile.update({ where: { id: userId }, data: { plan: newPlan, stripeCustomerId: session.customer as string, stripeSubId: session.subscription as string, }, }); await runDowngradeReconciliation(userId, fromPlan, newPlan).catch( (err) => console.error("[stripe-webhook] reconciliation error:", err), ); } break; } case "customer.subscription.updated": { const sub = stripeEvent.data.object as Stripe.Subscription; const customerId = sub.customer as string; const profile = await db.profile.findFirst({ where: { stripeCustomerId: customerId }, select: { id: true, plan: true }, }); if (profile) { const isActive = ["active", "trialing"].includes(sub.status); const newPlan = (isActive ? profile.plan : "free") as Plan; const fromPlan = profile.plan as Plan; await db.profile.update({ where: { id: profile.id }, data: { plan: newPlan, premiumUntil: sub.current_period_end ? new Date(sub.current_period_end * 1000) : null, }, }); await runDowngradeReconciliation(profile.id, fromPlan, newPlan).catch( (err) => console.error("[stripe-webhook] reconciliation error:", err), ); } break; } case "customer.subscription.deleted": { const sub = stripeEvent.data.object as Stripe.Subscription; const customerId = sub.customer as string; const profile = await db.profile.findFirst({ where: { stripeCustomerId: customerId }, select: { id: true, plan: true, nickname: true }, }); if (profile) { const fromPlan = profile.plan as Plan; await db.profile.update({ where: { id: profile.id }, data: { plan: "free", premiumUntil: null }, }); await runDowngradeReconciliation(profile.id, fromPlan, "free").catch( (err) => console.error("[stripe-webhook] reconciliation error:", err), ); // Magic Hard-Lock Reveal bei Kündigung: Removal-Passwörter per Mail, // damit der User die gesperrten Mac/Windows-Profile entfernen kann. try { const creds = await listMagicRemovalCredentials(profile.id); if (creds.length > 0) { const config = useRuntimeConfig(event); const resendApiKey = (config as any).resendApiKey as string | undefined; const supabase = serverSupabaseServiceRole(event); const { data } = await supabase.auth.admin.getUserById(profile.id); const email = data?.user?.email; if (email && resendApiKey) { await sendMagicRemovalEmail({ recipientEmail: email, recipientNickname: profile.nickname, credentials: creds, reason: "cancellation", resendApiKey, }).catch(() => {}); } } } catch (err) { console.error("[stripe-webhook] magic removal reveal failed:", err); } } break; } } return { received: true }; });