Devices/Magic: - Offline-Profil-Enroll deaktiviert (410) — Lock-PW würde im Klartext im Download landen; stationärer Schutz läuft jetzt nur über Rebreak Magic - Mac-DNS-Template: ProhibitDisablement (Filter nicht abschaltbar) - Push "Neues Gerät verbunden" an mobile Geräte bei neuer Bindung - Realtime auf user_devices → Settings aktualisiert Magic-Bindings live - Geräte-Detail-Sheet (Tap auf Gerät): Status, verbunden-seit, Schutz-Donut Hard-Lock (server-gehaltenes Removal-PW, User sieht es nie): - magic_removal_password generiert/gespeichert + in Profil injiziert (Lazy-Backfill) - Reveal NUR bei Account-Löschung (user/delete) + Kündigung (stripe webhook), per Resend-Mail + in-Response - Signing config-gated (inaktiv ohne Cert; Lock greift auch unsigniert) Migrations: user_devices-Realtime-Publication + magic_removal_password-Spalten Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
157 lines
5.0 KiB
TypeScript
157 lines
5.0 KiB
TypeScript
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 };
|
||
});
|