rebreak-monorepo/backend/server/utils/downgrade-reconciliation.ts
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

190 lines
6.4 KiB
TypeScript

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<void> {
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<typeof usePrisma>,
): Promise<void> {
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<typeof usePrisma>,
): Promise<void> {
// 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<typeof usePrisma>,
): Promise<void> {
// 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<void> {
// 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
});
}