From 06d320908f1b77e4854731cfbf3c815afb2b1d50 Mon Sep 17 00:00:00 2001
From: Martin
Date: Mon, 21 Sep 2026 10:13:27 +0200
Subject: [PATCH] =?UTF-8?q?L17=20Pakete:=20Stufen=20Basis/Profi,=20Lotse-C?=
=?UTF-8?q?hat-Pl=C3=A4tze=20und=20Kontingent?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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
---
messages/de/lotse.json | 8 +-
messages/de/offline.json | 4 +-
messages/de/plans.json | 93 ++++++++
messages/en/lotse.json | 8 +-
messages/en/offline.json | 4 +-
messages/en/plans.json | 93 ++++++++
.../20260921100000_pakete/migration.sql | 36 +++
prisma/schema.prisma | 29 +++
scripts/check-module-guards.ts | 2 +
scripts/lib/demo-seed.ts | 18 ++
scripts/lib/e2e-fixture.ts | 1 +
scripts/lib/lotse-chat-fixture.ts | 6 +
scripts/test-e2e-tenant-isolation.ts | 1 +
scripts/test-pakete-gates.ts | 213 ++++++++++++++++++
scripts/test-pakete-rules.ts | 79 +++++++
scripts/test-pakete-seats.ts | 191 ++++++++++++++++
src/app/(app)/dashboard/page.tsx | 10 +-
src/app/(app)/layout.tsx | 10 +-
src/app/(app)/planning/layout.tsx | 6 +-
src/app/(app)/settings/lotse/page.tsx | 6 +-
src/app/(app)/settings/page.tsx | 7 +-
src/app/(app)/work-orders/[id]/page.tsx | 9 +-
src/app/(field)/m/(core)/lotse/layout.tsx | 10 +-
src/app/(field)/m/(core)/lotse/page.tsx | 25 +-
src/app/(field)/m/layout.tsx | 17 +-
src/app/(platform)/admin/[id]/page.tsx | 13 +-
src/app/(platform)/admin/page.tsx | 4 +
src/components/field/bottom-nav.tsx | 10 +-
src/components/lotse/chat/lotse-chat.tsx | 15 +-
src/components/lotse/chat/seat-admin.tsx | 180 +++++++++++++++
src/components/lotse/chat/unavailable.tsx | 23 ++
src/components/plans/plan-admin-card.tsx | 82 +++++++
src/components/plans/plan-form.tsx | 78 +++++++
src/components/plans/profi-hint.tsx | 21 ++
src/lib/lotse/chat.ts | 4 +
src/lib/nav.ts | 18 +-
src/lib/offline/outbox-core.ts | 3 +
src/lib/plans.ts | 122 ++++++++++
src/server/actions/lotse/_chat-state.ts | 2 +-
src/server/actions/lotse/seats.ts | 40 ++++
src/server/actions/plans-platform.ts | 38 ++++
src/server/api/respond.ts | 6 +-
src/server/backup/topology.ts | 2 +
src/server/db.ts | 2 +
src/server/dsgvo/pii-fields.ts | 3 +
src/server/modules.ts | 28 ++-
src/server/plan.ts | 111 +++++++++
src/server/services/lotse/chat/access.ts | 55 ++++-
src/server/services/lotse/chat/engine.ts | 5 +-
src/server/services/lotse/chat/seats.ts | 130 +++++++++++
src/server/services/lotse/chat/transcribe.ts | 2 +-
src/server/services/lotse/chat/usage.ts | 144 ++++++++++++
src/server/services/lotse/settings.ts | 17 +-
src/server/services/planning/access.ts | 4 +
src/server/services/planning/schedule.ts | 2 +
src/server/services/planning/team-settings.ts | 2 +
src/server/services/planning/watch.ts | 3 +
src/server/services/plans/platform.ts | 66 ++++++
src/server/services/sync/apply.ts | 40 ++++
src/server/services/work-orders/transition.ts | 6 +-
60 files changed, 2076 insertions(+), 91 deletions(-)
create mode 100644 messages/de/plans.json
create mode 100644 messages/en/plans.json
create mode 100644 prisma/migrations/20260921100000_pakete/migration.sql
create mode 100644 scripts/test-pakete-gates.ts
create mode 100644 scripts/test-pakete-rules.ts
create mode 100644 scripts/test-pakete-seats.ts
create mode 100644 src/components/lotse/chat/seat-admin.tsx
create mode 100644 src/components/lotse/chat/unavailable.tsx
create mode 100644 src/components/plans/plan-admin-card.tsx
create mode 100644 src/components/plans/plan-form.tsx
create mode 100644 src/components/plans/profi-hint.tsx
create mode 100644 src/lib/plans.ts
create mode 100644 src/server/actions/lotse/seats.ts
create mode 100644 src/server/actions/plans-platform.ts
create mode 100644 src/server/plan.ts
create mode 100644 src/server/services/lotse/chat/seats.ts
create mode 100644 src/server/services/lotse/chat/usage.ts
create mode 100644 src/server/services/plans/platform.ts
diff --git a/messages/de/lotse.json b/messages/de/lotse.json
index be3cd64..06dad6c 100644
--- a/messages/de/lotse.json
+++ b/messages/de/lotse.json
@@ -317,11 +317,15 @@
"transition_forbidden": "Dafür fehlt die Berechtigung.",
"not_editable": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
"reason_required": "Der Grund fehlt.",
- "use_billing_overview": "Nur über die Abrechnungsübersicht möglich."
+ "use_billing_overview": "Nur über die Abrechnungsübersicht möglich.",
+ "not_in_plan": "Der Lotse ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet.",
+ "no_seat": "Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.",
+ "quota_exhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht."
},
"unavailable": {
"title": "Lotse-Chat nicht verfügbar",
"back": "Zur Übersicht"
- }
+ },
+ "quotaExhausted": "Das Chat-Kontingent des Betriebs ist für diesen Monat aufgebraucht. Ab dem nächsten Monat kannst du wieder schreiben – bei Fragen hilft das Büro."
}
}
diff --git a/messages/de/offline.json b/messages/de/offline.json
index 89336f8..edca747 100644
--- a/messages/de/offline.json
+++ b/messages/de/offline.json
@@ -92,7 +92,9 @@
"internal": "Technischer Fehler – wird automatisch erneut versucht.",
"timeOverlap": "Der Zeitnachtrag überschneidet sich mit einer bereits erfassten Zeit und wurde nicht übernommen.",
"timeWindow": "Der Zeitnachtrag liegt außerhalb der erlaubten 7 Tage, in der Zukunft oder ist zu lang und wurde nicht übernommen.",
- "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln."
+ "otherSession": "Auf einem anderen Auftrag lief noch eine Zeiterfassung – der Start wurde nicht übernommen. Bitte erneut starten und wechseln.",
+ "notInPlan": "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden.",
+ "moduleDisabled": "Diese Funktion ist für diesen Betrieb ausgeschaltet. Die Eingabe wurde nicht übernommen, bitte im Büro melden."
},
"view": {
"title": "Offline-Ansicht",
diff --git a/messages/de/plans.json b/messages/de/plans.json
new file mode 100644
index 0000000..8de00bf
--- /dev/null
+++ b/messages/de/plans.json
@@ -0,0 +1,93 @@
+{
+ "tier": {
+ "BASIS": "Basis",
+ "PROFI": "Profi"
+ },
+ "colTier": "Paket",
+ "notInTier": "Nur im Paket Profi",
+ "profiHint": {
+ "title": "Im Paket Profi enthalten",
+ "text": "Diese Funktion gehört zum Paket Profi. Ihr Betrieb nutzt das Paket Basis – für ein Upgrade wenden Sie sich bitte an Ihren Craftvia-Ansprechpartner."
+ },
+ "admin": {
+ "title": "Paket & Lotse-Chat",
+ "sub": "Paket des Betriebs, Chat-Plätze für Monteure und Verbrauch im laufenden Monat",
+ "package": "Paket",
+ "trial": "Testphase (alle Funktionen)",
+ "packageReadOnly": "Paket und Anzahl der Chat-Plätze legt der Anbieter fest. Hier vergeben Sie die gekauften Plätze an Ihre Monteure und Teamleiter.",
+ "basisHint": "Lotse und Lotse-Chat sind im Paket Profi enthalten. Bereits vergebene Plätze und Chatverläufe bleiben gespeichert und sind nach einem Wechsel zu Profi wieder verfügbar.",
+ "seatsTitle": "Chat-Plätze",
+ "seatsCount": "{assigned} von {purchased} Plätzen vergeben",
+ "trialSeats": "Während der Testphase dürfen alle Monteure und Teamleiter den Lotse-Chat nutzen – Plätze müssen nicht vergeben werden (Kontingent: {perSeat} Chats je Person und Monat).",
+ "noSeatsBought": "Noch keine Chat-Plätze gebucht. Plätze bucht der Anbieter für Ihren Betrieb.",
+ "overbooked": "Mehr Plätze vergeben als gebucht ({purchased}). Nur die zuerst vergebenen Plätze gelten – bitte Plätze entziehen.",
+ "noFieldUsers": "Keine aktiven Nutzer mit Feldrolle (Monteur, Teamleiter).",
+ "seatAssigned": "Platz vergeben",
+ "seatOverbooked": "Über der Platzanzahl",
+ "seatNone": "Kein Platz",
+ "assign": "Platz vergeben",
+ "revoke": "Platz entziehen",
+ "assignLabel": "Chat-Platz an {name} vergeben",
+ "revokeLabel": "Chat-Platz von {name} entziehen",
+ "saved": "Chat-Platz gespeichert.",
+ "usageTitle": "Verbrauch {month}",
+ "usageText": "{used} von {quota} Chats",
+ "quotaRule": "Kontingent: {perSeat} Chats je vergebenem Platz und Monat, gemeinsam für den Betrieb (Basis: {basis}). Ein Chat ist eine Nachricht an den Lotsen.",
+ "warn80": "{percent} % des Monatskontingents sind genutzt.",
+ "exhausted": "Das Monatskontingent ist ausgeschöpft. Weitere Chats werden als Mehrverbrauch in Paketen à {pack} berechnet.",
+ "hardLimitReached": "Das Monatskontingent ist ausgeschöpft. Der Lotse-Chat ist bis zum Monatsende gesperrt (hartes Limit).",
+ "overage": "Mehrverbrauch: {overage} Chats – entspricht {packs, plural, one {# Paket} other {# Paketen}} à {pack} Chats.",
+ "perUserCaption": "Chats je Nutzer im laufenden Monat",
+ "perUserName": "Nutzer",
+ "perUserChats": "Chats",
+ "errors": {
+ "no_seats_left": "Alle gebuchten Plätze sind vergeben. Bitte zuerst einen Platz entziehen oder weitere Plätze beim Anbieter buchen.",
+ "not_eligible": "Diese Person kann keinen Chat-Platz erhalten (keine aktive Feldrolle).",
+ "not_in_plan": "Der Lotse-Chat ist im Paket Profi enthalten.",
+ "disabled": "Lotse ist für diesen Betrieb ausgeschaltet.",
+ "forbidden": "Dafür fehlt die Berechtigung.",
+ "trial_expired": "Testphase abgelaufen – nur Lesezugriff.",
+ "failed": "Speichern nicht möglich."
+ }
+ },
+ "platform": {
+ "title": "Paket & Lotse-Chat",
+ "tier": "Paket",
+ "trialProfi": "Testphase = Profi",
+ "seats": "Chat-Plätze",
+ "seatsValue": "{assigned} vergeben / {purchased} gebucht",
+ "hardLimit": "Hartes Limit",
+ "hardLimitOn": "an – Sperre beim Kontingent",
+ "hardLimitOff": "aus – Mehrverbrauch wird gezählt",
+ "rule": "{perSeat} Chats je vergebenem Platz und Monat (Zeitzone des Mandanten); Mehrverbrauch in Paketen à {pack}.",
+ "currentMonth": "Laufender Monat · {month}",
+ "previousMonth": "Vormonat · {month}",
+ "chats": "{used} von {quota} Chats",
+ "overage": "Mehrverbrauch {overage} Chats → {packs, plural, one {# Paket} other {# Pakete}} à {pack}",
+ "noOverage": "Kein Mehrverbrauch",
+ "edit": "Paket ändern …",
+ "editTitle": "Paket & Chat-Plätze ändern",
+ "editText": "Die Änderung wirkt sofort für alle Nutzer des Mandanten und wird im Plattform-Audit protokolliert.",
+ "tierHint": {
+ "BASIS": "Ohne KI und Planung: kein Lotse, keine Plantafel/Live-Lage, keine Abrechnungsübersicht, kein Notdienst, kein Auftragsimport.",
+ "PROFI": "Alle Funktionen inkl. Planung, Abrechnungsübersicht, Notdienst, Import und Lotse."
+ },
+ "downgradeHint": "Wechsel zu Basis: Daten bleiben erhalten, nur der Zugriff endet. Zurück zu Profi ist alles wieder da.",
+ "seatsInput": "Gebuchte Chat-Plätze",
+ "seatsHint": "Der Mandanten-Admin vergibt die Plätze an Nutzer mit Feldrolle.",
+ "hardLimitInput": "Hartes Limit",
+ "hardLimitHint": "Nach Ausschöpfen des Monatskontingents wird der Chat gesperrt statt Mehrverbrauch zu zählen.",
+ "confirm": "Ich habe die Auswirkungen geprüft und bestätige die Änderung.",
+ "submit": "Speichern",
+ "submitting": "Speichern …",
+ "close": "Schließen",
+ "saved": "Paket gespeichert.",
+ "errors": {
+ "confirm_required": "Bitte die Änderung bestätigen.",
+ "invalid_input": "Bitte Eingaben prüfen (Plätze 0–1000).",
+ "tenant_archived": "Der Mandant ist archiviert.",
+ "platform_admin_required": "Nur Voll-Administratoren dürfen das Paket ändern.",
+ "failed": "Speichern nicht möglich."
+ }
+ }
+}
diff --git a/messages/en/lotse.json b/messages/en/lotse.json
index aa89269..69ceb32 100644
--- a/messages/en/lotse.json
+++ b/messages/en/lotse.json
@@ -317,11 +317,15 @@
"transition_forbidden": "You lack the permission.",
"not_editable": "The report has been submitted and can no longer be changed.",
"reason_required": "The reason is missing.",
- "use_billing_overview": "Only possible via the billing overview."
+ "use_billing_overview": "Only possible via the billing overview.",
+ "not_in_plan": "The Lotse is included in the Profi package and not activated for this company.",
+ "no_seat": "The Lotse chat is not activated for you yet – please ask the office.",
+ "quota_exhausted": "The company's chat quota for this month is used up."
},
"unavailable": {
"title": "Lotse chat not available",
"back": "Back to overview"
- }
+ },
+ "quotaExhausted": "The company's chat quota for this month is used up. You can write again from next month – the office can help with questions."
}
}
diff --git a/messages/en/offline.json b/messages/en/offline.json
index b2ec4a9..889c76e 100644
--- a/messages/en/offline.json
+++ b/messages/en/offline.json
@@ -92,7 +92,9 @@
"internal": "Technical error – will retry automatically.",
"timeOverlap": "The added time overlaps with time already recorded and was not accepted.",
"timeWindow": "The added time is outside the allowed 7 days, in the future or too long and was not accepted.",
- "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch."
+ "otherSession": "Time was still running on another order – the start was not accepted. Please start again and switch.",
+ "notInPlan": "Included in the Profi package – not activated for this company. The entry was not applied, please contact the office.",
+ "moduleDisabled": "This function is switched off for this company. The entry was not applied, please contact the office."
},
"view": {
"title": "Offline view",
diff --git a/messages/en/plans.json b/messages/en/plans.json
new file mode 100644
index 0000000..561249f
--- /dev/null
+++ b/messages/en/plans.json
@@ -0,0 +1,93 @@
+{
+ "tier": {
+ "BASIS": "Basic",
+ "PROFI": "Profi"
+ },
+ "colTier": "Package",
+ "notInTier": "Profi package only",
+ "profiHint": {
+ "title": "Included in the Profi package",
+ "text": "This function is part of the Profi package. Your company uses the Basic package – please contact your Craftvia representative to upgrade."
+ },
+ "admin": {
+ "title": "Package & Lotse chat",
+ "sub": "Package of the company, chat seats for technicians and usage in the current month",
+ "package": "Package",
+ "trial": "Trial (all functions)",
+ "packageReadOnly": "The provider sets the package and the number of chat seats. Here you assign the purchased seats to your technicians and team leads.",
+ "basisHint": "Lotse and the Lotse chat are included in the Profi package. Assigned seats and chat histories are kept and available again after switching to Profi.",
+ "seatsTitle": "Chat seats",
+ "seatsCount": "{assigned} of {purchased} seats assigned",
+ "trialSeats": "During the trial all technicians and team leads may use the Lotse chat – no seats need to be assigned (quota: {perSeat} chats per person and month).",
+ "noSeatsBought": "No chat seats purchased yet. The provider books seats for your company.",
+ "overbooked": "More seats assigned than purchased ({purchased}). Only the seats assigned first are valid – please revoke seats.",
+ "noFieldUsers": "No active users with a field role (technician, team lead).",
+ "seatAssigned": "Seat assigned",
+ "seatOverbooked": "Above the seat number",
+ "seatNone": "No seat",
+ "assign": "Assign seat",
+ "revoke": "Revoke seat",
+ "assignLabel": "Assign chat seat to {name}",
+ "revokeLabel": "Revoke chat seat from {name}",
+ "saved": "Chat seat saved.",
+ "usageTitle": "Usage {month}",
+ "usageText": "{used} of {quota} chats",
+ "quotaRule": "Quota: {perSeat} chats per assigned seat and month, shared by the company (basis: {basis}). One chat is one message to the Lotse.",
+ "warn80": "{percent} % of the monthly quota are used.",
+ "exhausted": "The monthly quota is used up. Further chats are billed as overage in packs of {pack}.",
+ "hardLimitReached": "The monthly quota is used up. The Lotse chat is blocked until the end of the month (hard limit).",
+ "overage": "Overage: {overage} chats – equals {packs, plural, one {# pack} other {# packs}} of {pack} chats.",
+ "perUserCaption": "Chats per user in the current month",
+ "perUserName": "User",
+ "perUserChats": "Chats",
+ "errors": {
+ "no_seats_left": "All purchased seats are assigned. Please revoke a seat first or book more seats with the provider.",
+ "not_eligible": "This person cannot get a chat seat (no active field role).",
+ "not_in_plan": "The Lotse chat is included in the Profi package.",
+ "disabled": "Lotse is switched off for this company.",
+ "forbidden": "You are not allowed to do this.",
+ "trial_expired": "Trial expired – read-only access.",
+ "failed": "Could not save."
+ }
+ },
+ "platform": {
+ "title": "Package & Lotse chat",
+ "tier": "Package",
+ "trialProfi": "Trial = Profi",
+ "seats": "Chat seats",
+ "seatsValue": "{assigned} assigned / {purchased} purchased",
+ "hardLimit": "Hard limit",
+ "hardLimitOn": "on – blocked at the quota",
+ "hardLimitOff": "off – overage is counted",
+ "rule": "{perSeat} chats per assigned seat and month (tenant timezone); overage in packs of {pack}.",
+ "currentMonth": "Current month · {month}",
+ "previousMonth": "Previous month · {month}",
+ "chats": "{used} of {quota} chats",
+ "overage": "Overage {overage} chats → {packs, plural, one {# pack} other {# packs}} of {pack}",
+ "noOverage": "No overage",
+ "edit": "Change package …",
+ "editTitle": "Change package & chat seats",
+ "editText": "The change applies immediately to all users of the tenant and is logged in the platform audit.",
+ "tierHint": {
+ "BASIS": "Without AI and planning: no Lotse, no planning board/live situation, no billing overview, no emergency service, no order import.",
+ "PROFI": "All functions incl. planning, billing overview, emergency service, import and Lotse."
+ },
+ "downgradeHint": "Switching to Basic keeps all data, only access ends. Back on Profi everything is available again.",
+ "seatsInput": "Purchased chat seats",
+ "seatsHint": "The tenant admin assigns the seats to users with a field role.",
+ "hardLimitInput": "Hard limit",
+ "hardLimitHint": "When the monthly quota is used up the chat is blocked instead of counting overage.",
+ "confirm": "I checked the impact and confirm the change.",
+ "submit": "Save",
+ "submitting": "Saving …",
+ "close": "Close",
+ "saved": "Package saved.",
+ "errors": {
+ "confirm_required": "Please confirm the change.",
+ "invalid_input": "Please check the input (seats 0–1000).",
+ "tenant_archived": "The tenant is archived.",
+ "platform_admin_required": "Only full administrators may change the package.",
+ "failed": "Could not save."
+ }
+ }
+}
diff --git a/prisma/migrations/20260921100000_pakete/migration.sql b/prisma/migrations/20260921100000_pakete/migration.sql
new file mode 100644
index 0000000..b84a893
--- /dev/null
+++ b/prisma/migrations/20260921100000_pakete/migration.sql
@@ -0,0 +1,36 @@
+-- L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze
+-- Additiv: Stufe + Platzanzahl + hartes Limit am Mandanten (Default PROFI = bestehende Mandanten unverändert),
+-- Tabelle lotse_chat_seats (Mandanten-RLS), Index für die Monatszählung der Chat-Nachrichten.
+
+
+-- CreateEnum
+CREATE TYPE "TenantTier" AS ENUM ('BASIS', 'PROFI');
+
+-- AlterTable
+ALTER TABLE "tenants" ADD COLUMN "lotse_chat_hard_limit" BOOLEAN NOT NULL DEFAULT false,
+ADD COLUMN "lotse_chat_seats" INTEGER NOT NULL DEFAULT 0,
+ADD COLUMN "tier" "TenantTier" NOT NULL DEFAULT 'PROFI';
+
+-- CreateTable
+CREATE TABLE "lotse_chat_seats" (
+ "id" TEXT NOT NULL,
+ "tenant_id" TEXT NOT NULL,
+ "user_id" TEXT NOT NULL,
+ "assigned_by_id" TEXT,
+ "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "lotse_chat_seats_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "lotse_chat_seats_tenant_id_created_at_idx" ON "lotse_chat_seats"("tenant_id", "created_at");
+
+-- CreateIndex
+CREATE UNIQUE INDEX "lotse_chat_seats_tenant_id_user_id_key" ON "lotse_chat_seats"("tenant_id", "user_id");
+
+-- CreateIndex
+CREATE INDEX "lotse_messages_tenant_id_role_created_at_idx" ON "lotse_messages"("tenant_id", "role", "created_at");
+
+
+-- Mandantentrennung (docs/craftvia/MIGRATIONS.md)
+SELECT enable_tenant_rls('lotse_chat_seats');
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index 9761c17..3051b84 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -61,6 +61,13 @@ model Tenant {
trialExpiredNoticeAt DateTime? @map("trial_expired_notice_at")
trialDeletionNoticeAt DateTime? @map("trial_deletion_notice_at")
+ // L17 Pakete: Stufe (Basis ohne KI/Planung) + Lotse-Chat-Plätze (Plattform-Daten, nur Betreiber ändert)
+ tier TenantTier @default(PROFI)
+ /// Anzahl gekaufter Lotse-Chat-Plätze (Vergabe durch den Mandanten-Admin)
+ lotseChatSeats Int @default(0) @map("lotse_chat_seats")
+ /// true = nach Ausschöpfen des Monatskontingents gesperrt statt Mehrverbrauch
+ lotseChatHardLimit Boolean @default(false) @map("lotse_chat_hard_limit")
+
users User[]
roles Role[]
auditLogs AuditLog[]
@@ -1505,6 +1512,8 @@ model LotseMessage {
conversation LotseConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([tenantId, conversationId, createdAt])
+ // L17 Pakete: Monatszählung der Chats (role = user) je Mandant
+ @@index([tenantId, role, createdAt])
@@map("lotse_messages")
}
@@ -1595,3 +1604,23 @@ model TenantExport {
@@index([tenantId, createdAt])
@@map("tenant_exports")
}
+
+// ── L17 Pakete (Basis/Profi) & Lotse-Chat-Plätze ───────────────────────────────
+
+enum TenantTier {
+ BASIS
+ PROFI
+}
+
+/// Vergebener Lotse-Chat-Platz (Mandanten-Admin vergibt bis zur vom Betreiber gesetzten Anzahl).
+model LotseChatSeat {
+ id String @id @default(cuid())
+ tenantId String @map("tenant_id")
+ userId String @map("user_id")
+ assignedById String? @map("assigned_by_id")
+ createdAt DateTime @default(now()) @map("created_at")
+
+ @@unique([tenantId, userId])
+ @@index([tenantId, createdAt])
+ @@map("lotse_chat_seats")
+}
diff --git a/scripts/check-module-guards.ts b/scripts/check-module-guards.ts
index 4ee9524..e636da2 100644
--- a/scripts/check-module-guards.ts
+++ b/scripts/check-module-guards.ts
@@ -61,6 +61,8 @@ const ACTION_MODULE: Record = {
// (requireSession + requirePermission + requireApiContext; Export bewusst auch im Nur-Lesen-Zustand)
"trial-platform.ts": "EXEMPT",
"trial-tenant.ts": "EXEMPT",
+ // L17 Pakete: Stufe/Chat-Plätze/hartes Limit durch den Betreiber (requirePlatformFullAdmin)
+ "plans-platform.ts": "EXEMPT",
// L15 Testphase: öffentliche Selbstanmeldung ohne Session — jede Action MUSS das Rate-Limit prüfen
"trial-signup.ts": "PUBLIC",
};
diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts
index 911b1d5..bbb143a 100644
--- a/scripts/lib/demo-seed.ts
+++ b/scripts/lib/demo-seed.ts
@@ -422,10 +422,26 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis
});
}
+ // ---------- L17 Pakete: Profi mit 3 Lotse-Chat-Plätzen für die Demo-Monteure (idempotent) ----------
+ await seedDemoPlan(tenantId, { tier: "PROFI", seats: 3, seatUserIds: [users.tech, users.tech2, users.tech3], assignedById: users.admin });
+
const orders = await bo.db.workOrder.count({ where: { OR: [{ externalOrderNumber: { startsWith: "DEMO-" } }, { isEmergency: true, emergencyReason: { startsWith: "Wasserrohrbruch im Keller" } }] } });
return { orders, created };
}
+/**
+ * L17 Pakete: package tier + chat seats of a demo tenant. Idempotent (seat rows per user are unique);
+ * existing seats of other users are left alone.
+ */
+export async function seedDemoPlan(tenantId: string, opts: { tier: "BASIS" | "PROFI"; seats: number; seatUserIds?: string[]; assignedById?: string }): Promise {
+ await prisma.tenant.update({ where: { id: tenantId }, data: { tier: opts.tier, lotseChatSeats: opts.seats, lotseChatHardLimit: false } });
+ const db = dbForTenant(tenantId);
+ for (const userId of opts.seatUserIds ?? []) {
+ const existing = await db.lotseChatSeat.findFirst({ where: { userId }, select: { id: true } });
+ if (!existing) await db.lotseChatSeat.create({ data: { tenantId, userId, assignedById: opts.assignedById ?? null } });
+ }
+}
+
/** Isolation fixture for the second tenant: one customer + one work order. */
export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Promise {
const admin = ctxOf(tenantId, adminUserId, "tenant-admin");
@@ -437,6 +453,8 @@ export async function seedDemo2Tenant(tenantId: string, adminUserId: string): Pr
const site = await createSite(admin, { customerId: customer.id, name: "Schaltanlage Halle 2", street: "Am Hafen", houseNumber: "3", postalCode: "24103", city: "Kiel", accessNotes: "Anmeldung beim Werkschutz." });
await createWorkOrder(admin, { title: "Prüfung Unterverteilung nach DGUV V3", customerId: customer.id, siteId: site.id, externalOrderNumber: "DEMO2-01", status: "planned", plannedStart: day(3, 8), plannedEnd: day(3, 12) });
}
+ // L17 Pakete: demo2 = Basis (zeigt die Sperren: keine Planung, Abrechnung, Notdienst, Import, Lotse)
+ await seedDemoPlan(tenantId, { tier: "BASIS", seats: 0 });
}
/** Close the owner client used by the services (the seed has its own client). */
diff --git a/scripts/lib/e2e-fixture.ts b/scripts/lib/e2e-fixture.ts
index ee3f108..9dcc769 100644
--- a/scripts/lib/e2e-fixture.ts
+++ b/scripts/lib/e2e-fixture.ts
@@ -134,6 +134,7 @@ export async function cleanupTenants(slugs: string[]): Promise {
await prisma.mailLog.deleteMany(w);
await prisma.authToken.deleteMany(w);
await prisma.aiGeneration.deleteMany(w);
+ await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
await prisma.signature.deleteMany(w);
await prisma.report.deleteMany(w);
await prisma.photo.deleteMany(w);
diff --git a/scripts/lib/lotse-chat-fixture.ts b/scripts/lib/lotse-chat-fixture.ts
index e570c26..21ee926 100644
--- a/scripts/lib/lotse-chat-fixture.ts
+++ b/scripts/lib/lotse-chat-fixture.ts
@@ -36,6 +36,7 @@ export async function cleanupChatTenants(prefix: string) {
const ids = tenants.map((t) => t.id);
if (ids.length) {
const w = { where: { tenantId: { in: ids } } };
+ await prisma.lotseChatSeat.deleteMany(w); // L17 Pakete
await prisma.lotseActionProposal.deleteMany(w);
await prisma.lotseMessage.deleteMany(w);
await prisma.lotseConversation.deleteMany(w);
@@ -119,6 +120,11 @@ export async function createChatFixture(prefix: string) {
await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo3.id, key: "fertige_montage", label: "Fertige Montage" } });
const wo4 = await order("A-LC-4", { customerId: company.id, siteId: site2.id, title: "Regler einstellen", status: "in_progress", signatureRequired: false, ...tomorrow }, [tech.id]);
+ // L17 Pakete: the chat needs a seat per user (Profi is the default tier)
+ await prisma.tenant.updateMany({ where: { id: { in: [tA.id, tB.id] } }, data: { lotseChatSeats: 10 } });
+ for (const u of [tech, tech2, outsider, admin]) await prisma.lotseChatSeat.create({ data: { tenantId: tA.id, userId: u.id } });
+ await prisma.lotseChatSeat.create({ data: { tenantId: tB.id, userId: techB.id } });
+
const customerB = await prisma.customer.create({ data: { tenantId: tB.id, companyName: "Kunde B" } });
const woB = await prisma.workOrder.create({ data: { tenantId: tB.id, number: "B-LC-1", customerId: customerB.id, title: "Auftrag B", status: "in_progress", ...today } });
await prisma.workOrderAssignee.create({ data: { tenantId: tB.id, workOrderId: woB.id, userId: techB.id } });
diff --git a/scripts/test-e2e-tenant-isolation.ts b/scripts/test-e2e-tenant-isolation.ts
index bb6de7c..1171457 100644
--- a/scripts/test-e2e-tenant-isolation.ts
+++ b/scripts/test-e2e-tenant-isolation.ts
@@ -142,6 +142,7 @@ async function createModelRows(A: TenantFixture): Promise {
put("LotseMessage", (await prisma.lotseMessage.create({ data: { tenantId: t, conversationId: lotseConversation.id, role: "user", text: "ZZ" } })).id, { text: MARK });
put("LotseActionProposal", (await prisma.lotseActionProposal.create({ data: { tenantId: t, conversationId: lotseConversation.id, userId: uid, kind: "add_note", payload: {}, payloadHash: "0".repeat(64), expiresAt: new Date(Date.now() + 1_800_000) } })).id, { kind: MARK });
put("TenantExport", (await prisma.tenantExport.create({ data: { tenantId: t, status: "done", fileName: "zz.zip" } })).id, { fileName: MARK }); // L15 Testphase
+ put("LotseChatSeat", (await prisma.lotseChatSeat.create({ data: { tenantId: t, userId: uid } })).id, { assignedById: MARK }); // L17 Pakete
return rows;
}
diff --git a/scripts/test-pakete-gates.ts b/scripts/test-pakete-gates.ts
new file mode 100644
index 0000000..03a924f
--- /dev/null
+++ b/scripts/test-pakete-gates.ts
@@ -0,0 +1,213 @@
+// L17 Pakete — Durchsetzung der Paketstufe an allen Mechanismen: Action (assertModuleEnabled im
+// moduleGuard), API (requireApiContext/toErrorResponse), Layout-Guard-Funktionen (moduleState,
+// requirePlanFeature), Sync (Offline-Op → rejected mit Klartext), Planung (planningAccess & Co.),
+// isLotseEnabled, Navigation; TenantModule aus + Profi; Wechsel Profi → Basis → Profi ohne Datenverlust;
+// nur der Betreiber ändert die Stufe.
+// Lauf: npx tsx scripts/test-pakete-gates.ts (auch mit RLS_ENFORCED=true)
+
+import "dotenv/config";
+import { randomUUID } from "node:crypto";
+import { readFileSync } from "node:fs";
+import { join } from "node:path";
+import type { Session } from "next-auth";
+import { prisma, dbForTenant } from "../src/server/db";
+import { assertModuleEnabled, ModuleDisabledError } from "../src/server/modules";
+import { effectiveModules, isModuleActive, isPlanFeatureAvailable, moduleState, PROFI_HINT_PATH, requirePlanFeature } from "../src/server/plan";
+import { toErrorResponse } from "../src/server/api/respond";
+import { applyOperations } from "../src/server/services/sync/apply";
+import { getPlanningBoard } from "../src/server/services/planning/board";
+import { getLiveSituation } from "../src/server/services/planning/live";
+import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
+import { recommendAssignments } from "../src/server/services/planning/recommend";
+import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
+import { getPlanningToday } from "../src/server/services/planning/summary";
+import { runPlanningWatch } from "../src/server/services/planning/watch";
+import { isLotseEnabled, updateLotseSettings } from "../src/server/services/lotse/settings";
+import { billingModuleActive } from "../src/server/services/work-orders/transition";
+import { updateTenantPlan } from "../src/server/services/plans/platform";
+import { problemKey } from "../src/lib/offline/outbox-core";
+import type { SyncOperationInput } from "../src/lib/sync/envelope";
+import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
+
+const SLUG_A = "zz-pak-gates-a";
+const SLUG_B = "zz-pak-gates-b";
+const PLATFORM_EMAIL = "platform-pakete@zz-pakete.test";
+const READONLY_EMAIL = "readonly-pakete@zz-pakete.test";
+
+const fakeSession = (tenantId: string, userId: string) => ({ user: { tenantId, id: userId } }) as unknown as Session;
+const src = (p: string) => readFileSync(join(process.cwd(), p), "utf8");
+
+function emergencyOp(): SyncOperationInput {
+ return {
+ clientOpId: randomUUID(),
+ opType: "emergency.create",
+ payload: {
+ clientIds: { workOrder: randomUUID(), session: randomUUID(), customer: randomUUID(), site: randomUUID() },
+ customer: { mode: "new", firstName: "Lutz", lastName: "Meier", phone: "0171 1234567", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
+ site: { mode: "new", street: "Elbchaussee", houseNumber: "12", postalCode: "22763", city: "Hamburg" },
+ onSiteContact: { name: "Lutz Meier", phone: "0171 1234567" },
+ reason: "Wasserrohrbruch im Keller",
+ },
+ clientCreatedAt: new Date().toISOString(),
+ };
+}
+
+async function extraCleanup(tenantIds: string[]) {
+ await prisma.lotseChatSeat.deleteMany({ where: { tenantId: { in: tenantIds } } });
+ await prisma.workOrderMilestone.deleteMany({ where: { tenantId: { in: tenantIds } } });
+ await prisma.platformAdmin.deleteMany({ where: { email: { in: [PLATFORM_EMAIL, READONLY_EMAIL] } } });
+}
+
+runSuite("L17 Pakete – Durchsetzung", [SLUG_A, SLUG_B], async () => {
+ await extraCleanup([]);
+ const A = await createTenant(SLUG_A);
+ const B = await createTenant(SLUG_B);
+ try {
+ await prisma.tenant.update({ where: { id: A.tenantId }, data: { tier: "BASIS" } });
+ const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
+ const readonly = await prisma.platformAdmin.create({ data: { email: READONLY_EMAIL, name: "ZZ Nur-Lesen", passwordHash: "x", role: "readonly" } });
+
+ section("Stufe am Mandanten");
+ ok((await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } })).tier === "PROFI", "neuer Mandant: Default Profi");
+
+ section("Layout-Guard-Funktionen (moduleState / requirePlanFeature)");
+ for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(A.tenantId, key)) === "not_in_plan", `Basis: ${key} → not_in_plan`);
+ for (const key of ["customers", "sites", "teams", "work_orders", "field", "reports", "documents", "notifications"]) ok((await moduleState(A.tenantId, key)) === "enabled", `Basis: ${key} aktiv`);
+ for (const key of ["billing", "emergency", "imports", "lotse"]) ok((await moduleState(B.tenantId, key)) === "enabled", `Profi: ${key} aktiv`);
+ let redirected = "";
+ try {
+ await requirePlanFeature(A.tenantId, "planning");
+ } catch (err) {
+ redirected = String((err as { digest?: string }).digest ?? (err as Error).message);
+ }
+ ok(redirected.includes(PROFI_HINT_PATH), `Planungs-Layout (Basis) → Hinweis ${PROFI_HINT_PATH}`);
+ ok((await codeOf(requirePlanFeature(B.tenantId, "planning"))) === "ok", "Planungs-Layout (Profi) → kein Redirect");
+ const modulesSrc = src("src/server/modules.ts");
+ ok(/export async function requireModule[\s\S]*moduleState\(/.test(modulesSrc) && modulesSrc.includes("PROFI_HINT_PATH"), "requireModule nutzt moduleState (Stufe + Schalter) und leitet auf den Profi-Hinweis");
+ ok(src("src/app/(app)/planning/layout.tsx").includes('requirePlanFeature(session.user.tenantId, "planning")'), "Planungs-Layout prüft das Paket-Feature");
+
+ section("Action (moduleGuard → assertModuleEnabled)");
+ let actionErr: unknown = null;
+ try {
+ await assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "billing");
+ } catch (err) {
+ actionErr = err;
+ }
+ ok(actionErr instanceof ModuleDisabledError && actionErr.reason === "not_in_plan", "Basis: Abrechnungs-Action → ModuleDisabledError(not_in_plan)");
+ const denied = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, action: "denied", entity: "module", entityId: "billing" } });
+ ok(!!denied, "verweigerter Zugriff wird auditiert");
+ for (const key of ["emergency", "imports", "lotse"]) {
+ ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), key))).startsWith("error:"), `Basis: ${key}-Action gesperrt`);
+ }
+ ok((await codeOf(assertModuleEnabled(fakeSession(A.tenantId, A.users.admin.id), "customers"))) === "ok", "Basis: Kunden-Action erlaubt");
+ ok((await codeOf(assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "billing"))) === "ok", "Profi: Abrechnungs-Action erlaubt");
+ ok(/await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/action-guard.ts")), "moduleGuard prüft über assertModuleEnabled");
+
+ section("API (requireApiContext → 403 mit Klartext)");
+ ok(/if \(moduleKey\) await assertModuleEnabled\(session, moduleKey\)/.test(src("src/server/api/context.ts")), "requireApiContext prüft über assertModuleEnabled");
+ const res = toErrorResponse(actionErr);
+ const body = (await res.json()) as { error: { code: string; details?: { reason?: string; message?: string } } };
+ ok(res.status === 403 && body.error.details?.reason === "not_in_plan" && /Paket Profi/.test(body.error.details?.message ?? ""), "API: 403 mit reason not_in_plan und Klartext");
+ const planningErr = await getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }).catch((e) => e);
+ const planningRes = toErrorResponse(planningErr);
+ ok(planningRes.status === 403, "Planungs-API (Basis) → 403");
+
+ section("Planung (Feature ohne eigenes Modul)");
+ ok(!(await isPlanFeatureAvailable(A.tenantId, "planning")) && (await isPlanFeatureAvailable(B.tenantId, "planning")), "Feature planning: Basis nein, Profi ja");
+ await expectCode(() => getPlanningBoard(A.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel forbidden");
+ await expectCode(() => getLiveSituation(A.ctx.backoffice), "forbidden", "Basis: Live-Lage forbidden");
+ const woA = await prisma.workOrder.create({ data: { tenantId: A.tenantId, number: "ZZ-PAK-1", customerId: A.customerId, siteId: A.siteId, title: "Paket-Test", status: "draft" } });
+ await expectCode(() => scheduleWorkOrder(A.ctx.backoffice, { workOrderId: woA.id, teamId: A.teamId, plannedStart: new Date().toISOString(), baseVersion: woA.version } as never), "forbidden", "Basis: Einplanen forbidden");
+ await expectCode(() => recommendAssignments(A.ctx.backoffice, { workOrderId: woA.id }), "forbidden", "Basis: Empfehlungen forbidden");
+ await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.teamId, { dailyCapacityMinutes: 400, workingDays: 31 }), "forbidden", "Basis: Kolonnenkapazität forbidden");
+ ok((await getPlanningToday(A.ctx.backoffice)) === null, "Basis: Dashboard-Kachel Planung ausgeblendet");
+ const watch = await runPlanningWatch(A.tenantId);
+ ok(watch.overrun === 0 && watch.followupAtRisk === 0 && watch.capacityFreed === 0, "Basis: keine Verzugs-/Frühfertig-Meldungen");
+ ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Profi: Plantafel erlaubt");
+ ok((await codeOf(updateTeamPlanningSettings(B.ctx.admin, B.teamId, { dailyCapacityMinutes: 420, workingDays: 31 }))) === "ok", "Profi: Kolonnenkapazität erlaubt");
+
+ section("Lotse, Abrechnung, Navigation");
+ ok(!(await isLotseEnabled(A.ctx.admin)) && (await isLotseEnabled(B.ctx.admin)), "isLotseEnabled: Basis aus, Profi an");
+ ok(!(await billingModuleActive(A.ctx.admin)) && (await billingModuleActive(B.ctx.admin)), "Abrechnungsübersicht: Basis aus (direktes „abgerechnet“ wie bei deaktiviertem Modul)");
+ const effA = await effectiveModules(A.tenantId);
+ ok(["billing", "emergency", "imports", "lotse"].every((k) => effA.inactive.has(k) && effA.notInPlan.has(k)) && effA.lockedFeatures.has("planning"), "Navigation Basis: gesperrte Module + Planung");
+ const effB = await effectiveModules(B.tenantId);
+ ok(effB.inactive.size === 0 && effB.lockedFeatures.size === 0, "Navigation Profi: alles sichtbar");
+
+ section("Sync (Offline-Op eines gesperrten Moduls → rejected mit Klartext)");
+ const opA = emergencyOp();
+ const syncA = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
+ const rA = syncA.results[0];
+ ok(rA.status === "rejected" && rA.errorCode === "forbidden" && (rA.message ?? "").startsWith("not_in_plan") && /Paket Profi/.test(rA.message ?? ""), `Basis: emergency.create → rejected mit Klartext (${rA.message?.slice(0, 60)}…)`);
+ const storedA = await prisma.syncOperation.findFirst({ where: { tenantId: A.tenantId, clientOpId: opA.clientOpId } });
+ ok(storedA?.status === "rejected", "abgewiesene Op gespeichert (nicht still verworfen)");
+ ok((await prisma.workOrder.count({ where: { tenantId: A.tenantId, isEmergency: true } })) === 0, "kein Notdienstauftrag angelegt");
+ const again = await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [opA] });
+ ok(again.results[0].status === "duplicate", "Wiederholung derselben Op → duplicate (Ergebnis bleibt)");
+ ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rA.message } }) === "notInPlan", "Gerät zeigt Klartext „Im Paket Profi enthalten“ (problem.notInPlan)");
+ const msA = await prisma.workOrderMilestone.create({ data: { tenantId: A.tenantId, workOrderId: woA.id, title: "ZZ" } });
+ const mOp: SyncOperationInput = { clientOpId: randomUUID(), opType: "milestone.reach", payload: { workOrderId: woA.id, milestoneId: msA.id }, clientCreatedAt: new Date().toISOString() };
+ const mRes = (await applyOperations(A.ctx.tech, { deviceId: "zz-dev", operations: [mOp] })).results[0];
+ ok(mRes.status === "rejected" && (mRes.message ?? "").startsWith("not_in_plan"), "Basis: milestone.reach → rejected");
+ const opB = emergencyOp();
+ const rB = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [opB] })).results[0];
+ ok(rB.status === "applied", `Profi: emergency.create angewendet (${rB.status}${rB.message ? `: ${rB.message}` : ""})`);
+
+ section("TenantModule aus + Profi → gesperrt");
+ await prisma.tenantModule.upsert({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, update: { enabled: false }, create: { tenantId: B.tenantId, moduleKey: "emergency", enabled: false } });
+ ok((await moduleState(B.tenantId, "emergency")) === "disabled", "Profi + Schalter aus → disabled");
+ let offErr: unknown = null;
+ try {
+ await assertModuleEnabled(fakeSession(B.tenantId, B.users.admin.id), "emergency");
+ } catch (err) {
+ offErr = err;
+ }
+ ok(offErr instanceof ModuleDisabledError && offErr.reason === "disabled", "Action → ModuleDisabledError(disabled)");
+ const rOff = (await applyOperations(B.ctx.tech, { deviceId: "zz-dev", operations: [emergencyOp()] })).results[0];
+ ok(rOff.status === "rejected" && (rOff.message ?? "").startsWith("module_disabled"), "Sync → rejected (module_disabled)");
+ ok(problemKey({ status: "rejected", opType: "emergency.create", lastError: { code: "forbidden", message: rOff.message } }) === "moduleDisabled", "Gerät: problem.moduleDisabled");
+ await prisma.tenantModule.update({ where: { tenantId_moduleKey: { tenantId: B.tenantId, moduleKey: "emergency" } }, data: { enabled: true } });
+ ok(await isModuleActive(B.tenantId, "emergency"), "wieder eingeschaltet → aktiv");
+
+ section("Nur der Betreiber ändert die Stufe");
+ await expectCode(() => updateTenantPlan({ platformAdminId: B.users.admin.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Mandanten-Admin (kein Plattform-Konto) → forbidden");
+ await expectCode(() => updateTenantPlan({ platformAdminId: readonly.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 5, lotseChatHardLimit: false }), "forbidden", "Nur-Lesen-Plattform-Admin → forbidden");
+ await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "GOLD" as never, lotseChatSeats: 5, lotseChatHardLimit: false }), "invalid", "ungültige Stufe → invalid");
+ await expectCode(() => updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: -1, lotseChatHardLimit: false }), "invalid", "negative Platzanzahl → invalid");
+ await updateLotseSettings(B.ctx.admin, { enabled: true, addressForm: "neutral" });
+ const bAfterSettings = await prisma.tenant.findUniqueOrThrow({ where: { id: B.tenantId } });
+ ok(bAfterSettings.tier === "PROFI" && bAfterSettings.lotseChatSeats === 0, "Lotse-Einstellungen des Mandanten ändern weder Stufe noch Plätze");
+ ok(!/\btier:|lotseChatSeats|lotseChatHardLimit/.test(src("src/server/services/lotse/settings.ts") + src("src/server/actions/tenant-settings.ts") + src("src/server/actions/lotse-settings.ts")), "keine Mandanten-Action schreibt Stufe/Plätze");
+
+ section("Wechsel Profi → Basis → Profi ohne Datenverlust");
+ await prisma.lotseChatSeat.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
+ const conv = await prisma.lotseConversation.create({ data: { tenantId: B.tenantId, userId: B.users.tech.id } });
+ await prisma.lotseMessage.create({ data: { tenantId: B.tenantId, conversationId: conv.id, role: "user", text: "Hallo" } });
+ const snapshot = async () => ({
+ emergencies: await prisma.workOrder.count({ where: { tenantId: B.tenantId, isEmergency: true } }),
+ seats: await prisma.lotseChatSeat.count({ where: { tenantId: B.tenantId } }),
+ messages: await prisma.lotseMessage.count({ where: { tenantId: B.tenantId } }),
+ teams: await prisma.team.findMany({ where: { tenantId: B.tenantId }, select: { id: true, dailyCapacityMinutes: true }, orderBy: { id: "asc" } }),
+ });
+ const before = await snapshot();
+ const down = await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "BASIS", lotseChatSeats: 3, lotseChatHardLimit: true });
+ ok(down.tier === "BASIS" && down.lotseChatSeats === 3 && down.lotseChatHardLimit, "Betreiber setzt Basis, 3 Plätze, hartes Limit");
+ const audit = await prisma.auditLog.findFirst({ where: { tenantId: B.tenantId, scope: "platform", entity: "tenant_plan" }, orderBy: { createdAt: "desc" } });
+ ok(!!audit && (audit.before as { tier?: string })?.tier === "PROFI" && (audit.after as { tier?: string })?.tier === "BASIS" && audit.actorId === platform.id, "Plattform-Audit mit before/after");
+ ok((await moduleState(B.tenantId, "emergency")) === "not_in_plan" && !(await isPlanFeatureAvailable(B.tenantId, "planning")), "Basis: Zugriff endet");
+ await expectCode(() => getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }), "forbidden", "Basis: Plantafel gesperrt");
+ ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "Basis: Notdiensteinsätze, Plätze, Chats, Kolonnen unverändert");
+ await updateTenantPlan({ platformAdminId: platform.id }, B.tenantId, { tier: "PROFI", lotseChatSeats: 3, lotseChatHardLimit: false });
+ ok((await moduleState(B.tenantId, "emergency")) === "enabled" && (await isPlanFeatureAvailable(B.tenantId, "planning")), "zurück auf Profi: Zugriff wieder da");
+ ok(JSON.stringify(await snapshot()) === JSON.stringify(before), "zurück auf Profi: alle Daten vorhanden");
+ ok((await codeOf(getPlanningBoard(B.ctx.backoffice, { from: "2026-09-21", to: "2026-09-25" }))) === "ok", "Plantafel wieder erlaubt");
+
+ section("Mandantentrennung der Stufe");
+ ok((await prisma.tenant.findUniqueOrThrow({ where: { id: A.tenantId } })).tier === "BASIS", "Änderung an B lässt A unberührt");
+ ok((await dbForTenant(B.tenantId).syncOperation.findMany({ where: { clientOpId: opA.clientOpId } })).length === 0, "B sieht die abgewiesene Op von A nicht");
+ } finally {
+ await extraCleanup([A.tenantId, B.tenantId]);
+ await prisma.lotseMessage.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
+ await prisma.lotseConversation.deleteMany({ where: { tenantId: { in: [A.tenantId, B.tenantId] } } });
+ }
+});
diff --git a/scripts/test-pakete-rules.ts b/scripts/test-pakete-rules.ts
new file mode 100644
index 0000000..069b013
--- /dev/null
+++ b/scripts/test-pakete-rules.ts
@@ -0,0 +1,79 @@
+// L17 Pakete — reine Stufenregeln (src/lib/plans.ts), ohne Datenbank.
+// Lauf: npx tsx scripts/test-pakete-rules.ts
+
+import {
+ chatOverage,
+ chatQuota,
+ DEFAULT_TENANT_TIER,
+ effectiveTier,
+ isFeatureInTier,
+ isModuleInTier,
+ isQuotaBlocked,
+ isTenantTier,
+ LOTSE_CHAT_CHATS_PER_SEAT,
+ LOTSE_CHAT_OVERAGE_PACK,
+ modulesLockedByTier,
+ monthKeyOfDay,
+ nextMonthKey,
+ overagePacks,
+ previousMonthKey,
+ PROFI_ONLY_MODULES,
+ usageLevel,
+ usagePercent,
+} from "../src/lib/plans";
+import { MODULE_KEYS } from "../src/lib/modules";
+import { NAV_ITEMS, visibleNavItems } from "../src/lib/nav";
+
+let failures = 0;
+const ok = (cond: boolean, msg: string) => {
+ console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
+ if (!cond) failures++;
+};
+
+console.log("— Stufen —");
+ok(DEFAULT_TENANT_TIER === "PROFI", "Default-Stufe ist Profi");
+ok(isTenantTier("BASIS") && isTenantTier("PROFI") && !isTenantTier("GOLD") && !isTenantTier(null), "isTenantTier erkennt nur BASIS/PROFI");
+ok(effectiveTier("BASIS", "TRIAL") === "PROFI", "Testphase läuft immer als Profi");
+ok(effectiveTier("BASIS", "FULL") === "BASIS" && effectiveTier("PROFI", "FULL") === "PROFI", "Vollversion: gespeicherte Stufe gilt");
+ok(effectiveTier(undefined, "FULL") === "PROFI", "unbekannte Stufe → Default Profi");
+
+console.log("\n— Module je Stufe —");
+const profiOnly = ["billing", "emergency", "imports", "lotse"];
+ok(JSON.stringify([...PROFI_ONLY_MODULES].sort()) === JSON.stringify([...profiOnly].sort()), "Nur-Profi-Module: billing, emergency, imports, lotse");
+for (const key of MODULE_KEYS) {
+ ok(isModuleInTier("PROFI", key), `Profi enthält ${key}`);
+ ok(isModuleInTier("BASIS", key) === !profiOnly.includes(key), `Basis ${profiOnly.includes(key) ? "sperrt" : "enthält"} ${key}`);
+}
+ok(modulesLockedByTier("PROFI").length === 0 && modulesLockedByTier("BASIS").length === 4, "modulesLockedByTier");
+ok(!isFeatureInTier("BASIS", "planning") && !isFeatureInTier("BASIS", "lotse_chat"), "Basis: keine Planung, kein Lotse-Chat");
+ok(isFeatureInTier("PROFI", "planning") && isFeatureInTier("PROFI", "lotse_chat"), "Profi: Planung und Lotse-Chat");
+
+console.log("\n— Kontingent —");
+ok(LOTSE_CHAT_CHATS_PER_SEAT === 150 && LOTSE_CHAT_OVERAGE_PACK === 100, "150 Chats je Platz, Mehrverbrauch in Paketen à 100");
+ok(chatQuota(3) === 450 && chatQuota(0) === 0 && chatQuota(-2) === 0, "Kontingent = Plätze × 150");
+ok(chatOverage(212, 450) === 0 && chatOverage(460, 450) === 10, "Mehrverbrauch = über dem Kontingent");
+ok(overagePacks(0) === 0 && overagePacks(1) === 1 && overagePacks(100) === 1 && overagePacks(101) === 2 && overagePacks(250) === 3, "angefangene 100er-Pakete zählen");
+ok(usageLevel(212, 450) === "ok", "212/450 → ok");
+ok(usageLevel(360, 450) === "warn" && usageLevel(359, 450) === "ok", "ab 80 % → warn");
+ok(usageLevel(450, 450) === "exhausted" && usageLevel(451, 450) === "over", "ausgeschöpft / Mehrverbrauch");
+ok(usageLevel(0, 0) === "ok" && usageLevel(1, 0) === "over", "ohne Kontingent zählt jede Nutzung als Mehrverbrauch");
+ok(usagePercent(212, 450) === 47 && usagePercent(900, 450) === 100 && usagePercent(0, 0) === 0, "Balken in Prozent (gedeckelt)");
+ok(!isQuotaBlocked(500, 450, false), "ohne hartes Limit wird weitergezählt");
+ok(isQuotaBlocked(450, 450, true) && !isQuotaBlocked(449, 450, true), "hartes Limit sperrt ab dem Kontingent");
+
+console.log("\n— Monate —");
+ok(monthKeyOfDay("2026-09-21") === "2026-09", "Monat eines Tags");
+ok(previousMonthKey("2026-01") === "2025-12" && previousMonthKey("2026-09") === "2026-08", "Vormonat inkl. Jahreswechsel");
+ok(nextMonthKey("2026-12") === "2027-01" && nextMonthKey("2026-09") === "2026-10", "Folgemonat inkl. Jahreswechsel");
+
+console.log("\n— Navigation —");
+const all = NAV_ITEMS.flatMap((i) => i.permissions ?? []);
+const basisNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(profiOnly), lockedFeatures: new Set(["planning", "lotse_chat"]), permissions: all });
+const profiNav = visibleNavItems(NAV_ITEMS, { disabledModules: new Set(), lockedFeatures: new Set(), permissions: all });
+ok(!basisNav.some((i) => i.href.startsWith("/planning")), "Basis: keine Planung in der Navigation");
+ok(!basisNav.some((i) => ["/billing", "/imports", "/work-orders/emergency-review"].includes(i.href)), "Basis: keine Abrechnung/Import/Notdienst-Prüfung");
+ok(basisNav.some((i) => i.href === "/work-orders") && basisNav.some((i) => i.href === "/work-orders/time-approvals") && basisNav.some((i) => i.href === "/reports"), "Basis: Aufträge, Zeitfreigabe, Berichte bleiben");
+ok(profiNav.some((i) => i.href === "/planning") && profiNav.some((i) => i.href === "/billing"), "Profi: Planung und Abrechnung sichtbar");
+
+console.log(failures === 0 ? "\nAlle Prüfungen grün." : `\n${failures} FEHLER.`);
+process.exit(failures === 0 ? 0 : 1);
diff --git a/scripts/test-pakete-seats.ts b/scripts/test-pakete-seats.ts
new file mode 100644
index 0000000..d5f4c48
--- /dev/null
+++ b/scripts/test-pakete-seats.ts
@@ -0,0 +1,191 @@
+// L17 Pakete — Lotse-Chat pro Nutzer: Platzvergabe bis zur Grenze (darüber invalid), Chat ohne Platz →
+// blocked no_seat, mit Platz → ok, Testphase ohne Platzprüfung, Kontingentzählung über die Monatsgrenze
+// (Zeitzone), Mehrverbrauch weiterzählen, hartes Limit → blocked quota_exhausted, Feldrollen vergeben
+// keine Plätze, Mandantentrennung (B sieht/ändert keine Plätze/Zählung von A), Betreiber-Übersicht.
+// Lauf: npx tsx scripts/test-pakete-seats.ts (auch mit RLS_ENFORCED=true)
+
+import "dotenv/config";
+import { prisma, dbForTenant } from "../src/server/db";
+import { FakeLotseChatProvider } from "../src/server/ai/lotse/chat-fake";
+import { assertLotseChatUsable, canUseLotseChat, isChatQuotaBlocked } from "../src/server/services/lotse/chat/access";
+import { getChatView } from "../src/server/services/lotse/chat/conversations";
+import { sendLotseMessage } from "../src/server/services/lotse/chat/engine";
+import { getSeatOverview, listEligibleUsers, setChatSeat } from "../src/server/services/lotse/chat/seats";
+import { getChatUsage, getChatUsageForAdmin, monthBounds, monthKeyOf } from "../src/server/services/lotse/chat/usage";
+import { getTenantPlanOverview, updateTenantPlan } from "../src/server/services/plans/platform";
+import { ServiceError } from "../src/server/services/context";
+import { codeOf, createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
+
+const SLUG_A = "zz-pak-seats-a";
+const SLUG_B = "zz-pak-seats-b";
+const PLATFORM_EMAIL = "platform-seats@zz-pakete.test";
+
+async function reasonOf(p: Promise): Promise {
+ try {
+ await p;
+ return "ok";
+ } catch (err) {
+ if (err instanceof ServiceError) return `${err.code}/${(err.details as { reason?: string } | undefined)?.reason ?? err.message}`;
+ return `error:${(err as Error).message}`;
+ }
+}
+
+async function extraCleanup(tenantIds: string[]) {
+ const w = { where: { tenantId: { in: tenantIds } } };
+ await prisma.lotseChatSeat.deleteMany(w);
+ await prisma.lotseActionProposal.deleteMany(w);
+ await prisma.lotseMessage.deleteMany(w);
+ await prisma.lotseConversation.deleteMany(w);
+ await prisma.platformAdmin.deleteMany({ where: { email: PLATFORM_EMAIL } });
+}
+
+runSuite("L17 Pakete – Lotse-Chat-Plätze & Kontingent", [SLUG_A, SLUG_B], async () => {
+ await extraCleanup([]);
+ const A = await createTenant(SLUG_A);
+ const B = await createTenant(SLUG_B);
+ try {
+ const platform = await prisma.platformAdmin.create({ data: { email: PLATFORM_EMAIL, name: "ZZ Plattform", passwordHash: "x", role: "full" } });
+ const actor = { platformAdminId: platform.id };
+ await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
+ await updateTenantPlan(actor, B.tenantId, { tier: "PROFI", lotseChatSeats: 5, lotseChatHardLimit: false });
+
+ section("Berechtigte Nutzer (Feldrolle: lotse:use + field:execute)");
+ const eligible = (await listEligibleUsers(A.ctx.admin)).map((u) => u.id);
+ const u = A.users;
+ ok([u.tech.id, u.tech2.id, u.outsider.id, u.lead.id, u.lead2.id].every((id) => eligible.includes(id)), "Monteure und Teamleiter berechtigt");
+ ok(!eligible.includes(u.backoffice.id), "Backoffice ohne field:execute nicht berechtigt");
+ ok(eligible.includes(u.admin.id), "Mandantenadministrator hat beide Rechte → berechtigt (Regel = Rechte, nicht Rollenname)");
+
+ section("Chat ohne Platz → blocked no_seat");
+ ok(!(await canUseLotseChat(A.ctx.tech)), "canUseLotseChat ohne Platz → false (kein Navigationseintrag)");
+ ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/no_seat", "assertLotseChatUsable → blocked no_seat");
+ ok((await reasonOf(getChatView(A.ctx.tech))) === "blocked/no_seat", "direkter Aufruf /m/lotse (getChatView) → no_seat");
+ const provider = () => new FakeLotseChatProvider([{ text: "Alles klar." }]);
+ ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Hallo" }, { provider: provider() }))) === "blocked/no_seat", "Senden ohne Platz → no_seat");
+ ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === 0, "ohne Platz wird nichts gespeichert");
+
+ section("Platzvergabe bis zur Grenze");
+ const r1 = await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true });
+ ok(r1.changed, "Platz 1 an Monteur vergeben");
+ await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
+ const overview = await getSeatOverview(A.ctx.admin);
+ ok(overview.purchased === 2 && overview.assigned === 2 && !overview.overbooked, "2 von 2 Plätzen vergeben");
+ ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true }))) === "invalid/no_seats_left", "dritter Platz → invalid no_seats_left");
+ ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Grenze serverseitig gehalten");
+ await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
+ const parallel = await Promise.allSettled([u.tech2.id, u.outsider.id].map((userId) => setChatSeat(A.ctx.admin, { userId, assigned: true })));
+ ok(parallel.filter((p) => p.status === "fulfilled").length === 1 && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "ein freier Platz, zwei parallele Vergaben → genau eine gelingt");
+ for (const userId of [u.tech2.id, u.outsider.id]) await setChatSeat(A.ctx.admin, { userId, assigned: false });
+ await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true });
+ ok(!(await setChatSeat(A.ctx.admin, { userId: u.tech.id, assigned: true })).changed, "erneutes Vergeben idempotent");
+ ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: false }))) === "ok", "Entzug ohne Platz idempotent");
+ const auditCreate = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.tech.id, action: "create" } });
+ ok(!!auditCreate && (auditCreate.after as { userId?: string })?.userId === u.tech.id && auditCreate.actorId === u.admin.id, "Audit bei Vergabe (after)");
+
+ section("Chat mit Platz → ok");
+ ok(await canUseLotseChat(A.ctx.tech), "canUseLotseChat mit Platz → true");
+ ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Chat-Ansicht mit Platz");
+ const view = await sendLotseMessage(A.ctx.tech, { text: "Wie ist der Stand?" }, { provider: provider() });
+ ok(view.messages.some((m) => m.role === "user"), "Nachricht mit Platz gesendet");
+ ok((await canUseLotseChat(A.ctx.lead)) && !(await canUseLotseChat(A.ctx.tech2)), "Teamleiter mit Platz ja, Monteur ohne Platz nein");
+
+ section("Entzug + Überbelegung");
+ await setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: false });
+ ok((await reasonOf(assertLotseChatUsable(A.ctx.lead))) === "blocked/no_seat", "nach Entzug → no_seat");
+ const auditDelete = await prisma.auditLog.findFirst({ where: { tenantId: A.tenantId, entity: "lotse_chat_seat", entityId: u.lead.id, action: "delete" } });
+ ok(!!auditDelete && (auditDelete.before as { userId?: string })?.userId === u.lead.id, "Audit bei Entzug (before)");
+ await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
+ await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 1, lotseChatHardLimit: false });
+ const over = await getSeatOverview(A.ctx.admin);
+ ok(over.overbooked && over.users.find((x) => x.id === u.tech.id)?.valid === true && over.users.find((x) => x.id === u.tech2.id)?.valid === false, "Betreiber senkt auf 1: nur der zuerst vergebene Platz gilt");
+ ok((await reasonOf(assertLotseChatUsable(A.ctx.tech2))) === "blocked/no_seat", "überzähliger Platz → no_seat");
+ await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
+ ok(await canUseLotseChat(A.ctx.tech2), "wieder 2 Plätze → beide nutzbar");
+
+ section("Feldrollen und Backoffice vergeben keine Plätze");
+ for (const p of ["tech", "lead", "backoffice"] as const) {
+ await expectCode(() => setChatSeat(A.ctx[p], { userId: u.outsider.id, assigned: true }), "forbidden", `${p} → forbidden`);
+ await expectCode(() => getSeatOverview(A.ctx[p]), "forbidden", `${p} sieht die Platzverwaltung nicht`);
+ await expectCode(() => getChatUsageForAdmin(A.ctx[p]), "forbidden", `${p} sieht den Verbrauch nicht`);
+ }
+ ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.backoffice.id, assigned: true }))) === "invalid/not_eligible", "Platz an Nutzer ohne Feldrolle → invalid not_eligible");
+ await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "DEACTIVATED" } });
+ await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: false });
+ ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.outsider.id, assigned: true }))) === "invalid/not_eligible", "deaktivierter Nutzer → invalid not_eligible");
+ await prisma.user.update({ where: { id: u.outsider.id }, data: { status: "ACTIVE" } });
+ await setChatSeat(A.ctx.admin, { userId: u.tech2.id, assigned: true });
+
+ section("Mandantentrennung");
+ ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: true }))) === "invalid/not_eligible", "B vergibt keinen Platz an Nutzer aus A");
+ ok((await reasonOf(setChatSeat(B.ctx.admin, { userId: u.tech.id, assigned: false }))) === "ok" && (await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId, userId: u.tech.id } })) === 1, "B kann den Platz von A nicht entziehen");
+ const bOverview = await getSeatOverview(B.ctx.admin);
+ ok(!bOverview.users.some((x) => [u.tech.id, u.tech2.id].includes(x.id)) && bOverview.assigned === 0, "B sieht keine Plätze/Nutzer von A");
+ ok((await dbForTenant(B.tenantId).lotseChatSeat.findMany()).length === 0, "Tenant-Client von B findet keine Platz-Zeilen von A");
+ ok((await getChatUsageForAdmin(B.ctx.admin)).used === 0, "B zählt keine Chats von A");
+ ok((await reasonOf(assertLotseChatUsable(B.ctx.tech))) === "blocked/no_seat", "B-Monteur hat keinen Platz (Plätze gelten je Mandant)");
+
+ section("Kontingentzählung über die Monatsgrenze (Zeitzone Europe/Berlin)");
+ const tz = "Europe/Berlin";
+ ok(monthKeyOf(new Date("2026-02-28T23:30:00Z"), tz) === "2026-03" && monthKeyOf(new Date("2026-02-28T22:30:00Z"), tz) === "2026-02", "Monat in Mandanten-Zeitzone");
+ const march = monthBounds("2026-03", tz);
+ ok(march.from.toISOString() === "2026-02-28T23:00:00.000Z" && march.to.toISOString() === "2026-03-31T22:00:00.000Z", "Monatsgrenzen inkl. Sommerzeit");
+ const conv = await prisma.lotseConversation.create({ data: { tenantId: A.tenantId, userId: u.tech2.id } });
+ const msg = (createdAt: string, role: "user" | "assistant" = "user") => ({ tenantId: A.tenantId, conversationId: conv.id, role, text: "x", createdAt: new Date(createdAt) });
+ await prisma.lotseMessage.createMany({
+ data: [msg("2026-02-28T22:30:00Z"), msg("2026-02-28T23:30:00Z"), msg("2026-02-28T23:31:00Z", "assistant"), msg("2026-03-31T21:59:00Z"), msg("2026-03-31T22:00:00Z")],
+ });
+ const feb = await getChatUsage(A.ctx.admin, { monthKey: "2026-02" });
+ const mar = await getChatUsage(A.ctx.admin, { monthKey: "2026-03" });
+ const apr = await getChatUsage(A.ctx.admin, { monthKey: "2026-04" });
+ ok(feb.used === 1 && mar.used === 2 && apr.used === 1, `Zählung je Monat (Feb ${feb.used}, Mär ${mar.used}, Apr ${apr.used}); Lotse-Antworten zählen nicht`);
+ ok(mar.perUser.length === 1 && mar.perUser[0].userId === u.tech2.id && mar.perUser[0].count === 2, "Aufschlüsselung je Nutzer");
+
+ section("Mehrverbrauch weiterzählen, hartes Limit");
+ const now = new Date();
+ const current = await getChatUsage(A.ctx.admin, { now });
+ ok(current.quota === 300 && current.quotaBasis === 2, `Kontingent = 2 vergebene Plätze × 150 (${current.quota})`);
+ const fill = 300 - current.used;
+ const recent = new Date(Math.max(new Date(current.from).getTime() + 60_000, now.getTime() - 60_000));
+ await prisma.lotseMessage.createMany({ data: Array.from({ length: fill + 5 }, () => ({ tenantId: A.tenantId, conversationId: conv.id, role: "user" as const, text: "y", createdAt: recent })) });
+ const overUsage = await getChatUsage(A.ctx.admin, { now });
+ ok(overUsage.used === 305 && overUsage.overage === 5 && overUsage.overagePacks === 1 && overUsage.level === "over" && !overUsage.blocked, "305/300: Mehrverbrauch 5 → 1 Paket, nicht gesperrt");
+ ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "ok", "ohne hartes Limit: Senden weiter möglich");
+ const sent = await sendLotseMessage(A.ctx.tech, { text: "Noch eine Frage" }, { provider: provider(), now: () => now });
+ ok(sent.messages.length > 0 && (await getChatUsage(A.ctx.admin, { now })).used === 306, "Mehrverbrauch wird weitergezählt (306)");
+ await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: true });
+ ok(await isChatQuotaBlocked(A.ctx.tech, now), "hartes Limit → Kontingent gesperrt");
+ ok((await reasonOf(assertLotseChatUsable(A.ctx.tech, { send: true, now }))) === "blocked/quota_exhausted", "Senden → blocked quota_exhausted");
+ const beforeCount = await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } });
+ ok((await reasonOf(sendLotseMessage(A.ctx.tech, { text: "Gesperrt?" }, { provider: provider(), now: () => now }))) === "blocked/quota_exhausted", "sendLotseMessage → quota_exhausted");
+ ok((await prisma.lotseMessage.count({ where: { tenantId: A.tenantId } })) === beforeCount, "gesperrte Nachricht wird nicht gespeichert/gezählt");
+ ok((await codeOf(getChatView(A.ctx.tech))) === "ok", "Verlauf bleibt lesbar (Hinweis statt Eingabefeld)");
+ ok(!(await isChatQuotaBlocked(B.ctx.tech, now)), "hartes Limit von A wirkt nicht auf B");
+
+ section("Testphase: ohne Platzprüfung, Kontingent = Berechtigte × 150");
+ await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "TRIAL", trialStartedAt: new Date(), trialEndsAt: new Date(Date.now() + 10 * 86400_000), tier: "BASIS", lotseChatHardLimit: true } });
+ ok((await canUseLotseChat(B.ctx.tech)) && (await canUseLotseChat(B.ctx.outsider)), "Testphase: jeder berechtigte Nutzer darf chatten (auch bei gespeicherter Stufe Basis)");
+ const trialUsage = await getChatUsage(B.ctx.admin, { now });
+ const eligibleB = (await listEligibleUsers(B.ctx.admin)).length;
+ ok(trialUsage.isTrial && trialUsage.quota === eligibleB * 150 && !trialUsage.hardLimit, `Testphase: Kontingent ${eligibleB} × 150, kein hartes Limit`);
+ ok((await codeOf(sendLotseMessage(B.ctx.outsider, { text: "Test" }, { provider: provider() }))) === "ok", "Testphase: Senden ohne Platz");
+ ok((await codeOf(assertLotseChatUsable(B.ctx.backoffice))) === "forbidden", "Testphase: ohne Feldrolle weiterhin kein Chat");
+ await prisma.tenant.update({ where: { id: B.tenantId }, data: { plan: "FULL", trialStartedAt: null, trialEndsAt: null, tier: "PROFI", lotseChatHardLimit: false } });
+
+ section("Basis sperrt den Chat, Plätze bleiben");
+ await updateTenantPlan(actor, A.tenantId, { tier: "BASIS", lotseChatSeats: 2, lotseChatHardLimit: false });
+ ok((await reasonOf(assertLotseChatUsable(A.ctx.tech))) === "blocked/not_in_plan", "Basis → blocked not_in_plan");
+ ok(!(await canUseLotseChat(A.ctx.tech)), "Basis: kein „Lotse fragen“/Navigationseintrag");
+ ok((await prisma.lotseChatSeat.count({ where: { tenantId: A.tenantId } })) === 2, "Plätze bleiben gespeichert");
+ ok((await reasonOf(setChatSeat(A.ctx.admin, { userId: u.lead.id, assigned: true }))) === "blocked/not_in_plan", "Basis: keine neue Vergabe");
+ await updateTenantPlan(actor, A.tenantId, { tier: "PROFI", lotseChatSeats: 2, lotseChatHardLimit: false });
+ ok(await canUseLotseChat(A.ctx.tech), "zurück auf Profi: Chat mit bestehendem Platz wieder nutzbar");
+
+ section("Betreiber-Übersicht (Grundlage der Rechnung)");
+ const op = await getTenantPlanOverview(actor, A.tenantId, now);
+ ok(op.plan.lotseChatSeats === 2 && op.seatsAssigned === 2 && op.current.used === 306 && op.current.overage === 6 && op.current.overagePacks === 1, `laufender Monat: 306 Chats, Mehrverbrauch 6 → 1 Paket`);
+ ok(op.previous.monthKey !== op.current.monthKey && op.previous.used >= 0, `Vormonat ${op.previous.monthKey}: ${op.previous.used} Chats`);
+ await expectCode(() => getTenantPlanOverview({ platformAdminId: u.admin.id }, A.tenantId), "forbidden", "Mandanten-Admin hat keine Betreiber-Übersicht");
+ } finally {
+ await extraCleanup([A.tenantId, B.tenantId]);
+ }
+});
diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx
index 9e812f6..b93b132 100644
--- a/src/app/(app)/dashboard/page.tsx
+++ b/src/app/(app)/dashboard/page.tsx
@@ -27,6 +27,8 @@ import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { GettingStarted } from "@/components/trial/getting-started";
+import { ProfiHint } from "@/components/plans/profi-hint";
+import { effectiveModules } from "@/server/plan";
type SP = Record;
@@ -54,8 +56,9 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
const t = await getTranslations("dashboard");
const tw = await getTranslations("workOrders");
- const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
- const moduleEnabled = !moduleRow || moduleRow.enabled;
+ // L17 Pakete: effektive Module (Schalter + Paketstufe) zentral
+ const modules = await effectiveModules(ctx.tenantId);
+ const moduleEnabled = !modules.inactive.has("work_orders");
const f = parseListParams(sp);
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
@@ -72,6 +75,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
{t("moduleDisabled")}
)}
+ {sp.module === "profi" && } {/* L17 Pakete: Funktion nicht in der Paketstufe */}
{!tiles ? (
{t("workOrdersDisabled")}
@@ -148,7 +152,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
- {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || can("billing:read"))).map(({ key, icon: Icon, tone }) => {
+ {TILES.filter((tile) => (tile.key !== "sync_conflicts" || can("work_order:write")) && (tile.key !== "time_approvals" || can("time:approve")) && (tile.key !== "billing_ready" || (can("billing:read") && !modules.inactive.has("billing"))) && (tile.key !== "emergency_new" || !modules.inactive.has("emergency"))).map(({ key, icon: Icon, tone }) => {
const count = key === "billing_ready" ? tiles.billingReady : key === "sync_conflicts" ? tiles.syncConflicts : key === "time_approvals" ? tiles.timeApprovals : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "billing_ready" ? "/billing" : key === "sync_conflicts" ? "/work-orders/conflicts" : key === "time_approvals" ? "/work-orders/time-approvals" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index b892683..ba8618b 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -3,6 +3,7 @@ import { getTranslations } from "next-intl/server";
import { signOut } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import { requireAppAccess } from "@/server/app-access";
+import { effectiveModules } from "@/server/plan";
import { NAV_ITEMS, visibleNavItems } from "@/lib/nav";
import { resolveTenantBranding } from "@/lib/brand";
import { Button } from "@/components/ui/button";
@@ -30,17 +31,18 @@ export default async function AppLayout({
// Navigation aus src/lib/nav.ts, gefiltert nach aktiven Modulen + Rechten (Komfort;
// Seiten/Actions prüfen serverseitig selbst).
- const [moduleRows, brandingSettings] = await Promise.all([
- dbForTenant(session.user.tenantId).tenantModule.findMany(),
+ // L17 Pakete: effektive Module (TenantModule + Paketstufe) zentral aus src/server/plan.ts
+ const [modules, brandingSettings] = await Promise.all([
+ effectiveModules(session.user.tenantId),
dbForTenant(session.user.tenantId).tenantSettings.findUnique({
where: { tenantId: session.user.tenantId },
select: { accent: true },
}),
]);
const branding = resolveTenantBranding(brandingSettings);
- const disabledModules = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
const navItems = visibleNavItems(NAV_ITEMS, {
- disabledModules,
+ disabledModules: modules.inactive,
+ lockedFeatures: modules.lockedFeatures,
permissions: session.user.permissions ?? [],
});
const mainNav = navItems.filter((i) => i.section === "main");
diff --git a/src/app/(app)/planning/layout.tsx b/src/app/(app)/planning/layout.tsx
index 0ddf0c6..96485f3 100644
--- a/src/app/(app)/planning/layout.tsx
+++ b/src/app/(app)/planning/layout.tsx
@@ -1,7 +1,9 @@
import { requireModule } from "@/server/modules";
+import { requirePlanFeature } from "@/server/plan";
-/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
+/** Modul-Gate „work_orders" + Paket-Feature „planning" (L17) für Plantafel und Live-Lage (L13 Planung). */
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
- await requireModule("work_orders");
+ const session = await requireModule("work_orders");
+ await requirePlanFeature(session.user.tenantId, "planning"); // L17 Pakete: Planung nur in Profi
return <>{children}>;
}
diff --git a/src/app/(app)/settings/lotse/page.tsx b/src/app/(app)/settings/lotse/page.tsx
index a74f5e9..344a76a 100644
--- a/src/app/(app)/settings/lotse/page.tsx
+++ b/src/app/(app)/settings/lotse/page.tsx
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
import { ArrowLeft, CheckCircle2, ListChecks, MinusCircle, ShieldCheck, XCircle } from "lucide-react";
import { LotseMark } from "@/components/lotse/lotse-mark";
+import { LotseSeatAdmin } from "@/components/lotse/chat/seat-admin";
import { PageHead } from "@/components/mockup-ui";
import { Button } from "@/components/ui/button";
import { saveLotseSettings } from "@/server/actions/lotse-settings";
@@ -24,7 +25,7 @@ function ProviderStatus({ ok, labels }: { ok: boolean; labels: { ok: string; off
* /settings/lotse (tenant:manage): Lotse on/off (module toggle `lotse`), address form, transparency on
* which data goes to which provider, link to the AI log. Deliberately not module-gated (re-enabling).
*/
-export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string }> }) {
+export default async function LotseSettingsPage({ searchParams }: { searchParams: Promise<{ saved?: string; error?: string; seat?: string; seatError?: string }> }) {
const ctx = await readCtx();
if (!can(ctx, "tenant:manage")) redirect("/dashboard");
const [sp, s, t, locale] = await Promise.all([searchParams, getLotseSettings(ctx), getTranslations("lotse"), getLocale()]);
@@ -159,6 +160,9 @@ export default async function LotseSettingsPage({ searchParams }: { searchParams
+
+ {/* L17 Pakete: Paket (nur lesend), Lotse-Chat-Plätze, Monatsverbrauch */}
+
);
}
diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx
index ffc63b0..a899783 100644
--- a/src/app/(app)/settings/page.tsx
+++ b/src/app/(app)/settings/page.tsx
@@ -12,6 +12,7 @@ import { BRAND } from "@/lib/brand";
import { updateTenantSettings } from "@/server/actions/tenant-settings";
import { ShieldCheck, History } from "lucide-react";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
+import { effectiveModules } from "@/server/plan";
// TODO(i18n): Texte dieser Seite in messages//settings.json überführen
// (aus dem Fundament übernommen; nicht Teil des Rückbaus).
@@ -28,9 +29,9 @@ export default async function SettingsPage({
const db = dbForTenant(session.user.tenantId);
const sp = await searchParams;
- const [s, moduleRows] = await Promise.all([
+ const [s, modules] = await Promise.all([
db.tenantSettings.findUnique({ where: { tenantId: session.user.tenantId } }),
- db.tenantModule.findMany(),
+ effectiveModules(session.user.tenantId), // L17 Pakete: Schalter + Paketstufe
]);
// Audit-Trail nur laden, wenn das Popup offen ist (?audit=1). Der Mandanten-
@@ -50,7 +51,7 @@ export default async function SettingsPage({
}
}
// Fehlende TenantModule-Zeile ⇒ Modul gilt als aktiv (Default an).
- const disabled = new Set(moduleRows.filter((m) => !m.enabled).map((m) => m.moduleKey));
+ const disabled = modules.inactive;
const v = (x?: string | null) => x ?? "";
return (
diff --git a/src/app/(app)/work-orders/[id]/page.tsx b/src/app/(app)/work-orders/[id]/page.tsx
index 551480b..772256d 100644
--- a/src/app/(app)/work-orders/[id]/page.tsx
+++ b/src/app/(app)/work-orders/[id]/page.tsx
@@ -29,7 +29,7 @@ import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
-import { reasonRequired } from "@/server/services/work-orders/transition";
+import { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
type Tab = (typeof TABS)[number];
@@ -103,8 +103,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
]);
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
- const billingModule = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
- const billingOverviewActive = !billingModule || billingModule.enabled;
+ const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
const billingTransitions = transitions.filter(
(to) =>
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
@@ -188,7 +187,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
+ items={TABS.filter((k) => k !== "billing" || billingOverviewActive).map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/>
@@ -211,7 +210,7 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
}
/>
)}
- {tab === "billing" && }
+ {tab === "billing" && billingOverviewActive && }
{tab === "documents" && }
{tab === "history" && }
diff --git a/src/app/(field)/m/(core)/lotse/layout.tsx b/src/app/(field)/m/(core)/lotse/layout.tsx
index c4faabe..3794bfe 100644
--- a/src/app/(field)/m/(core)/lotse/layout.tsx
+++ b/src/app/(field)/m/(core)/lotse/layout.tsx
@@ -1,7 +1,15 @@
+import { requireSession } from "@/server/auth";
import { requireModule } from "@/server/modules";
+import { moduleState } from "@/server/plan";
+import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
-/** Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx. */
+/**
+ * Modul-Gate „lotse" für den Lotse-Chat (L16); „field" gilt bereits über (core)/layout.tsx.
+ * L17 Pakete: nicht in der Paketstufe (Basis) → freundlicher Hinweis statt Weiterleitung aufs Dashboard.
+ */
export default async function LotseChatLayout({ children }: Readonly<{ children: React.ReactNode }>) {
+ const session = await requireSession();
+ if ((await moduleState(session.user.tenantId, "lotse")) === "not_in_plan") return ;
await requireModule("lotse");
return <>{children}>;
}
diff --git a/src/app/(field)/m/(core)/lotse/page.tsx b/src/app/(field)/m/(core)/lotse/page.tsx
index 0f67906..d59376f 100644
--- a/src/app/(field)/m/(core)/lotse/page.tsx
+++ b/src/app/(field)/m/(core)/lotse/page.tsx
@@ -1,17 +1,18 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server";
-import { ChevronLeft, CircleSlash } from "lucide-react";
+import { ChevronLeft } from "lucide-react";
import type { ChatView } from "@/lib/lotse/chat";
import { isAiConfigured } from "@/server/ai/client";
import { transcriptionConfig } from "@/server/ai/transcription/openai-compatible";
import { ServiceError } from "@/server/services/context";
import { fieldPageContext } from "@/server/services/field/page-context";
+import { isChatQuotaBlocked } from "@/server/services/lotse/chat/access";
import { getChatView } from "@/server/services/lotse/chat/conversations";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { LotseMark } from "@/components/lotse/lotse-mark";
import { LotseChat } from "@/components/lotse/chat/lotse-chat";
-import { card } from "@/components/field/ui";
+import { LotseChatUnavailable } from "@/components/lotse/chat/unavailable";
/**
* `/m/lotse` — Lotse chat for technicians (lane L16). `?order=` opens the chat with order context
@@ -27,24 +28,11 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
if (!(err instanceof ServiceError)) throw err;
if (err.code === "not_found") notFound();
const reason = (err.details as { reason?: string } | undefined)?.reason;
- const code = reason === "disabled" || reason === "chat_disabled" ? reason : err.code === "forbidden" ? "forbidden" : "generic";
- return (
-
-
-
-
- {t("chat.unavailable.title")}
-
- {t(`chat.errors.${code}`)}
-
- {t("chat.unavailable.back")}
-
-
-
- );
+ const code = reason && ["disabled", "chat_disabled", "not_in_plan", "no_seat"].includes(reason) ? reason : err.code === "forbidden" ? "forbidden" : "generic";
+ return ;
}
- const timeZone = await tenantTimezone(ctx);
+ const [timeZone, quotaExhausted] = await Promise.all([tenantTimezone(ctx), isChatQuotaBlocked(ctx)]); // L17: hard limit → hint
return (
{view.workOrder && (
@@ -64,6 +52,7 @@ export default async function LotseChatPage({ searchParams }: { searchParams: Pr
transcription={transcriptionConfig().configured}
locale={locale}
timeZone={timeZone}
+ quotaExhausted={quotaExhausted}
/>
);
diff --git a/src/app/(field)/m/layout.tsx b/src/app/(field)/m/layout.tsx
index 937d222..1ed8517 100644
--- a/src/app/(field)/m/layout.tsx
+++ b/src/app/(field)/m/layout.tsx
@@ -6,6 +6,7 @@ import { fieldPageContext } from "@/server/services/field/page-context";
import { getMyActiveSession } from "@/server/services/field/sessions";
import { countPendingTimeEntries } from "@/server/services/field/time-entries";
import { canUseLotseChat } from "@/server/services/lotse/chat/access";
+import { isModuleActive } from "@/server/plan";
import { cn } from "@/lib/utils";
import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice";
@@ -16,20 +17,26 @@ import { OfflineRuntime } from "@/components/offline/offline-runtime";
import { TrialBanner } from "@/components/trial/trial-banner";
/** L12: own running/paused session + open approvals for the shell (never blocks the page). */
-async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean }> {
+async function shellTimeState(): Promise<{ clock: ClockSession | null; approvals: number; lotseChat: boolean; emergency: boolean }> {
try {
const ctx = await fieldPageContext();
- const [session, approvals, lotseChat] = await Promise.all([can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null), countPendingTimeEntries(ctx), canUseLotseChat(ctx)]);
+ const [session, approvals, lotseChat, emergency] = await Promise.all([
+ can(ctx, "field:execute") ? getMyActiveSession(ctx) : Promise.resolve(null),
+ countPendingTimeEntries(ctx),
+ canUseLotseChat(ctx),
+ isModuleActive(ctx.tenantId, "emergency"), // L17 Pakete: Notdienst nur in Profi
+ ]);
return {
clock: session
? { status: session.status, workOrderId: session.workOrderId, number: session.number, title: session.title, segmentType: session.segmentType, segmentStartedAt: session.segmentStartedAt, closedSeconds: session.closedSeconds }
: null,
approvals,
- lotseChat, // L16
+ lotseChat, // L16 (L17: inkl. Chat-Platz)
+ emergency,
};
} catch {
// module "field" disabled or no field permissions: shell without clock/badge
- return { clock: null, approvals: 0, lotseChat: false };
+ return { clock: null, approvals: 0, lotseChat: false, emergency: true };
}
}
@@ -60,7 +67,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
{children}
-
+
);
}
diff --git a/src/app/(platform)/admin/[id]/page.tsx b/src/app/(platform)/admin/[id]/page.tsx
index f692b64..9ffb8cf 100644
--- a/src/app/(platform)/admin/[id]/page.tsx
+++ b/src/app/(platform)/admin/[id]/page.tsx
@@ -7,6 +7,7 @@ import { platformAuth } from "@/server/platform-auth";
import { Button } from "@/components/ui/button";
import { PageHead, Pill } from "@/components/mockup-ui";
import { MODULES } from "@/lib/modules";
+import { effectiveTier, isModuleInTier } from "@/lib/plans";
import { setTenantStatus, toggleTenantModule, setTenantMfaRequired, setTenantLocale } from "@/server/actions/admin";
import { resolveMfaRequired } from "@/lib/mfa-policy";
import { createTenantUser, updateTenantUser, setTenantUserRoles, setTenantUserStatus } from "@/server/actions/platform-users";
@@ -20,6 +21,7 @@ import { UserCreateForm, UserEditForm } from "@/components/user-forms";
import { AuditTrailModal, type AuditRow } from "@/components/audit-trail";
import { RestoreModalBody, ExportModalBody, DsgvoModalBody, type SnapshotOption, type SubjectOption } from "@/components/backup-admin-panel";
import { TrialAdminCard } from "@/components/trial/trial-admin-card";
+import { PlanAdminCard } from "@/components/plans/plan-admin-card";
const STATUS_TONE: Record = { ACTIVE: "ok", SUSPENDED: "warn", ARCHIVED: "mut" };
@@ -28,7 +30,7 @@ export default async function AdminTenantPage({
searchParams,
}: {
params: Promise<{ id: string }>;
- searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string }>;
+ searchParams: Promise<{ new?: string; edit?: string; audit?: string; modules?: string; users?: string; restore?: string; export?: string; dsgvo?: string; trial?: string; invited?: string; trialDone?: string; plan?: string; planDone?: string }>;
}) {
// Zugriff (Plattform-Session + MFA) wird im (platform)/layout.tsx erzwungen.
const { id } = await params;
@@ -58,6 +60,9 @@ export default async function AdminTenantPage({
const editUser = sp.edit ? tenant.users.find((u) => u.id === sp.edit) : null;
const base = `/admin/${tenant.id}`;
+ // L17 Pakete: Module außerhalb der Paketstufe wirken trotz Schalter wie deaktiviert
+ const tierNow = effectiveTier(tenant.tier, tenant.plan);
+ const tp = await getTranslations("plans");
const moduleState = new Map(tenant.modules.map((m) => [m.moduleKey, m.enabled]));
const isOn = (key: string) => moduleState.get(key) ?? true;
@@ -226,6 +231,11 @@ export default async function AdminTenantPage({
notice={sp.trial === "created" ? (sp.invited ? "invited" : "created") : sp.trialDone ? "done" : null}
/>
+ {/* L17 Pakete: Stufe, Lotse-Chat-Plätze, hartes Limit, Verbrauch (Grundlage der Rechnung) */}
+ {platformSession?.user?.id && (
+
+ )}
+
{/* Lebenszyklus */}
{t("lifecycleTitle")}
@@ -342,6 +352,7 @@ export default async function AdminTenantPage({
{m.href ?? m.key}
+ {!isModuleInTier(tierNow, m.key) &&
{tp("notInTier")}}
{on ? t("moduleActive") : t("moduleInactive")}
+ )}
);
}
diff --git a/src/components/lotse/chat/seat-admin.tsx b/src/components/lotse/chat/seat-admin.tsx
new file mode 100644
index 0000000..53cd4d3
--- /dev/null
+++ b/src/components/lotse/chat/seat-admin.tsx
@@ -0,0 +1,180 @@
+import { getLocale, getTranslations } from "next-intl/server";
+import { CheckCircle2, CircleDashed, Gauge, Info, Package, TriangleAlert, XCircle } from "lucide-react";
+import { Pill } from "@/components/mockup-ui";
+import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
+import { cn } from "@/lib/utils";
+import { setChatSeatAction } from "@/server/actions/lotse/seats";
+import { getTenantPlan } from "@/server/plan";
+import type { ServiceCtx } from "@/server/services/context";
+import { getSeatOverview } from "@/server/services/lotse/chat/seats";
+import { getChatUsageForAdmin } from "@/server/services/lotse/chat/usage";
+
+const LEVEL_TONE = { ok: "var(--ok)", warn: "var(--warn)", exhausted: "var(--warn)", over: "var(--risk)" } as const;
+
+/**
+ * L17 Pakete — /settings/lotse (tenant:manage): package (read-only), Lotse chat seats „x von y vergeben"
+ * with a switch per user with a field role, monthly usage bar with text, 80 % / overage hints and the
+ * per-user breakdown. Every change runs through setChatSeatAction (server-side limits + audit).
+ */
+export async function LotseSeatAdmin({ ctx, seatError, seatSaved }: { ctx: ServiceCtx; seatError?: string; seatSaved?: boolean }) {
+ const [t, locale, plan] = await Promise.all([getTranslations("plans"), getLocale(), getTenantPlan(ctx.tenantId)]);
+ const nf = new Intl.NumberFormat(locale);
+ const profi = plan.tier === "PROFI";
+ const [seats, usage] = profi ? await Promise.all([getSeatOverview(ctx), getChatUsageForAdmin(ctx)]) : [null, null];
+ const free = seats ? Math.max(0, seats.purchased - seats.assigned) : 0;
+ const monthLabel = usage ? new Date(`${usage.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" }) : "";
+
+ return (
+
+
+
+
+ {t("admin.title")}
+
+
{t("admin.sub")}
+
+
+
{t("admin.package")}
+
{t(`tier.${plan.tier}`)}
+ {plan.isTrial &&
{t("admin.trial")}}
+
+
+ {t("admin.packageReadOnly")}
+
+ {seatSaved && (
+
+ {t("admin.saved")}
+
+ )}
+ {seatError && (
+
+ {t.has(`admin.errors.${seatError}`) ? t(`admin.errors.${seatError}`) : t("admin.errors.failed")}
+
+ )}
+
+ {!profi || !seats || !usage ? (
+
+ {t("admin.basisHint")}
+
+ ) : (
+ <>
+ {/* Plätze */}
+
+
{t("admin.seatsTitle")}
+ {plan.isTrial ? (
+
+ {t("admin.trialSeats", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT })}
+
+ ) : (
+
{t("admin.seatsCount", { assigned: seats.assigned, purchased: seats.purchased })}
+ )}
+ {seats.overbooked && (
+
+ {t("admin.overbooked", { purchased: seats.purchased })}
+
+ )}
+ {!plan.isTrial && seats.purchased === 0 &&
{t("admin.noSeatsBought")}
}
+ {seats.users.length === 0 ? (
+
{t("admin.noFieldUsers")}
+ ) : (
+
+ )}
+
+
+ {/* Monatsverbrauch */}
+
+
+ {t("admin.usageTitle", { month: monthLabel })}
+
+
+
{t("admin.usageText", { used: nf.format(usage.used), quota: nf.format(usage.quota) })}
+
{t("admin.quotaRule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, basis: usage.quotaBasis })}
+ {usage.level === "warn" && (
+
+ {t("admin.warn80", { percent: usage.percent })}
+
+ )}
+ {(usage.level === "exhausted" || usage.level === "over") && usage.hardLimit && (
+
+ {t("admin.hardLimitReached")}
+
+ )}
+ {usage.level === "exhausted" && !usage.hardLimit && (
+
+ {t("admin.exhausted", { pack: LOTSE_CHAT_OVERAGE_PACK })}
+
+ )}
+ {usage.overage > 0 && (
+
+ {t("admin.overage", { overage: nf.format(usage.overage), packs: usage.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK })}
+
+ )}
+ {usage.perUser.length > 0 && (
+
+ {t("admin.perUserCaption")}
+
+
+ | {t("admin.perUserName")} |
+ {t("admin.perUserChats")} |
+
+
+
+ {usage.perUser.map((u) => (
+
+ | {u.name} |
+ {nf.format(u.count)} |
+
+ ))}
+
+
+ )}
+
+ >
+ )}
+
+ );
+}
diff --git a/src/components/lotse/chat/unavailable.tsx b/src/components/lotse/chat/unavailable.tsx
new file mode 100644
index 0000000..3e74743
--- /dev/null
+++ b/src/components/lotse/chat/unavailable.tsx
@@ -0,0 +1,23 @@
+import Link from "next/link";
+import { getTranslations } from "next-intl/server";
+import { CircleSlash } from "lucide-react";
+import { card } from "@/components/field/ui";
+
+/** „Lotse-Chat nicht verfügbar" (L16; L17: kein Platz / nicht im Paket) — text + icon, back to /m. */
+export async function LotseChatUnavailable({ reason }: { reason: string }) {
+ const t = await getTranslations("lotse");
+ return (
+
+
+
+
+ {t("chat.unavailable.title")}
+
+ {t.has(`chat.errors.${reason}`) ? t(`chat.errors.${reason}`) : t("chat.errors.generic")}
+
+ {t("chat.unavailable.back")}
+
+
+
+ );
+}
diff --git a/src/components/plans/plan-admin-card.tsx b/src/components/plans/plan-admin-card.tsx
new file mode 100644
index 0000000..61a6ff5
--- /dev/null
+++ b/src/components/plans/plan-admin-card.tsx
@@ -0,0 +1,82 @@
+import Link from "next/link";
+import { getLocale, getTranslations } from "next-intl/server";
+import { Button } from "@/components/ui/button";
+import { Modal } from "@/components/modal";
+import { Pill } from "@/components/mockup-ui";
+import { LOTSE_CHAT_CHATS_PER_SEAT, LOTSE_CHAT_OVERAGE_PACK } from "@/lib/plans";
+import { updateTenantPlanAction } from "@/server/actions/plans-platform";
+import type { ChatUsage } from "@/server/services/lotse/chat/usage";
+import { getTenantPlanOverview, MAX_LOTSE_CHAT_SEATS } from "@/server/services/plans/platform";
+import { PlanForm } from "./plan-form";
+
+/**
+ * L17 Pakete — card "Paket & Lotse-Chat" on the platform tenant detail page: tier, seats assigned /
+ * purchased, chats of the current and previous month, overage and the resulting packs of 100 (basis for
+ * the invoice). Full admins change tier/seats/hard limit in a confirmation popup (?plan=edit).
+ */
+export async function PlanAdminCard({ tenantId, platformAdminId, isFullAdmin, base, edit, done }: { tenantId: string; platformAdminId: string; isFullAdmin: boolean; base: string; edit: boolean; done: boolean }) {
+ const [t, locale, overview] = await Promise.all([getTranslations("plans.platform"), getLocale(), getTenantPlanOverview({ platformAdminId }, tenantId)]);
+ const tt = await getTranslations("plans");
+ const nf = new Intl.NumberFormat(locale);
+ const { plan, seatsAssigned, current, previous } = overview;
+ const monthName = (u: ChatUsage) => new Date(`${u.monthKey}-15T12:00:00Z`).toLocaleDateString(locale, { month: "long", year: "numeric", timeZone: "UTC" });
+
+ const usageRows = [current, previous].map((u) => (
+
+
{u === current ? t("currentMonth", { month: monthName(u) }) : t("previousMonth", { month: monthName(u) })}
+
{t("chats", { used: nf.format(u.used), quota: nf.format(u.quota) })}
+
+ {u.overage > 0 ? t("overage", { overage: nf.format(u.overage), packs: u.overagePacks, pack: LOTSE_CHAT_OVERAGE_PACK }) : t("noOverage")}
+
+
+ ));
+
+ return (
+
+
{t("title")}
+ {done &&
{t("saved")}
}
+
+
+
- {t("tier")}
+
-
+ {tt(`tier.${plan.storedTier}`)}
+ {plan.isTrial && {t("trialProfi")}}
+
+
+
+
- {t("seats")}
+ - {t("seatsValue", { assigned: seatsAssigned, purchased: plan.lotseChatSeats })}
+
+
+
- {t("hardLimit")}
+ - {plan.lotseChatHardLimit ? t("hardLimitOn") : t("hardLimitOff")}
+
+
+
{t("rule", { perSeat: LOTSE_CHAT_CHATS_PER_SEAT, pack: LOTSE_CHAT_OVERAGE_PACK })}
+
{usageRows}
+ {isFullAdmin && (
+
+
+
+ )}
+
+ {isFullAdmin && edit && (
+
+
+
+ )}
+
+ );
+}
diff --git a/src/components/plans/plan-form.tsx b/src/components/plans/plan-form.tsx
new file mode 100644
index 0000000..76f7d1e
--- /dev/null
+++ b/src/components/plans/plan-form.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useActionState } from "react";
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { TENANT_TIERS, type TenantTier } from "@/lib/plans";
+import type { PlatformPlanState } from "@/server/actions/plans-platform";
+
+/** L17 Pakete: operator form (tier, seats, hard limit) with mandatory confirmation checkbox. */
+export function PlanForm({
+ action,
+ tier,
+ seats,
+ hardLimit,
+ maxSeats,
+ closeHref,
+}: {
+ action: (prev: PlatformPlanState, fd: FormData) => Promise;
+ tier: TenantTier;
+ seats: number;
+ hardLimit: boolean;
+ maxSeats: number;
+ closeHref: string;
+}) {
+ const t = useTranslations("plans.platform");
+ const tt = useTranslations("plans");
+ const [state, formAction, pending] = useActionState(action, { status: "idle" });
+ return (
+
+ );
+}
diff --git a/src/components/plans/profi-hint.tsx b/src/components/plans/profi-hint.tsx
new file mode 100644
index 0000000..d60cf8c
--- /dev/null
+++ b/src/components/plans/profi-hint.tsx
@@ -0,0 +1,21 @@
+import { getTranslations } from "next-intl/server";
+import { Sparkles } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * L17 Pakete: quiet hint "Im Paket Profi enthalten" instead of an error page — shown when a page of a
+ * module/feature outside the tenant's package tier was opened (redirect `/dashboard?module=profi`).
+ * Text + icon, colours only via tokens.
+ */
+export async function ProfiHint({ className, compact = false }: { className?: string; compact?: boolean }) {
+ const t = await getTranslations("plans");
+ return (
+
+
+
+
{t("profiHint.title")}
+ {!compact &&
{t("profiHint.text")}
}
+
+
+ );
+}
diff --git a/src/lib/lotse/chat.ts b/src/lib/lotse/chat.ts
index 1a646e4..34fe4aa 100644
--- a/src/lib/lotse/chat.ts
+++ b/src/lib/lotse/chat.ts
@@ -175,6 +175,10 @@ export const LOTSE_CHAT_ERROR_CODES = [
"tampered",
"blocked",
"offline",
+ // L17 Pakete
+ "not_in_plan",
+ "no_seat",
+ "quota_exhausted",
] as const;
export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number];
diff --git a/src/lib/nav.ts b/src/lib/nav.ts
index 59607cb..2c086cc 100644
--- a/src/lib/nav.ts
+++ b/src/lib/nav.ts
@@ -23,6 +23,7 @@ import {
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
+import type { PlanFeature } from "@/lib/plans";
import type { Permission } from "@/server/rbac";
/**
@@ -47,14 +48,16 @@ export interface NavItem {
sub?: boolean;
/** L13: active only on exactly this path (not on sub paths) */
exact?: boolean;
+ /** L17 Pakete: feature without own module that the package tier must include (planning) */
+ feature?: PlanFeature;
}
export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
- { href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
- { href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
- { href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
+ { href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
+ { href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
+ { href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", feature: "planning", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
{
href: "/work-orders",
label: "workOrders",
@@ -81,14 +84,19 @@ export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/settings/export", label: "dataExport", icon: Download, permissions: ["tenant:manage"], section: "admin" }, // L15
];
-/** Filtert die Navigation nach aktiven Modulen und Rechten der Session. */
+/**
+ * Filtert die Navigation nach aktiven Modulen und Rechten der Session. `disabledModules` = effektiv
+ * inaktive Module (L17: abgeschaltet ODER nicht in der Paketstufe), `lockedFeatures` = nicht enthaltene
+ * Paket-Features (Planung).
+ */
export function visibleNavItems(
items: readonly NavItem[],
- opts: { disabledModules: ReadonlySet; permissions: readonly string[] },
+ opts: { disabledModules: ReadonlySet; permissions: readonly string[]; lockedFeatures?: ReadonlySet },
): NavItem[] {
return items.filter(
(item) =>
(!item.module || !opts.disabledModules.has(item.module)) &&
+ (!item.feature || !opts.lockedFeatures?.has(item.feature)) &&
(!item.permissions?.length || item.permissions.some((p) => opts.permissions.includes(p))),
);
}
diff --git a/src/lib/offline/outbox-core.ts b/src/lib/offline/outbox-core.ts
index 168a45e..1e78843 100644
--- a/src/lib/offline/outbox-core.ts
+++ b/src/lib/offline/outbox-core.ts
@@ -333,6 +333,9 @@ export function problemKey(e: Pick 0 ? "over" : "ok";
+ if (used > quota) return "over";
+ if (used === quota) return "exhausted";
+ return used >= quota * LOTSE_CHAT_WARN_RATIO ? "warn" : "ok";
+}
+
+/** Share of the quota in percent (0–100 for the bar; overage caps at 100). */
+export function usagePercent(used: number, quota: number): number {
+ if (quota <= 0) return used > 0 ? 100 : 0;
+ return Math.min(100, Math.round((used / quota) * 100));
+}
+
+/** Hard limit reached → no further chat messages. */
+export function isQuotaBlocked(used: number, quota: number, hardLimit: boolean): boolean {
+ return hardLimit && used >= quota;
+}
+
+/** Month key `YYYY-MM` of a day key `YYYY-MM-DD`. */
+export function monthKeyOfDay(dayKey: string): string {
+ return dayKey.slice(0, 7);
+}
+
+/** Previous month key of `YYYY-MM`. */
+export function previousMonthKey(monthKey: string): string {
+ const [y, m] = monthKey.split("-").map(Number);
+ return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, "0")}`;
+}
+
+/** Next month key of `YYYY-MM`. */
+export function nextMonthKey(monthKey: string): string {
+ const [y, m] = monthKey.split("-").map(Number);
+ return m === 12 ? `${y + 1}-01` : `${y}-${String(m + 1).padStart(2, "0")}`;
+}
diff --git a/src/server/actions/lotse/_chat-state.ts b/src/server/actions/lotse/_chat-state.ts
index 0c58763..bcdfe14 100644
--- a/src/server/actions/lotse/_chat-state.ts
+++ b/src/server/actions/lotse/_chat-state.ts
@@ -13,7 +13,7 @@ export function chatErrorState(err: unknown, view?: ChatView): LotseChatActionSt
const code = reason && (LOTSE_CHAT_ERROR_CODES as readonly string[]).includes(reason) ? (reason as LotseChatErrorCode) : err.code;
return { status: "error", code, at, ...withView };
}
- if (err instanceof ModuleDisabledError) return { status: "error", code: "disabled", at, ...withView };
+ if (err instanceof ModuleDisabledError) return { status: "error", code: err.reason === "not_in_plan" ? "not_in_plan" : "disabled", at, ...withView };
if (err instanceof ZodError) return { status: "error", code: "invalid", at, ...withView };
if (err instanceof ForbiddenError) return { status: "error", code: "forbidden", at, ...withView };
console.error("[actions/lotse/chat]", err);
diff --git a/src/server/actions/lotse/seats.ts b/src/server/actions/lotse/seats.ts
new file mode 100644
index 0000000..52f44f3
--- /dev/null
+++ b/src/server/actions/lotse/seats.ts
@@ -0,0 +1,40 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { moduleGuard } from "@/server/action-guard";
+import { ModuleDisabledError } from "@/server/modules";
+import { ForbiddenError } from "@/server/rbac";
+import { ctxFromGuard, ServiceError } from "@/server/services/context";
+import { setChatSeat } from "@/server/services/lotse/chat/seats";
+
+/**
+ * L17 Pakete — /settings/lotse: assign / revoke a Lotse chat seat (tenant admin, `tenant:manage`).
+ * moduleGuard("lotse"): only while the Lotse is part of the package and switched on; the service checks
+ * eligibility, the purchased number (serialised) and writes the audit.
+ */
+
+const guard = moduleGuard("lotse");
+
+function errorCode(err: unknown): string {
+ if (err instanceof ServiceError) return (err.details as { reason?: string } | undefined)?.reason ?? err.code;
+ if (err instanceof ModuleDisabledError) return err.reason;
+ if (err instanceof ForbiddenError) return "forbidden";
+ return "failed";
+}
+
+/** Form fields: userId, assigned ("1" = assign, otherwise revoke). */
+export async function setChatSeatAction(fd: FormData): Promise {
+ let target = "/settings/lotse?seat=saved#seats";
+ try {
+ const ctx = ctxFromGuard(await guard("tenant:manage"));
+ await setChatSeat(ctx, { userId: String(fd.get("userId") ?? ""), assigned: fd.get("assigned") === "1" });
+ revalidatePath("/settings/lotse");
+ revalidatePath("/m", "layout");
+ } catch (err) {
+ const code = errorCode(err);
+ if (code === "failed") console.error("[actions/lotse/seats]", err);
+ target = `/settings/lotse?seatError=${encodeURIComponent(code)}#seats`;
+ }
+ redirect(target);
+}
diff --git a/src/server/actions/plans-platform.ts b/src/server/actions/plans-platform.ts
new file mode 100644
index 0000000..6ae311f
--- /dev/null
+++ b/src/server/actions/plans-platform.ts
@@ -0,0 +1,38 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { redirect } from "next/navigation";
+import { requirePlatformFullAdmin } from "@/server/platform-auth";
+import { ServiceError } from "@/server/services/context";
+import { updateTenantPlan } from "@/server/services/plans/platform";
+
+/**
+ * L17 Pakete — operator action on the platform tenant detail page: tier, Lotse chat seats, hard limit.
+ * EXEMPT from module gating; authorisation via the separate platform session (full admins only). The
+ * service re-checks the actor against the PlatformAdmin store and writes the platform audit.
+ */
+
+export type PlatformPlanState = { status: "idle" } | { status: "error"; code: string };
+
+export async function updateTenantPlanAction(tenantId: string, _prev: PlatformPlanState, fd: FormData): Promise {
+ const { admin } = await requirePlatformFullAdmin();
+ if (fd.get("confirm") !== "on") return { status: "error", code: "confirm_required" };
+ try {
+ await updateTenantPlan(
+ { platformAdminId: admin.id },
+ tenantId,
+ {
+ tier: String(fd.get("tier") ?? "") as "BASIS" | "PROFI",
+ lotseChatSeats: String(fd.get("lotseChatSeats") ?? "").trim() || "0",
+ lotseChatHardLimit: fd.get("lotseChatHardLimit") === "on",
+ },
+ );
+ } catch (err) {
+ return { status: "error", code: err instanceof ServiceError ? err.message : "failed" };
+ }
+ revalidatePath("/admin");
+ revalidatePath(`/admin/${tenantId}`);
+ // the tenant shell renders navigation/module gates per request from the plan
+ revalidatePath("/", "layout");
+ redirect(`/admin/${tenantId}?planDone=1`);
+}
diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts
index 49a2ff7..316ccad 100644
--- a/src/server/api/respond.ts
+++ b/src/server/api/respond.ts
@@ -3,6 +3,7 @@ import { ZodError } from "zod";
import { ServiceError } from "@/server/services/context";
import { ForbiddenError } from "@/server/rbac";
import { ModuleDisabledError } from "@/server/modules";
+import { PROFI_ONLY_MESSAGE } from "@/server/plan";
/**
* JSON response helpers for ALL /api/v1 route handlers (spec §29.2, L10b: single adapter —
@@ -73,7 +74,10 @@ export function toErrorResponse(err: unknown): Response {
);
}
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
- if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
+ if (err instanceof ModuleDisabledError) {
+ // L17 Pakete: Modul nicht in der Paketstufe → Klartext für Clients
+ return err.reason === "not_in_plan" ? errorResponse("forbidden", "not included in plan", { reason: "not_in_plan", module: err.moduleKey, message: PROFI_ONLY_MESSAGE }) : errorResponse("forbidden", "module disabled");
+ }
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
console.error("[api] unhandled error", err);
return errorResponse("internal", "internal error");
diff --git a/src/server/backup/topology.ts b/src/server/backup/topology.ts
index 8099168..2b4616e 100644
--- a/src/server/backup/topology.ts
+++ b/src/server/backup/topology.ts
@@ -81,6 +81,8 @@ export const TENANT_MODELS: readonly string[] = [
"LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
+ // L17 Pakete: vergebene Lotse-Chat-Plätze
+ "LotseChatSeat",
];
/**
diff --git a/src/server/db.ts b/src/server/db.ts
index c15afa3..3abf9bd 100644
--- a/src/server/db.ts
+++ b/src/server/db.ts
@@ -132,6 +132,8 @@ const TENANT_MODELS = new Set([
"LotseActionProposal",
// L15 Testphase: Datenexport des Mandanten
"TenantExport",
+ // L17 Pakete: vergebene Lotse-Chat-Plätze
+ "LotseChatSeat",
// WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier.
// Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts
// (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('')` in der Migration
diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts
index 2027236..c29e21f 100644
--- a/src/server/dsgvo/pii-fields.ts
+++ b/src/server/dsgvo/pii-fields.ts
@@ -61,6 +61,9 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [
{ model: "LotseActionProposal", field: "confirmedById" },
// L15 Testphase: Datenexport
{ model: "TenantExport", field: "requestedById" },
+ // L17 Pakete: Lotse-Chat-Platz (Inhaber + vergebende Person)
+ { model: "LotseChatSeat", field: "userId" },
+ { model: "LotseChatSeat", field: "assignedById" },
// Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is
// tenant business data under data processing — not part of the employee subject export.
];
diff --git a/src/server/modules.ts b/src/server/modules.ts
index 79af632..f650aa1 100644
--- a/src/server/modules.ts
+++ b/src/server/modules.ts
@@ -1,8 +1,8 @@
import { redirect } from "next/navigation";
import type { Session } from "next-auth";
import { requireSession } from "@/server/auth";
-import { dbForTenant } from "@/server/db";
import { writeAuditLog } from "@/server/audit";
+import { moduleState, PROFI_HINT_PATH, PROFI_ONLY_MESSAGE } from "@/server/plan";
/**
* Serverseitige Modul-Durchsetzung (§3.4): blockiert den Zugriff auf ein für den
@@ -12,17 +12,21 @@ import { writeAuditLog } from "@/server/audit";
*/
export async function requireModule(moduleKey: string) {
const session = await requireSession();
- const row = await dbForTenant(session.user.tenantId).tenantModule.findUnique({
- where: { tenantId_moduleKey: { tenantId: session.user.tenantId, moduleKey } },
- });
- if (row && !row.enabled) redirect("/dashboard?module=disabled");
+ // L17 Pakete: Stufe + TenantModule zentral (src/server/plan.ts); nicht im Paket → ruhiger Hinweis
+ const state = await moduleState(session.user.tenantId, moduleKey);
+ if (state === "not_in_plan") redirect(PROFI_HINT_PATH);
+ if (state === "disabled") redirect("/dashboard?module=disabled");
return session;
}
/** Wird geworfen, wenn eine Server-Action ein für den Mandanten deaktiviertes Modul betrifft. */
export class ModuleDisabledError extends Error {
- constructor(public readonly moduleKey: string) {
- super(`Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
+ constructor(
+ public readonly moduleKey: string,
+ /** L17 Pakete: `not_in_plan` = Modul gehört nicht zur Paketstufe des Mandanten (Basis). */
+ public readonly reason: "disabled" | "not_in_plan" = "disabled",
+ ) {
+ super(reason === "not_in_plan" ? `Modul „${moduleKey}": ${PROFI_ONLY_MESSAGE}` : `Modul „${moduleKey}" ist für diesen Mandanten deaktiviert.`);
this.name = "ModuleDisabledError";
}
}
@@ -37,17 +41,17 @@ export class ModuleDisabledError extends Error {
*/
export async function assertModuleEnabled(session: Session, moduleKey: string) {
const tenantId = session.user.tenantId;
- const row = await dbForTenant(tenantId).tenantModule.findUnique({
- where: { tenantId_moduleKey: { tenantId, moduleKey } },
- });
- if (row && !row.enabled) {
+ // L17 Pakete: effektive Freischaltung = Stufe erlaubt UND TenantModule aktiv (src/server/plan.ts)
+ const state = await moduleState(tenantId, moduleKey);
+ if (state !== "enabled") {
await writeAuditLog({
tenantId,
actorId: session.user.id,
action: "denied",
entity: "module",
entityId: moduleKey,
+ ...(state === "not_in_plan" ? { after: { reason: "not_in_plan" } } : {}),
});
- throw new ModuleDisabledError(moduleKey);
+ throw new ModuleDisabledError(moduleKey, state === "not_in_plan" ? "not_in_plan" : "disabled");
}
}
diff --git a/src/server/plan.ts b/src/server/plan.ts
new file mode 100644
index 0000000..8c23bd7
--- /dev/null
+++ b/src/server/plan.ts
@@ -0,0 +1,111 @@
+import { redirect } from "next/navigation";
+import { dbForTenant, prisma } from "@/server/db";
+import { ServiceError } from "@/server/services/context";
+import { MODULE_KEYS } from "@/lib/modules";
+import { effectiveTier, isFeatureInTier, isModuleInTier, PLAN_FEATURES, type PlanFeature, type TenantTier } from "@/lib/plans";
+
+/**
+ * L17 Pakete — the ONE server-side place that combines the package tier (Tenant.tier, trial = PROFI)
+ * with the tenant module switches (TenantModule). Effective module = tier includes it AND the module
+ * is switched on (missing TenantModule row = on). Every gate uses these functions:
+ * - requireModule / assertModuleEnabled (src/server/modules.ts) → layouts, moduleGuard, requireApiContext
+ * - sync apply (services/sync/apply.ts, ops of a locked module → rejected with plain text)
+ * - navigation (app layout, mobile shell), isLotseEnabled, planningAccess.
+ * `Tenant` is a platform table (no tenant_id/RLS) and is read with the owner client by id; only the
+ * operator changes tier/seats (services/plans/platform.ts).
+ */
+
+export type TenantPlanInfo = {
+ /** Effective tier (trial → PROFI). */
+ tier: TenantTier;
+ /** Stored tier (what the operator set). */
+ storedTier: TenantTier;
+ isTrial: boolean;
+ lotseChatSeats: number;
+ lotseChatHardLimit: boolean;
+};
+
+export const PLAN_SELECT = { tier: true, plan: true, lotseChatSeats: true, lotseChatHardLimit: true } as const;
+
+export function toPlanInfo(row: { tier: TenantTier; plan: string; lotseChatSeats: number; lotseChatHardLimit: boolean } | null): TenantPlanInfo {
+ if (!row) return { tier: "PROFI", storedTier: "PROFI", isTrial: false, lotseChatSeats: 0, lotseChatHardLimit: false };
+ return {
+ tier: effectiveTier(row.tier, row.plan),
+ storedTier: row.tier,
+ isTrial: row.plan === "TRIAL",
+ lotseChatSeats: row.lotseChatSeats,
+ lotseChatHardLimit: row.lotseChatHardLimit,
+ };
+}
+
+export async function getTenantPlan(tenantId: string): Promise {
+ return toPlanInfo(await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT }));
+}
+
+/** Plain-language hint for API clients / sync results (UI texts come from messages `plans.*`). */
+export const PROFI_ONLY_MESSAGE = "Im Paket Profi enthalten – für diesen Betrieb nicht freigeschaltet.";
+
+export type ModuleState = "enabled" | "disabled" | "not_in_plan";
+
+/** Effective state of one module: tier first, then the tenant switch. */
+export async function moduleState(tenantId: string, moduleKey: string): Promise {
+ const plan = await getTenantPlan(tenantId);
+ if (!isModuleInTier(plan.tier, moduleKey)) return "not_in_plan";
+ const row = await dbForTenant(tenantId).tenantModule.findUnique({
+ where: { tenantId_moduleKey: { tenantId, moduleKey } },
+ select: { enabled: true },
+ });
+ return row && !row.enabled ? "disabled" : "enabled";
+}
+
+export async function isModuleActive(tenantId: string, moduleKey: string): Promise {
+ return (await moduleState(tenantId, moduleKey)) === "enabled";
+}
+
+export type EffectiveModules = {
+ tier: TenantTier;
+ isTrial: boolean;
+ /** Modules switched off by the tenant/operator OR not included in the tier. */
+ inactive: Set;
+ /** Subset of `inactive`: locked only by the tier ("Im Paket Profi enthalten"). */
+ notInPlan: Set;
+ /** Features (planning, lotse_chat) not included in the tier. */
+ lockedFeatures: Set;
+};
+
+/** All module states in two queries (navigation, settings overview). */
+export async function effectiveModules(tenantId: string): Promise {
+ const [plan, rows] = await Promise.all([getTenantPlan(tenantId), dbForTenant(tenantId).tenantModule.findMany({ select: { moduleKey: true, enabled: true } })]);
+ const notInPlan = new Set(MODULE_KEYS.filter((k) => !isModuleInTier(plan.tier, k)));
+ const inactive = new Set([...notInPlan, ...rows.filter((r) => !r.enabled).map((r) => r.moduleKey)]);
+ const lockedFeatures = new Set(PLAN_FEATURES.filter((f) => !isFeatureInTier(plan.tier, f)));
+ return { tier: plan.tier, isTrial: plan.isTrial, inactive, notInPlan, lockedFeatures };
+}
+
+export async function isPlanFeatureAvailable(tenantId: string, feature: PlanFeature): Promise {
+ return isFeatureInTier((await getTenantPlan(tenantId)).tier, feature);
+}
+
+/** `forbidden` with reason `not_in_plan` — callers that hide sections on `forbidden` keep working. */
+export class PlanFeatureError extends ServiceError {
+ constructor(public readonly feature: string) {
+ super("forbidden", "not_in_plan", { reason: "not_in_plan", feature, message: PROFI_ONLY_MESSAGE });
+ this.name = "PlanFeatureError";
+ }
+}
+
+export function isNotInPlanError(err: unknown): boolean {
+ return err instanceof ServiceError && err.message === "not_in_plan";
+}
+
+export async function assertPlanFeature(tenantId: string, feature: PlanFeature): Promise {
+ if (!(await isPlanFeatureAvailable(tenantId, feature))) throw new PlanFeatureError(feature);
+}
+
+/** Dashboard hint target for pages of a locked module/feature (quiet hint instead of an error page). */
+export const PROFI_HINT_PATH = "/dashboard?module=profi";
+
+/** Layout gate for a feature without own module (planning). */
+export async function requirePlanFeature(tenantId: string, feature: PlanFeature): Promise {
+ if (!(await isPlanFeatureAvailable(tenantId, feature))) redirect(PROFI_HINT_PATH);
+}
diff --git a/src/server/services/lotse/chat/access.ts b/src/server/services/lotse/chat/access.ts
index 25909c1..01641ac 100644
--- a/src/server/services/lotse/chat/access.ts
+++ b/src/server/services/lotse/chat/access.ts
@@ -1,27 +1,62 @@
+import { isFeatureInTier } from "@/lib/plans";
+import { getTenantPlan } from "@/server/plan";
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
import { isLotseEnabled } from "../settings";
+import { hasValidChatSeat } from "./seats";
+import { getChatUsage } from "./usage";
/**
- * Gate of the Lotse chat for technicians (lane L16): `lotse:use` + `field:execute`, Lotse module on
- * (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure" (TenantSettings.lotseChatEnabled,
- * default on). Switched off → `blocked` (reason disabled / chat_disabled).
+ * Gate of the Lotse chat for technicians (lane L16, extended by L17 Pakete) — the ONE place for:
+ * - permissions `lotse:use` + `field:execute`,
+ * - package tier PROFI (Basis → `blocked not_in_plan`; trial = PROFI),
+ * - Lotse module on (TenantModule `lotse`) and the tenant switch „Lotse-Chat für Monteure",
+ * - a chat seat of the user (`blocked no_seat`; trial: no seat check),
+ * - when SENDING: the monthly quota with hard limit (`blocked quota_exhausted`; without hard limit the
+ * usage keeps counting as overage). The token budget (budget.ts) stays as a separate safety net.
*/
+export type LotseChatBlockReason = "not_in_plan" | "disabled" | "chat_disabled" | "no_seat";
+
export async function isLotseChatEnabled(ctx: Pick): Promise {
if (!(await isLotseEnabled(ctx))) return false;
const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
return s?.lotseChatEnabled ?? true;
}
-/** UI convenience: may the user open the chat at all (permissions + switches)? Never a security check. */
-export async function canUseLotseChat(ctx: ServiceCtx): Promise {
- return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await isLotseChatEnabled(ctx));
+/** Tenant- and user-level gate without permissions and quota; null = open. */
+export async function lotseChatBlockReason(ctx: ServiceCtx): Promise {
+ const plan = await getTenantPlan(ctx.tenantId);
+ if (!isFeatureInTier(plan.tier, "lotse_chat")) return "not_in_plan";
+ if (!(await isLotseEnabled(ctx))) return "disabled";
+ const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
+ if (s && !s.lotseChatEnabled) return "chat_disabled";
+ if (!plan.isTrial && !(await hasValidChatSeat(ctx, ctx.userId, plan.lotseChatSeats))) return "no_seat";
+ return null;
}
-export async function assertLotseChatUsable(ctx: ServiceCtx): Promise {
+/** UI convenience: may the user open the chat at all (permissions + switches + seat)? Never a security check. */
+export async function canUseLotseChat(ctx: ServiceCtx): Promise {
+ return can(ctx, "lotse:use") && can(ctx, "field:execute") && (await lotseChatBlockReason(ctx)) === null;
+}
+
+const BLOCK_MESSAGES: Record = {
+ not_in_plan: "lotse chat not included in plan",
+ disabled: "lotse disabled for tenant",
+ chat_disabled: "lotse chat disabled for tenant",
+ no_seat: "no lotse chat seat",
+};
+
+/** Hard limit reached for the current month (tenant timezone)? */
+export async function isChatQuotaBlocked(ctx: ServiceCtx, now: Date = new Date()): Promise {
+ return (await getChatUsage(ctx, { now, perUser: false })).blocked;
+}
+
+export async function assertLotseChatUsable(ctx: ServiceCtx, opts: { send?: boolean; now?: Date } = {}): Promise {
assertCan(ctx, "lotse:use");
assertCan(ctx, "field:execute");
- if (!(await isLotseEnabled(ctx))) throw new ServiceError("blocked", "lotse disabled for tenant", { reason: "disabled" });
- const s = await ctx.db.tenantSettings.findFirst({ select: { lotseChatEnabled: true } });
- if (s && !s.lotseChatEnabled) throw new ServiceError("blocked", "lotse chat disabled for tenant", { reason: "chat_disabled" });
+ const reason = await lotseChatBlockReason(ctx);
+ if (reason) throw new ServiceError("blocked", BLOCK_MESSAGES[reason], { reason });
+ if (opts.send && (await isChatQuotaBlocked(ctx, opts.now))) {
+ throw new ServiceError("blocked", "lotse chat quota exhausted", { reason: "quota_exhausted" });
+ }
}
diff --git a/src/server/services/lotse/chat/engine.ts b/src/server/services/lotse/chat/engine.ts
index 27986f2..94b4924 100644
--- a/src/server/services/lotse/chat/engine.ts
+++ b/src/server/services/lotse/chat/engine.ts
@@ -84,9 +84,10 @@ async function historyTurns(ctx: ServiceCtx, conversationId: string, env: Minimi
}
export async function sendLotseMessage(ctx: ServiceCtx, raw: SendMessageInput, deps: ChatDeps = defaultChatDeps()): Promise {
- await assertLotseChatUsable(ctx);
- const input = sendMessageSchema.parse(raw);
const now = (deps.now ?? (() => new Date()))();
+ // L17 Pakete: seat + monthly quota (hard limit → blocked quota_exhausted) before anything is stored
+ await assertLotseChatUsable(ctx, { send: true, now });
+ const input = sendMessageSchema.parse(raw);
const conversation = (await openConversation(ctx, { conversationId: input.conversationId, workOrderId: input.workOrderId, create: true }))!;
if (!deps.provider) throw new ServiceError("invalid", "lotse provider not configured", { reason: "not_configured" });
await assertTokenBudget(ctx, now);
diff --git a/src/server/services/lotse/chat/seats.ts b/src/server/services/lotse/chat/seats.ts
new file mode 100644
index 0000000..112a8c8
--- /dev/null
+++ b/src/server/services/lotse/chat/seats.ts
@@ -0,0 +1,130 @@
+import { z } from "zod";
+import { writeAuditLog } from "@/server/audit";
+import { getTenantPlan } from "@/server/plan";
+import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
+
+/**
+ * L17 Pakete — Lotse chat seats. The operator sets the number of purchased seats per tenant
+ * (Tenant.lotseChatSeats, platform only); the tenant admin (`tenant:manage`) assigns them to users with
+ * a field role (`lotse:use` + `field:execute`). Assigning beyond the purchased number → `invalid`.
+ *
+ * If the operator reduces the number below the assigned seats, the rows stay (nothing is deleted),
+ * but only the OLDEST `lotseChatSeats` assignments are valid (createdAt, id) — the admin sees the
+ * overbooking and revokes seats. Switching the tenant to Basis keeps all rows.
+ */
+
+type SeatCtx = Pick;
+
+const ELIGIBLE_PERMISSIONS = ["lotse:use", "field:execute"] as const;
+
+/** Where clause: active members whose roles grant BOTH chat permissions (DB-authoritative). */
+export function eligibleUserWhere() {
+ return {
+ status: "ACTIVE" as const,
+ AND: ELIGIBLE_PERMISSIONS.map((key) => ({ userRoles: { some: { role: { rolePermissions: { some: { permission: { key } } } } } } })),
+ };
+}
+
+export async function listEligibleUsers(ctx: SeatCtx): Promise<{ id: string; name: string; email: string }[]> {
+ return ctx.db.user.findMany({ where: eligibleUserWhere(), select: { id: true, name: true, email: true }, orderBy: [{ name: "asc" }, { id: "asc" }] });
+}
+
+export async function countEligibleUsers(ctx: SeatCtx): Promise {
+ return ctx.db.user.count({ where: eligibleUserWhere() });
+}
+
+/** User ids holding a VALID seat (the oldest `purchased` assignments). */
+export async function validSeatUserIds(ctx: SeatCtx, purchased: number): Promise> {
+ if (purchased <= 0) return new Set();
+ const rows = await ctx.db.lotseChatSeat.findMany({ orderBy: [{ createdAt: "asc" }, { id: "asc" }], take: purchased, select: { userId: true } });
+ return new Set(rows.map((r) => r.userId));
+}
+
+export async function hasValidChatSeat(ctx: SeatCtx, userId: string, purchased: number): Promise {
+ return (await validSeatUserIds(ctx, purchased)).has(userId);
+}
+
+/** Assigned seats that count (capped at the purchased number) — basis of the monthly quota. */
+export async function countAssignedSeats(ctx: SeatCtx, purchased: number): Promise<{ assigned: number; counted: number }> {
+ const assigned = await ctx.db.lotseChatSeat.count();
+ return { assigned, counted: Math.min(assigned, Math.max(0, purchased)) };
+}
+
+export type SeatOverview = {
+ purchased: number;
+ assigned: number;
+ /** more rows than purchased seats (operator reduced the number) */
+ overbooked: boolean;
+ users: { id: string; name: string; email: string; assigned: boolean; valid: boolean; assignedAt: string | null }[];
+};
+
+/** Admin view: purchased/assigned seats + all users with a field role and their seat state. */
+export async function getSeatOverview(ctx: ServiceCtx): Promise {
+ assertCan(ctx, "tenant:manage");
+ const plan = await getTenantPlan(ctx.tenantId);
+ const [eligible, rows, valid] = await Promise.all([
+ listEligibleUsers(ctx),
+ ctx.db.lotseChatSeat.findMany({ select: { userId: true, createdAt: true } }),
+ validSeatUserIds(ctx, plan.lotseChatSeats),
+ ]);
+ const seatOf = new Map(rows.map((r) => [r.userId, r.createdAt]));
+ const users = eligible.map((u) => ({ ...u, assigned: seatOf.has(u.id), valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null }));
+ // seats of users who lost the field role / were deactivated still occupy a seat → listed too
+ const eligibleIds = new Set(eligible.map((u) => u.id));
+ const orphanIds = rows.map((r) => r.userId).filter((id) => !eligibleIds.has(id));
+ if (orphanIds.length) {
+ const orphans = await ctx.db.user.findMany({ where: { id: { in: orphanIds } }, select: { id: true, name: true, email: true } });
+ for (const u of orphans) users.push({ ...u, assigned: true, valid: valid.has(u.id), assignedAt: seatOf.get(u.id)?.toISOString() ?? null });
+ }
+ return { purchased: plan.lotseChatSeats, assigned: rows.length, overbooked: rows.length > plan.lotseChatSeats, users };
+}
+
+export const seatAssignmentSchema = z.object({ userId: z.string().trim().min(1).max(64), assigned: z.boolean() });
+export type SeatAssignmentInput = z.input;
+
+/**
+ * Assign or revoke a seat (`tenant:manage`). Assigning: the user must be an active member with a field
+ * role (else `invalid not_eligible`) and a purchased seat must be free (else `invalid no_seats_left`).
+ * Serialised per tenant by a row lock on the tenant settings (parallel admins cannot exceed the number).
+ * Idempotent: assigning an assigned / revoking a free seat changes nothing. Audit before/after.
+ */
+export async function setChatSeat(ctx: ServiceCtx, raw: SeatAssignmentInput): Promise<{ userId: string; assigned: boolean; changed: boolean }> {
+ assertCan(ctx, "tenant:manage");
+ const input = seatAssignmentSchema.parse(raw);
+ const plan = await getTenantPlan(ctx.tenantId);
+ if (input.assigned && plan.tier !== "PROFI") throw new ServiceError("blocked", "not_in_plan", { reason: "not_in_plan" });
+
+ const result = await inTransaction(ctx, async (tx) => {
+ // row lock: serialises seat changes of this tenant until commit
+ await tx.db.tenantSettings.updateMany({ data: { updatedAt: new Date() } });
+ const existing = await tx.db.lotseChatSeat.findFirst({ where: { userId: input.userId }, select: { id: true, userId: true, createdAt: true, assignedById: true } });
+ if (!input.assigned) {
+ if (!existing) return { changed: false, before: null, after: null };
+ await tx.db.lotseChatSeat.delete({ where: { id: existing.id } });
+ return { changed: true, before: { userId: existing.userId, assignedById: existing.assignedById }, after: null };
+ }
+ if (existing) return { changed: false, before: null, after: null };
+ const user = await tx.db.user.findFirst({ where: { AND: [{ id: input.userId }, eligibleUserWhere()] }, select: { id: true } });
+ if (!user) {
+ // unknown id, other tenant, inactive or without field role — never reveal which
+ throw new ServiceError("invalid", "not_eligible", { reason: "not_eligible" });
+ }
+ const assigned = await tx.db.lotseChatSeat.count();
+ if (assigned >= plan.lotseChatSeats) throw new ServiceError("invalid", "no_seats_left", { reason: "no_seats_left", purchased: plan.lotseChatSeats, assigned });
+ const row = await tx.db.lotseChatSeat.create({ data: { tenantId: tx.tenantId, userId: user.id, assignedById: ctx.userId }, select: { id: true, userId: true, assignedById: true } });
+ return { changed: true, before: null, after: { userId: row.userId, assignedById: row.assignedById } };
+ });
+
+ if (result.changed) {
+ await writeAuditLog({
+ tenantId: ctx.tenantId,
+ actorId: ctx.userId,
+ action: input.assigned ? "create" : "delete",
+ entity: "lotse_chat_seat",
+ entityId: input.userId,
+ before: result.before ?? undefined,
+ after: result.after ?? undefined,
+ });
+ }
+ return { userId: input.userId, assigned: input.assigned, changed: result.changed };
+}
diff --git a/src/server/services/lotse/chat/transcribe.ts b/src/server/services/lotse/chat/transcribe.ts
index 3d8f213..b08ebd7 100644
--- a/src/server/services/lotse/chat/transcribe.ts
+++ b/src/server/services/lotse/chat/transcribe.ts
@@ -18,7 +18,7 @@ export type ChatTranscriptionDeps = { provider: TranscriptionProvider | null };
export const defaultChatTranscriptionDeps = (): ChatTranscriptionDeps => ({ provider: getTranscriptionProvider() });
export async function transcribeChatAudio(ctx: ServiceCtx, bytes: Buffer, deps: ChatTranscriptionDeps = defaultChatTranscriptionDeps()): Promise<{ text: string }> {
- await assertLotseChatUsable(ctx);
+ await assertLotseChatUsable(ctx, { send: true }); // L17: dictation only while sending is possible
if (!deps.provider) throw new ServiceError("invalid", "transcription not configured", { reason: "not_configured" });
if (bytes.byteLength === 0 || bytes.byteLength > CHAT_AUDIO_MAX_BYTES) throw new ServiceError("invalid", "audio size", { reason: "invalid" });
const sniffed = sniffMime(bytes);
diff --git a/src/server/services/lotse/chat/usage.ts b/src/server/services/lotse/chat/usage.ts
new file mode 100644
index 0000000..f150586
--- /dev/null
+++ b/src/server/services/lotse/chat/usage.ts
@@ -0,0 +1,144 @@
+import { dayBounds, dayKeyOf } from "@/lib/planning/days";
+import {
+ chatOverage,
+ chatQuota,
+ isQuotaBlocked,
+ monthKeyOfDay,
+ nextMonthKey,
+ overagePacks,
+ previousMonthKey,
+ usageLevel,
+ usagePercent,
+ type TenantTier,
+ type UsageLevel,
+} from "@/lib/plans";
+import { dbForTenant } from "@/server/db";
+import { getTenantPlan } from "@/server/plan";
+import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
+import { tenantTimezone } from "@/server/services/work-orders/_shared";
+import { countAssignedSeats, countEligibleUsers } from "./seats";
+
+/**
+ * L17 Pakete — usage of the Lotse chat: ONE chat = one message of a user to the Lotse
+ * (`LotseMessage.role = user`). Counted per calendar month in the tenant timezone, for the whole tenant
+ * and per user. Quota = counted seats × 150 (trial: eligible users × 150). Overage keeps counting and is
+ * billed by the operator in packs of 100; with the hard limit the chat is blocked at the quota.
+ *
+ * Note: the Lotse retention job deletes chat histories after `AI_GENERATION_RETENTION_DAYS` (default
+ * 180) — keep it above ~62 days so the previous month stays complete for billing.
+ */
+
+const MONTH_KEY_RE = /^\d{4}-(0[1-9]|1[0-2])$/;
+
+export function isMonthKey(v: unknown): v is string {
+ return typeof v === "string" && MONTH_KEY_RE.test(v);
+}
+
+/** [from, to) instants of a calendar month in the timezone (DST-safe via day bounds). */
+export function monthBounds(monthKey: string, timeZone: string): { from: Date; to: Date } {
+ if (!isMonthKey(monthKey)) throw new ServiceError("invalid", "month_invalid");
+ return { from: dayBounds(`${monthKey}-01`, timeZone).start, to: dayBounds(`${nextMonthKey(monthKey)}-01`, timeZone).start };
+}
+
+export function monthKeyOf(now: Date, timeZone: string): string {
+ return monthKeyOfDay(dayKeyOf(now, timeZone));
+}
+
+export type ChatUsage = {
+ monthKey: string;
+ from: string;
+ to: string;
+ tier: TenantTier;
+ isTrial: boolean;
+ seatsPurchased: number;
+ seatsAssigned: number;
+ /** seats that count for the quota (trial: eligible users) */
+ quotaBasis: number;
+ used: number;
+ quota: number;
+ overage: number;
+ overagePacks: number;
+ level: UsageLevel;
+ percent: number;
+ hardLimit: boolean;
+ /** hard limit reached → sending is blocked */
+ blocked: boolean;
+ perUser: { userId: string; name: string; count: number }[];
+};
+
+type UsageCtx = Pick;
+
+async function countChats(ctx: UsageCtx, from: Date, to: Date): Promise {
+ return ctx.db.lotseMessage.count({ where: { role: "user", createdAt: { gte: from, lt: to } } });
+}
+
+async function countChatsPerUser(ctx: UsageCtx, from: Date, to: Date): Promise {
+ const groups = await ctx.db.lotseMessage.groupBy({ by: ["conversationId"], where: { role: "user", createdAt: { gte: from, lt: to } }, _count: { _all: true } });
+ if (!groups.length) return [];
+ const conversations = await ctx.db.lotseConversation.findMany({ where: { id: { in: groups.map((g) => g.conversationId) } }, select: { id: true, userId: true } });
+ const userOf = new Map(conversations.map((c) => [c.id, c.userId]));
+ const perUser = new Map();
+ for (const g of groups) {
+ const userId = userOf.get(g.conversationId);
+ if (userId) perUser.set(userId, (perUser.get(userId) ?? 0) + g._count._all);
+ }
+ const users = await ctx.db.user.findMany({ where: { id: { in: [...perUser.keys()] } }, select: { id: true, name: true } });
+ const nameOf = new Map(users.map((u) => [u.id, u.name]));
+ return [...perUser.entries()].map(([userId, count]) => ({ userId, name: nameOf.get(userId) ?? "—", count })).sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
+}
+
+/**
+ * Usage of one month (default: current month in the tenant timezone). No permission check — callers are
+ * the chat gate (own tenant), the tenant admin view (`getChatUsageForAdmin`) and the operator view.
+ */
+export async function getChatUsage(ctx: UsageCtx, opts: { monthKey?: string; now?: Date; perUser?: boolean } = {}): Promise {
+ const timeZone = await tenantTimezone(ctx as ServiceCtx);
+ const monthKey = opts.monthKey ?? monthKeyOf(opts.now ?? new Date(), timeZone);
+ const { from, to } = monthBounds(monthKey, timeZone);
+ const plan = await getTenantPlan(ctx.tenantId);
+ const [used, seats, eligible, perUser] = await Promise.all([
+ countChats(ctx, from, to),
+ countAssignedSeats(ctx, plan.lotseChatSeats),
+ plan.isTrial ? countEligibleUsers(ctx) : Promise.resolve(0),
+ opts.perUser === false ? Promise.resolve([]) : countChatsPerUser(ctx, from, to),
+ ]);
+ const quotaBasis = plan.isTrial ? eligible : seats.counted;
+ const quota = chatQuota(quotaBasis);
+ const overage = chatOverage(used, quota);
+ // the hard limit does not apply to a trial (no purchased seats; the token budget stays the safety net)
+ const hardLimit = plan.lotseChatHardLimit && !plan.isTrial;
+ return {
+ monthKey,
+ from: from.toISOString(),
+ to: to.toISOString(),
+ tier: plan.tier,
+ isTrial: plan.isTrial,
+ seatsPurchased: plan.lotseChatSeats,
+ seatsAssigned: seats.assigned,
+ quotaBasis,
+ used,
+ quota,
+ overage,
+ overagePacks: overagePacks(overage),
+ level: usageLevel(used, quota),
+ percent: usagePercent(used, quota),
+ hardLimit,
+ blocked: isQuotaBlocked(used, quota, hardLimit),
+ perUser,
+ };
+}
+
+/** Tenant admin (`tenant:manage`): current month with per-user breakdown. */
+export async function getChatUsageForAdmin(ctx: ServiceCtx, opts: { now?: Date } = {}): Promise {
+ assertCan(ctx, "tenant:manage");
+ return getChatUsage(ctx, { now: opts.now, perUser: true });
+}
+
+/** Operator view (platform admin, see services/plans/platform.ts): current and previous month. */
+export async function getChatUsageForOperator(tenantId: string, now: Date = new Date()): Promise<{ current: ChatUsage; previous: ChatUsage }> {
+ const ctx: UsageCtx = { db: dbForTenant(tenantId), tenantId };
+ const current = await getChatUsage(ctx, { now, perUser: false });
+ const previous = await getChatUsage(ctx, { monthKey: previousMonthKey(current.monthKey), perUser: false });
+ return { current, previous };
+}
+
diff --git a/src/server/services/lotse/settings.ts b/src/server/services/lotse/settings.ts
index 2e8980e..2654344 100644
--- a/src/server/services/lotse/settings.ts
+++ b/src/server/services/lotse/settings.ts
@@ -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): Promise {
+ 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): Promise {
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):
return !row || row.enabled;
}
+async function isModuleInPlan(tenantId: string, moduleKey: string): Promise {
+ 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 {
if (!(await isLotseEnabled(ctx))) throw new ServiceError("forbidden", "lotse disabled for tenant", { reason: "disabled" });
@@ -40,7 +53,7 @@ export async function lotseVoice(ctx: Pick): 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,
diff --git a/src/server/services/planning/access.ts b/src/server/services/planning/access.ts
index 75b2cf8..cb990c3 100644
--- a/src/server/services/planning/access.ts
+++ b/src/server/services/planning/access.ts
@@ -1,10 +1,13 @@
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
+import { assertPlanFeature } from "@/server/plan";
/**
* Who may use the planning views (L13):
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
* - everyone else (Monteur): forbidden.
+ * L17 Pakete: planning is a PROFI feature — Basis → `forbidden` (reason `not_in_plan`) for board, live
+ * situation, recommendations, freed capacity, dashboard tiles and the planning API.
*/
export type PlanningAccess = {
all: boolean;
@@ -15,6 +18,7 @@ export type PlanningAccess = {
};
export async function planningAccess(ctx: ServiceCtx): Promise {
+ await assertPlanFeature(ctx.tenantId, "planning");
if (can(ctx, "work_order:read_all")) {
return {
all: true,
diff --git a/src/server/services/planning/schedule.ts b/src/server/services/planning/schedule.ts
index 998d21d..4a3ae46 100644
--- a/src/server/services/planning/schedule.ts
+++ b/src/server/services/planning/schedule.ts
@@ -3,6 +3,7 @@ import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/pl
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
+import { assertPlanFeature } from "@/server/plan";
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
@@ -44,6 +45,7 @@ export type ScheduleResult = {
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise {
assertCan(ctx, "work_order:assign");
assertCan(ctx, "work_order:write");
+ await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete
const input = parseInput(scheduleSchema, raw);
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
diff --git a/src/server/services/planning/team-settings.ts b/src/server/services/planning/team-settings.ts
index 4d9a35f..5bd5802 100644
--- a/src/server/services/planning/team-settings.ts
+++ b/src/server/services/planning/team-settings.ts
@@ -1,6 +1,7 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
+import { assertPlanFeature } from "@/server/plan";
import { parseInput } from "@/server/services/work-orders/_shared";
/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */
@@ -13,6 +14,7 @@ export type TeamPlanningSettingsInput = z.input;
export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) {
assertCan(ctx, "team:manage");
+ await assertPlanFeature(ctx.tenantId, "planning"); // L17 Pakete: Kolonnenkapazität gehört zur Planung
const input = parseInput(settingsSchema, raw);
const before = await ctx.db.team.findFirst({
where: { id: teamId, deletedAt: null },
diff --git a/src/server/services/planning/watch.ts b/src/server/services/planning/watch.ts
index 9221c38..92dcc29 100644
--- a/src/server/services/planning/watch.ts
+++ b/src/server/services/planning/watch.ts
@@ -15,6 +15,7 @@ import { emitEvent } from "@/server/events";
import { PERMISSIONS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
+import { isPlanFeatureAvailable } from "@/server/plan";
import {
loadPlannedOrders,
loadTeams,
@@ -339,6 +340,8 @@ export async function runPlanningWatch(
): Promise {
const ctx = watchContext(tenantId);
const now = opts.now ?? new Date();
+ // L17 Pakete: Basis enthält keine Planung → keine Verzugs-/Frühfertig-Meldungen
+ if (!(await isPlanFeatureAvailable(tenantId, "planning"))) return { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
const emit = opts.emit ?? emitEvent;
const ledger = opts.ledger ?? auditAlertLedger;
const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
diff --git a/src/server/services/plans/platform.ts b/src/server/services/plans/platform.ts
new file mode 100644
index 0000000..6c32ec6
--- /dev/null
+++ b/src/server/services/plans/platform.ts
@@ -0,0 +1,66 @@
+import { z } from "zod";
+import { dbForTenant, prisma } from "@/server/db";
+import { writeAuditLog } from "@/server/audit";
+import { ServiceError } from "@/server/services/context";
+import { TENANT_TIERS } from "@/lib/plans";
+import { getChatUsageForOperator, type ChatUsage } from "@/server/services/lotse/chat/usage";
+import { countAssignedSeats } from "@/server/services/lotse/chat/seats";
+import { toPlanInfo, PLAN_SELECT, type TenantPlanInfo } from "@/server/plan";
+
+/**
+ * L17 Pakete — operator (platform full admin) operations: package tier, number of purchased Lotse chat
+ * seats and the hard limit. Tenant administrators can NOT change these (they have no PlatformAdmin row →
+ * `forbidden`); the adapter additionally requires the platform session (src/server/actions/plans-platform.ts).
+ * Audit: scope platform, attached to the tenant (before/after), same pattern as the L15 trial actions.
+ * Switching PROFI → BASIS keeps all data (seats, billing records, chats); only access ends.
+ */
+
+export type PlatformActor = { platformAdminId: string };
+
+async function assertPlatformFullAdmin(actor: PlatformActor): Promise {
+ const admin = actor.platformAdminId
+ ? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true, role: true } })
+ : null;
+ if (!admin || admin.status !== "ACTIVE" || admin.role !== "full") throw new ServiceError("forbidden", "platform_admin_required");
+}
+
+export const MAX_LOTSE_CHAT_SEATS = 1000;
+
+export const tenantPlanSchema = z.object({
+ tier: z.enum(TENANT_TIERS),
+ lotseChatSeats: z.coerce.number().int().min(0).max(MAX_LOTSE_CHAT_SEATS),
+ lotseChatHardLimit: z.boolean(),
+});
+export type TenantPlanInput = z.input;
+
+export async function updateTenantPlan(actor: PlatformActor, tenantId: string, raw: TenantPlanInput): Promise {
+ await assertPlatformFullAdmin(actor);
+ const parsed = tenantPlanSchema.safeParse(raw);
+ if (!parsed.success) throw new ServiceError("invalid", "invalid_input", parsed.error.issues.map((i) => ({ path: i.path.join("."), code: i.code })));
+ const input = parsed.data;
+ const before = await prisma.tenant.findUnique({ where: { id: tenantId }, select: { id: true, status: true, ...PLAN_SELECT } });
+ if (!before) throw new ServiceError("not_found", "tenant not found");
+ if (before.status === "ARCHIVED") throw new ServiceError("invalid", "tenant_archived");
+ const after = await prisma.tenant.update({ where: { id: tenantId }, data: input, select: PLAN_SELECT });
+ const snap = (r: { tier: string; lotseChatSeats: number; lotseChatHardLimit: boolean }) => ({ tier: r.tier, lotseChatSeats: r.lotseChatSeats, lotseChatHardLimit: r.lotseChatHardLimit });
+ await writeAuditLog({ tenantId, scope: "platform", actorId: actor.platformAdminId, action: "update", entity: "tenant_plan", entityId: tenantId, before: snap(before), after: snap(after) });
+ return toPlanInfo(after);
+}
+
+export type TenantPlanOverview = {
+ plan: TenantPlanInfo;
+ seatsAssigned: number;
+ current: ChatUsage;
+ previous: ChatUsage;
+};
+
+/** Operator view for the tenant detail page: plan, seats assigned/purchased, chats of the current and previous month. */
+export async function getTenantPlanOverview(actor: PlatformActor, tenantId: string, now: Date = new Date()): Promise {
+ const admin = actor.platformAdminId ? await prisma.platformAdmin.findUnique({ where: { id: actor.platformAdminId }, select: { status: true } }) : null;
+ if (!admin || admin.status !== "ACTIVE") throw new ServiceError("forbidden", "platform_admin_required");
+ const row = await prisma.tenant.findUnique({ where: { id: tenantId }, select: PLAN_SELECT });
+ if (!row) throw new ServiceError("not_found", "tenant not found");
+ const plan = toPlanInfo(row);
+ const [{ current, previous }, seats] = await Promise.all([getChatUsageForOperator(tenantId, now), countAssignedSeats({ db: dbForTenant(tenantId), tenantId }, plan.lotseChatSeats)]);
+ return { plan, seatsAssigned: seats.assigned, current, previous };
+}
diff --git a/src/server/services/sync/apply.ts b/src/server/services/sync/apply.ts
index 0f122a4..55ff958 100644
--- a/src/server/services/sync/apply.ts
+++ b/src/server/services/sync/apply.ts
@@ -14,6 +14,7 @@ import { attachPhoto } from "@/server/services/field/photos";
import { attachVoiceNote } from "@/server/services/field/voice";
// TODO(merge L2): replace with "@/server/services/work-orders/transition"
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
+import { moduleState, PROFI_ONLY_MESSAGE } from "@/server/plan";
import { EXTERNAL_OP_OWNERS, EXTERNAL_OPS } from "./external-ops";
/**
@@ -78,6 +79,37 @@ function errorMessage(err: ServiceError): string {
class NotAvailable extends Error {}
+/**
+ * L17 Pakete: sync ops that belong to a module other than `field` (the sync route itself is gated by
+ * `field`). One place for all offline ops — the effective module state comes from src/server/plan.ts.
+ */
+export const OP_MODULES: Partial> = {
+ "emergency.create": "emergency",
+ "milestone.reach": "billing",
+};
+
+const LOCKED_MODULE_MESSAGE: Record = {
+ emergency: {
+ not_in_plan: "Notdienst ist im Paket Profi enthalten und für diesen Betrieb nicht freigeschaltet – der Notdiensteinsatz wurde nicht übernommen. Bitte im Büro melden.",
+ disabled: "Notdienst ist für diesen Betrieb ausgeschaltet – der Notdiensteinsatz wurde nicht übernommen. Bitte im Büro melden.",
+ },
+ billing: {
+ not_in_plan: "Meilensteine gehören zur Abrechnungsübersicht (Paket Profi) – die Meldung wurde nicht übernommen.",
+ disabled: "Die Abrechnungsübersicht ist für diesen Betrieb ausgeschaltet – die Meldung wurde nicht übernommen.",
+ },
+};
+
+async function lockedModuleMessage(ctx: ServiceCtx, opType: SyncOpType): Promise {
+ const moduleKey = OP_MODULES[opType];
+ if (!moduleKey) return null;
+ const state = await moduleState(ctx.tenantId, moduleKey);
+ if (state === "enabled") return null;
+ // `: ` like `other_session_running:…` — the device maps the code to its own texts
+ // (lib/offline/outbox-core.ts#problemKey), other API clients show the plain text.
+ const text = LOCKED_MODULE_MESSAGE[moduleKey]?.[state] ?? (state === "not_in_plan" ? PROFI_ONLY_MESSAGE : "Modul ist für diesen Betrieb ausgeschaltet.");
+ return `${state === "not_in_plan" ? "not_in_plan" : "module_disabled"}: ${text}`;
+}
+
/** Route a validated op to the field handler or the registered module of another lane. */
async function dispatch(ctx: ServiceCtx, payload: unknown, op: SyncOperationInput): Promise {
const handler = FIELD_HANDLERS[op.opType];
@@ -188,6 +220,14 @@ async function applyOne(ctx: ServiceCtx, deviceId: string, op: SyncOperationInpu
return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "invalid", message };
}
+ // 2b. L17 Pakete: ops of a module that is switched off or not part of the package tier → rejected
+ // with plain text (stored, visible in the device's sync list — never silently dropped)
+ const locked = await lockedModuleMessage(ctx, op.opType);
+ if (locked) {
+ const rec = await record(ctx, op, deviceId, "rejected", { message: locked }, "forbidden");
+ return rec === "duplicate" ? { ...base, status: "duplicate" } : { ...base, status: "rejected", errorCode: "forbidden", message: locked };
+ }
+
try {
// 3. conflict check
if (CONFLICTING_OPS.includes(op.opType)) {
diff --git a/src/server/services/work-orders/transition.ts b/src/server/services/work-orders/transition.ts
index 22e664c..23cb903 100644
--- a/src/server/services/work-orders/transition.ts
+++ b/src/server/services/work-orders/transition.ts
@@ -12,6 +12,7 @@ import {
type WorkOrderBase,
} from "@/server/services/work-orders/_shared";
import { transitionBlockers } from "@/server/services/work-orders/completion";
+import { isModuleActive } from "@/server/plan";
/** Permission decision for a single transition (scope is checked separately by loading the order). */
export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean {
@@ -116,8 +117,7 @@ export async function applyTransition(
return { id: wo.id, status: to, version, from };
}
-/** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule). */
+/** L14: billing overview active for the tenant (missing TenantModule row = enabled, like requireModule; L17: and part of the package tier). */
export async function billingModuleActive(ctx: ServiceCtx): Promise {
- const row = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "billing" }, select: { enabled: true } });
- return !row || row.enabled;
+ return isModuleActive(ctx.tenantId, "billing");
}