import { getProfile } from "../../db/profile"; import { getPlanLimits, type Plan } from "../../utils/plan-features"; import { usePrisma } from "../../utils/prisma"; /** * GET /api/plan/change-preview?to= * * Zeigt dem User vorab was sich bei einem Plan-Wechsel ändert. * Frontend baut den Briefing-Screen (§4, pricing-tiers.md) gegen diesen Endpoint. * * Response-Shape: siehe types unten. */ type ResourceKey = | "global_blocklist" | "custom_domains" | "mail_accounts" | "protected_devices" | "coach" | "tts" | "voice_picker" | "group_creation"; interface ChangeEntry { resource: ResourceKey; current: number | string; newLimit: number | string; overBy: number; action: "keep" | "limited" | "paused" | "grace_then_off" | "degraded" | "unlocked"; detail: string; graceUntilDays?: number; } interface ChangePreviewResponse { from: Plan; to: Plan; direction: "upgrade" | "downgrade" | "same"; gains: string[]; keeps: string[]; changes: ChangeEntry[]; } const VALID_PLANS: Plan[] = ["free", "pro", "legend"]; const PLAN_ORDER: Record = { free: 0, pro: 1, legend: 2 }; export default defineEventHandler(async (event): Promise => { const user = await requireUser(event); const query = getQuery(event); const toPlan = query.to as string | undefined; if (!toPlan || !VALID_PLANS.includes(toPlan as Plan)) { throw createError({ statusCode: 400, data: { error: "INVALID_PLAN", message: `to must be one of: ${VALID_PLANS.join(", ")}`, }, }); } const profile = await getProfile(user.id); const rawPlan = profile?.plan ?? "free"; const fromPlan = (rawPlan === "premium" ? "legend" : rawPlan === "standard" ? "pro" : rawPlan) as Plan; const to = toPlan as Plan; const isFoundingMember = profile?.foundingMember ?? false; const direction: "upgrade" | "downgrade" | "same" = PLAN_ORDER[to] > PLAN_ORDER[fromPlan] ? "upgrade" : PLAN_ORDER[to] < PLAN_ORDER[fromPlan] ? "downgrade" : "same"; // Founding Member verliert nichts — leere changes if (isFoundingMember && direction === "downgrade") { return { from: fromPlan, to, direction: "same", gains: [], keeps: [ "Dein Streak, deine Logs, dein Coach", "Dein bisheriger Schutz — alles bleibt", "Als Founding Member bleibt dein Plan dauerhaft erhalten", ], changes: [], }; } const fromLimits = getPlanLimits(fromPlan); const toLimits = getPlanLimits(to); // ── Aktuelle Ressourcen-Counts laden ────────────────────────────────────── const db = usePrisma(); const [ activeMailCount, activeDomainCount, activeDeviceCount, ] = await Promise.all([ db.mailConnection.count({ where: { userId: user.id, isActive: true, pausedAt: null } }), db.userCustomDomain.count({ where: { userId: user.id, status: { notIn: ["approved", "rejected"] } }, }), db.protectedDevice.count({ where: { userId: user.id, status: { in: ["active", "pending"] } }, }), ]); const changes: ChangeEntry[] = []; const gains: string[] = []; const keeps: string[] = [ "Dein Streak, deine Logs, dein Coach — alles bleibt", "Nichts wird gelöscht. Alles Pausierte kommt sofort zurück wenn du wieder upgradest", ]; // ── Global Blocklist ─────────────────────────────────────────────────────── if (fromLimits.globalBlocklist !== toLimits.globalBlocklist) { if (direction === "downgrade") { // full → curated changes.push({ resource: "global_blocklist", current: "volle Liste (~208.000 Domains)", newLimit: "kuratierte Kernliste (~1.000 Domains)", overBy: 0, action: "grace_then_off", detail: "Du hast noch 14 Tage Zugang zur vollen Blocklist. Danach sind deine " + "eigenen Domains weiter aktiv — trag jetzt deine wichtigsten ein.", graceUntilDays: 14, }); } else { // curated → full gains.push("Volle Glücksspiel-Blocklist (~208.000 bekannte Domains)"); } } else if (direction !== "same") { // Gleicher Wert — keine Änderung nötig, aber zum Kontext nennen } // ── Custom Domains ──────────────────────────────────────────────────────── if (fromLimits.customDomains !== toLimits.customDomains) { if (direction === "downgrade") { const newLimit = toLimits.customDomains; const overBy = Math.max(0, activeDomainCount - newLimit); changes.push({ resource: "custom_domains", current: activeDomainCount, newLimit, overBy, action: "keep", // grandfathered — alle bleiben aktiv detail: overBy > 0 ? `Du hast ${activeDomainCount} eigene Domains, ${to}-Plan erlaubt ${newLimit}. ` + `Alle bleiben aktiv — du kannst erst wieder welche hinzufügen wenn du unter ${newLimit} bist.` : `Du hast ${activeDomainCount} von ${toLimits.customDomains} möglichen Domains — kein Überlauf.`, }); } else { gains.push( `Bis zu ${toLimits.customDomains} eigene Domains${toLimits.domainRefill ? " (Slots füllen sich auf wenn deine Domain in die globale Liste aufgenommen wird)" : ""}`, ); } } // ── Domain Refill ───────────────────────────────────────────────────────── if (!fromLimits.domainRefill && toLimits.domainRefill && direction === "upgrade") { gains.push( "Domain-Slot-Refill: wenn ReBreak eine deiner eingereichten Domains genehmigt, wird der Slot frei", ); } // ── Mail-Accounts ───────────────────────────────────────────────────────── const newMailLimit = toLimits.mailAgents; if (fromLimits.mailAgents !== newMailLimit) { if (direction === "downgrade" && newMailLimit !== Infinity) { const overBy = Math.max(0, activeMailCount - newMailLimit); if (overBy > 0) { changes.push({ resource: "mail_accounts", current: activeMailCount, newLimit: newMailLimit, overBy, action: "paused", detail: `Du hast ${activeMailCount} verbundene Postfächer, ${to}-Plan schützt ${newMailLimit}. ` + `Die ${overBy} zuletzt hinzugefügten werden pausiert — nicht gelöscht. ` + "Ein letzter Scan läuft noch durch bevor sie pausiert werden. " + "Bei Re-Upgrade kommen sie sofort zurück.", }); } else { changes.push({ resource: "mail_accounts", current: activeMailCount, newLimit: newMailLimit, overBy: 0, action: "keep", detail: `Du hast ${activeMailCount} von ${newMailLimit} möglichen Postfächern — kein Überlauf.`, }); } } else if (direction === "upgrade") { const limitText = newMailLimit === Infinity ? "unbegrenzt" : String(newMailLimit); gains.push( newMailLimit === Infinity ? "Unbegrenzt viele Mail-Postfächer schützen (Echtzeit-Scan geplant)" : `Bis zu ${limitText} Mail-Postfächer`, ); } } // ── Protected Devices (Mac/Windows DNS-Profile) ─────────────────────────── if (fromLimits.maxProtectedDevices !== toLimits.maxProtectedDevices) { if (direction === "downgrade" && toLimits.maxProtectedDevices === 0) { const overBy = activeDeviceCount; if (overBy > 0) { changes.push({ resource: "protected_devices", current: activeDeviceCount, newLimit: 0, overBy, action: "degraded", detail: `Du hast ${activeDeviceCount} geschützte(s) Gerät(e). Diese laufen noch 14 Tage auf voller ` + "Blocklist weiter. Danach liefert der DNS-Filter für diese Geräte keinen Schutz mehr — " + "das Profil bleibt auf dem Gerät (entferne es manuell unter System-Einstellungen). " + "Bei Re-Upgrade auf Legend: sofort wieder voll aktiv.", graceUntilDays: 14, }); } } else if (direction === "upgrade") { gains.push( `Bis zu ${toLimits.maxProtectedDevices} weitere Geräte (Mac/Windows) per DNS-Profil schützen`, ); } } // ── Coach ───────────────────────────────────────────────────────────────── if (fromLimits.aiModel !== toLimits.aiModel) { if (direction === "downgrade") { changes.push({ resource: "coach", current: friendlyModelName(fromLimits.aiModel), newLimit: friendlyModelName(toLimits.aiModel), overBy: 0, action: "limited", detail: "Dein Coach läuft ab jetzt auf einem anderen Modell — er ist weiter da, immer.", }); } else { gains.push(`Lyra läuft auf ${friendlyModelName(toLimits.aiModel)} — feinfühligere Gespräche`); } } // ── TTS ─────────────────────────────────────────────────────────────────── if ( fromLimits.voice.provider !== toLimits.voice.provider || fromLimits.voice.dailyQuotaSeconds !== toLimits.voice.dailyQuotaSeconds ) { if (direction === "downgrade") { const fromQuota = fromLimits.voice.dailyQuotaSeconds === 0 ? "unbegrenzt" : `${fromLimits.voice.dailyQuotaSeconds / 60} Min/Tag`; const toQuota = toLimits.voice.dailyQuotaSeconds === 0 ? "unbegrenzt" : `${toLimits.voice.dailyQuotaSeconds / 60} Min/Tag`; changes.push({ resource: "tts", current: `${friendlyProviderName(fromLimits.voice.provider)}, ${fromQuota}`, newLimit: `${friendlyProviderName(toLimits.voice.provider)}, ${toQuota}`, overBy: 0, action: "limited", detail: `Sprachausgabe wechselt auf ${friendlyProviderName(toLimits.voice.provider)} (${toQuota}). ` + "Deine gewählte Stimme merken wir uns — bei Re-Upgrade sofort wieder aktiv.", }); } else { const toQuota = toLimits.voice.dailyQuotaSeconds === 0 ? "unbegrenzt" : `${toLimits.voice.dailyQuotaSeconds / 60} Min/Tag`; gains.push( `${friendlyProviderName(toLimits.voice.provider)} Sprachausgabe (${toQuota})`, ); } } // ── Voice Picker (Legend-only) ──────────────────────────────────────────── if (fromPlan === "legend" && to !== "legend" && direction === "downgrade") { changes.push({ resource: "voice_picker", current: "Stimme wählbar", newLimit: "Standard-Stimme", overBy: 0, action: "limited", detail: "Die Lyra-Stimme wird auf Standard zurückgesetzt. Deine Auswahl bleibt gespeichert — " + "bei Re-Upgrade sofort wieder aktiv.", }); } else if (to === "legend" && fromPlan !== "legend" && direction === "upgrade") { gains.push("Lyra-Stimme frei wählbar"); } // ── Group Creation (Legend-only) ────────────────────────────────────────── if (fromLimits.canCreateGroup && !toLimits.canCreateGroup && direction === "downgrade") { changes.push({ resource: "group_creation", current: "Gruppen gründen erlaubt", newLimit: "Keine neuen Gruppen", overBy: 0, action: "keep", // bestehende Gruppen bleiben (grandfathered) detail: "Bestehende Gruppen bleiben — du bleibst Admin. " + "Neue Gruppen gründen geht erst wieder ab Legend.", }); } else if (!fromLimits.canCreateGroup && toLimits.canCreateGroup && direction === "upgrade") { gains.push("Eigene Community-Gruppen gründen"); } // ── Same-Plan: alles leer ───────────────────────────────────────────────── if (direction === "same") { return { from: fromPlan, to, direction: "same", gains: [], keeps, changes: [], }; } return { from: fromPlan, to, direction, gains, keeps, changes, }; }); function friendlyModelName(model: string): string { if (model.includes("claude-3.5-haiku") || model.includes("claude-3-haiku")) return "Claude (Haiku)"; if (model.includes("llama-3.3-70b")) return "Llama 70B"; if (model.includes("llama-3.1-8b")) return "Llama 8B"; return model; } function friendlyProviderName(provider: string): string { const map: Record = { elevenlabs: "ElevenLabs", cartesia: "Cartesia", google: "Google", openai: "OpenAI", azure: "Azure", }; return map[provider] ?? provider; }