- plan-features.ts: globalBlocklist 'curated'|'full' (curated = 30-domain stub,
TODO real ~1-2k HaGeZi subset); maxAppDevices vs maxProtectedDevices split
(legend maxProtectedDevices: 2); mail 1/3/Infinity
- limit-enforcement structured errors on mail/connect, custom-domains/add, devices/enroll
({ error:'plan_limit', resource, current, limit }); approved-own-submissions already
excluded from custom-domain count (slot frees on approval)
- server/utils/downgrade-reconciliation.ts: founding-member exemption; re-upgrade
reactivates paused mail + degraded devices; downgrade pauses newest-N mail accounts
(isActive=false, pausedAt, pausedReason; pre-pause sets nextScanAt=now for a final
sweep — real direct IMAP scan is TODO/stub); degrades excess device profiles
(status='degraded', degradedAt); free → globalBlocklistGraceUntil = now+14d;
custom domains grandfathered
- set-plan.post.ts + stripe/webhook.post.ts: run reconciliation on plan change;
set-plan accepts { foundingMember } for testing
- GET /api/plan/change-preview?to=<plan>: gains/keeps/changes per resource (8 axes),
founding-member → direction 'same'
- me.get.ts: + foundingMember, globalBlocklistGraceUntil, planLimits block
- blocklist + mail-scan honour globalBlocklistGraceUntil (grace → treat as 'full')
- db: countMailConnections/getMailConnections exclude paused; getAllMailConnections;
getDeviceBlocklistMode (active|grace|passthrough|revoked)
- migration 20260511_tier_system_phase2 (profiles.founding_member +
global_blocklist_grace_until; mail_connections.paused_at/paused_reason;
protected_devices.degraded_at). prisma generate + build:backend clean.
TODOs (separate tickets): founding-member auto-counter on signup; real direct IMAP
final-scan (not just nextScanAt nudge); real curated blocklist data + wiring the
stub into the blocklist response for free users.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
130 lines
3.8 KiB
TypeScript
130 lines
3.8 KiB
TypeScript
import Stripe from "stripe";
|
||
import { usePrisma } from "../../utils/prisma";
|
||
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
||
import type { Plan } from "../../utils/plan-features";
|
||
|
||
/**
|
||
* 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 },
|
||
});
|
||
|
||
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),
|
||
);
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
return { received: true };
|
||
});
|