L17 Pakete: Stufen Basis/Profi, Lotse-Chat-Plätze und Kontingent

- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
  Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
  Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
  requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
  Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
  hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
  und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 10:13:27 +02:00
co-authored by Claude Opus 5
parent 4d5be72ebb
commit 06d320908f
60 changed files with 2076 additions and 91 deletions
+15 -2
View File
@@ -5,6 +5,8 @@ import { AI_MODEL, isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { getTenantPlan } from "@/server/plan";
import { isModuleInTier } from "@/lib/plans";
import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
/**
@@ -14,7 +16,14 @@ import { envMonthlyTokenLimit, getTokenBudget } from "./budget";
export const LOTSE_MODULE_KEY = "lotse";
/** Lotse usable for the tenant: module switched on AND included in the package tier (L17: Basis has no AI). */
export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
if (!(await isModuleInPlan(ctx.tenantId, LOTSE_MODULE_KEY))) return false;
return isLotseSwitchedOn(ctx);
}
/** Only the tenant switch (settings form shows it independently of the tier). */
async function isLotseSwitchedOn(ctx: Pick<ServiceCtx, "db" | "tenantId">): Promise<boolean> {
const row = await ctx.db.tenantModule.findUnique({
where: { tenantId_moduleKey: { tenantId: ctx.tenantId, moduleKey: LOTSE_MODULE_KEY } },
select: { enabled: true },
@@ -22,6 +31,10 @@ export async function isLotseEnabled(ctx: Pick<ServiceCtx, "db" | "tenantId">):
return !row || row.enabled;
}
async function isModuleInPlan(tenantId: string, moduleKey: string): Promise<boolean> {
return isModuleInTier((await getTenantPlan(tenantId)).tier, moduleKey);
}
/** Throws `forbidden` (details.reason = "disabled") when the tenant switched the Lotse off. */
export async function assertLotseEnabled(ctx: ServiceCtx): Promise<void> {
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
@@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick<ServiceCtx, "db">): Promise<{ address
export async function getLotseSettings(ctx: ServiceCtx) {
assertCan(ctx, "tenant:manage");
const [enabled, s, budget] = await Promise.all([
isLotseEnabled(ctx),
isLotseSwitchedOn(ctx),
ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, lotseChatEnabled: true } }),
getTokenBudget(ctx),
]);
@@ -73,7 +86,7 @@ export async function updateLotseSettings(ctx: ServiceCtx, raw: LotseSettingsInp
const stored = await ctx.db.tenantSettings.findFirst({ select: { lotseAddressForm: true, aiMonthlyTokenLimit: true, lotseChatEnabled: true } });
const before = {
enabled: await isLotseEnabled(ctx),
enabled: await isLotseSwitchedOn(ctx),
addressForm: stored?.lotseAddressForm ?? null,
monthlyTokenLimit: stored?.aiMonthlyTokenLimit ?? null,
chatEnabled: stored?.lotseChatEnabled ?? true,