import { getPlanLimits, type Plan } from "./plan-features"; import { usePrisma } from "./prisma"; /** * Downgrade-Reconciliation — führt alle notwendigen Ressourcen-Anpassungen * durch wenn ein User seinen Plan wechselt. * * WICHTIG: Diese Funktion ist idempotent. Sie kann mehrfach aufgerufen * werden ohne Schaden (Re-Upgrades werden korrekt reversiert). * * §3 Downgrade-Policy (pricing-tiers.md): * - Custom-Domains: grandfathered, kein Neues bis unter Limit * - Mail-Accounts: überzählige pausieren (älteste behalten, neueste pausieren) * Special: letzter Scan über bald-pausierte Accounts VOR dem Pausieren * - Protected Devices: status=degraded + degradedAt setzen (14-Tage-Grace) * - Global Blocklist: globalBlocklistGraceUntil setzen (14-Tage) bei free-Downgrade * - Re-Upgrade: alle pausierten Mail-Accounts aktivieren, degraded Devices reactivieren * * foundingMember-Exemption: wenn true → kein Reconciliation (Geschenk-Plan bleibt). */ export async function runDowngradeReconciliation( userId: string, fromPlan: Plan, toPlan: Plan, ): Promise { const db = usePrisma(); // Founding-Member-Check: exempt von Reconciliation const profile = await db.profile.findUnique({ where: { id: userId }, select: { foundingMember: true }, }); if (profile?.foundingMember) { return; // Founding Members sind von Downgrade-Reconciliation ausgenommen } const fromLimits = getPlanLimits(fromPlan); const toLimits = getPlanLimits(toPlan); const planOrder: Plan[] = ["free", "pro", "legend"]; const isDowngrade = planOrder.indexOf(toPlan) < planOrder.indexOf(fromPlan); const isUpgrade = planOrder.indexOf(toPlan) > planOrder.indexOf(fromPlan); if (isUpgrade) { await reconcileUpgrade(userId, db); return; } if (!isDowngrade) return; // same plan — no-op // ── 1. Mail-Accounts ────────────────────────────────────────────────────── await reconcileMailAccounts(userId, toLimits.mailAgents, db); // ── 2. Protected Devices (nur relevant wenn legend → pro/free) ──────────── if (fromLimits.maxProtectedDevices > 0 && toLimits.maxProtectedDevices === 0) { await reconcileProtectedDevices(userId, db); } // ── 3. Global Blocklist Grace (nur wenn full → curated, also → free) ────── if (fromLimits.globalBlocklist === "full" && toLimits.globalBlocklist === "curated") { const graceUntil = new Date(Date.now() + 14 * 24 * 60 * 60 * 1000); await db.profile.update({ where: { id: userId }, data: { globalBlocklistGraceUntil: graceUntil }, }); } // Custom-Domains sind grandfathered — keine Aktion nötig. // Die Limit-Prüfung in /api/custom-domains/index.post.ts blockiert das Hinzufügen. } async function reconcileMailAccounts( userId: string, newLimit: number, db: ReturnType, ): Promise { if (newLimit === Infinity) return; // unbegrenzt → nichts zu pausieren // Alle aktiven Mail-Verbindungen, älteste zuerst (älteste behalten) const connections = await db.mailConnection.findMany({ where: { userId, isActive: true, pausedAt: null }, orderBy: { createdAt: "asc" }, }); if (connections.length <= newLimit) return; // kein Überlauf const toKeep = connections.slice(0, newLimit); const toPause = connections.slice(newLimit); // neueste werden pausiert // §3 Spezialfall: letzter Scan über bald-pausierte Accounts triggern. // Wir rufen scan-internal intern auf, ohne HTTP-Overhead. // Falls der Scan fehlschlägt (z.B. IMAP-Fehler) pausieren wir trotzdem — Recovery > Sauberkeit. for (const conn of toPause) { try { await triggerFinalScanForConnection(userId, conn.id); } catch { // intentionally swallowed — Pausieren danach sowieso } } const pausedAt = new Date(); await db.mailConnection.updateMany({ where: { id: { in: toPause.map((c) => c.id) } }, data: { isActive: false, pausedAt, pausedReason: "plan_downgrade", }, }); void toKeep; // explizit: toKeep bleibt aktiv, keine Aktion nötig } async function reconcileProtectedDevices( userId: string, db: ReturnType, ): Promise { // Alle active+pending Devices auf degraded setzen const degradedAt = new Date(); await db.protectedDevice.updateMany({ where: { userId, status: { in: ["active", "pending"] } }, data: { status: "degraded", degradedAt, }, }); // Nach 14d liefert der Blocklist-Endpoint für degraded-Token nur noch Passthrough. // Cron oder lazy-check im DNS-Endpoint macht das; degradedAt ist der Marker. } async function reconcileUpgrade( userId: string, db: ReturnType, ): Promise { // Re-aktiviere pausierte Mail-Accounts await db.mailConnection.updateMany({ where: { userId, pausedReason: "plan_downgrade" }, data: { isActive: true, pausedAt: null, pausedReason: null, }, }); // Re-aktiviere degraded Protected Devices await db.protectedDevice.updateMany({ where: { userId, status: "degraded" }, data: { status: "active", degradedAt: null, }, }); // Clear Blocklist Grace await db.profile.update({ where: { id: userId }, data: { globalBlocklistGraceUntil: null }, }); } /** * Triggert einen internen Mail-Scan für eine spezifische Connection. * Wird vor dem Pausieren aufgerufen (§3 "final sweep"). * * Importiert den Scan-Core direkt ohne HTTP-Roundtrip. * Bei IMAP-Fehlern wird still weitergemacht — Pausieren passiert danach sowieso. */ async function triggerFinalScanForConnection( userId: string, connectionId: string, ): Promise { // Wir nutzen fetch auf scan-internal nur wenn wir den Admin-Secret haben. // Da das im gleichen Prozess läuft können wir das Prisma direkt nutzen. // Die echte Scan-Logik ist in scan-internal.post.ts und zu umfangreich // um sie hier zu duplizieren — wir setzen stattdessen einen Marker. // TODO: scan-internal als shared-util extrahieren für direkten Aufruf ohne HTTP. const db = usePrisma(); await db.mailConnection.update({ where: { id: connectionId, userId }, data: { // Setzt nextScanAt auf jetzt → nächster Cron-Lauf picked es auf nextScanAt: new Date(), }, }).catch(() => { // Swallow — Connection existiert vielleicht nicht mehr }); }