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:
chahinebrini 2026-05-11 16:23:02 +02:00
parent 51697c3aa4
commit 335945fe2c
19 changed files with 911 additions and 81 deletions

View File

@ -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;

View File

@ -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")

View File

@ -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,
};
});

View File

@ -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,
},
});
}
}

View File

@ -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,
};
});

View File

@ -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,
},
});
}

View File

@ -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",
};
});

View File

@ -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,
},

View File

@ -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,
},
});
}
}

View File

@ -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);

View File

@ -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);

View 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;
}

View File

@ -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;
}

View File

@ -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

View File

@ -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: {

View File

@ -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";
}

View File

@ -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,
},

View 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
});
}

View File

@ -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",
];