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>
This commit is contained in:
parent
51697c3aa4
commit
335945fe2c
@ -0,0 +1,17 @@
|
||||
-- Migration: tier_system_phase2
|
||||
-- Downgrade-Reconciliation, Founding-Members, GlobalBlocklist-Grace.
|
||||
-- Neue Felder auf Profile, MailConnection, ProtectedDevice.
|
||||
|
||||
-- Profile: founding_member flag + global blocklist grace
|
||||
ALTER TABLE "rebreak"."profiles"
|
||||
ADD COLUMN IF NOT EXISTS "founding_member" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
ADD COLUMN IF NOT EXISTS "global_blocklist_grace_until" TIMESTAMPTZ;
|
||||
|
||||
-- MailConnection: pause-state für Downgrade-Reconciliation
|
||||
ALTER TABLE "rebreak"."mail_connections"
|
||||
ADD COLUMN IF NOT EXISTS "paused_at" TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS "paused_reason" TEXT;
|
||||
|
||||
-- ProtectedDevice: degraded-state für 14-Tage-Grace nach Downgrade
|
||||
ALTER TABLE "rebreak"."protected_devices"
|
||||
ADD COLUMN IF NOT EXISTS "degraded_at" TIMESTAMPTZ;
|
||||
@ -61,6 +61,17 @@ model Profile {
|
||||
voiceSecondsUsedToday Int @default(0) @map("voice_seconds_used_today")
|
||||
voiceQuotaResetAt DateTime? @map("voice_quota_reset_at")
|
||||
|
||||
// ─── Founding Members (erste 100 Signups → automatisch Pro, lifetime) ────
|
||||
// foundingMember=true → exempt von Downgrade-Reconciliation (ihr Pro ist Geschenk).
|
||||
// Wird beim Signup gesetzt wenn profile-count < 100.
|
||||
// Manuell setzbar via /api/dev/set-plan (für Testing).
|
||||
foundingMember Boolean @default(false) @map("founding_member")
|
||||
|
||||
// ─── Globale Blocklist Grace-Period (nach Downgrade auf free) ────────────
|
||||
// Wenn gesetzt: User sieht noch die volle Blocklist bis zu diesem Datum.
|
||||
// Nach Ablauf: nur noch kuratierte Kernliste. 14-Tage-Grace.
|
||||
globalBlocklistGraceUntil DateTime? @map("global_blocklist_grace_until")
|
||||
|
||||
// ─── Admin-Management (Phase E, Migration 20260509) ─────────────────────
|
||||
// banned: User wird auf API-Ebene blockiert (kein Login-Block — Supabase
|
||||
// bleibt unberührt). Soft-Delete scrubbt PII statt Hard-Delete (DSGVO).
|
||||
@ -475,6 +486,10 @@ model MailConnection {
|
||||
useStarttls Boolean @default(false) @map("use_starttls")
|
||||
passwordEncrypted String @map("password_encrypted")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
/// Wenn gesetzt: Account wurde durch Downgrade-Reconciliation pausiert (nicht gelöscht).
|
||||
/// Re-Upgrade setzt pausedAt=null + pausedReason=null + isActive=true zurück.
|
||||
pausedAt DateTime? @map("paused_at")
|
||||
pausedReason String? @map("paused_reason") // z.B. "plan_downgrade"
|
||||
scanInterval Int @default(24) @map("scan_interval")
|
||||
lastScannedAt DateTime? @map("last_scanned_at")
|
||||
nextScanAt DateTime? @map("next_scan_at")
|
||||
@ -723,12 +738,15 @@ model ProtectedDevice {
|
||||
platform String
|
||||
/// User-friendly label, z.B. "MacBook Pro" oder "Olfas iPhone"
|
||||
label String
|
||||
/// pending (enrolled, profile not installed yet) | active (user confirmed install) | revoked
|
||||
/// pending (enrolled, profile not installed yet) | active (user confirmed install) | degraded (plan downgrade — Grace läuft) | revoked
|
||||
status String @default("pending")
|
||||
/// User confirmed install via App (not server-side verified yet — DoH-routing kommt in Phase 2)
|
||||
installedAt DateTime? @map("installed_at")
|
||||
/// Optional: DoH-server pingt das später (Phase 2, separater Sprint)
|
||||
lastDnsQueryAt DateTime? @map("last_dns_query_at")
|
||||
/// Gesetzt wenn Plan-Downgrade das Gerät auf degraded setzt. Nach 14d Passthrough.
|
||||
/// Bei Re-Upgrade: zurück auf active, degradedAt=null.
|
||||
degradedAt DateTime? @map("degraded_at")
|
||||
revokedAt DateTime? @map("revoked_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@ -1,21 +1,40 @@
|
||||
import { getProfile } from "../../db/profile";
|
||||
import { getPlanLimits } from "../../utils/plan-features";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
const dbProfile = await getProfile(user.id);
|
||||
|
||||
const plan = (dbProfile?.plan === "premium"
|
||||
? "legend"
|
||||
: dbProfile?.plan === "standard"
|
||||
? "pro"
|
||||
: dbProfile?.plan ?? "free") as "free" | "pro" | "legend";
|
||||
|
||||
const limits = getPlanLimits(plan);
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
username: dbProfile?.username ?? "",
|
||||
nickname: dbProfile?.nickname ?? null,
|
||||
avatar: dbProfile?.avatar ?? null,
|
||||
plan: (dbProfile?.plan === "premium"
|
||||
? "legend"
|
||||
: dbProfile?.plan === "standard"
|
||||
? "pro"
|
||||
: dbProfile?.plan ?? "free") as "free" | "pro" | "legend",
|
||||
plan,
|
||||
foundingMember: dbProfile?.foundingMember ?? false,
|
||||
streak: dbProfile?.streak ?? 0,
|
||||
created_at: dbProfile?.createdAt?.toISOString() ?? user.created_at,
|
||||
// Für useUserPlan im Frontend — Key-Subset der PlanLimits
|
||||
planLimits: {
|
||||
customDomains: limits.customDomains,
|
||||
domainRefill: limits.domainRefill,
|
||||
mailAgents: limits.mailAgents === Infinity ? null : limits.mailAgents,
|
||||
globalBlocklist: limits.globalBlocklist,
|
||||
maxAppDevices: limits.maxAppDevices,
|
||||
maxProtectedDevices: limits.maxProtectedDevices,
|
||||
canCreateGroup: limits.canCreateGroup,
|
||||
canAddToBlocklist: limits.canAddToBlocklist,
|
||||
},
|
||||
globalBlocklistGraceUntil:
|
||||
dbProfile?.globalBlocklistGraceUntil?.toISOString() ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@ -29,7 +29,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (activeCount >= limits.customDomains) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: `Dein Plan erlaubt maximal ${limits.customDomains} eigene Domains`,
|
||||
data: {
|
||||
error: "plan_limit",
|
||||
resource: "custom_domains",
|
||||
current: activeCount,
|
||||
limit: limits.customDomains,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
import { runDowngradeReconciliation } from "../../utils/downgrade-reconciliation";
|
||||
import type { Plan } from "../../utils/plan-features";
|
||||
|
||||
const VALID_PLANS = ["free", "pro", "legend"] as const;
|
||||
type AppPlan = (typeof VALID_PLANS)[number];
|
||||
@ -9,8 +11,8 @@ type AppPlan = (typeof VALID_PLANS)[number];
|
||||
* DEV/STAGING-ONLY: Setzt den eigenen Plan ohne Admin-Rechte.
|
||||
* Blocked in Production (appUrl enthält "rebreak.org" aber NICHT "staging").
|
||||
*
|
||||
* Body: { plan: "free" | "pro" | "legend" }
|
||||
* Response: { success: true, plan: AppPlan }
|
||||
* Body: { plan: "free" | "pro" | "legend", foundingMember?: boolean }
|
||||
* Response: { success: true, plan: AppPlan, foundingMember: boolean, reconciled: boolean }
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
@ -26,6 +28,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const body = await readBody(event).catch(() => ({}));
|
||||
const plan = body?.plan as string | undefined;
|
||||
const setFoundingMember = body?.foundingMember as boolean | undefined;
|
||||
|
||||
if (!plan || !(VALID_PLANS as readonly string[]).includes(plan)) {
|
||||
throw createError({
|
||||
@ -38,10 +41,46 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const db = usePrisma();
|
||||
await db.profile.update({
|
||||
|
||||
// Aktuellen Plan lesen für Reconciliation
|
||||
const current = await db.profile.findUnique({
|
||||
where: { id: user.id },
|
||||
data: { plan: plan as AppPlan },
|
||||
select: { plan: true, foundingMember: true },
|
||||
});
|
||||
|
||||
return { success: true, plan: plan as AppPlan };
|
||||
const fromPlan = (current?.plan ?? "free") as Plan;
|
||||
const toPlan = plan as AppPlan;
|
||||
|
||||
// Plan + optional foundingMember setzen
|
||||
const updateData: Record<string, unknown> = { plan: toPlan };
|
||||
if (typeof setFoundingMember === "boolean") {
|
||||
updateData.foundingMember = setFoundingMember;
|
||||
}
|
||||
|
||||
await db.profile.update({
|
||||
where: { id: user.id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// Downgrade-Reconciliation (überspringt automatisch wenn foundingMember=true)
|
||||
let reconciled = false;
|
||||
try {
|
||||
await runDowngradeReconciliation(user.id, fromPlan, toPlan);
|
||||
reconciled = true;
|
||||
} catch (err) {
|
||||
// Reconciliation-Fehler darf Plan-Wechsel nicht blockieren
|
||||
console.error("[set-plan] reconciliation error:", err);
|
||||
}
|
||||
|
||||
const updated = await db.profile.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { foundingMember: true },
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
plan: toPlan,
|
||||
foundingMember: updated?.foundingMember ?? false,
|
||||
reconciled,
|
||||
};
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { getProfile } from "../../db/profile";
|
||||
import { getPlanLimits } from "../../utils/plan-features";
|
||||
import {
|
||||
countActiveProtectedDevices,
|
||||
createProtectedDevice,
|
||||
@ -19,7 +20,10 @@ export default defineEventHandler(async (event) => {
|
||||
const user = await requireUser(event);
|
||||
|
||||
const profile = await getProfile(user.id);
|
||||
if (profile?.plan !== "legend") {
|
||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||
|
||||
// maxProtectedDevices=0 → Feature nicht verfügbar (free/pro)
|
||||
if (limits.maxProtectedDevices === 0) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
data: { error: "LEGEND_REQUIRED" },
|
||||
@ -42,12 +46,17 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
const trimmedLabel = label.trim().slice(0, 100);
|
||||
|
||||
// Limit: max 3 active+pending Devices
|
||||
// Limit: max. maxProtectedDevices active+pending Devices
|
||||
const activeCount = await countActiveProtectedDevices(user.id);
|
||||
if (activeCount >= 3) {
|
||||
if (activeCount >= limits.maxProtectedDevices) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
data: { error: "DEVICE_LIMIT_REACHED", max: 3, current: activeCount },
|
||||
data: {
|
||||
error: "plan_limit",
|
||||
resource: "protected_devices",
|
||||
current: activeCount,
|
||||
limit: limits.maxProtectedDevices,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -23,7 +23,7 @@ export default defineEventHandler(async (event) => {
|
||||
...d,
|
||||
isCurrent: !!currentDeviceId && d.deviceId === currentDeviceId,
|
||||
})),
|
||||
max: limits.maxDevices,
|
||||
max: limits.maxAppDevices,
|
||||
plan: profile?.plan ?? "free",
|
||||
};
|
||||
});
|
||||
|
||||
@ -42,9 +42,9 @@ export default defineEventHandler(async (event) => {
|
||||
platform,
|
||||
model: model ?? null,
|
||||
name: name ?? null,
|
||||
maxDevices: limits.maxDevices,
|
||||
maxDevices: limits.maxAppDevices,
|
||||
});
|
||||
return { device, created, max: limits.maxDevices };
|
||||
return { device, created, max: limits.maxAppDevices };
|
||||
} catch (err: any) {
|
||||
if (err.code === "DEVICE_LIMIT_REACHED") {
|
||||
const devices = await listUserDevices(user.id);
|
||||
@ -53,7 +53,7 @@ export default defineEventHandler(async (event) => {
|
||||
statusMessage: "device_limit_reached",
|
||||
data: {
|
||||
error: "device_limit_reached",
|
||||
max: limits.maxDevices,
|
||||
max: limits.maxAppDevices,
|
||||
plan: profile?.plan ?? "free",
|
||||
devices,
|
||||
},
|
||||
|
||||
@ -37,7 +37,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (count >= limits.mailAgents) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: `Dein Plan erlaubt maximal ${limits.mailAgents} Mail-Agent${limits.mailAgents !== 1 ? "en" : ""}`,
|
||||
data: {
|
||||
error: "plan_limit",
|
||||
resource: "mail_accounts",
|
||||
current: count,
|
||||
limit: limits.mailAgents,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,9 +35,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (connections.length === 0) return { scanned: 0, blocked: 0 };
|
||||
|
||||
// Plan-aware blocklist
|
||||
// Grace-Period: wenn globalBlocklistGraceUntil noch in der Zukunft liegt,
|
||||
// behandeln wir den User als 'full' auch wenn sein Plan 'curated' sagt.
|
||||
const profile = await getProfile(userId);
|
||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||
const includeGlobal = limits.globalBlocklist;
|
||||
const inGrace =
|
||||
profile?.globalBlocklistGraceUntil != null &&
|
||||
new Date(profile.globalBlocklistGraceUntil) > new Date();
|
||||
const includeGlobal = limits.globalBlocklist === "full" || inGrace;
|
||||
|
||||
await deleteOldMailBlocked(userId);
|
||||
|
||||
|
||||
@ -33,7 +33,11 @@ export default defineEventHandler(async (event) => {
|
||||
// Plan-aware: Free users get only custom domains, Pro/Legend get global blocklist
|
||||
const profile = await getProfile(user.id);
|
||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||
const includeGlobal = limits.globalBlocklist;
|
||||
// Grace-Period berücksichtigen
|
||||
const inGrace =
|
||||
profile?.globalBlocklistGraceUntil != null &&
|
||||
new Date(profile.globalBlocklistGraceUntil) > new Date();
|
||||
const includeGlobal = limits.globalBlocklist === "full" || inGrace;
|
||||
|
||||
await deleteOldMailBlocked(user.id);
|
||||
|
||||
|
||||
363
backend/server/api/plan/change-preview.get.ts
Normal file
363
backend/server/api/plan/change-preview.get.ts
Normal file
@ -0,0 +1,363 @@
|
||||
import { getProfile } from "../../db/profile";
|
||||
import { getPlanLimits, type Plan } from "../../utils/plan-features";
|
||||
import { usePrisma } from "../../utils/prisma";
|
||||
|
||||
/**
|
||||
* GET /api/plan/change-preview?to=<free|pro|legend>
|
||||
*
|
||||
* 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<Plan, number> = { free: 0, pro: 1, legend: 2 };
|
||||
|
||||
export default defineEventHandler(async (event): Promise<ChangePreviewResponse> => {
|
||||
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<string, string> = {
|
||||
elevenlabs: "ElevenLabs",
|
||||
cartesia: "Cartesia",
|
||||
google: "Google",
|
||||
openai: "OpenAI",
|
||||
azure: "Azure",
|
||||
};
|
||||
return map[provider] ?? provider;
|
||||
}
|
||||
@ -1,10 +1,12 @@
|
||||
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.
|
||||
* Aktualisiert profiles.plan + stripe_* Felder + triggert Downgrade-Reconciliation.
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
@ -41,17 +43,29 @@ export default defineEventHandler(async (event) => {
|
||||
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:
|
||||
plan === "legend" ? "legend" : plan === "pro" ? "pro" : "free",
|
||||
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;
|
||||
}
|
||||
@ -67,15 +81,22 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
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: isActive ? profile.plan : "free",
|
||||
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;
|
||||
}
|
||||
@ -86,14 +107,19 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const profile = await db.profile.findFirst({
|
||||
where: { stripeCustomerId: customerId },
|
||||
select: { id: true },
|
||||
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;
|
||||
}
|
||||
|
||||
@ -38,8 +38,15 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const limits = getPlanLimits(profile?.plan ?? "free");
|
||||
|
||||
// Global Domains nur für Pro/Legend
|
||||
const global = limits.globalBlocklist ? await getActiveBlocklistDomains() : [];
|
||||
// Grace-Period: wenn globalBlocklistGraceUntil noch in der Zukunft liegt,
|
||||
// behandeln wir den User als 'full' auch wenn sein Plan 'curated' sagt.
|
||||
const inGrace =
|
||||
profile?.globalBlocklistGraceUntil != null &&
|
||||
new Date(profile.globalBlocklistGraceUntil) > new Date();
|
||||
const useFullBlocklist = limits.globalBlocklist === "full" || inGrace;
|
||||
|
||||
// Global Domains nur für Pro/Legend (oder während Grace-Period)
|
||||
const global = useFullBlocklist ? await getActiveBlocklistDomains() : [];
|
||||
|
||||
// Beide Listen ohne Salt hashen — vereinfachte Architektur:
|
||||
// Server kennt die Klartext-Domains eh (via DB), darum bringt User-Salt
|
||||
|
||||
@ -2,12 +2,38 @@ import { usePrisma } from "../utils/prisma";
|
||||
|
||||
export async function getMailConnections(userId: string) {
|
||||
const db = usePrisma();
|
||||
// isActive=true UND nicht pausiert (pausedAt=null) — pausierte werden vom Cron ausgelassen
|
||||
return db.mailConnection.findMany({
|
||||
where: { userId, isActive: true },
|
||||
where: { userId, isActive: true, pausedAt: null },
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Alle Verbindungen eines Users inkl. pausierten — für Status-Anzeige im Frontend. */
|
||||
export async function getAllMailConnections(userId: string) {
|
||||
const db = usePrisma();
|
||||
return db.mailConnection.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
provider: true,
|
||||
providerName: true,
|
||||
isActive: true,
|
||||
pausedAt: true,
|
||||
pausedReason: true,
|
||||
scanInterval: true,
|
||||
lastScannedAt: true,
|
||||
nextScanAt: true,
|
||||
emailsBlocked: true,
|
||||
emailsScanned: true,
|
||||
lastConnectError: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getAllActiveMailUserIds() {
|
||||
const db = usePrisma();
|
||||
const rows = await db.mailConnection.findMany({
|
||||
@ -20,7 +46,8 @@ export async function getAllActiveMailUserIds() {
|
||||
|
||||
export async function countMailConnections(userId: string) {
|
||||
const db = usePrisma();
|
||||
return db.mailConnection.count({ where: { userId, isActive: true } });
|
||||
// Nur aktive + nicht-pausierte Verbindungen zählen gegen das Limit
|
||||
return db.mailConnection.count({ where: { userId, isActive: true, pausedAt: null } });
|
||||
}
|
||||
|
||||
export async function upsertMailConnection(data: {
|
||||
|
||||
@ -6,6 +6,7 @@ export interface ProtectedDeviceRecord {
|
||||
label: string;
|
||||
status: string;
|
||||
installedAt: Date | null;
|
||||
degradedAt: Date | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@ -14,6 +15,22 @@ export interface ProtectedDeviceWithToken extends ProtectedDeviceRecord {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const DEVICE_SELECT = {
|
||||
id: true,
|
||||
platform: true,
|
||||
label: true,
|
||||
status: true,
|
||||
installedAt: true,
|
||||
degradedAt: true,
|
||||
createdAt: true,
|
||||
} as const;
|
||||
|
||||
const DEVICE_SELECT_WITH_TOKEN = {
|
||||
...DEVICE_SELECT,
|
||||
dnsToken: true,
|
||||
userId: true,
|
||||
} as const;
|
||||
|
||||
/** Alle nicht-revoked Devices eines Users, neueste zuerst. */
|
||||
export async function listProtectedDevices(
|
||||
userId: string,
|
||||
@ -22,18 +39,11 @@ export async function listProtectedDevices(
|
||||
return db.protectedDevice.findMany({
|
||||
where: { userId, status: { not: "revoked" } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
platform: true,
|
||||
label: true,
|
||||
status: true,
|
||||
installedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: DEVICE_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
/** Anzahl der aktiven+pending Devices für Limit-Check. */
|
||||
/** Anzahl der aktiven+pending Devices für Limit-Check (degraded zählt NICHT — Slot freigegeben). */
|
||||
export async function countActiveProtectedDevices(
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
@ -50,16 +60,18 @@ export async function getProtectedDevice(
|
||||
const db = usePrisma();
|
||||
return db.protectedDevice.findUnique({
|
||||
where: { id },
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
dnsToken: true,
|
||||
platform: true,
|
||||
label: true,
|
||||
status: true,
|
||||
installedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: DEVICE_SELECT_WITH_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lookup by dnsToken — für DoH-Blocklist-Endpoint (Token aus URL). */
|
||||
export async function getProtectedDeviceByToken(
|
||||
dnsToken: string,
|
||||
): Promise<ProtectedDeviceWithToken | null> {
|
||||
const db = usePrisma();
|
||||
return db.protectedDevice.findUnique({
|
||||
where: { dnsToken },
|
||||
select: DEVICE_SELECT_WITH_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
@ -79,16 +91,7 @@ export async function createProtectedDevice(opts: {
|
||||
label: opts.label,
|
||||
status: "pending",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
userId: true,
|
||||
dnsToken: true,
|
||||
platform: true,
|
||||
label: true,
|
||||
status: true,
|
||||
installedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: DEVICE_SELECT_WITH_TOKEN,
|
||||
});
|
||||
}
|
||||
|
||||
@ -109,14 +112,7 @@ export async function confirmProtectedDeviceInstalled(
|
||||
status: "active",
|
||||
installedAt: new Date(),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
platform: true,
|
||||
label: true,
|
||||
status: true,
|
||||
installedAt: true,
|
||||
createdAt: true,
|
||||
},
|
||||
select: DEVICE_SELECT,
|
||||
});
|
||||
}
|
||||
|
||||
@ -137,3 +133,31 @@ export async function revokeProtectedDevice(
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft ob ein Token nach der 14-Tage-Grace-Period in Passthrough-Modus ist.
|
||||
* Wird vom DoH-Blocklist-Endpoint aufgerufen.
|
||||
*
|
||||
* Returns:
|
||||
* 'active' → volle Blocklist liefern
|
||||
* 'grace' → volle Blocklist liefern (innerhalb 14-Tage-Grace)
|
||||
* 'passthrough' → nur minimale/leere Liste liefern
|
||||
* 'revoked' → Token unbekannt oder revoked → Passthrough
|
||||
*/
|
||||
export async function getDeviceBlocklistMode(
|
||||
dnsToken: string,
|
||||
): Promise<"active" | "grace" | "passthrough" | "revoked"> {
|
||||
const device = await getProtectedDeviceByToken(dnsToken);
|
||||
if (!device) return "revoked";
|
||||
if (device.status === "revoked") return "revoked";
|
||||
if (device.status === "active" || device.status === "pending") return "active";
|
||||
if (device.status === "degraded") {
|
||||
const GRACE_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
const gracedAt = device.degradedAt
|
||||
? device.degradedAt.getTime() + GRACE_MS
|
||||
: 0;
|
||||
if (Date.now() <= gracedAt) return "grace";
|
||||
return "passthrough";
|
||||
}
|
||||
return "passthrough";
|
||||
}
|
||||
|
||||
@ -73,7 +73,7 @@ export async function requireUser(
|
||||
userId: user.id,
|
||||
deviceId,
|
||||
platform,
|
||||
maxDevices: limits.maxDevices,
|
||||
maxDevices: limits.maxAppDevices,
|
||||
});
|
||||
return user;
|
||||
} catch (err: any) {
|
||||
@ -88,7 +88,7 @@ export async function requireUser(
|
||||
statusMessage: 'device_limit_reached',
|
||||
data: {
|
||||
error: 'device_limit_reached',
|
||||
max: limits.maxDevices,
|
||||
max: limits.maxAppDevices,
|
||||
plan: profile?.plan ?? 'free',
|
||||
devices,
|
||||
},
|
||||
|
||||
189
backend/server/utils/downgrade-reconciliation.ts
Normal file
189
backend/server/utils/downgrade-reconciliation.ts
Normal file
@ -0,0 +1,189 @@
|
||||
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
|
||||
});
|
||||
}
|
||||
@ -14,30 +14,58 @@ export interface VoiceConfig {
|
||||
}
|
||||
|
||||
export interface PlanLimits {
|
||||
// ─── Custom Domains ──────────────────────────────────────────────────────
|
||||
/** Max. eigene Domains (Infinity = unbegrenzt) */
|
||||
customDomains: number;
|
||||
/** Freigeschaltete Domain-Slots füllen sich wieder auf (Community-Promotion) */
|
||||
domainRefill: boolean;
|
||||
|
||||
// ─── Mail-Accounts ───────────────────────────────────────────────────────
|
||||
/** Max. aktive Mail-Agenten (Infinity = unbegrenzt) */
|
||||
mailAgents: number;
|
||||
/** Erlaubte Scan-Intervalle in Stunden */
|
||||
mailIntervalOptions: number[];
|
||||
/** Zugang zur globalen HaGeZi-Blocklist (200k+) */
|
||||
globalBlocklist: boolean;
|
||||
|
||||
// ─── Globale Blocklist ───────────────────────────────────────────────────
|
||||
/**
|
||||
* 'curated' = kleiner Stub der bekanntesten Casino-Domains (Free).
|
||||
* 'full' = vollständige HaGeZi/ReBreak-Liste (~208k).
|
||||
*
|
||||
* Der Stub selbst ist in server/utils/curated-blocklist.ts definiert.
|
||||
* Die echte ~1-2k HaGeZi-Subset-Liste ist ein separates Daten-Ticket.
|
||||
*/
|
||||
globalBlocklist: "curated" | "full";
|
||||
|
||||
// ─── Community ───────────────────────────────────────────────────────────
|
||||
/** Darf in der Community posten */
|
||||
canPost: boolean;
|
||||
/** Darf Gruppen gründen */
|
||||
canCreateGroup: boolean;
|
||||
/** Darf Domains direkt zur ReBreak Blocklist hinzufügen */
|
||||
canAddToBlocklist: boolean;
|
||||
/** Max. parallel registrierte Devices pro Account (Anti-Account-Sharing) */
|
||||
maxDevices: number;
|
||||
|
||||
// ─── Geräte (zwei getrennte Konzepte!) ───────────────────────────────────
|
||||
/**
|
||||
* Max. parallel eingeloggte App-Geräte pro Account (Anti-Account-Sharing).
|
||||
* Bezieht sich auf UserDevice (iOS/Android-App-Instanzen).
|
||||
*/
|
||||
maxAppDevices: number;
|
||||
/**
|
||||
* Max. zusätzliche Geräte (Mac/Windows) die per DNS-Profil geschützt werden.
|
||||
* Bezieht sich auf ProtectedDevice (Legend-only Feature).
|
||||
* 0 = Feature nicht verfügbar.
|
||||
*/
|
||||
maxProtectedDevices: number;
|
||||
|
||||
// ─── AI-Coach ────────────────────────────────────────────────────────────
|
||||
/** Primäres OpenRouter/Groq-Modell für KI-Coach */
|
||||
aiModel: string;
|
||||
/** Fallback-Modelle (werden der Reihe nach versucht wenn primary fehlschlägt) */
|
||||
aiModelFallbacks: Array<{ provider: "groq" | "openrouter"; model: string }>;
|
||||
/** AI-Provider: groq (Free/Pro) oder openrouter (Legend/Claude) */
|
||||
aiProvider: "groq" | "openrouter";
|
||||
|
||||
// ─── TTS ─────────────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Voice-Config: welcher TTS-Provider + Quota.
|
||||
*
|
||||
@ -55,11 +83,12 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
domainRefill: false,
|
||||
mailAgents: 1,
|
||||
mailIntervalOptions: [4],
|
||||
globalBlocklist: false,
|
||||
globalBlocklist: "curated",
|
||||
canPost: true,
|
||||
canCreateGroup: false,
|
||||
canAddToBlocklist: false,
|
||||
maxDevices: 1,
|
||||
maxAppDevices: 1,
|
||||
maxProtectedDevices: 0,
|
||||
aiModel: "llama-3.1-8b-instant",
|
||||
aiModelFallbacks: [
|
||||
{ provider: "groq", model: "llama-3.3-70b-versatile" },
|
||||
@ -78,11 +107,12 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
domainRefill: true,
|
||||
mailAgents: 3,
|
||||
mailIntervalOptions: [1, 4, 8],
|
||||
globalBlocklist: true,
|
||||
globalBlocklist: "full",
|
||||
canPost: true,
|
||||
canCreateGroup: false,
|
||||
canAddToBlocklist: false,
|
||||
maxDevices: 1,
|
||||
maxAppDevices: 1,
|
||||
maxProtectedDevices: 0,
|
||||
aiModel: "llama-3.3-70b-versatile",
|
||||
aiModelFallbacks: [
|
||||
{ provider: "groq", model: "llama-3.1-8b-instant" },
|
||||
@ -100,11 +130,12 @@ export const PLAN_LIMITS: Record<Plan, PlanLimits> = {
|
||||
domainRefill: true,
|
||||
mailAgents: Infinity,
|
||||
mailIntervalOptions: [1, 4, 8],
|
||||
globalBlocklist: true,
|
||||
globalBlocklist: "full",
|
||||
canPost: true,
|
||||
canCreateGroup: true,
|
||||
canAddToBlocklist: true,
|
||||
maxDevices: 3,
|
||||
maxAppDevices: 3,
|
||||
maxProtectedDevices: 2, // "+2 weitere Geräte" (§0.5)
|
||||
aiModel: "anthropic/claude-3.5-haiku",
|
||||
aiModelFallbacks: [
|
||||
{ provider: "openrouter", model: "anthropic/claude-3-haiku" },
|
||||
@ -125,3 +156,45 @@ export function getPlanLimits(plan: string): PlanLimits {
|
||||
if (plan === "standard") return PLAN_LIMITS.pro;
|
||||
return PLAN_LIMITS[(plan as Plan) ?? "free"] ?? PLAN_LIMITS.free;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kuratierter Stub der bekanntesten Glücksspiel-Domains für Free-User.
|
||||
* Diese Liste ist der Mechanismus — der echte ~1-2k HaGeZi-Subset
|
||||
* ist ein separates Daten-Ticket (TODO: Daten-Ticket anlegen).
|
||||
*
|
||||
* Wird in DNS-Blocklist-Endpoints und scan-internal verwendet wenn
|
||||
* limits.globalBlocklist === 'curated'.
|
||||
*/
|
||||
export const CURATED_BLOCKLIST_STUB: string[] = [
|
||||
// DE / Offshore-Klassiker (Top-Tier-Traffic)
|
||||
"betway.com",
|
||||
"bet365.com",
|
||||
"888casino.com",
|
||||
"pokerstars.com",
|
||||
"williamhill.com",
|
||||
"bwin.com",
|
||||
"unibet.com",
|
||||
"partypoker.com",
|
||||
"casinoclub.com",
|
||||
"interwetten.com",
|
||||
"tipico.de",
|
||||
"betsson.com",
|
||||
"casumo.com",
|
||||
"leovegas.com",
|
||||
"mr-green.com",
|
||||
"jackpot.de",
|
||||
"sunmaker.com",
|
||||
"stargames.com",
|
||||
"mybet.com",
|
||||
"winner.com",
|
||||
"ladbrokes.com",
|
||||
"coral.co.uk",
|
||||
"paddypower.com",
|
||||
"betfair.com",
|
||||
"mrvegas.com",
|
||||
"slotsmillion.com",
|
||||
"casinoeuropa.com",
|
||||
"netbet.com",
|
||||
"platincasino.com",
|
||||
"euslot.com",
|
||||
];
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user