- 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>
190 lines
6.4 KiB
TypeScript
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
|
|
});
|
|
}
|