diff --git a/docs/craftvia/API.md b/docs/craftvia/API.md
index 48ee815..9576f7c 100644
--- a/docs/craftvia/API.md
+++ b/docs/craftvia/API.md
@@ -20,7 +20,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
| Code | HTTP | Bedeutung / `details` |
|---|---|---|
| `unauthorized` | 401 | nicht angemeldet, Konto inaktiv, Sitzung invalidiert |
-| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request |
+| `forbidden` | 403 | Recht fehlt, Modul deaktiviert, Passwortwechsel nötig, Cross-Site-Request; Modul/Funktion nicht im Paket (L17: `details = { reason: "not_in_plan", module?, message }`) |
| `not_found` | 404 | unbekannt, fremder Mandant oder außerhalb des Scopes |
| `conflict` | 409 | Versionskonflikt (`baseVersion`), Doppelbestätigung/unzulässiger Zustand, mögliche Dubletten (`details.reason = "possible_duplicates"`, `details.candidates`) |
| `invalid` | 422 | Validierung (Zod: `details = [{ path, code }]`), fehlerhaftes JSON/Multipart |
@@ -40,6 +40,7 @@ Alle Routen antworten im Fehlerfall mit `Cache-Control: no-store` und
- **Idempotenz:** Eine wiederholte `clientOpId` (je Mandant) liefert `duplicate` mit dem gespeicherten Ergebnis. Ist die ID bereits durch einen anderen Nutzer belegt, wird die Operation `rejected`.
- **Konflikte:** `work_order.transition` und `report.submit` verlangen `baseVersion`. Weicht sie von `WorkOrder.version` ab, lautet das Ergebnis `conflict`, `entityVersion` ist dann die aktuelle Version. Alle anderen Operationen sind additiv (Client-IDs in den Payloads, z. B. `clientId`, werden über `idMap` auf Server-IDs abgebildet).
- Den opType-Katalog mit den Payload-Schemas enthält `src/lib/sync/ops.ts` (Spec: Komponenten `SyncPayload*`).
+- L17 Pakete: Operationen eines Moduls außerhalb des Pakets bzw. eines ausgeschalteten Moduls (`emergency.create` → Notdienst, `milestone.reach` → Abrechnung) werden gespeichert als `rejected` mit `errorCode = "forbidden"` und `message = "not_in_plan: "` bzw. `"module_disabled: "`.
- REST-Schreibrouten für Aufträge (`PATCH /work-orders/{id}`, `/assign`, `/transition`) akzeptieren optional `baseVersion` und antworten bei Abweichung mit `409`.
## Uploads
diff --git a/docs/craftvia/DEPLOY.md b/docs/craftvia/DEPLOY.md
index 86cb870..9b5f73c 100644
--- a/docs/craftvia/DEPLOY.md
+++ b/docs/craftvia/DEPLOY.md
@@ -236,6 +236,43 @@ verarbeitet `tenant-export` (Datenexport). Details, Schreibsperre und manueller
[TESTPHASE.md](TESTPHASE.md). Vor dem Go-live die Platzhaltertexte für Nutzungsbedingungen und
Datenschutz (`messages/*/trial.json` → `legal.*`) ersetzen.
+### 7.5 Pakete Basis/Profi und Lotse-Chat-Plätze (L17)
+
+Die App rechnet nicht ab – sie setzt das Paket durch, verwaltet Chat-Plätze und zählt die Nutzung,
+damit der Betreiber außerhalb der App die Rechnung stellt. Regeln: `src/lib/plans.ts`, Durchsetzung:
+`src/server/plan.ts`.
+
+| Paket | Inhalt |
+|---|---|
+| **Basis** | Aufträge, Kunden, Objekte, Teams, mobile App/Offline, Berichte + Unterschrift + PDF-Versand, Zeiterfassung inkl. Freigabe, Dokumente, Benachrichtigungen – **ohne KI und Planung** |
+| **Profi** (Default) | zusätzlich Planung (Plantafel, Live-Lage, Kapazität, Empfehlungen, Verzugs-/Frühfertig-Hinweise), Abrechnungsübersicht, Notdienst, Auftragsimport, Lotse (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung) und – je Platz – der Lotse-Chat |
+
+**Paket, Plätze, hartes Limit setzen** (nur Plattform-Voll-Admins): Betreiber-Portal → Mandant
+öffnen → Karte „Paket & Lotse-Chat“ → „Paket ändern …“ → Paket, Anzahl gebuchter Chat-Plätze
+(0–1000) und „Hartes Limit“ wählen, Bestätigung ankreuzen, speichern. Jede Änderung steht im
+Audit-Trail des Mandanten (`tenant_plan`, Scope Plattform, vorher/nachher). Die Mandantenliste zeigt
+das Paket als Badge. Mandanten-Admins sehen das Paket nur lesend und vergeben unter
+`/settings/lotse` die gebuchten Plätze an Nutzer mit Feldrolle (`lotse:use` + `field:execute`);
+mehr als gebucht ist serverseitig ausgeschlossen. Senkt der Betreiber die Anzahl unter die vergebenen
+Plätze, gelten nur die zuerst vergebenen; der Admin sieht „Über der Platzanzahl“ und entzieht Plätze.
+
+**Wechsel Profi → Basis:** Daten bleiben vollständig erhalten (Einsätze, Abrechnungseinträge, Plätze,
+Chatverläufe), nur der Zugriff endet: Seiten leiten auf das Dashboard mit dem Hinweis „Im Paket Profi
+enthalten“, Actions/API antworten `403` (`details.reason = "not_in_plan"`), Offline-Operationen eines
+gesperrten Moduls (Notdienst, Meilensteine) werden mit Klartext abgewiesen. Zurück auf Profi ist alles
+wieder da. **Testphase** (`plan = TRIAL`) läuft immer als Profi ohne Platzprüfung.
+
+**Kontingent und Mehrverbrauch ablesen:** Ein Chat = eine Nachricht eines Nutzers an den Lotsen. Je
+vergebenem Platz 150 Chats pro Kalendermonat (Zeitzone des Mandanten), gemeinsam für den Betrieb
+(Testphase: berechtigte Nutzer × 150). Darüber wird weitergezählt; abgerechnet wird der Mehrverbrauch in
+Paketen à 100 (angefangene Pakete zählen). Die Karte im Betreiber-Portal zeigt für den **laufenden
+Monat und den Vormonat** „x von y Chats“, den Mehrverbrauch und die Anzahl der 100er-Pakete – das ist
+die Grundlage der Rechnung (Vormonat nach Monatsende ablesen). Mit „Hartes Limit“ wird der Chat beim
+Kontingent gesperrt (Monteure sehen einen Hinweis statt des Eingabefelds, kein Mehrverbrauch). Die
+Token-Obergrenze (`AI_MONTHLY_TOKEN_LIMIT`, `services/lotse/budget.ts`) bleibt als Sicherheitsnetz
+unabhängig davon aktiv. Hinweis: Chatverläufe werden nach `AI_GENERATION_RETENTION_DAYS` gelöscht –
+den Wert über ~62 Tagen lassen, sonst fehlen Vormonats-Chats in der Zählung.
+
## 8. Rate Limits
| Bereich | Env | Default | Zählung |
diff --git a/docs/craftvia/lanes/pakete.md b/docs/craftvia/lanes/pakete.md
new file mode 100644
index 0000000..04190a1
--- /dev/null
+++ b/docs/craftvia/lanes/pakete.md
@@ -0,0 +1,102 @@
+# Lane L17 – Pakete (Basis/Profi) & Lotse-Chat pro Nutzer (`lane/pakete`)
+
+Stand: 2026-09-21 · Basis `4d5be72` (`feature/craftvia-mvp`) · Betriebsdoku: [../DEPLOY.md §7.5](../DEPLOY.md)
+
+Ziel: Zwei Pakete durchsetzen und den Lotse-Chat als Zusatzmodul pro Nutzer mit gemeinsamem Kontingent führen. **Keine Abrechnung in der App** – die App setzt Pakete durch, verwaltet Plätze und zählt Nutzung; der Betreiber stellt die Rechnung außerhalb.
+
+## 1. Umfang / umgesetzte Entscheidungen
+
+| Punkt | Umsetzung |
+|---|---|
+| **1 Datenmodell** | Migration `20260921100000_pakete` (additiv): `enum TenantTier { BASIS PROFI }`, am `Tenant` `tier` (Default `PROFI` → alle Bestandsmandanten und Testphasen unverändert), `lotseChatSeats Int @default(0)`, `lotseChatHardLimit Boolean @default(false)` (Plattform-Daten, keine RLS). Neue Mandanten-Tabelle `lotse_chat_seats` (`LotseChatSeat`: tenantId, userId, assignedById, createdAt; unique tenantId+userId) mit `enable_tenant_rls`, in beiden `TENANT_MODELS`, `userId`/`assignedById` in `pii-fields.ts`. Index `lotse_messages(tenant_id, role, created_at)` für die Monatszählung. |
+| **2 Zentrale Regeln** `src/lib/plans.ts` (client-sicher, rein) | Stufen, `PROFI_ONLY_MODULES = billing, emergency, imports, lotse`, Features ohne eigenes Modul `planning`, `lotse_chat`; `effectiveTier` (Testphase → Profi), `isModuleInTier`, `isFeatureInTier`, `LOTSE_CHAT_CHATS_PER_SEAT = 150`, `LOTSE_CHAT_OVERAGE_PACK = 100`, `chatQuota`, `chatOverage`, `overagePacks` (angefangene Pakete zählen), `usageLevel` (ok / warn ab 80 % / exhausted / over), `usagePercent`, `isQuotaBlocked`, Monatsschlüssel. |
+| **3 Durchsetzung an einer Stelle** `src/server/plan.ts` | `moduleState(tenantId, key)` = `not_in_plan` (Stufe) → `disabled` (TenantModule) → `enabled`; `effectiveModules` (Navigation/Übersichten), `isModuleActive`, `assertPlanFeature`/`requirePlanFeature` (Planung). Genutzt von **`requireModule`** (Layouts: nicht im Paket → `/dashboard?module=profi` mit ruhigem Hinweis „Im Paket Profi enthalten“), **`assertModuleEnabled`** (moduleGuard + `requireApiContext`; `ModuleDisabledError.reason = "not_in_plan"`, Audit `denied`, API → `403` mit `details.reason = "not_in_plan"` + Klartext), **Sync** (`services/sync/apply.ts#OP_MODULES`: `emergency.create` → emergency, `milestone.reach` → billing; gesperrt → gespeichert `rejected`/`forbidden` mit `not_in_plan: ` bzw. `module_disabled: …`, Gerät zeigt eigenen Text `offline.problem.notInPlan|moduleDisabled`), **Navigation** (Backoffice-Sidebar über `effectiveModules` + `NavItem.feature`, mobile Bottom-Navigation ohne „Notdienst“/„Lotse“), **`isLotseEnabled`** (Berichtsentwurf, Vollständigkeitsprüfung, Sprachnotiz-Zusammenfassung, Transkription), **Planung** (`planningAccess` → Plantafel, Live-Lage, Kapazität, Empfehlungen, Frühfertig, Dashboard-Kacheln; zusätzlich `scheduleWorkOrder`, `updateTeamPlanningSettings`, `runPlanningWatch` überspringt Basis; Planungs-Layout `requirePlanFeature`). Abrechnungs-Schalter (`billingModuleActive`, Auftragsdetail) laufen ebenfalls über `plan.ts` – Basis verhält sich wie „Abrechnungsübersicht deaktiviert“ (direktes „abgerechnet“, Tab ausgeblendet). |
+| **4 Lotse-Chat-Plätze** | `services/lotse/chat/access.ts` ist die eine Prüfstelle: Rechte → Stufe (`blocked not_in_plan`) → Lotse an (`disabled`) → Chat-Schalter (`chat_disabled`) → Platz (`blocked no_seat`, Testphase ohne Platzprüfung) → beim **Senden** Kontingent mit hartem Limit (`blocked quota_exhausted`). `canUseLotseChat` (Navigation, „Lotse fragen“) schließt den Platz ein. `seats.ts`: berechtigt = aktive Nutzer mit `lotse:use` + `field:execute` (DB-Rollen), Vergabe/Entzug `setChatSeat` (tenant:manage, Zod, Audit `lotse_chat_seat` before/after, idempotent, serialisiert über eine Zeilensperre der Mandanten-Einstellungen → parallele Vergaben überschreiten die Grenze nicht). Gültig sind die ältesten `lotseChatSeats` Zuweisungen (Überbelegung nach Senkung durch den Betreiber sichtbar). `usage.ts`: Chats im Monat gesamt und je Nutzer (Kalendermonat in der Mandanten-Zeitzone, DST-sicher), Kontingent = gezählte Plätze × 150 (Testphase: Berechtigte × 150, kein hartes Limit), Mehrverbrauch, 100er-Pakete, Stufe, Sperre. Die Token-Obergrenze (`budget.ts`) bleibt unverändert als Sicherheitsnetz. |
+| **5 Mandanten-Admin** `/settings/lotse` | Neue Karte „Paket & Lotse-Chat“: Paket nur lesend (Badge, Testphase-Hinweis), „x von y Plätzen vergeben“, Liste aller Nutzer mit Feldrolle mit Schalter je Nutzer (Button `role="switch"`, ≥ 44 px, Status als Text + Icon, deaktiviert ohne freien Platz – serverseitig ohnehin geprüft), Monatsverbrauch als Balken (`role="meter"`) mit Text „212 von 450 Chats“, Hinweis ab 80 %, bei ausgeschöpftem Kontingent / Mehrverbrauch (inkl. Paketzahl) / hartem Limit, Aufschlüsselung je Nutzer. Basis: Hinweis, Plätze bleiben gespeichert. Action `actions/lotse/seats.ts` (`moduleGuard("lotse")` + `guard("tenant:manage")`). |
+| **6 Mobil** | Ohne Platz kein Eintrag „Lotse“ in der Bottom-Navigation und kein „Lotse fragen“; `/m/lotse` zeigt „Der Lotse-Chat ist für dich noch nicht freigeschaltet – frag im Büro nach.“ Basis: Hinweis statt Weiterleitung (Lotse-Layout). Hartes Limit erreicht: Hinweis statt Eingabefeld (Verlauf bleibt lesbar, Chips deaktiviert); auch nach einer abgewiesenen Nachricht. |
+| **7 Betreiber** `/admin/[id]` | Karte „Paket & Lotse-Chat“: Stufe, Plätze vergeben/gebucht, hartes Limit, Chats laufender Monat und Vormonat („x von y Chats“), Mehrverbrauch → 100er-Pakete (Grundlage der Rechnung). „Paket ändern …“ (nur Voll-Admins) → Popup mit Stufe, Platzanzahl (0–1000), hartem Limit und Pflicht-Bestätigung; `services/plans/platform.ts#updateTenantPlan` prüft den Akteur gegen den PlatformAdmin-Store, Zod, Plattform-Audit `tenant_plan` (before/after). Mandantenliste: Spalte „Paket“ als Badge; Modul-Popup markiert Module außerhalb der Stufe („Nur im Paket Profi“). |
+| **8 Demo-Seed** | `demo` = Profi mit 3 Plätzen für Max, Nora, Paul (Monteure; Chat funktioniert weiter), `demo2` = Basis (zeigt die Sperren). Idempotent (`seedDemoPlan`). |
+| **9 i18n/Doku** | Namespace `plans` (de/en), Ergänzungen in `lotse.json` (`chat.errors.not_in_plan/no_seat/quota_exhausted`, `chat.quotaExhausted`) und `offline.json` (`problem.notInPlan/moduleDisabled`); `DEPLOY.md §7.5` (wie der Betreiber Pakete/Plätze setzt, wie Mehrverbrauch abgelesen wird), `API.md` (403-Details, Sync-Abweisung). |
+
+### Entscheidungen
+
+- **Admin als Platzinhaber:** Berechtigung = Rechte `lotse:use` + `field:execute` (nicht der Rollenname). Der Mandantenadministrator hat alle Rechte und kann sich daher selbst einen Platz geben; Backoffice nicht.
+- **Kontingent „je vergebenem Platz“:** zählt vergebene Plätze, höchstens die gebuchte Anzahl.
+- **Sync-Abweisung ist endgültig** (gespeichert, wie andere deterministische Ablehnungen). Nach einem Upgrade muss der Monteur den Notdienst erneut erfassen.
+- **Hartes Limit gilt nicht in der Testphase** (keine gebuchten Plätze; Token-Budget bleibt).
+- **Basis sperrt auch die Platzvergabe** (moduleGuard `lotse`), Plätze bleiben gespeichert.
+
+## 2. Dateien
+
+**Neu**
+- `prisma/migrations/20260921100000_pakete/migration.sql`
+- `src/lib/plans.ts`, `src/server/plan.ts`
+- `src/server/services/lotse/chat/{seats,usage}.ts`, `src/server/services/plans/platform.ts`
+- `src/server/actions/lotse/seats.ts`, `src/server/actions/plans-platform.ts`
+- `src/components/plans/{profi-hint,plan-admin-card,plan-form}.tsx`, `src/components/lotse/chat/{seat-admin,unavailable}.tsx`
+- `messages/{de,en}/plans.json`, dieser Bericht
+- Tests: `scripts/test-pakete-{rules,gates,seats}.ts`
+
+**Eingriffe in Fundament-/Fremddateien (klein, markiert „L17“)**
+
+| Datei | Eingriff | Grund |
+|---|---|---|
+| `prisma/schema.prisma` | 3 Tenant-Felder, Enum, Modell `LotseChatSeat`, Index an `LotseMessage` | Punkt 1 |
+| `src/server/db.ts`, `src/server/backup/topology.ts`, `src/server/dsgvo/pii-fields.ts` | `LotseChatSeat` | Pflicht neue Tenant-Tabelle |
+| `src/server/modules.ts` | `requireModule`/`assertModuleEnabled` über `moduleState`; `ModuleDisabledError.reason` | zentrale Durchsetzung |
+| `src/server/api/respond.ts` | 403 mit `details.reason = not_in_plan` | Klartext für Clients |
+| `src/lib/nav.ts`, `src/app/(app)/layout.tsx` | `NavItem.feature`, `lockedFeatures`; effektive Module | Navigation |
+| `src/app/(app)/dashboard/page.tsx` (L2) | Hinweis `?module=profi`, Kacheln Abrechnung/Notdienst nur bei aktivem Modul | ruhiger Hinweis |
+| `src/app/(app)/settings/page.tsx` | Modulübersicht über effektive Module | Anzeige |
+| `src/app/(app)/work-orders/[id]/page.tsx`, `src/server/services/work-orders/transition.ts` (L2/L14) | `billingModuleActive` über `plan.ts`, Tab „Meilensteine & Abrechnung“ nur bei aktiver Abrechnung | Basis = Abrechnung aus |
+| `src/server/services/planning/{access,schedule,team-settings,watch}.ts`, `src/app/(app)/planning/layout.tsx` (L13) | Feature-Prüfung `planning` | Planung nur Profi |
+| `src/server/services/sync/apply.ts` (L4), `src/lib/offline/outbox-core.ts` (L7), `messages/{de,en}/offline.json` | `OP_MODULES` + Klartext; `problemKey` | Sync-Sperre |
+| `src/app/(field)/m/layout.tsx`, `src/components/field/bottom-nav.tsx` (L4) | Notdienst-Eintrag nur bei aktivem Modul, Spaltenzahl dynamisch | mobile Navigation |
+| `src/server/services/lotse/{settings.ts,chat/access.ts,chat/engine.ts,chat/transcribe.ts}`, `src/lib/lotse/chat.ts`, `src/server/actions/lotse/_chat-state.ts`, `src/components/lotse/chat/lotse-chat.tsx`, `src/app/(field)/m/(core)/lotse/{layout,page}.tsx`, `src/app/(app)/settings/lotse/page.tsx`, `messages/{de,en}/lotse.json` (L9/L16) | Stufe in `isLotseEnabled`, Platz/Kontingent im Chat-Gate, Hinweise, Admin-Karte | Punkte 4–6 |
+| `src/app/(platform)/admin/page.tsx`, `admin/[id]/page.tsx` | Badge-Spalte, Karte, Modul-Popup-Markierung | Punkt 7 |
+| `src/components/audit-trail.tsx` | Labels `lotse_chat_seat`, `tenant_plan` | Audit-Viewer |
+| `scripts/check-module-guards.ts` | `plans-platform.ts` EXEMPT | Top-Level-Action |
+| `scripts/lib/demo-seed.ts` | `seedDemoPlan`, demo Profi + 3 Plätze, demo2 Basis | Punkt 8 |
+| `scripts/lib/e2e-fixture.ts`, `scripts/test-e2e-tenant-isolation.ts` | Cleanup + Fixture-Zeile `LotseChatSeat` | Test verlangt alle Tenant-Modelle |
+| `scripts/lib/lotse-chat-fixture.ts` (L16) | 10 Plätze + Sitz je Testnutzer | bestehende Chat-Tests brauchen jetzt einen Platz |
+| `scripts/smoke-auth.ts` | L17-Prüfungen (Admin-Karte, Teamleiterin ohne Platz, Monteur mit Platz, demo2 Basis) | Smoke |
+| `docs/craftvia/{DEPLOY,API}.md` | §7.5, 403/Sync | Betrieb |
+
+Keine neuen npm-Abhängigkeiten, keine Änderung an `rbac.ts`, keine neuen env-Variablen.
+
+## 3. Tests
+
+| Skript | Prüfungen | Inhalt |
+|---|---|---|
+| `test-pakete-rules.ts` | 51 | Stufen, Testphase = Profi, Module/Features je Stufe, Kontingent/Mehrverbrauch/100er-Pakete, Warnstufen, hartes Limit, Monatsschlüssel, Navigation Basis/Profi |
+| `test-pakete-gates.ts` | 74 | Default Profi; Layout-Guard-Funktionen (`moduleState`, `requirePlanFeature` → Redirect `?module=profi`, Quelltextnachweis `requireModule`/Planungs-Layout); Action (`assertModuleEnabled` → `not_in_plan` + Audit) für billing/emergency/imports/lotse, Basis-Module frei; API 403 mit Klartext, Planungs-API 403; Planung (Plantafel, Live-Lage, Einplanen, Empfehlungen, Kapazität, Dashboard-Kachel, Watch) gesperrt / Profi erlaubt; `isLotseEnabled`, Abrechnungsschalter, Navigation; Sync: `emergency.create`/`milestone.reach` → rejected mit Klartext, gespeichert, kein Auftrag, Duplikat, Gerätetext; Profi → angewendet; TenantModule aus + Profi → gesperrt (Action, Sync); nur Betreiber ändert die Stufe (Mandanten-Admin, Nur-Lesen-Admin → forbidden, ungültige Werte → invalid, Mandanten-Einstellungen ändern keine Stufe); Wechsel Profi → Basis → Profi ohne Datenverlust mit Plattform-Audit; Mandantentrennung |
+| `test-pakete-seats.ts` | 68 | berechtigte Nutzer; ohne Platz → `no_seat` (View, Senden, nichts gespeichert); Vergabe bis zur Grenze, darüber `invalid no_seats_left`, parallele Vergabe (genau eine), idempotent, Audit create/delete; mit Platz → ok/Senden; Entzug; Überbelegung nach Senkung; Feldrollen/Backoffice → forbidden (Vergabe, Übersicht, Verbrauch); ohne Feldrolle / deaktiviert → `not_eligible`; Mandantentrennung (B vergibt/entzieht nichts von A, sieht keine Plätze/Zählung); Monatsgrenze in Europe/Berlin inkl. Sommerzeit, Antworten zählen nicht, je Nutzer; Mehrverbrauch weiterzählen (305/300 → 1 Paket), hartes Limit → `quota_exhausted` ohne Speichern, Verlauf lesbar, B unberührt; Testphase ohne Platzprüfung (Kontingent Berechtigte × 150, kein hartes Limit); Basis sperrt Chat und Vergabe, Plätze bleiben; Betreiber-Übersicht laufender Monat/Vormonat |
+
+**Gate (`npm run gate`) grün:** prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in fremden Dateien), build inkl. Guard-Check (42 Action-Dateien), **87/87 Testskripte** (vorher 84 + 3 neue). Bestehende Tests unverändert grün; angepasst wurde nur die L16-Chat-Fixture (Plätze).
+
+**RLS-Lauf** `RLS_ENFORCED=true` (Lane-DB `craftvia_pakete`, Rolle `craftvia_app`): `test-pakete-gates`, `test-pakete-seats`, `test-e2e-tenant-isolation`, `test-rls-enforcement`, `test-lotse-chat-engine` – 5/5 grün.
+
+**HTTP-Smoke** (`next build && next start -p 3117`, `scripts/smoke-auth.ts` ohne Passworteingabe): **152/155** – alle neuen L17-Prüfungen grün (Admin: Karte „Paket & Lotse-Chat“, „3 von 3 Plätzen vergeben“; Teamleiterin ohne Platz: kein Nav-Eintrag, Hinweis „frag im Büro nach“, kein „Lotse fragen“; Monteur mit Platz: Chat, Nav-Einträge Lotse/Notdienst; demo2 Basis: Planung/Abrechnung/Import/Notdienst-Prüfung → `/dashboard?module=profi` mit Hinweis und ohne gesperrte Links, `/api/v1/planning/board` und `/api/v1/billing` → 403). Die **3 Fehlschläge sind vorbestehend**: demo2-Seiten `/dashboard`, `/work-orders`, `/customers` enthalten „Elbblick“, weil `messages/*/marketing.json` (Produktseite, Commits `31343d3`/`4815a95`) den Beispieltext „Wohnanlage Elbblick“ enthält und die Messages an den Client serialisiert werden – kein Datenleck, aber die Isolationsprüfung schlägt an. Server danach beendet.
+
+**Visuell:** nicht im Browser geprüft (Anmeldung hätte Passwort- oder Token-Eingabe erfordert). Bitte `/settings/lotse` (1024/768 px), `/admin/` (Karte + Popup) und `/m/lotse` ohne Platz / bei hartem Limit (375 px) ansehen.
+
+## 4. Offene Punkte
+
+1. **Smoke-Isolationsprüfung „Elbblick“** (vorbestehend, s. o.): Beispieltext in `marketing.json` umbenennen oder die Prüfung auf Daten-Marker umstellen.
+2. **Vormonat mit heutiger Platzzahl:** Die Betreiber-Karte rechnet das Kontingent des Vormonats mit den *aktuell* vergebenen Plätzen (keine Historie der Platzanzahl). Ändert sich die Platzzahl im Monat, ist der Vormonatswert eine Näherung → ggf. monatlichen Snapshot (Tabelle) nachziehen.
+3. **Aufbewahrung:** Die Zählung basiert auf `LotseMessage`; `AI_GENERATION_RETENTION_DAYS` muss > ~62 Tage bleiben (Default 180), sonst fehlen Vormonats-Chats. Eine DSGVO-Löschung eines Nutzers entfernt auch seine Chats aus der Zählung.
+4. **Hartes Limit unter Parallelität:** geprüft vor dem Speichern; gleichzeitige Nachrichten können das Kontingent um wenige Chats überschreiten.
+5. **Worker:** `import-extraction` prüft die Stufe nicht (nur die Testphasen-Sperre). Nach einem Wechsel auf Basis laufen bereits eingereihte Importe noch durch; neue Importe sind gesperrt. Transkription ist über `isLotseEnabled` gesperrt.
+6. **Abrechnungseinträge im Hintergrund:** Der L14-Event-Hook legt auch bei Basis weiter Abrechnungskandidaten an (unsichtbar, nach Upgrade vorhanden) – bewusst belassen („Daten bleiben erhalten“).
+7. **Sync-Abweisung endgültig:** Eine im Basis-Paket offline erfasste Notdienst-Operation wird nach einem Upgrade nicht automatisch nachgeholt.
+8. `services/emergency/sync-ops.ts` prüft den Modulschalter weiterhin selbst (redundant zur zentralen Sync-Prüfung, unverändert).
+
+## 5. Screens / Routen
+
+| Route | Zugriff | Inhalt |
+|---|---|---|
+| `/dashboard?module=profi` | Backoffice | Hinweis „Im Paket Profi enthalten“ (Ziel aller gesperrten Profi-Seiten) |
+| `/settings/lotse` | `tenant:manage` | Karte „Paket & Lotse-Chat“: Paket, Plätze, Schalter je Nutzer, Monatsverbrauch |
+| `/m/lotse` | Feldrollen | ohne Platz / Basis: Hinweis; hartes Limit: Hinweis statt Eingabefeld |
+| `/admin` | Plattform | Spalte „Paket“ |
+| `/admin/?plan=edit` | Plattform-Voll-Admin | Karte „Paket & Lotse-Chat“ + Popup „Paket & Chat-Plätze ändern“ |
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/smoke-auth.ts b/scripts/smoke-auth.ts
index e97a54b..f55b133 100644
--- a/scripts/smoke-auth.ts
+++ b/scripts/smoke-auth.ts
@@ -76,6 +76,9 @@ function plans(x: Awaited>): Plan[] {
{ path: "/settings/audit" },
{ path: "/settings/email" },
{ path: "/settings/lotse", mustContain: "Lotse-Chat für Monteure" },
+ // L17 Pakete: Paket nur lesend, Plätze + Verbrauch
+ { path: "/settings/lotse", mustContain: ["Paket & Lotse-Chat", "Profi", "3 von 3 Plätzen vergeben", "Max Monteur", "Platz entziehen", "Chats"] },
+ { path: "/settings", mustContain: "Freigeschaltete Module" },
{ path: "/settings/lotse/protocol" },
{ path: "/settings/order-types", mustContain: "Montage" },
{ path: "/settings/checklists", mustContain: "Montage Heizung/Sanitär" },
@@ -154,6 +157,10 @@ function plans(x: Awaited>): Plan[] {
// L13 Planung: team lead read-only, own team only
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
+ // L17 Pakete: Teamleiterin ohne Chat-Platz → kein Navigationseintrag, freundlicher Hinweis
+ { path: "/m", mustNotContain: ['href="/m/lotse"'] },
+ { path: "/m/lotse", mustContain: "frag im Büro nach", mustNotContain: ['id="lotse-chat-input"'] },
+ ...(x.d14 ? [{ path: m(x.d14), mustContain: x.d14.number, mustNotContain: [`href="/m/lotse?order=${x.d14.id}"`] }] : []),
],
},
{
@@ -189,6 +196,7 @@ function plans(x: Awaited>): Plan[] {
{ path: "/m/lotse", mustContain: ["Lotse-Chat", "Neuer Chat", "Sprechen"] },
{ path: `/m/lotse?order=${x.d07.id}`, mustContain: [x.d07.number, "Lotse-Chat"] },
{ path: m(x.d07), mustContain: "Lotse fragen" },
+ { path: "/m", mustContain: ['href="/m/lotse"', 'href="/m/emergency"'] }, // L17: Platz vergeben, Profi
...(x.d08 ? [{ path: `/m/lotse?order=${x.d08.id}`, expect: [404] }] : []),
// L14 Abrechnungsübersicht: Meilensteine mobil, Backoffice-Übersicht ohne Recht
{ path: m(x.d07), mustContain: ["Meilensteine", "Smoke-Meilenstein", "Erreicht melden"] },
@@ -224,15 +232,25 @@ function plans(x: Awaited>): Plan[] {
{ path: `/customers/${x.customer.id}`, expect: [404] },
{ path: `/sites/${x.site.id}`, expect: [404] },
{ path: `/reports/${x.approved.id}`, expect: [404] },
- ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
- ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
+ // L17: demo2 is Basis → module layouts redirect before the (foreign) id is looked up
+ ...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [307, 404] }] : []),
+ ...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [307, 404] }] : []),
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
- { path: "/planning", mustNotContain: ["Team Nord", x.d07.number] },
- { path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] },
- { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] },
- // L14 Abrechnungsübersicht: keine Einträge von demo
- { path: "/billing", mustContain: "Abrechnungsübersicht", mustNotContain: [x.d18.number, "Elbblick"] },
- ...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [404] }, { path: `/billing/print?ids=${x.billing.id}`, mustNotContain: [x.d18.number] }] : []),
+ // L17 Pakete: demo2 = Basis → Planung, Abrechnung, Import, Notdienst, Lotse gesperrt (ruhiger Hinweis)
+ { path: "/planning", expect: [307], redirectTo: "/dashboard" },
+ { path: "/planning/live", expect: [307], redirectTo: "/dashboard" },
+ { path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [403, 404] },
+ { path: "/api/v1/planning/board", expect: [403] },
+ { path: "/billing", expect: [307], redirectTo: "/dashboard" },
+ { path: "/imports", expect: [307], redirectTo: "/dashboard" },
+ { path: "/work-orders/emergency-review", expect: [307], redirectTo: "/dashboard" },
+ { path: "/api/v1/billing", expect: [403] },
+ { path: "/dashboard?module=profi", mustContain: ["Im Paket Profi enthalten", "Offene Aufträge"], mustNotContain: ['href="/planning"', 'href="/billing"', 'href="/imports"', 'href="/work-orders/emergency-review"'] },
+ { path: "/settings/lotse", mustContain: ["Paket & Lotse-Chat", "Basis", "im Paket Profi enthalten"] },
+ { path: "/work-orders", mustContain: "DGUV" },
+ { path: "/customers" },
+ { path: "/reports" },
+ ...(x.billing ? [{ path: `/billing/${x.billing.id}`, expect: [307, 404] }] : []),
{ path: wo(x.d07, "?tab=billing"), expect: [404] },
],
},
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");
}