chahinebrini 335945fe2c feat(tier): plan limits Rev.2 + downgrade reconciliation + change-preview (Phase 2 backend)
- 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>
2026-05-11 16:23:02 +02:00

130 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 };
});