13 Commits

Author SHA1 Message Date
schalli 7188733f70 docs(quick-261002-icv): Freigabestufe Verwalten
Tessera CI/CD / Lint & Type Check (push) Successful in 56s
Tessera CI/CD / Tests (push) Successful in 2m3s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 21s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:42:22 +02:00
schalli 0e6e55ef65 fix(quick-261002-icv): Freigaben-Matrix zeigt Bereichsnamen statt Kennungen
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:40:04 +02:00
schalli eaf2c4574a feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog
- Matrix und Benutzerdetails liefern und zeigen die Stufe, Auswahlfeld Benutzen/Verwalten
- Gruppen mit Verwalten sind in den Benutzerdetails markiert
- Administrationshandbuch, Anwenderanleitung und Changelog beschreiben die Stufen

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:35:00 +02:00
schalli c2ebc8daa0 feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- Proxmox-Schreibwege und Handelsware-Einstellungen auf @ModuleManage umgestellt
- DKV-Fleet: ganze Klasse Verwalten-Stufe, Benutzen allein bleibt ohne Zugriff
- Metadaten-Test belegt umgestellte und bewusst Administratoren vorbehaltene Handler
- Webseiten (Proxmox, Handelsware, Widget) folgen canManage, DKV-Zugriffsseite erklärt die Stufe

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:31:08 +02:00
schalli a222711ad9 feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- Migration: ModuleGrant.level (USE/MANAGE), Bestand bleibt USE
- ModuleAccessService.getModuleAccessLevels als einzige Auflösung, MANAGE gewinnt
- @ModuleManage(slug) am ModuleGuard, GET /modules/active liefert canManage
- Kantinenabrechnung: Einstellungen für Benutzer mit Verwalten, Web-Hook useCanManageModule

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 13:28:24 +02:00
schalli b94d267584 docs(quick-261002-fm5): Finanzbuchhaltung-Module Kantinenabrechnung und Handelsware
Tessera CI/CD / Lint & Type Check (push) Successful in 53s
Tessera CI/CD / Tests (push) Successful in 1m45s
Tessera CI/CD / Desktop-Pakete bauen (push) Successful in 18s
Tessera CI/CD / Build & Publish Images (push) Successful in 3m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:52:33 +02:00
schalli 46d19da54a fix(quick-261002-fm5): Einstellungstexte ohne Mandanten-Hinweis
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:52:21 +02:00
schalli 14933753e7 docs: Kantinenabrechnung und Handelsware in Changelog und Anwenderanleitung
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:47:09 +02:00
schalli 42b89f110b feat(handelsware-datev): Modulseite mit Import, Konten und Einstellungen
- Reiter Import: Vorschau mit neu-Markierung, Buchungsdatum (TTMM) aus dem Dateinamen, Download speichert neue Konten
- Reiter Konten: anlegen, bearbeiten, loeschen mit Rueckfrage, CSV-Import (ersetzt alles, nach Rueckfrage) und -Export
- Reiter Einstellungen nur fuer Administratoren; Texte de/en

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:47:09 +02:00
schalli 44c1d4351f feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export
- Prisma-Modelle HandelswareDatevConfig und HandelswareKonto mit Zeilenschutz (Migration 20261002130000)
- XLSX lesen (B1 Kopf, A/B ab Zeile 2, Zahl oder deutscher Text), Konten zuordnen, TXT erzeugen
- neue Konten werden nur beim Export in einer mandantengebundenen Transaktion gespeichert (409 bei geaenderter Liste)
- Konten-CSV Import (alles ersetzen) und Export mit Schutz vor Formeleinschleusung
- Einstellungen nur fuer Administratoren, statische Routen vor accounts/:id

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:38:22 +02:00
schalli 1f85277a3b feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung
- neue Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
- CSV (UTF-8, UTF-8 mit BOM, Windows-1252) pruefen, Vorschau mit Zeilenfehlern, DATEV-Lohn-ASCII-Export
- Beraternummer, Mandantennummer, Lohnart je Mandant als Admin-Einstellung (KantineDatevConfig mit Zeilenschutz), anfangs leer
- hochgeladene Daten werden nicht gespeichert; Download per Blob (auch im Desktop-Client)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:32:55 +02:00
schalli 0edd6e9b1a docs: Sitzung fortgesetzt, HANDOFF nach Freigabe 1.9.2 entfernt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 11:25:41 +02:00
schalli f7d4be0c7c wip: pausiert nach Freigabe 1.9.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-02 10:56:14 +02:00
105 changed files with 8861 additions and 267 deletions
+20 -9
View File
@@ -4,7 +4,7 @@ phase: quick-auftraege-1.9.x (keine GSD-Phase)
task: 0 task: 0
total_tasks: 0 total_tasks: 0
status: paused status: paused
last_updated: 2026-09-30T18:00:00.000Z last_updated: 2026-10-02T08:00:00.000Z
--- ---
# BLOCKING CONSTRAINTS — Read Before Anything Else # BLOCKING CONSTRAINTS — Read Before Anything Else
@@ -12,23 +12,34 @@ last_updated: 2026-09-30T18:00:00.000Z
- [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt. - [ ] CONSTRAINT: live NICHT pushen/taggen, bis der User es verlangt.
- [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit. - [ ] CONSTRAINT: Gebündelt pushen, nicht nach jeder Kleinigkeit.
- [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus. - [ ] CONSTRAINT: Browser-Prüfungen im Dunkelmodus.
- [ ] CONSTRAINT: Nie Wichtiges (Logo, Text) nur in Mailbilder packen – OWA zeigt eingebettete Bilder nicht. - [ ] CONSTRAINT: Echte Kundenzertifikate/Schlüssel nie ins Repo, lokale Kopien nach dem Test löschen.
<current_state> <current_state>
main = live = v1.9.0 (a257bc3), CI + Release grün. alpha zuletzt 714f731 (Inhalt identisch). Arbeitsbaum sauber. main = live = v1.9.2 (2d6caec). Tag gepusht; CI-Läufe 481/482/483 grün, Release Tessera 1.9.2 mit Setup.exe + AppImage. Arbeitsbaum sauber.
</current_state> </current_state>
<completed_work> <completed_work>
- 30.09.: Windows-Test bestanden, Review seit 26.09. + Fixes, v1.8.0. - 01.10.: Desktop-Favoriten öffnen im System-Browser (261001-cxo), Erinnerung-Cursor (261001-g68), Favoriten-Logo-Rückfall (261001-hbi); Freigabe 1.9.1, live gezogen.
- Dashboard-Skalierung (nie scrollen), eigene Module Keep-Alive, Favoriten enger. - 01./02.10.: Zertifikat-Manager Reiter „Übersicht“ (261001-l4q): Paket/ZIP hochladen, Teile erkennen und zuordnen, jedes Teil in jedem Format; Desktop-Client speichert blob:/data:-Downloads selbst (VM gegen alpha nachgewiesen); geschützte PFX nur ruhiger Hinweis.
- Sicherheit: Rolle/Aktiv-Status je Anfrage aus DB; /login-Weiterleitung. - Kalender-Test gehärtet (CI-Flake).
- Willkommensmail + eigene Vorlage (Platzhalter, Vorschau, Testmail, Anmeldehinweise), Spalte Letzte Anmeldung; v1.9.0. - 02.10.: Freigabe 1.9.2.
</completed_work> </completed_work>
<remaining_work> <remaining_work>
- User: live auf 1.9.0 ziehen (df -h / vorher), Willkommensmail in OWA prüfen. - User: live auf 1.9.2 ziehen (df -h / vorher), alpha auf 2d6caec.
- Nächster Chat: NEUES MODUL (Auftrag kommt vom User).
</remaining_work> </remaining_work>
<decisions_made>
- 1.9.2 statt 1.10.0 – ausdrücklicher Wunsch des Users.
- Logo-Rückfall DuckDuckGo nur für öffentliche Hosts.
- Aussteller-PFX-Passwort unbekannt und unnötig → ruhiger Hinweis.
</decisions_made>
<context>
VM 8233: Client 1.9.1 Stand c1b2654 an alpha; Aussteller-ZIP auf dem VM-Desktop. alpha-Admin admin / admin1234.
</context>
<next_action> <next_action>
Nachfragen, ob live gezogen und OWA ok; sonst neuen Auftrag abwarten. Neuen Chat abwarten: User bringt ein neues Modul. Fragen, ob live auf 1.9.2 gezogen ist.
</next_action> </next_action>
-33
View File
@@ -1,33 +0,0 @@
{
"version": "1.0",
"timestamp": "2026-09-30T18:00:00.000Z",
"phase": null,
"phase_name": "Quick-Auftraege nach Freigabe 1.9.0 (keine GSD-Phase)",
"phase_dir": null,
"plan": null,
"task": 0,
"total_tasks": 0,
"status": "paused",
"completed_tasks": [
{"id": 1, "name": "30.09.: Windows-Test (Tray-Update + Erinnerungs-Toast) bestanden; Review aller Aenderungen seit 26.09. + Fixes; Freigabe 1.8.0 (af78157)", "status": "done"},
{"id": 2, "name": "Dashboard 1:1-Skalierung (__canvas, passt Inhalt ein = nie scrollen), eigene Module Keep-Alive (max 5), Favoriten-Kachelansicht enger + lange Namen klein/zweizeilig", "status": "done"},
{"id": 3, "name": "Sicherheit: JwtStrategy liest Rolle/isActive/mustChangePassword je Anfrage aus DB; /login leitet Angemeldete aufs Dashboard", "status": "done"},
{"id": 4, "name": "Willkommensmail (Briefsymbol Benutzerliste, jederzeit an jeden) + Spalte Letzte Anmeldung + eigene Vorlage je Mandant (Admin -> Willkommensmail, Platzhalter, Vorschau, Testmail, Anmeldehinweise editierbar); Freigabe 1.9.0 (a257bc3)", "status": "done"}
],
"remaining_tasks": [],
"blockers": [],
"async_jobs": [],
"human_actions_pending": [
{"action": "Live-Server auf v1.9.0 ziehen (vorher df -h /, ggf. docker image prune -f, nie -a)", "context": "CI 11 Laeufe gruen, Release Tessera 1.9.0 mit Setup.exe + AppImage", "blocking": false},
{"action": "Willkommensmail in OWA (owa.ctl.de) pruefen: dunkler Kopf ohne weisse Luecke", "context": "OWA zeigt CID-Bilder nicht, Outlook-Programm schon", "blocking": false}
],
"decisions": [
{"decision": "Dashboard: alles mitskalieren inkl. Schrift, nie scrollen; Leinwand = Flaeche beim ersten Oeffnen je Reiter", "rationale": "AskUserQuestion 30.09.", "phase": "quick"},
{"decision": "Keine naechtliche Docker-Aufraeumung auf alpha", "rationale": "User 30.09.: passt so", "phase": "quick"},
{"decision": "Willkommensmail jederzeit an jeden Benutzer; Link Passwort festlegen 7 Tage", "rationale": "User 30.09.", "phase": "quick"},
{"decision": "PMG ohne API-Token (Proxmox kennt keine), eigener Auditor-Benutzer; Anleitung im Admin-Handbuch", "rationale": "Proxmox Bugzilla 5849 offen", "phase": "quick"}
],
"uncommitted_files": [],
"next_action": "Nichts offen von Claudes Seite. Beim Start: fragen, ob live auf 1.9.0 gezogen ist und ob die Willkommensmail in OWA passt; sonst neuen Auftrag abwarten.",
"context_notes": "main = live = v1.9.0 (a257bc3). alpha lief zuletzt auf 714f731 (= Inhalt 1.9.0). Lokaler Stack aus 714f731 gebaut. Test-Postfach MailHog nur bei Bedarf: docker run -d --rm --name mailhog --network tessera-ctl_backend-net --network-alias mailhog -p 127.0.0.1:8025:8025 mailhog/mailhog. Diagnose auf alpha ohne Passwort: JWT im api-Container mit crypto + process.env.JWT_SECRET signieren (nur lesend, kurzlebig)."
}
+7 -5
View File
@@ -6,7 +6,7 @@ current_phase_name: desktop-client-fertigstellen
status: verified status: verified
stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren." stopped_at: "22.09.2026: 1.3.0 freigegeben; danach quick-260922-hk4 — Bilderrahmen-Bilder liegen jetzt im Dateibereich (user-files) statt in der Datenbank, Umzug laeuft automatisch beim Start, Selbstheilung aus der alten data-Spalte eingebaut; im Browser nachgewiesen. NAECHSTER SCHRITT, vom Nutzer noch nicht bestaetigt: (1) einmaliges Aufraeumen, damit ein Modul seine Dashboard-Kachel selbst mitbringt (heute sieben Hartkodierungen je Kachel; Katalog zeigt auch Kacheln gesperrter Module; gesperrte Kachel bleibt leer statt zu erklaeren) — das Geruest WIDGET_MODULE_MAP existiert und ist leer; (2) danach das Proxmox-Modul (PVE/PBS/PMG) und seine Kachel. Offen beim Nutzer: Live-Server auf 1.3.0 ziehen, neuen Client per Browser installieren."
last_updated: "2026-09-23T15:30:00.000Z" last_updated: "2026-09-23T15:30:00.000Z"
last_activity: 2026-10-01 last_activity: 2026-10-02
last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0 last_activity_desc: Quick 260928-ujj — Design Mosaik uebernommen, Hintergrund pro Benutzer in der DB; Freigabe 1.5.0
state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2 state_head: 4d485432c003a6caf68f6d85aff7de0bd27794e2
progress: progress:
@@ -31,7 +31,7 @@ See: .planning/PROJECT.md (updated 2026-07-17)
Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden) Phase: 18 (desktop-client-fertigstellen) — COMPLETE (2026-09-17, Verifikation passed, Windows-Bedienprobe bestanden)
Plan: 6 of 6 Plan: 6 of 6
Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen Status: Alle 18 Phasen abgeschlossen; Version 1.2.0 freigegeben. Kein laufender Meilenstein. Nach 1.2.0 auf main (Beta): Bildmarke in Akzentfarbe, CI-Desktop-Skip, Favoriten-Symbol/-Sortierung, Desktop-Server-Adresse, Update in der App (signiert), Versionszeile auf der Setup-Seite — alles verifiziert und auf VM/CI nachgewiesen
Last activity: 2026-09-30 - v1.9.0 freigegeben (Willkommensmail + Vorlage, Rollen je Anfrage aus DB, Dashboard-Skalierung, Keep-Alive eigene Module); pausiert mit HANDOFF Last activity: 2026-10-02 - Quick 261002-icv: Freigabestufe Verwalten (lokal, nicht gepusht); 261002-fm5 Finanzbuchhaltung gepusht (CI 484 gruen)
Progress: [██████████] 99% Progress: [██████████] 99%
@@ -487,6 +487,8 @@ Gerettet aus `.continue-here.md`. Relevant fuer die noch offenen Live-Tests.
| 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) | | 261001-g68 | Erinnerung: Cursor sprang beim Schreiben der Beschreibung in den Titel (Fokus-Effekt hing an inline onClose, Kachel zeichnet alle 10 s neu) – Fokus nur beim Oeffnen | 2026-10-01 | siehe git log | [261001-g68](./quick/261001-g68-erinnerung-cursor-springt-aus-beschreibu/) |
| 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) | | 261001-hbi | Favoriten: Logo fuer per JavaScript gesetzte Symbole (hosteurope.de) – Rueckfall auf DuckDuckGo-Symboldienst beim Ausliefern, nur oeffentliche Seiten | 2026-10-01 | siehe git log | [261001-hbi](./quick/261001-hbi-favoriten-logo-fuer-per-javascript-geset/) |
| 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) | | 261001-l4q | Zertifikat-Manager: Reiter Übersicht (Paket/ZIP hochladen, Teile erkennen/zuordnen, jedes Teil in jedem Format) + Desktop speichert blob-Downloads selbst | 2026-10-01 | siehe git log | [261001-l4q](./quick/261001-l4q-zertifikatsmodul-paket-hochladen-uebersi/) |
| 261002-fm5 | Finanzbuchhaltung: Module Kantinenabrechnung und Handelsware (DATEV-Export), im Browser nachgewiesen | 2026-10-02 | 1f85277..HEAD | [261002-fm5-finanzbuchhaltung-module-kantinenabrechn](.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/) |
| 261002-icv | Modul-Freigabe mit Stufe Verwalten (Modul-Einstellungen ohne Admin; Kantine, Handelsware, Proxmox, DKV), im Browser nachgewiesen | 2026-10-02 | a222711..HEAD | [261002-icv-modul-freigabe-mit-stufe-verwalten-modul](.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/) |
## Deferred Items ## Deferred Items
@@ -528,8 +530,8 @@ sind. Kein Anlass, sie vorher erneut vorzulegen.
## Session Continuity ## Session Continuity
Last session: 2026-09-22T13:40:00Z Last session: 2026-10-02T09:10:00Z
Resumed: 2026-09-21 (abends) ueber /gsd-resume-work; seitdem Bilderrahmen, XFrame (inkl. Ausschnitt), Desktop-Korrekturen, Freigabe 1.3.0, Bilder in den Dateibereich. Resumed: 2026-10-02 ueber /gsd-resume-work (HANDOFF nach Freigabe 1.9.2 eingelesen und entfernt).
Stopped at: hk4 fertig und nachgewiesen. Dem Nutzer vorgelegt: erst das Aufraeumen (Modul bringt seine Kachel selbst mit), dann Proxmox-Modul + Kachel — Antwort steht aus. Stopped at: Session resumed — wartet auf Rueckmeldung live/alpha auf 1.9.2 und den Auftrag fuer das neue Modul.
Resume file: None Resume file: None
Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live Last activity: 2026-09-29 - Quick 260929-if2 Erinnerungen-Widget (lokal, nicht gepusht); v1.7.0 auf alpha+live
@@ -0,0 +1,343 @@
---
phase: quick-261002-fm5
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-fm5
description: "Finanzbuchhaltung: Module Kantinenabrechnung (kantine-datev) und Handelsware (handelsware-datev)"
date: 2026-10-02
files_modified:
# Task 1 — category + Kantinenabrechnung end-to-end (tracer)
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql
- apps/api/src/accounting/decode-csv-text.ts
- apps/api/src/accounting/decode-csv-text.spec.ts
- apps/api/src/kantine-datev/kantine-datev.types.ts
- apps/api/src/kantine-datev/kantine-csv.parser.ts
- apps/api/src/kantine-datev/kantine-csv.parser.spec.ts
- apps/api/src/kantine-datev/kantine-csv.validator.ts
- apps/api/src/kantine-datev/kantine-csv.validator.spec.ts
- apps/api/src/kantine-datev/kantine-datev.transformer.ts
- apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts
- apps/api/src/kantine-datev/kantine-datev.pipeline.ts
- apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts
- apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts
- apps/api/src/kantine-datev/kantine-datev.service.ts
- apps/api/src/kantine-datev/kantine-datev.service.spec.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/kantine-datev/kantine-datev.controller.spec.ts
- apps/api/src/kantine-datev/kantine-datev.seed.ts
- apps/api/src/kantine-datev/kantine-datev.module.ts
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/lib/download-base64.ts
- apps/web/src/lib/kantine-datev-api.ts
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/components/modules/module-tile.tsx
- apps/web/src/lib/stores/nav-store.ts
- apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx
- apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
- apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
# Task 2 — Handelsware API + Prisma
- apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
- apps/api/src/handelsware-datev/handelsware-datev.types.ts
- apps/api/src/handelsware-datev/handelsware-xlsx.ts
- apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts
- apps/api/src/handelsware-datev/handelsware-transform.ts
- apps/api/src/handelsware-datev/handelsware-transform.spec.ts
- apps/api/src/handelsware-datev/handelsware-konten-csv.ts
- apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts
- apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts
- apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts
- apps/api/src/handelsware-datev/handelsware-datev.service.ts
- apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts
- apps/api/src/handelsware-datev/handelsware-datev.seed.ts
- apps/api/src/handelsware-datev/handelsware-datev.module.ts
# Task 3 — Handelsware web + docs + local stack
- apps/web/src/lib/handelsware-datev-api.ts
- apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
autonomous: true
requirements: [QUICK-261002-fm5]
estimate:
tokens: 420000
raw_tokens: 420000
tasks: 3
confidence: low
must_haves:
truths:
- "A user with a grant for kantine-datev sees a new sidebar group 'Finanzbuchhaltung' (en 'Financial accounting') with the entry 'Kantinenabrechnung'; same group holds 'Handelsware' when granted"
- "Uploading a canteen CSV (UTF-8, UTF-8 with BOM, or Windows-1252, CRLF or LF) shows row count, billing month MM/YYYY, total amount, row errors with line numbers and warnings; nothing from the upload is written to the database or logs"
- "Clicking download on a valid CSV yields a DATEV Lohn ASCII file: header Beraternr TAB Mandantennr TAB MM/YYYY + 8 empty columns, detail rows TAB PersonalNr TAB TAB Lohnart TAB -Betrag + 6 empty columns, exactly 11 columns per line, CRLF everywhere and at the end, quality check passed"
- "Beraternummer, Mandantennummer, Lohnart, Standard-Erloeskonto and Startwert Gegenkonto start empty per tenant; while empty the modules block processing with a clear German hint; only ADMIN/SUPER_ADMIN can change them; only digits are accepted"
- "Uploading a Handelsware XLSX shows a preview (Buchungstext, Umsatz abs dot 2 decimals, S/H, Gegenkonto, Datum TTMM, Erloeskonto); unknown products get the next free Gegenkonto and a visible 'neu' marker; the date is derived from the filename MMYY and is editable with TTMM validation"
- "New accounts are persisted only when the user downloads the TXT, in one tenant-bound transaction that re-checks for conflicts (409 when the account list changed since the preview)"
- "Tab 'Konten' lists, creates, edits, deletes accounts, imports CSV Name;Gegenkonto;Konto (replace-all after confirmation) and exports CSV"
- "Downloads work in the browser and in the Tauri desktop client (client-side blob download, same mechanism as cert-manager)"
artifacts:
- path: apps/api/src/kantine-datev/kantine-datev.transformer.ts
provides: "DATEV Lohn ASCII generation + quality check (ported from source transformer.ts, settings-driven header/Lohnart)"
- path: apps/api/src/kantine-datev/kantine-datev.controller.ts
provides: "GET/PUT settings, POST preview, POST export under modules/kantine-datev with @UseModule('kantine-datev')"
- path: apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql
provides: "KantineDatevConfig table with ENABLE+FORCE RLS and tenant_isolation_policy"
- path: apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
provides: "HandelswareDatevConfig + HandelswareKonto tables (unique tenantId+name) with RLS"
- path: apps/api/src/handelsware-datev/handelsware-datev.service.ts
provides: "Settings, account CRUD, CSV import/export, preview, export with withTenantTransaction"
- path: apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
provides: "Kantinenabrechnung UI (upload, preview, download, admin settings)"
- path: apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
provides: "Handelsware UI with tabs Import / Konten / Einstellungen"
key_links:
- from: apps/api/src/kantine-datev/kantine-datev.seed.ts
to: "Module row slug kantine-datev, category accounting"
via: "ModuleRegistryService.seedModule in KantineDatevModule.onModuleInit"
pattern: "category: 'accounting'"
- from: apps/web/src/lib/module-loader.ts
to: apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
via: "MODULE_REGISTRY entry rendered by [category]/[moduleSlug] ModuleShell"
pattern: "'kantine-datev'"
- from: apps/web/src/messages/de.json
to: "sidebar category label"
via: "moduleCategories.accounting read by useCategoryLabel"
pattern: "\"accounting\": \"Finanzbuchhaltung\""
- from: apps/api/src/handelsware-datev/handelsware-datev.service.ts
to: "HandelswareKonto rows"
via: "withTenantTransaction(this.prisma, tenantId, async (tx) => ...) on export and CSV replace"
pattern: "withTenantTransaction"
---
<objective>
Port the two finance features from the colleague's Tauri app (`user-files/headflow/app/src/modules/datev/` = canteen billing, `user-files/headflow/app/src/modules/handelsware/` = merchandise; nothing else from that app) into Tessera as two modules, `kantine-datev` ("Kantinenabrechnung") and `handelsware-datev` ("Handelsware"), grouped under a new sidebar category `accounting` ("Finanzbuchhaltung" / "Financial accounting").
Purpose: the finance team uses Tessera instead of a separate desktop tool; access via the existing activation + ModuleGrants; no company-specific defaults (Tessera is a multi-tenant product).
Output: two API modules with pure, tested processing functions, two Prisma migrations with RLS, two web module pages, i18n de/en, docs + CHANGELOG, local stack rebuilt. No push.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Source of business rules (read, do not copy UI/Tauri code):
@user-files/headflow/app/src/modules/datev/parser.ts
@user-files/headflow/app/src/modules/datev/validator.ts
@user-files/headflow/app/src/modules/datev/transformer.ts
@user-files/headflow/app/src/modules/datev/types.ts
@user-files/headflow/app/src/modules/handelsware/handelswareService.ts
@user-files/headflow/app/src/modules/handelsware/handelswareTypes.ts
Download filename rules live in the source UI: `datev/ui/DatevPreview.tsx` (downloadFileName, lines ~33-35) and `handelsware/ui/HandelswarePage.tsx` (getExportFilename, lines ~30-34).
Tessera analogs (patterns to follow):
- Module seed + module class: `apps/api/src/cert-manager/cert-manager.seed.ts`, `apps/api/src/cert-manager/cert-manager.module.ts`
- Controller with `@UseModule`, `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, `requireTenantId`: `apps/api/src/proxmox/proxmox.controller.ts`
- Multer upload (memory, 5 MB limit), `UploadedFileLike` (`buffer`, `originalname`, `size`): `apps/api/src/cert-manager/cert-manager.controller.ts`
- Tenant-bound Prisma: `forTenant` (assignment form `const tenantPrisma = forTenant(this.prisma, tenantId)`) and `withTenantTransaction(this.prisma, tenantId, async (tx) => ...)` in `apps/api/src/prisma/prisma-tenant.extension.ts` (header comment explains why never `$transaction` on a bound client)
- Singleton-per-tenant config model: `DkvModuleConfig` in `apps/api/prisma/schema.prisma`; RLS migration header + SQL form: `apps/api/prisma/migrations/20260923140000_proxmox_server/migration.sql`
- RLS gates: `apps/api/src/prisma/rls-coverage.spec.ts`, `apps/api/src/prisma/rls-access-inventory.spec.ts` (+ Fundstellentabelle in `docs/mandantentrennung-zugriffsklassifikation.md`)
- Route-order test: `apps/api/src/custom-modules/custom-modules.controller.spec.ts` (describe "Routen-Reihenfolge")
- Web: module page + PageHeader + tabs `apps/web/src/app/(portal)/modules/cert-manager/page.tsx`; layout gate `apps/web/src/app/(portal)/modules/cert-manager/layout.tsx`; drop area `apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx` (uses certManager texts, so build a module-local equivalent instead of importing it); blob download `downloadBase64` in `apps/web/src/app/(portal)/modules/cert-manager/actions.ts`; API client style `apps/web/src/lib/custom-modules-api.ts`; admin detection `useAuthStore` in `apps/web/src/app/(portal)/modules/proxmox/page.tsx`; page test with real `NextIntlClientProvider` + mocked api client + mocked auth store `apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx`
- Registries: `apps/web/src/lib/module-loader.ts` (MODULE_REGISTRY), `apps/web/src/lib/module-identity.ts` (ICONS + ModuleIconId), `apps/web/src/components/modules/module-tile.tsx` (GLYPHS), `apps/web/src/lib/stores/nav-store.ts` (MODULE_TITLE_KEYS), `packages/shared/src/index.ts` (MODULE_CATEGORIES), `apps/web/src/messages/{de,en}.json` (`moduleCategories`), `apps/web/src/app/(portal)/modules/module-layouts.test.tsx`
Project memory that applies: NestJS static routes before `:id` (unit tests do not catch shadowing); db container has no host port (use container IP); plain `docker compose up` does not rebuild; no customer-specific defaults; app texts use formal "Sie"; do not push.
</context>
<coverage_audit>
Sources: the orchestrator task description (GOAL) only — no ROADMAP phase, REQUIREMENTS, RESEARCH.md or CONTEXT.md for this quick task.
| Source item | Covered by |
|---|---|
| New category `accounting`, de "Finanzbuchhaltung" / en "Financial accounting" | Task 1 |
| Kantine: CSV upload, UTF-8 / cp1252 detection | Task 1 |
| Kantine: validation exactly as parser.ts/validator.ts, month from "bis", multi-month warning | Task 1 |
| Kantine: preview (rows, month, total, row errors with lines, warnings) | Task 1 |
| Kantine: DATEV Lohn ASCII per transformer.ts + quality check | Task 1 |
| Kantine: Berater/Mandant/Lohnart as admin settings per tenant, empty default, blocked hint, numeric | Task 1 |
| Kantine: no persistence of uploaded data; pure functions + Vitest (cp1252 umlaut, CRLF/LF, multi-month, invalid rows) | Task 1 |
| Handelsware: XLSX B1 header, A/B rows, number or German string | Task 2 |
| Handelsware: Prisma model per tenant with RLS, unique name per tenant, migration | Task 2 |
| Handelsware: preview columns, auto Gegenkonto (max+1 / Startwert), "neu" marker | Task 2 (API) + Task 3 (UI) |
| Handelsware: persist new accounts only on export, one transaction, conflict re-check | Task 2 (API) + Task 3 (UI) |
| Handelsware: Buchungsdatum from filename MMYY, editable, TTMM validation | Task 2 (API) + Task 3 (UI) |
| Handelsware: TXT format, CRLF, filename `<Prefix>_<MMYY>.txt`, UTF-8 + open question in SUMMARY | Task 2 + Task 3 |
| Handelsware: Konten tab CRUD, CSV import replace-all with confirmation, CSV export | Task 2 (API) + Task 3 (UI) |
| Handelsware: settings Standard-Erloeskonto / Startwert Gegenkonto, empty, blocked hint | Task 2 + Task 3 |
| ModuleGrants access, de (Sie) + en texts, existing upload/download patterns, desktop-safe downloads | Tasks 1-3 |
| Static routes before `:id` + declaration-order test | Task 2 |
| Tests api + web, tsc, biome | Tasks 1-3 |
| Local migration via container IP, `docker compose up -d --build api web` | Tasks 1-3 |
| Browser check (Playwright, dark mode) or hand-off note in SUMMARY | Task 3 |
| Atomic commit per task, no push | Tasks 1-3 |
</coverage_audit>
<tasks>
<task type="tracer">
<name>Task 1: Category "Finanzbuchhaltung" + Kantinenabrechnung end-to-end (API, migration, web)</name>
<files>packages/shared/src/index.ts, apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql, apps/api/src/accounting/decode-csv-text.ts, apps/api/src/accounting/decode-csv-text.spec.ts, apps/api/src/kantine-datev/kantine-datev.types.ts, apps/api/src/kantine-datev/kantine-csv.parser.ts, apps/api/src/kantine-datev/kantine-csv.parser.spec.ts, apps/api/src/kantine-datev/kantine-csv.validator.ts, apps/api/src/kantine-datev/kantine-csv.validator.spec.ts, apps/api/src/kantine-datev/kantine-datev.transformer.ts, apps/api/src/kantine-datev/kantine-datev.transformer.spec.ts, apps/api/src/kantine-datev/kantine-datev.pipeline.ts, apps/api/src/kantine-datev/kantine-datev.pipeline.spec.ts, apps/api/src/kantine-datev/dto/kantine-datev-settings.dto.ts, apps/api/src/kantine-datev/kantine-datev.service.ts, apps/api/src/kantine-datev/kantine-datev.service.spec.ts, apps/api/src/kantine-datev/kantine-datev.controller.ts, apps/api/src/kantine-datev/kantine-datev.controller.spec.ts, apps/api/src/kantine-datev/kantine-datev.seed.ts, apps/api/src/kantine-datev/kantine-datev.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md, apps/web/src/messages/de.json, apps/web/src/messages/en.json, apps/web/src/lib/download-base64.ts, apps/web/src/lib/kantine-datev-api.ts, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/lib/stores/nav-store.ts, apps/web/src/app/(portal)/modules/kantine-datev/layout.tsx, apps/web/src/app/(portal)/modules/kantine-datev/page.tsx, apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx</files>
<behavior>
- decodeCsvText: valid UTF-8 bytes (with or without BOM) decode unchanged and BOM is dropped; bytes that are invalid UTF-8 (e.g. "Müller" encoded Windows-1252, i.e. Buffer latin1) decode via windows-1252 to "Müller"; "€" byte 0x80 in cp1252 becomes "€"
- parseKantinenCsv (port of source parser.ts): CRLF and LF input give identical rows; empty lines skipped; header with fewer than 11 columns gives row-1 error "…Ist das Trennzeichen korrekt (Semikolon)?"; data line with fewer than 11 columns gives error with its 1-based line number and is skipped
- validateKantinenData (port of source validator.ts, same messages): non-numeric PersonalNr, missing/invalid Betrag (regex digits with optional comma decimals — "1.234,56" and "-5,00" are rejected exactly as in the source), date not TT.MM.JJJJ, von/bis in different months are errors with row = index + 2; billing month from "bis" as MM/YYYY; two different months produce the source warning text and keep the first month
- transformBetrag: "7,94" → "-7.94", "51,5" → "-51.50", "0,00" → "-0.00"
- generateDatevOutput(records, month, settings): header = beraterNr, mandantNr, month + 8 empty columns; each detail = empty, PersonalNr, empty, lohnart, betrag + 6 empty; every line 11 columns; CRLF joined plus trailing CRLF; qualityCheck passes on it and fails (with the source messages) on a LF-only string, on a 10-column line and on a positive Betrag
- processKantineCsv(buffer, settings | null): returns { rowCount, abrechnungsMonat, totalCents, errors[{row, field, code, message}], warnings[{code, message, params}], canExport, blockedReason }; zero data rows → error code noRows; settings missing → canExport false with blockedReason settingsMissing; buildExport refuses when errors exist or settings missing and runs qualityCheck before returning; export filename LuG_<beraterNr>_<mandantNr>_<MM>_<YYYY>.sic
- Settings DTO accepts only digit strings (1-10 digits) for all three fields; service returns { beraterNr, mandantNr, lohnart, configured } with nulls and configured=false when no row exists
- Controller: class path modules/kantine-datev, @UseModule('kantine-datev'), PUT settings carries @Roles(ADMIN, SUPER_ADMIN), GET settings / preview / export carry no role; export of a file with errors → 400; no settings → 400 with code settingsMissing
- Web page: shows the not-configured hint (admin sees the settings form, non-admin sees "Ein Administrator muss …"); after upload shows Zeilen / Abrechnungsmonat / Gesamtbetrag, warning list and error table with line numbers; download button disabled while errors exist; clicking download calls the export client and downloadBase64 with the returned filename
</behavior>
<action>
**Category (shared + i18n).** In `packages/shared/src/index.ts` add `"accounting"` to `MODULE_CATEGORIES` (keeps the module-categories spec in step with seeds; custom modules may then also use the group — intended). In `apps/web/src/messages/de.json` add `moduleCategories.accounting = "Finanzbuchhaltung"`, in `en.json` `"Financial accounting"`.
**Prisma + migration.** Add model `KantineDatevConfig` to `apps/api/prisma/schema.prisma` following `DkvModuleConfig`: `id` uuid, `tenantId String @unique`, `beraterNr String?`, `mandantNr String?`, `lohnart String?`, `createdAt`, `updatedAt`, `@@index([tenantId])`. Strings (not Int) so leading zeros survive; no `@default` on the three fields — the colleague's hardcoded header/Lohnart constants from source `transformer.ts` (KOPF_SPALTE_1, KOPF_SPALTE_2, DETAIL_SPALTE_4) must not appear anywhere as defaults, examples, placeholders or test values (use neutral test values such as 1234567 / 12345 / 1111). Hand-write `apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql` in the form of `20260923140000_proxmox_server`: German header comment (purpose, RLS without user dimension, no system_read_policy because there is no scheduler, grants via ALTER DEFAULT PRIVILEGES, switch note), CREATE TABLE, unique index `KantineDatevConfig_tenantId_key`, index on tenantId, ENABLE + FORCE ROW LEVEL SECURITY, `CREATE POLICY tenant_isolation_policy ... USING ("tenantId" = current_tenant_id())`. Run `pnpm --filter @tessera/api exec prisma generate`. Apply locally: get the IP with `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy` and confirm with `prisma migrate status` (same env).
**Shared decoder.** `apps/api/src/accounting/decode-csv-text.ts` exports `decodeCsvText(buffer: Buffer): string`: try `new TextDecoder('utf-8', { fatal: true })` (drops BOM by default); on TypeError fall back to `new TextDecoder('windows-1252')`. (Source also sniffed CP850 for the Konten CSV — not required here; Excel CSV is UTF-8 or cp1252.) Reused by Task 2.
**Pure functions (port, keep source German messages).** `kantine-datev.types.ts` ports source `types.ts` and adds a stable `code` to every error/warning (codes: headerMissing, headerColumns, columnCount, personalNrMissing, personalNrNotNumeric, betragMissing, betragFormat, vonMissing, vonFormat, bisMissing, bisFormat, multiMonthRange, noRows; warning multipleMonths with params.months) so the web can translate while `message` keeps the source text. `kantine-csv.parser.ts` = source `parser.ts` (input already decoded string). `kantine-csv.validator.ts` = source `validator.ts`, rules and regexes unchanged. `kantine-datev.transformer.ts` = source `transformer.ts` with the three constants replaced by a `KantineDatevSettings { beraterNr, mandantNr, lohnart }` argument to `generateDatevOutput`; `qualityCheck` unchanged; add `buildKantineExportFilename(settings, month)` per source DatevPreview rule. `kantine-datev.pipeline.ts`: `processKantineCsv(buffer, settings)` = decode → parse → validate → totals (sum Betrag in integer cents from the German string, only for rows that passed Betrag validation) → preview object; `buildKantineExport(buffer, settings)` = same steps, throws a typed error when errors exist / no rows / settings missing, generates output, runs `qualityCheck`, throws when it fails, returns `{ filename, content (base64 of the ASCII output), mimeType: 'text/plain' }` — same FileResponse shape as cert-manager. Never log row content or names.
**Service/DTO/controller.** `dto/kantine-datev-settings.dto.ts`: three required `@IsString() @Matches(/^\d{1,10}$/)` fields with German messages ("… darf nur Ziffern enthalten"). `kantine-datev.service.ts`: `getSettings(tenantId)` (findUnique by tenantId via `const tenantPrisma = forTenant(this.prisma, tenantId)`), `saveSettings(tenantId, dto)` (upsert on tenantId), `preview(tenantId, buffer)`, `export(tenantId, buffer)` (load settings, call pipeline, map typed errors to `BadRequestException({ code, message, errors })` / quality failure to `UnprocessableEntityException`). `kantine-datev.controller.ts`: `@Controller('modules/kantine-datev') @UseModule('kantine-datev')`, `requireTenantId` like ProxmoxController; `GET settings`, `PUT settings` with `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`, `POST preview` and `POST export` with `FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } })`, 400 when no file. No `:id` routes here. Uploaded buffers stay in memory (multer memory storage default) and are never persisted. `kantine-datev.seed.ts`: slug `kantine-datev`, name `Kantinenabrechnung`, version `1.0.0`, `category: 'accounting'`, description de "Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren" / en "Check canteen CSV files and export them as a DATEV payroll ASCII file", `isSystem: true`. `kantine-datev.module.ts` like CertManagerModule (imports ModuleRegistryModule, seeds in onModuleInit with try/catch + logger). Register `KantineDatevModule` in `apps/api/src/app.module.ts`.
**Access inventory.** Run `pnpm --filter @tessera/api exec vitest run rls-access-inventory rls-coverage`; add the Fundstellen row for `apps/api/src/kantine-datev/kantine-datev.service.ts | kantineDatevConfig | muss-mandantengebunden | gebunden | …` (German justification: Mandanten-Einstellung, no user dimension, migration 20261002120000) and a new area row `kantine-datev` plus an updated `Summe` row in `docs/mandantentrennung-zugriffsklassifikation.md`, measured with the gate loop like the previous entries (not copied).
**Web.** `apps/web/src/lib/download-base64.ts`: same body as cert-manager's `downloadBase64` (Blob + object URL + anchor with `download` + click + revoke) — this blob mechanism is what the desktop client saves since 1.9.2, so no Tauri-specific code. `apps/web/src/lib/kantine-datev-api.ts` in the style of `custom-modules-api.ts` (`credentials: 'include'`, `NEXT_PUBLIC_API_URL`, error class carrying status + code + message): `getKantineSettings`, `saveKantineSettings`, `previewKantineCsv(file)`, `exportKantineCsv(file)` (multipart field `file`). Module dir `apps/web/src/app/(portal)/modules/kantine-datev/`: `layout.tsx` = ModuleAccessGate with `moduleSlug="kantine-datev"`; `page.tsx` ('use client', default export) with `PageHeader moduleSlug="kantine-datev"`, tabs "Abrechnung" and (admins only, via `useAuthStore` role ADMIN/SUPER_ADMIN) "Einstellungen"; Abrechnung: module-local drop area (accept `.csv,text/csv`), note "Die hochgeladenen Daten werden nicht gespeichert.", summary (Zeilen, Abrechnungsmonat, Gesamtbetrag formatted de-DE EUR from totalCents), warnings, error table (Zeile, Feld, Meldung translated via `kantineDatev.errors.<code>`, falling back to `message`), download button "DATEV-Datei herunterladen" (disabled while errors or not configured; keeps the File in state and re-sends it to export). Not configured: admin sees hint + link to the settings tab, others see "Ein Administrator muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen." Einstellungen: three numeric inputs (inputMode numeric, client-side digits check, empty by default, no placeholders with real numbers), save with success/error feedback. All texts under namespace `kantineDatev` in de.json (formal "Sie") and en.json. Registries: `module-loader.ts` entry `'kantine-datev'` (dynamic import, ssr false); `module-identity.ts` new `ModuleIconId` `'utensils'` mapped from `kantine-datev`; `module-tile.tsx` GLYPHS entry `utensils` with the Lucide "utensils" stroke paths; `nav-store.ts` MODULE_TITLE_KEYS `'kantine-datev': 'kantineDatev.title'`; `module-layouts.test.tsx` adds `['kantine-datev', KantineDatevLayout]`. Write `kantine-datev.test.tsx` per the behavior list (real NextIntlClientProvider with de.json, mocked `@/lib/kantine-datev-api`, mocked auth store, mocked `@/lib/download-base64`).
**Finish.** Biome lint the touched files (`pnpm exec biome lint <files>` from repo root, fix findings in new files), type-check both apps, run the verify command, commit atomically (German subject, e.g. `feat(kantine-datev): Kantinenabrechnung als Modul in neuer Gruppe Finanzbuchhaltung`, attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/accounting src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev module-layouts module-categories && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -rnE '1387819|\b10001\b|\b9005\b' apps/api/src/kantine-datev 'apps/web/src/app/(portal)/modules/kantine-datev' apps/web/src/lib/kantine-datev-api.ts apps/api/prisma/migrations/20261002120000_kantine_datev_config)"</automated>
</verify>
<done>Migration applied locally (`prisma migrate status` up to date); all listed api and web tests green; both type-checks clean; biome clean on new files; no colleague-specific numbers in Kantine code/tests/migration; one commit on main, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Handelsware API — Prisma models with RLS, pure XLSX/TXT/CSV functions, service, controller</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql, apps/api/src/handelsware-datev/handelsware-datev.types.ts, apps/api/src/handelsware-datev/handelsware-xlsx.ts, apps/api/src/handelsware-datev/handelsware-xlsx.spec.ts, apps/api/src/handelsware-datev/handelsware-transform.ts, apps/api/src/handelsware-datev/handelsware-transform.spec.ts, apps/api/src/handelsware-datev/handelsware-konten-csv.ts, apps/api/src/handelsware-datev/handelsware-konten-csv.spec.ts, apps/api/src/handelsware-datev/dto/handelsware-settings.dto.ts, apps/api/src/handelsware-datev/dto/handelsware-account.dto.ts, apps/api/src/handelsware-datev/handelsware-datev.service.ts, apps/api/src/handelsware-datev/handelsware-datev.service.spec.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts, apps/api/src/handelsware-datev/handelsware-datev.seed.ts, apps/api/src/handelsware-datev/handelsware-datev.module.ts, apps/api/src/app.module.ts, docs/mandantentrennung-zugriffsklassifikation.md</files>
<behavior>
- parseHandelswareXlsx(buffer) on a workbook built in the test with XLSX.utils: B1 text/number → headerText string; rows from line 2 until A and B are both empty; numeric B used as is; German string "1.234,56" → 1234.56, "-12,5" → -12.5; non-numeric or empty B with text in A → rowError {line, code: umsatzInvalid}; row with empty A but value in B skipped (source behaviour); garbage bytes → typed invalidFile error; more than 10 000 data rows → tooManyRows
- calculateBuchungsdatum: "HWA 0326 Test.xlsx" → "3103", "HWA 0226.xlsx" → "2802", "x 0228.xlsx" → "2902" (leap year), month 00 or 13 or no 4-digit group → ""; isValidBuchungsdatum accepts "3103", rejects "3102", "0013", "abc", "310"
- formatAmount: 12.5 → {"12.50","S"}, 0 → {"0.00","S"}, -3.456 → {"3.46","H"}
- assignAccounts(rows, accounts, settings): known name → its gegenkonto + erloeskonto, isNew false; unknown names → max existing gegenkonto + 1, then + 2 …; empty list → first new = startGegenkonto exactly, next + 1; the same unknown name twice in one file reuses one new account; new accounts carry settings.erloeskonto; output lists newAccounts in first-seen order
- generateTxt: line 1 = TAB headerText TAB TAB TAB TAB; data = text TAB umsatz TAB S/H TAB gegenkonto TAB TTMM TAB erloeskonto; CRLF joined plus trailing CRLF; umlauts preserved (UTF-8)
- getExportFilename (source rule): "HWA 0326 Test.xlsx" → "HWA_0326.txt", "HWA0326.xlsx" → "HWA_0326.txt", "Liste.xlsx" → "Handelsware_Export.txt"
- parseKontenCsv: decodes via decodeCsvText, CRLF/LF, optional header line skipped when column 2 is not numeric, third column missing → settings erloeskonto (error missingErloeskonto when that setting is empty), invalid numbers or empty name → line errors, duplicate names → error duplicateName; generateKontenCsv writes Name;Gegenkonto;Konto lines with CRLF, prefixed with a UTF-8 BOM so Excel shows umlauts, and prefixes names starting with =, +, - or @ with an apostrophe (formula-injection guard); parseKontenCsv strips that apostrophe again so export → import round-trips
- Service: preview blocks with code settingsMissing while erloeskonto or startGegenkonto is null; export re-parses the uploaded file, recomputes inside withTenantTransaction and returns 409 code accountsChanged when the recomputed new accounts (name + gegenkonto) differ from the submitted list, otherwise creates them in the same transaction and returns FileResponse + createdCount; invalid buchungsdatum → 400; rowErrors → 400; CSV import replace runs deleteMany + createMany in one withTenantTransaction and changes nothing when any line is invalid; createAccount/updateAccount map Prisma P2002 to 409 nameTaken; update/delete of an unknown id → 404
- Controller: path modules/handelsware-datev, @UseModule('handelsware-datev'); PUT settings carries @Roles(ADMIN, SUPER_ADMIN), everything else no role; every static accounts route (GET accounts, POST accounts, GET accounts/export-csv, POST accounts/import-csv) is declared before PUT accounts/:id and DELETE accounts/:id (declaration-order test via Object.getOwnPropertyNames of the prototype)
</behavior>
<action>
**Prisma + migration.** Add to `apps/api/prisma/schema.prisma`: `HandelswareDatevConfig` (singleton per tenant like `KantineDatevConfig`: `tenantId @unique`, `erloeskonto Int?`, `startGegenkonto Int?`, timestamps, no defaults on the two numbers — the colleague's hardcoded Erlöskonto from the source must not become a default, placeholder or test value) and `HandelswareKonto` (`id` uuid, `tenantId`, `name String`, `gegenkonto Int`, `erloeskonto Int`, `createdAt`, `updatedAt`, `@@unique([tenantId, name])`, `@@index([tenantId])`; no relation to Tenant, like ProxmoxServer; gegenkonto deliberately not unique — the source allows shared counter accounts). Hand-write `apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql` with the German header comment and the same RLS form as Task 1 for both tables (unique indexes `HandelswareDatevConfig_tenantId_key` and `HandelswareKonto_tenantId_name_key`, ENABLE + FORCE, `tenant_isolation_policy`, no system_read_policy). `prisma generate`, then apply locally with the container-IP `prisma migrate deploy` exactly as in Task 1 and check `prisma migrate status`.
**Pure functions (port of source handelswareService.ts, write tests first).** `handelsware-datev.types.ts`: ImportRow {line, buchungstext, umsatz:number}, PreviewRow {line, buchungstext, umsatz:string, sollHaben, gegenkonto, erloeskonto, isNew}, NewAccount {name, gegenkonto, erloeskonto}, RowError {line, code, message}, settings type, FileResponse {filename, content, mimeType}. `handelsware-xlsx.ts`: `parseHandelswareXlsx(buffer)` with `XLSX.read(buffer, { type: 'buffer', cellFormula: false, cellHTML: false, cellStyles: false, sheetRows: MAX_ROWS + 1 })` (MAX_ROWS = 10 000 data rows; first sheet only, cells B1 and A/B from row 2 — source loop), plus `parseUmsatz(value)` (number → as is; string → trim, drop spaces and thousands dots, comma → dot, must match an optional minus + digits + optional decimals, else null). Improvement over the source (which silently used 0): invalid Umsatz becomes a row error. `handelsware-transform.ts`: `calculateBuchungsdatum(filename)` (source logic + month 1-12 guard), `isValidBuchungsdatum(ttmm)` (4 digits, month 1-12, day 1..days of that month, Feb up to 29), `formatAmount` (source), `assignAccounts(importRows, accounts, settings)` (exact, case-sensitive name match on the trimmed text as in the source; next free = max existing gegenkonto + 1, or settings.startGegenkonto when the list is empty — a documented choice: "Startwert" is the first number handed out), `generateTxt(headerText, rows, buchungsdatum)` (source format, rows take the edited date), `getExportFilename(importFilename)` (source regex and fallback). `handelsware-konten-csv.ts`: `parseKontenCsv(buffer, defaultErloeskonto)` using `decodeCsvText` from `apps/api/src/accounting/decode-csv-text.ts`, and `generateKontenCsv(accounts)`.
**DTOs, service, controller, seed, module.** `dto/handelsware-settings.dto.ts`: `erloeskonto`, `startGegenkonto` both `@IsInt() @Min(1) @Max(999999999)` with German messages. `dto/handelsware-account.dto.ts`: create/update with `name` (`@IsString`, trimmed, length 1-120), `gegenkonto`, `erloeskonto` (`@IsInt` 1-999999999). `handelsware-datev.service.ts`: settings get/upsert via `const tenantPrisma = forTenant(this.prisma, tenantId)`; `listAccounts` (orderBy name), `createAccount`, `updateAccount` (findFirst by id within tenant → 404), `deleteAccount`, `exportAccountsCsv` (FileResponse `Konten.csv`, `text/csv;charset=utf-8`), `importAccountsCsv(tenantId, buffer)` (parse all first, abort with 400 + line errors, else `withTenantTransaction(this.prisma, tenantId, async (tx) => …)` deleteMany + createMany, return count); `preview(tenantId, file)` → `{ headerText, suggestedBuchungsdatum, exportFilename, rows, newAccounts, rowErrors }`; `export(tenantId, file, buchungsdatum, submittedNewAccounts)` → validate TTMM, re-parse the file, then inside one `withTenantTransaction` load settings + accounts via `tx`, recompute with `assignAccounts`, compare to the submitted list (409 `accountsChanged`, German message "Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren."), createMany the new accounts (P2002 → 409 too), build TXT, return `{ ...FileResponse (UTF-8 bytes base64, text/plain;charset=utf-8), createdCount }`. Design note (record in SUMMARY): the export re-sends the original XLSX as multipart plus `buchungsdatum` and `newAccounts` (JSON string of the preview's new accounts) instead of a JSON body with all rows — the server re-derives every row from the same file, so the TXT cannot diverge from the workbook, and Express's 100 kB JSON body default does not cap large lists. `handelsware-datev.controller.ts`: `@Controller('modules/handelsware-datev') @UseModule('handelsware-datev')`, `requireTenantId`; declare in this order: GET settings, PUT settings (`@Roles(Role.ADMIN, Role.SUPER_ADMIN)`), POST preview, POST export (both `FileInterceptor('file', 5 MB)`), GET accounts, POST accounts, GET accounts/export-csv, POST accounts/import-csv (`FileInterceptor`, 1 MB), then PUT accounts/:id and DELETE accounts/:id. Parse the `newAccounts` field defensively (JSON.parse in try/catch, array of objects with string name and integer gegenkonto, at most 10 000 entries, else 400). Multer decodes `originalname` as latin1 — convert with `Buffer.from(name, 'latin1').toString('utf8')` before deriving date/filename. `handelsware-datev.seed.ts`: slug `handelsware-datev`, name `Handelsware`, `category: 'accounting'`, description de "Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei exportieren" / en "Map merchandise sales from Excel to revenue accounts and export a DATEV booking file", `isSystem: true`; module class like Task 1; register in `apps/api/src/app.module.ts`.
**Access inventory.** Run the two RLS specs; add Fundstellen rows for `apps/api/src/handelsware-datev/handelsware-datev.service.ts` × `handelswareDatevConfig` and × `handelswareKonto` with the Stand the spec measures (bound client + `tx` of withTenantTransaction), plus area row `handelsware-datev` and updated `Summe`, measured with the gate loop.
**Tests.** Specs per the behavior list; service spec with a fake PrismaService/tx (pattern: `apps/api/src/favorites/favorites.service.spec.ts` for withTenantTransaction fakes) covering conflict 409, createMany only on export, preview not writing, replace-import atomicity; controller spec for roles metadata, path, file-missing 400 and the declaration-order describe "Routen-Reihenfolge (statisch vor :id)". Biome lint touched files, `tsc --noEmit`, commit atomically (e.g. `feat(handelsware-datev): API, Kontenliste mit Zeilenschutz und DATEV-Export`). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/handelsware-datev src/accounting rls-coverage rls-access-inventory && pnpm --filter @tessera/api exec tsc --noEmit && test -z "$(grep -rn '8000' apps/api/src/handelsware-datev apps/api/prisma/migrations/20261002130000_handelsware_datev)"</automated>
</verify>
<done>Migration applied locally and `prisma migrate status` up to date; handelsware + accounting + RLS specs green (including the route declaration-order test); api type-check clean; no default or sample value equal to the colleague's Erlöskonto in API code/tests/migration; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Handelsware web (Import / Konten / Einstellungen), docs, CHANGELOG, local stack rebuild and smoke check</name>
<files>apps/web/src/lib/handelsware-datev-api.ts, apps/web/src/app/(portal)/modules/handelsware-datev/layout.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/AccountsTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/SettingsTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx, apps/web/src/app/(portal)/modules/module-layouts.test.tsx, apps/web/src/lib/module-loader.ts, apps/web/src/lib/module-identity.ts, apps/web/src/components/modules/module-tile.tsx, apps/web/src/lib/stores/nav-store.ts, apps/web/src/messages/de.json, apps/web/src/messages/en.json, CHANGELOG.md, docs/anleitung-anwender.md</files>
<behavior>
- Import tab: after upload the preview table shows Buchungstext, Umsatz, S/H, Gegenkonto, Datum, Erlöskonto; rows whose account is new show a visible "neu" badge and a summary line "N neue Konten werden beim Herunterladen gespeichert"
- The Buchungsdatum field is prefilled from suggestedBuchungsdatum, editable; an invalid TTMM shows an inline error and disables download; the Datum column follows the edited value
- Download calls the export client with the same File, the edited date and the preview's newAccounts, then downloadBase64(filename, content, mimeType) and a success message naming the count of saved accounts; a 409 accountsChanged shows the server hint and offers to reload the preview
- settingsMissing: admins see a hint pointing to the Einstellungen tab, other users see "Ein Administrator muss zuerst …"; rowErrors are listed with line numbers and block download
- Konten tab: list, add, edit, delete (with confirm); CSV import opens a confirmation "Alle N vorhandenen Konten werden ersetzt" before calling import; CSV export triggers downloadBase64
- Einstellungen tab visible only for ADMIN/SUPER_ADMIN, two numeric fields, empty by default
</behavior>
<action>
**API client.** `apps/web/src/lib/handelsware-datev-api.ts` in the style of `custom-modules-api.ts` with an error class carrying status + code + message: `getHandelswareSettings`, `saveHandelswareSettings`, `previewHandelsware(file)`, `exportHandelsware(file, buchungsdatum, newAccounts)` (multipart: file, buchungsdatum, newAccounts as JSON string), `listAccounts`, `createAccount`, `updateAccount(id, …)`, `deleteAccount(id)`, `importAccountsCsv(file)`, `exportAccountsCsv()`; also export a client-side `isValidBuchungsdatum` mirroring the API rule (same cases as Task 2 tests).
**Module UI.** `layout.tsx` = ModuleAccessGate with `moduleSlug="handelsware-datev"`. `page.tsx` ('use client', default export): `PageHeader moduleSlug="handelsware-datev"`, tabs "Import", "Konten", and "Einstellungen" (admins only via `useAuthStore`), tab pattern from cert-manager. `components/ImportTab.tsx`: module-local drop area (accept `.xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`), header text display, Buchungsdatum input (maxLength 4, inputMode numeric, label "Buchungsdatum (TTMM)"), export filename display, preview table per behavior with "neu" badge (accent token, readable in dark mode), totals of S and H, row errors, download button "Buchungsdatei herunterladen"; after a successful export notify the Konten tab to reload (shared state in page or a reload key). `components/AccountsTab.tsx`: table Name / Gegenkonto / Erlöskonto, inline add row, edit (inline or small modal following existing modal patterns), delete with confirm, "CSV importieren" (file input → confirmation dialog naming the current count and the replace effect → import → show count or line errors), "CSV exportieren" via `downloadBase64` from `apps/web/src/lib/download-base64.ts`. `components/SettingsTab.tsx`: "Standard-Erlöskonto" and "Startwert Gegenkonto" numeric inputs, empty by default, short explanations ("wird neuen Konten zugeordnet" / "erste Nummer, wenn die Kontenliste leer ist"), save feedback; no placeholder showing a real account number. All texts under namespace `handelswareDatev` in `de.json` (formal "Sie") and `en.json`, error codes translated via `handelswareDatev.errors.<code>` with server message fallback.
**Registries.** `module-loader.ts` entry `'handelsware-datev'`; `module-identity.ts` new ModuleIconId `'shopping-bag'` mapped from `handelsware-datev`; `module-tile.tsx` GLYPHS entry with the Lucide "shopping-bag" stroke paths; `nav-store.ts` `'handelsware-datev': 'handelswareDatev.title'`; `module-layouts.test.tsx` adds `['handelsware-datev', HandelswareDatevLayout]`.
**Tests.** `handelsware-datev.test.tsx` per the behavior list (real NextIntlClientProvider with de.json, mocked api client, auth store and download helper).
**Docs.** `CHANGELOG.md` under "## Unveröffentlicht" add "### Neu" with two plain-language entries (Kantinenabrechnung: CSV der Kantine prüfen, Fehler mit Zeilennummer, DATEV-Lohndatei herunterladen, Nummern einmalig vom Administrator hinterlegen, hochgeladene Daten werden nicht gespeichert; Handelsware: Excel-Liste hochladen, Konten automatisch zuordnen, neue Konten markiert und erst beim Herunterladen gespeichert, Buchungsdatum aus dem Dateinamen, Kontenliste pflegen und als CSV ein- und auslesen; both under the new group „Finanzbuchhaltung“; activation via Marktplatz + Freigabe). `docs/anleitung-anwender.md`: add "### Kantinenabrechnung" and "### Handelsware" under "## Die Module" plus table-of-contents entries, same tone as the existing module sections.
**Local stack + smoke.** Rebuild with `docker compose up -d --build api web`; check `docker compose logs api --tail 80` for both seed log lines and no migration error. Generate fictitious test files into the scratch directory and copy them to `.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/testdata/` for the browser check: a canteen CSV encoded Windows-1252 with CRLF, umlaut names, one invalid row and two billing months; a valid UTF-8 canteen CSV; an XLSX named like "HWA 0326 Test.xlsx" (B1 header, mixed numeric and German-string amounts, one negative, one unknown product), built with the api's `xlsx` package via `pnpm --filter @tessera/api exec node -e …`; a Konten CSV `Name;Gegenkonto;Konto`. If the Playwright MCP tool is available: in dark mode (theme button), activate both modules in the Marketplace, grant access, verify sidebar group "Finanzbuchhaltung", run each flow and confirm the downloaded files' content (11 columns + CRLF; TXT format; new accounts appear in Konten only after download). If Playwright is not available, state in the SUMMARY that the browser check is left to the orchestrator and list the testdata paths.
**SUMMARY must name, in plain words:** open question Handelsware TXT encoding (kept UTF-8 like the source; DATEV imports often expect Windows-1252/ANSI — umlauts in product names may need it); the export re-send design; the "Startwert" semantics; that only administrators change settings while all granted users maintain the Konten list; that the api's `xlsx` 0.18.5 is used for reading uploads (see threat model); browser check status.
Run the full api and web test suites, both type-checks, biome lint on touched files; commit atomically (e.g. `feat(handelsware-datev): Modulseite mit Import, Konten und Einstellungen`) and a separate docs commit if preferred. Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/web exec vitest run handelsware-datev kantine-datev module-layouts module-categories && pnpm --filter @tessera/web exec tsc --noEmit && pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && test -z "$(grep -rn '8000' 'apps/web/src/app/(portal)/modules/handelsware-datev' apps/web/src/lib/handelsware-datev-api.ts)" && docker compose logs api --tail 200 | grep -ciE 'kantine|handelsware'</automated>
</verify>
<done>Web tests (new + full suite) and api full suite green; type-checks clean; local stack rebuilt and both modules seeded; testdata files exist; CHANGELOG and user guide updated; browser check done or explicitly handed off in SUMMARY; commits made, nothing pushed.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser/desktop → API | Authenticated, granted module users upload CSV/XLSX files and send account data |
| API → PostgreSQL | Tenant-bound access to config and account tables under RLS |
| uploaded file → parser | Untrusted file content parsed in memory (TextDecoder, `xlsx`) |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-FM5-01 | Information disclosure | Kantine upload (names, personnel numbers) | high | mitigate | Pure in-memory processing (multer memory storage, no DB write, no file write); no logging of row content; preview returns only counts, month, total, row errors (field + line, no names) |
| T-FM5-02 | Elevation of privilege | Settings endpoints of both modules | medium | mitigate | `PUT settings` carries `@Roles(Role.ADMIN, Role.SUPER_ADMIN)`; all routes under `@UseModule(...)` (activation + grant); controller specs assert the metadata |
| T-FM5-03 | Information disclosure / Tampering | KantineDatevConfig, HandelswareDatevConfig, HandelswareKonto | high | mitigate | `tenantId` on every table, ENABLE + FORCE RLS + `tenant_isolation_policy`; all access via `forTenant` or `withTenantTransaction`; rls-coverage + rls-access-inventory specs updated and green |
| T-FM5-04 | Denial of service | Upload endpoints | medium | mitigate | Multer `fileSize` 5 MB (CSV import 1 MB), XLSX `sheetRows` cap (10 000 data rows), `newAccounts` array capped and parsed defensively |
| T-FM5-05 | Tampering | `xlsx` 0.18.5 reading crafted workbooks (known prototype-pollution/ReDoS advisories fixed in later SheetJS builds that are not on the npm registry) | medium | accept | Only authenticated, admin-granted internal users can upload; formulas/HTML/styles disabled, only first sheet cells A/B read, size and row caps; noted in SUMMARY. Upgrading the library is a separate decision outside this task |
| T-FM5-06 | Tampering | Handelsware export (client-submitted new accounts) | medium | mitigate | Server re-parses the uploaded file and recomputes the mapping inside one tenant-bound transaction; mismatch → 409; unique (tenantId, name) catches races (P2002 → 409) |
| T-FM5-07 | Tampering | CSV formula injection in Konten CSV export opened in Excel | low | mitigate | Account names starting with =, +, -, @ are prefixed with an apostrophe in `generateKontenCsv` (covered by a test) |
| T-FM5-SC | Tampering | npm/pip/cargo installs | low | accept | No package installs in this plan (`xlsx` already a dependency of apps/api) |
</threat_model>
<verification>
- `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test` green
- `pnpm --filter @tessera/api exec tsc --noEmit` and `pnpm --filter @tessera/web exec tsc --noEmit` clean
- Biome lint clean on all new files
- Local DB: both migrations applied (`prisma migrate status` up to date); stack rebuilt; api logs show both modules seeded
- Grep gates: no colleague-specific numbers in Kantine/Handelsware code, tests, migrations, web
- Browser check (Playwright, dark mode) done or explicitly handed to the orchestrator in SUMMARY
</verification>
<success_criteria>
- Sidebar shows group "Finanzbuchhaltung" with "Kantinenabrechnung" and "Handelsware" for granted users
- Canteen CSV (UTF-8 or Windows-1252) → correct preview and a DATEV Lohn ASCII file that passes the source quality check, using tenant settings
- Handelsware XLSX → preview with "neu" markers and editable date → TXT in the source format; new accounts saved only on download, atomically, with conflict detection
- Konten tab fully usable incl. CSV import (replace with confirmation) and export
- No company-specific defaults; settings empty until an administrator sets them
- Three atomic commits (plus optional docs commit) on main, not pushed
</success_criteria>
<output>
Create `.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/261002-fm5-SUMMARY.md` when done (include the open questions listed in Task 3).
</output>
@@ -0,0 +1,154 @@
---
phase: quick-261002-fm5
plan: 01
subsystem: finanzbuchhaltung
tags: [kantine-datev, handelsware-datev, datev, prisma-rls, nestjs, next-intl, xlsx]
requires: []
provides:
- Seitenleisten-Kategorie accounting (Finanzbuchhaltung / Financial accounting)
- Modul kantine-datev (Kantinenabrechnung) mit DATEV-Lohn-ASCII-Export
- Modul handelsware-datev (Handelsware) mit Kontenliste und DATEV-Buchungsdatei
affects: [module-registry, marketplace, sidebar, rls-access-inventory]
tech-stack:
added: []
patterns:
- reine, getestete Verarbeitungsfunktionen getrennt von Dienst und Controller
- Speicherung nur beim Export, in einer mandantengebundenen Transaktion mit erneuter Berechnung
- Blob-Download im Browser (wie der Zertifikat-Manager), keine Tauri-Sonderlogik
key-files:
created:
- apps/api/src/accounting/decode-csv-text.ts
- apps/api/src/accounting/decode-upload-filename.ts
- apps/api/src/kantine-datev/ (Parser, Validator, Transformer, Pipeline, Dienst, Controller, Seed, Modul, Tests)
- apps/api/src/handelsware-datev/ (XLSX, Transformation, Konten-CSV, Dienst, Controller, Seed, Modul, Tests)
- apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql
- apps/api/prisma/migrations/20261002130000_handelsware_datev/migration.sql
- apps/web/src/app/(portal)/modules/kantine-datev/ (Seite, Layout, Test)
- apps/web/src/app/(portal)/modules/handelsware-datev/ (Seite, Layout, drei Reiter, Test)
- apps/web/src/lib/kantine-datev-api.ts
- apps/web/src/lib/handelsware-datev-api.ts
- apps/web/src/lib/accounting-request.ts
- apps/web/src/lib/download-base64.ts
- apps/web/src/components/accounting/file-drop-area.tsx
- apps/web/src/components/accounting/tab-bar.tsx
modified:
- packages/shared/src/index.ts
- apps/api/prisma/schema.prisma
- apps/api/src/app.module.ts
- docs/mandantentrennung-zugriffsklassifikation.md
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
- apps/web/src/messages/umlaut-dictionary.ts
- apps/web/src/lib/module-loader.ts
- apps/web/src/lib/module-identity.ts
- apps/web/src/components/modules/module-tile.tsx
- apps/web/src/lib/stores/nav-store.ts
- apps/web/src/app/(portal)/modules/module-layouts.test.tsx
- CHANGELOG.md
- docs/anleitung-anwender.md
decisions:
- Handelsware-Export schickt die Excel-Datei erneut mit (Multipart) plus buchungsdatum und newAccounts (JSON-Text), statt einer JSON-Liste aller Zeilen
- Startwert Gegenkonto ist die erste vergebene Nummer bei leerer Kontenliste, sonst hoechstes vorhandenes Gegenkonto + 1
- Einstellungen aendern nur Administratoren, die Kontenliste pflegen alle Benutzer mit Modulzugriff
- Handelsware-TXT bleibt UTF-8 (wie die Vorlage), offene Frage zur Kodierung siehe unten
metrics:
duration: ca. 1 h 20 min
completed: 2026-10-02
status: complete
plan_head_before: 0edd6e9b1a1e0e594663129fdc185187ef81636e
plan_head_after: 14933753e70e85bb7c318ac347dd02a702e11841
commits: 4
actuals:
tokens: 63000
tasks: 3
commits: 4
---
# Phase quick-261002-fm5 Plan 01: Finanzbuchhaltung, Kantinenabrechnung und Handelsware
Zwei neue Module in der neuen Seitenleisten-Gruppe „Finanzbuchhaltung“: **Kantinenabrechnung** (Kantinen-CSV prüfen, DATEV-Lohndatei im ASCII-Format erzeugen) und **Handelsware** (Excel-Umsätze Erlöskonten zuordnen, Kontenliste pflegen, DATEV-Buchungsdatei als TXT erzeugen). Beide laufen über Aktivierung im Marktplatz plus Freigabe. Beraternummer, Mandantennummer, Lohnart, Standard-Erlöskonto und Startwert Gegenkonto sind je Mandant leer, bis ein Administrator sie einträgt. Nichts wurde gepusht.
## Commits
| Aufgabe | Commit | Inhalt |
|---|---|---|
| 1 (Tracer) | `1f85277` | Kategorie accounting, Kantinenabrechnung durchgängig (API, Migration, Web, Zugriffsinventar) |
| 2 | `44c1d43` | Handelsware API: Prisma-Modelle mit Zeilenschutz, XLSX/TXT/CSV-Funktionen, Dienst, Controller |
| 3 | `42b89f1` | Handelsware Modulseite (Import / Konten / Einstellungen), Registries, Umlaut-Allowlist |
| 3 (Doku) | `1493375` | CHANGELOG und Anwenderanleitung |
SUMMARY, STATE und PLAN sind wie verlangt nicht committet (Orchestrator).
## Prüfergebnisse (ehrlich)
- **API-Tests:** 109 Testdateien, **1857 Tests grün** (`pnpm --filter @tessera/api test`). Davon neu: accounting 8, kantine-datev 53, handelsware-datev 110 (Spec-Läufe zusammen 206 inkl. RLS-Gates).
- **Web-Tests:** 115 Testdateien, **1214 Tests grün** (`pnpm --filter @tessera/web test`). Davon neu: kantine-datev 7 + 1 Layout, handelsware-datev 15 + 1 Layout.
- **Type-Check:** `tsc --noEmit` für api und web **sauber**.
- **Biome:** `biome lint` auf allen neuen/geänderten Dateien ohne Befund. `biome check --write` (Format + Importsortierung) wurde auf die neuen Dateien angewandt. Bestehende, nicht von mir angefasste Dateien (z. B. `apps/api/src/proxmox`) sind schon vorher nicht formatrein; das habe ich nicht angefasst.
- **RLS-Gates:** `rls-coverage` und `rls-access-inventory` grün, Fundstellentabelle, Bereichszeilen, Summenzeile und Paarzählung (92 Paare) im Dokument nachgeführt.
- **Migrationen:** beide lokal über die Container-IP angewandt, `prisma migrate status` „Database schema is up to date“ (54 Migrationen), `prisma migrate diff` Schema gegen DB: „No difference detected“.
- **Grep-Gates:** keine Zahlen 1387819 / 10001 / 9005 in Kantine-Code, -Tests, -Web oder -Migration; keine 8000 in Handelsware-Code, -Tests, -Web, -Migration, auch nicht in den Testdateien.
- **Lokaler Stack:** `docker compose up -d --build api web` gebaut, API healthy. Logs: `Kantine-DATEV module seeded in registry`, `Handelsware-DATEV module seeded in registry`, alle Routen gemappt, kein Migrationsfehler. Zeilen in `Module`: `kantine-datev` und `handelsware-datev`, beide Kategorie `accounting`.
- **Browser-Prüfung:** nicht gemacht, liegt beim Orchestrator. Ich habe mich nicht angemeldet und keine Zugangsdaten gelesen. Das heißt: die Seiten laufen bisher nur gegen die Komponententests und gegen den gebauten Stack (Start, Routen, Seed), nicht gegen echte Klicks.
## Testdateien für die Browser-Prüfung
Alle in `/home/vicolab/projects/tessera-ctl/.planning/quick/261002-fm5-finanzbuchhaltung-module-kantinenabrechn/testdata/`, alles erfundene Daten, mit den Pipeline-Funktionen gegengeprüft:
| Datei | Zweck | Erwartung in der Vorschau |
|---|---|---|
| `kantine-cp1252-fehler.csv` | Windows-1252, CRLF, Umlaute, ein ungültiger Betrag, zwei Abrechnungsmonate | 4 Zeilen, Monat 03/2026, Gesamtbetrag 71,74 EUR, Fehler in Zeile 4 (Betrag), Warnung „03/2026, 04/2026“, Download gesperrt |
| `kantine-gueltig-utf8.csv` | gültig, UTF-8, LF | 4 Zeilen, 03/2026, 82,54 EUR, kein Fehler. Datei bei Einstellungen z. B. 1234567 / 12345 / 1111: `LuG_1234567_12345_03_2026.sic` |
| `HWA 0326 Test.xlsx` | B1 = 2026, gemischt Zahl und deutscher Text, ein negativer Wert, ein unbekanntes Produkt | Datumsvorschlag 3103, Dateiname `HWA_0326.txt` |
| `Konten.csv` | Windows-1252, `Name;Gegenkonto;Konto` | enthält 4 Konten, „Neues Produkt Saft“ fehlt absichtlich. Nach dem Import dieser Liste (Standard-Erlöskonto z. B. 4711, Startwert 2000) bekommt das Produkt Gegenkonto 2014 und die Markierung „neu“ |
Ablauf-Vorschlag: Kantine-Einstellungen leer lassen und Hinweis prüfen, dann Werte eintragen. Handelsware: erst Einstellungen setzen, dann `Konten.csv` im Reiter Konten importieren, danach die XLSX hochladen, „neu“ prüfen, Konten-Reiter vor und nach dem Download vergleichen.
## Abweichungen vom Plan
1. **[Regel 1 – Fehler] Zeilennummern aus der echten Datei.** Die Vorlage nutzt `Index + 2`; bei Leerzeilen oder übersprungenen Zeilen zeigt das eine falsche Zeile. Der Parser gibt jetzt die echte 1-basierte Dateizeile mit (`line`), der Validator nutzt sie und fällt ohne sie auf `Index + 2` zurück (Test deckt beides ab). Commit `1f85277`.
2. **[Regel 3 – blockierend] `sheetRows` = 10 002 statt `MAX_ROWS + 1`.** Mit `MAX_ROWS + 1` wäre die 10 001. Datenzeile nie sichtbar, „zu viele Zeilen“ also nicht erkennbar. Test prüft genau 10 000 (ok) und 10 001 (Fehler). Commit `44c1d43`.
3. **[Regel 2 – fehlende Absicherung] XLSX-Signaturprüfung.** SheetJS wirft bei Müll-Bytes nicht, es liest sie als Text. Ohne Prüfung des ZIP-/OLE-Kopfes wäre „garbage bytes → invalidFile“ nicht erfüllbar. Commit `44c1d43`.
4. **[Regel 2] Tabulator und Zeilenumbruch in Buchungstext und Kopftext werden durch ein Leerzeichen ersetzt**, sonst würden sie die Spalten der TXT-Datei zerreißen. Gleiches gilt für Kontennamen über das DTO (Tabulator abgelehnt). Commit `44c1d43`.
5. **[Regel 2] Dateinamen-Dekodierung** (`apps/api/src/accounting/decode-upload-filename.ts`): multer liefert UTF-8-Namen als latin1-gelesen; der Helfer kehrt das um, ohne einen schon richtigen Namen zu beschädigen. Der Plan nannte nur die Umwandlung; ein blindes `Buffer.from(name, 'latin1')` hätte Namen mit Zeichen über 255 zerstört. Commit `44c1d43`.
6. **Zusätzliche gemeinsame Web-Bausteine** (nicht in `files_modified`): `accounting-request.ts` (Anfrage, Fehlerklasse), `file-drop-area.tsx`, `tab-bar.tsx`. Sie ersetzen eine zweifach kopierte Ablagefläche, Reiterleiste und Fehlerbehandlung. Die Ablage importiert keine Texte aus dem Zertifikat-Manager, wie verlangt.
7. **Umlaut-Wächter:** das Wort „neues“ (korrektes Deutsch) stand nicht auf der Allowlist und ließ die volle Web-Suite rot werden; ergänzt in `umlaut-dictionary.ts` (nur die Teilmenge der Aufgabe 1 hatte das nicht gezeigt, die volle Suite schon).
8. **Doku:** Im Inhaltsverzeichnis der Anwenderanleitung fehlte Proxmox, ich habe es mit ergänzt; „fünf Module“ wurde zu „sieben“. Die alte Klassen-Tabelle im Zugriffsdokument (Zahl 83) war schon vorher veraltet (gezählt waren 89); ich habe sie nicht umgeschrieben, sondern wie bisher einen Nachtragsabsatz mit nachgezählten Werten (90, dann 92 Paare) ergänzt.
9. **TDD:** Aufgabe 2 und die Kantine-Funktionen sind mit den Tests zusammen entstanden und in je einem atomaren Commit gelandet; es gibt keine getrennten RED-Commits. Die Tests laufen gegen die finale Implementierung.
## Auth-Gates
Keine. Es wurde kein Login benötigt; ich habe keine Zugangsdaten gelesen oder verwendet (die Lesesperre für `.env` hat zudem gegriffen, als ich die Admin-Angaben nachsehen wollte, und ich habe es dabei belassen).
## Offene Fragen und Hinweise für Sie
- **Kodierung der Handelsware-TXT:** Ich habe UTF-8 beibehalten, wie in der Vorlage. DATEV-Importe erwarten oft Windows-1252 (ANSI). Enthalten Produktnamen Umlaute, kann DATEV sie falsch anzeigen. Das sollte die Kollegin am echten Import prüfen; die Umstellung wäre eine Zeile in `handelsware-datev.service.ts` (Ausgabe) plus `mimeType`.
- **Export schickt die Datei erneut:** Statt einer JSON-Liste aller Zeilen sendet der Export die Original-XLSX als Multipart plus `buchungsdatum` und `newAccounts` (JSON-Text). Der Server liest alle Zeilen selbst neu und berechnet die Zuordnung in derselben Transaktion; die TXT kann so nicht von der Arbeitsmappe abweichen, und die JSON-Grenze von Express (100 kB) kappt große Listen nicht.
- **„Startwert“-Bedeutung:** Er ist die erste Nummer, die vergeben wird, wenn die Kontenliste leer ist. Ist die Liste nicht leer, gilt höchstes vorhandenes Gegenkonto + 1 (wie die Vorlage mit festem 8000). Das ist eine Entscheidung von mir, bitte bestätigen.
- **Wer was ändert:** Nur Administratoren ändern die Einstellungen (beide Module). Die Kontenliste der Handelsware dürfen alle Benutzer mit Modulzugriff pflegen.
- **`xlsx` 0.18.5:** Das Lesen der hochgeladenen Excel-Dateien nutzt die bereits vorhandene `xlsx`-Abhängigkeit der API (0.18.5). Bekannte Hinweise (Prototype Pollution, ReDoS), die nur in Versionen behoben sind, die nicht in der npm-Registry stehen, sind laut Bedrohungsmodell T-FM5-05 bewusst akzeptiert: nur angemeldete, freigegebene interne Benutzer, Formeln/HTML/Formate abgeschaltet, nur erstes Blatt, Spalten A/B, 5 MB und 10 000 Zeilen Grenze. Ein Austausch der Bibliothek ist eine eigene Entscheidung.
- **Kantine: Betragsformat wie in der Vorlage:** „1.234,56“ und „-5,00“ werden als Fehler gemeldet (die Vorlage akzeptiert nur Ziffern mit optionalem Komma). Das ist so gewollt, könnte aber bei Kantinenexporten mit Tausenderpunkt hinderlich sein.
- **Desktop-Download:** Der Download läuft über denselben Blob-Mechanismus wie der Zertifikat-Manager (vom Desktop-Client seit 1.9.2 gespeichert). Im Desktop-Client selbst nicht getestet.
- **Icons:** `utensils` und `shopping-bag` habe ich den Lucide-Pfaden nach gezeichnet; optisch noch nicht gesehen.
## Known Stubs
Keine. Es gibt keine Platzhalter oder fest eingebauten leeren Werte, die in die Oberfläche fließen. Die Einstellungsfelder sind absichtlich leer (kein Standardwert), das ist Teil der Anforderung und kein Stub.
## Threat Flags
Keine neue Angriffsfläche außerhalb des Bedrohungsmodells. Umgesetzt: T-FM5-01 (keine Speicherung/Protokollierung der Kantinenzeilen; Test, dass die Vorschau keine Namen enthält), T-FM5-02 (Rollen-Metadaten per Test), T-FM5-03 (RLS plus Inventar), T-FM5-04 (5 MB, 1 MB für Konten-CSV, 10 000 Zeilen, `newAccounts` begrenzt), T-FM5-05 (akzeptiert, siehe oben), T-FM5-06 (Neuberechnung in der Transaktion, 409, P2002 → 409), T-FM5-07 (Apostroph vor `=+-@`, Rundlauf-Test).
## Self-Check: PASSED
- Dateien vorhanden: Migrationen, Dienste, Controller, Seiten, Testdaten (4 Dateien) — geprüft per `ls` und Lauf der Funktionen gegen die Testdaten.
- Commits vorhanden: `1f85277`, `44c1d43`, `42b89f1`, `1493375` (`git log`), `commits: 4` gemessen mit `git rev-list --count 0edd6e9..HEAD`.
- Keine Löschungen in den Commits (`git diff --diff-filter=D` leer).
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel)
- Marktplatz: beide Module unter „Finanzbuchhaltung“, aktiviert; Seitenleiste zeigt neue Kategorie.
- Kantine: Hinweis bei leeren Einstellungen; `kantine-cp1252-fehler.csv` → 4 Zeilen, 03/2026, 71,74 €, Warnung zwei Monate, Fehler Zeile 4, Download gesperrt. Einstellungen 1234567/12345/1111 gespeichert; `kantine-gueltig-utf8.csv` → `LuG_1234567_12345_03_2026.sic`, 11 Spalten, CRLF, Inhalt identisch zur Vorlage (Betrag 0 → `-0.00` wie in der Vorlage).
- Handelsware: Einstellungen 4711/2000; `Konten.csv` (cp1252) importiert, Umlaute korrekt; `HWA 0326 Test.xlsx` → Datum 3103, Kopftext 2026, „Neues Produkt Saft“ neu mit 2014/4711; Download `HWA_0326.txt` (UTF-8, CRLF); neues Konto erst danach in der Kontenliste.
- Korrektur: Einstellungstexte nannten „Ihres Mandanten“ → entfernt (de/en), Web-Suite 1214 grün.
- Desktop-Client-Download nicht geprüft.
@@ -0,0 +1,309 @@
---
phase: quick-261002-icv
plan: 01
type: execute
wave: 1
depends_on: []
quick_id: 261002-icv
description: "Modul-Freigabe mit Stufe: Benutzen (USE, Standard) und Verwalten (MANAGE)"
date: 2026-10-02
files_modified:
# Task 1 — tracer: DB level -> access resolution -> guard -> kantine settings -> /modules/active canManage -> web tab
- apps/api/prisma/schema.prisma
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
- apps/api/src/groups/migration-sql.spec.ts
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module-access.service.spec.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/module-registry/module.guard.spec.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/module-grants.service.spec.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/kantine-datev/kantine-datev.controller.spec.ts
- apps/web/src/lib/api.ts
- apps/web/src/lib/use-module-capability.ts
- apps/web/src/app/(portal)/modules/kantine-datev/page.tsx
- apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx
# Task 2 — remaining module conversions (API + their web pages), DKV gate
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/proxmox/proxmox-client.service.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/components/modules/module-access-gate.tsx
- apps/web/src/components/modules/module-access-gate.test.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx
- apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx
- apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx
- apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx
- apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx
- apps/web/src/messages/de.json
- apps/web/src/messages/en.json
# Task 3 — admin grant UI with level, docs, changelog, rebuild
- apps/web/src/app/(portal)/admin/modules/grants/page.tsx
- apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx
- apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx
- apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx
- docs/anleitung-administration.md
- docs/anleitung-anwender.md
- CHANGELOG.md
autonomous: true
requirements: [QUICK-261002-icv]
estimate:
tokens: 190000
raw_tokens: 190000
tasks: 3
confidence: low
must_haves:
truths:
- "An admin can set every module grant (group or single user) to Benutzen (USE) or Verwalten (MANAGE); grants that existed before the migration are USE"
- "A non-admin with MANAGE on a module can call that module's settings/administration endpoints (2xx) and sees its settings tab/controls; with only USE the same endpoints return 403 and the controls are hidden"
- "If a user has USE via one grant and MANAGE via another for the same module, the effective level is MANAGE"
- "MANAGE on module A grants nothing extra on module B, and a MANAGE grant on a deactivated module grants nothing"
- "Managers still cannot grant/revoke access, activate/deactivate modules, or reach users/groups/LDAP/SMTP/platform-wide tender settings (those stay @Roles(ADMIN, SUPER_ADMIN))"
- "ADMIN and SUPER_ADMIN keep full rights: they resolve to MANAGE on every active module"
- "DKV (dkv-fleet), admin-only for every handler today, becomes manager-level as a whole; USE-level DKV users get no API access (unchanged) and see an explanatory access page"
artifacts:
- path: "apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql"
provides: "ModuleGrantLevel enum + ModuleGrant.level NOT NULL DEFAULT 'USE'"
contains: "ModuleGrantLevel"
- path: "apps/api/src/module-registry/module.guard.ts"
provides: "ModuleManage(slug) decorator + MODULE_MANAGE_KEY enforced by ModuleGuard"
exports: ["ModuleGuard", "UseModule", "ModuleManage", "MODULE_SLUG_KEY", "MODULE_MANAGE_KEY"]
- path: "apps/api/src/module-registry/module-access.service.ts"
provides: "getModuleAccessLevels — single source of truth for access AND level"
- path: "apps/web/src/lib/use-module-capability.ts"
provides: "useCanManageModule(slug) display hook fed by GET /modules/active canManage"
- path: "apps/api/src/module-registry/module-manage-handlers.spec.ts"
provides: "metadata proof: converted handlers use ModuleManage, admin-only handlers keep @Roles"
key_links:
- from: "apps/api/src/module-registry/module.guard.ts"
to: "ModuleAccessService.getModuleAccessLevels"
via: "per-request memo request.moduleAccessLevels"
pattern: "getModuleAccessLevels"
- from: "apps/api/src/groups/dto/create-module-grant.dto.ts"
to: "ModuleGrantsService.grant -> ModuleGrant.level"
via: "POST /module-grants { level }"
pattern: "IsEnum\\(ModuleGrantLevel\\)"
- from: "GET /modules/active (canManage)"
to: "apps/web/src/lib/use-module-capability.ts -> module pages"
via: "useCanManageModule"
pattern: "useCanManageModule"
---
<objective>
Add a second level to every module grant: "Benutzen" (USE, default, today's behavior) and "Verwalten" (MANAGE = use the module AND change that module's own settings/configuration). Backend is the source of truth via a reusable `@ModuleManage('<slug>')` decorator on `ModuleGuard`; the web learns the effective level per module from `GET /modules/active` (`canManage`) and shows settings tabs/controls to admins AND managers. Admins assign the level in the grant matrix (groups) and the user access dialog (single users).
Locked decisions from the request (cited below as L-xx):
- L-01 Only admins grant modules and choose the level; module activation and grant endpoints stay admin-only; managers get no users/groups/global settings.
- L-02 MANAGE grantable to single users AND groups like today; USE+MANAGE via different grants → MANAGE wins.
- L-03 Admins/super-admins implicitly manage every module.
- L-04 Applies to ALL module-scoped admin-only handlers; truly system-wide/cross-module ones stay admin-only and are listed in the SUMMARY with reason. DKV: admin-only usage today → convert whole module to manager-level, document, never widen USE.
- L-05 Reusable decorator/guard, no per-controller ad-hoc checks; web gets the effective level (`canManage`) from the module-list endpoint.
- L-06 Prisma migration: enum level column on ModuleGrant, default USE, hand-written SQL, RLS gates/tests checked.
- L-07 Admin grant UI: level choice per grant (Benutzen / Verwalten), formal German "Sie" + English, level shown in grant lists.
- L-08 Module pages: settings tabs/controls for admins AND managers (replace role checks with per-module capability).
- L-09 Tests: guard/access resolution (user grant, group grant, mixed, admin bypass, no grant), controller metadata, DTO validation, web grant UI + one module settings tab; full api + web suites, tsc, biome on touched files.
- L-10 CHANGELOG (Unveröffentlicht, user-facing German) + docs/ where grants are explained.
- L-11 Local migration via container IP + `docker compose up -d --build api web`; no push.
- L-12 NestJS static routes before `:id` (no new routes planned); never mention "Mandant"/tenant in new user-facing texts.
Output: migration, level-aware access service + guard + decorator, converted controllers (kantine-datev, handelsware-datev, proxmox, dkv-fleet), admin grant UI with level, capability-driven module pages, tests, docs, changelog, rebuilt local stack. Three atomic commits on main, NOT pushed.
</objective>
<execution_context>
@~/.claude/gsd-core/workflows/execute-plan.md
@~/.claude/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/STATE.md
@./CLAUDE.md
Discovered facts the executor can rely on (verified during planning):
- `RolesGuard`, `JwtAuthGuard`, `TenantGuard` are global `APP_GUARD`s (apps/api/src/app.module.ts). Any handler still carrying `@Roles(ADMIN, SUPER_ADMIN)` blocks managers regardless of other guards — converted handlers MUST drop `@Roles`.
- `ModuleRegistryModule` exports `ModuleRegistryService`, `ModuleAccessService`, `ModuleGuard`; DkvModule, KantineDatevModule, HandelswareDatev, Proxmox modules already import it (no DI change needed).
- Module slugs: `kantine-datev`, `handelsware-datev`, `proxmox`, `dkv-fleet` (apps/api/src/dkv/dkv.seed.ts), `tender-radar`, `cert-manager`, `domaincheck`.
- `ModuleAccessService.getAccessibleModuleIds` is consumed by ModuleGuard, `getCatalogFlags`, `findAccessibleModules` and `apps/api/src/dashboard/dashboard.service.ts` — its signature must stay.
- `rls-access-inventory.spec.ts` keys on (file, model) pairs and bound/unbound state: every new Prisma access in module-access.service.ts / module-grants.service.ts MUST go through the existing `forTenant(...)` client of that method, and must not add `include:`/relation `select:` to new models.
- ValidationPipe is global with `whitelist: true, transform: true` (apps/api/src/main.ts).
- Web module pages are reachable via `/modules/<slug>` (own layout with `ModuleAccessGate`) AND via the sidebar link `/modules/<category>/<slug>` (generic `[category]/[moduleSlug]/page.tsx` → `ModuleAccessGate` → `ModuleShell`). Module page components are client components using `useAuthStore`.
- i18n namespaces: matrix uses `admin.groups.grants` (has `matrixCheckboxLabel`); UserAccessModal uses `admin.users.grants` (has `directCheckboxLabel`); matrix page texts `adminModules.grants`; gate texts `modules.accessDenied`; `proxmox.settings.accessDeniedText`; `kantineDatev.notConfigured.user`; `handelswareDatev.notConfigured.user`. `apps/web/src/messages/umlaut-guard.spec.ts` requires real umlauts in de.json.
- Migration convention: hand-written SQL with German header comment (model: apps/api/prisma/migrations/20261002120000_kantine_datev_config/migration.sql); latest migration is 20261002130000_handelsware_datev.
- Commits: German subject, conventional prefix, end with `Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>`. Never push.
@apps/api/src/module-registry/module.guard.ts
@apps/api/src/module-registry/module-access.service.ts
@apps/api/src/groups/module-grants.service.ts
@apps/api/src/groups/dto/create-module-grant.dto.ts
@apps/api/src/kantine-datev/kantine-datev.controller.ts
@apps/web/src/components/modules/module-access-gate.tsx
@apps/web/src/lib/module-access-actions.ts
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer — grant level end-to-end: DB column → access levels → ModuleManage guard → kantine settings → /modules/active canManage → kantine settings tab</name>
<files>apps/api/prisma/schema.prisma, apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql, apps/api/src/groups/migration-sql.spec.ts, apps/api/src/module-registry/module-access.service.ts, apps/api/src/module-registry/module-access.service.spec.ts, apps/api/src/module-registry/module.guard.ts, apps/api/src/module-registry/module.guard.spec.ts, apps/api/src/groups/dto/create-module-grant.dto.ts, apps/api/src/groups/dto/create-module-grant.dto.spec.ts, apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/api/src/kantine-datev/kantine-datev.controller.ts, apps/api/src/kantine-datev/kantine-datev.controller.spec.ts, apps/web/src/lib/api.ts, apps/web/src/lib/use-module-capability.ts, apps/web/src/app/(portal)/modules/kantine-datev/page.tsx, apps/web/src/app/(portal)/modules/kantine-datev/kantine-datev.test.tsx</files>
<behavior>
- ModuleAccessService.getModuleAccessLevels: USER with only a direct USE grant → Map {m1: USE}; direct USE + group MANAGE on same module → MANAGE (L-02); MANAGE only via group → MANAGE; MANAGE grant on a module whose TenantModuleActivation is inactive → absent; ADMIN and SUPER_ADMIN → every active module = MANAGE without grant queries (L-03); no grants → empty Map; rows without a `level` field (old mocks) count as USE.
- getAccessibleModuleIds still returns exactly the key set (existing spec cases keep passing); findAccessibleModules rows carry `canManage` true/false.
- ModuleGuard: @UseModule route + USE → allowed; @ModuleManage route + USE → ForbiddenException; + MANAGE → allowed; admin → allowed; no grant → ForbiddenException; MANAGE on module "a" while the route is ModuleManage('b') → ForbiddenException; a second canActivate on the same request object reuses request.moduleAccessLevels and does not call the service again; ModuleManage(slug) sets MODULE_SLUG_KEY, MODULE_MANAGE_KEY=true and guards metadata containing ModuleGuard.
- CreateModuleGrantDto: level 'USE', 'MANAGE' or omitted → valid; 'ADMIN' and lowercase 'manage' → validation error on `level`.
- ModuleGrantsService.grant: no level → create with USE; level MANAGE → create with MANAGE; existing USE row + level MANAGE → update to MANAGE and log line; existing MANAGE row + no level → no update, existing returned (repeat click never downgrades); P2002 race + level given → same update rule.
- migration-sql.spec: the new migration contains the CREATE TYPE and ADD COLUMN statements below.
- Kantine controller metadata: saveSettings has MODULE_MANAGE_KEY true, slug 'kantine-datev', no ROLES_KEY; getSettings/preview/export have no MODULE_MANAGE_KEY.
- Kantine web page: USER whose /modules/active entry has canManage true sees the "Einstellungen" tab; USER with canManage false does not; ADMIN sees it without any /modules/active fetch.
</behavior>
<action>
**Schema + migration (L-06).** In `apps/api/prisma/schema.prisma` add `enum ModuleGrantLevel { USE MANAGE }` next to `enum MembershipSource`, and on `model ModuleGrant` add `level ModuleGrantLevel @default(USE)` with a German comment (261002-icv: Freigabestufe; USE = Benutzen, Standard und Bestand; MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen ändern; Freigaben erteilen bleibt Administratoren vorbehalten). Hand-write `apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql`: German header comment in the style of 20261002120000_kantine_datev_config (purpose; existing rows become USE through the DEFAULT, so nobody gains rights; no new table, the existing ModuleGrant row policies filter rows not columns and stay unchanged, so rls-coverage needs nothing; PostgreSQL grants USAGE on new types to PUBLIC, so tessera_app can use the enum; switch-is-off note). Statements, exactly: `CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');` and `ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`. Add a describe block to `apps/api/src/groups/migration-sql.spec.ts` using its `readMigrationSql('_module_grant_level')` helper asserting both statements. Run `pnpm --filter @tessera/api exec prisma generate`. Apply locally (L-11): IP via `docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' tessera-ctl-db-1`, then `DATABASE_URL="postgresql://tessera:tessera_dev@<IP>:5432/tessera" pnpm --filter @tessera/api exec prisma migrate deploy`, confirm with `prisma migrate status` and a drift check `prisma migrate diff --from-url "$DATABASE_URL" --to-schema-datamodel prisma/schema.prisma --exit-code` (same env, run in apps/api via the filter) — exit code 0 means schema and SQL agree.
**Access resolution (L-02, L-03, L-05).** In `module-access.service.ts` add `getModuleAccessLevels(tenantId, userId, role): Promise<Map<string, ModuleGrantLevel>>` as the single resolution. Admin/SUPER_ADMIN branch: same activation query as today, every moduleId → MANAGE. Other roles: the same two grant queries as today (direct `userId`, group via `group: { memberships: { some: { userId } } }`), now selecting `{ moduleId: true, level: true }`; merge so MANAGE wins (any value other than 'MANAGE' counts as USE); intersect with the same active-activation query as today. Use the one `forTenant` client of the method for all accesses (rls-access-inventory). Rewrite `getAccessibleModuleIds` to return the key set of `getModuleAccessLevels` (signature unchanged). `findAccessibleModules` returns each catalog row spread plus `canManage: level === 'MANAGE'` (catalog query stays on `this.prisma` as today). Update the class/method doc comments (Freigabestufe, 261002-icv). Update the doc comment of `GET /modules/active` in module-registry.controller.ts only if you touch it — no code change there is needed.
**Guard + decorator (L-05).** In `module.guard.ts` export `MODULE_MANAGE_KEY = 'moduleManage'`. In `canActivate`, after the existing slug/tenant/user/findBySlug steps: read `requireManage` with `reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [handler, class])`; take `request.moduleAccessLevels` if it is already a Map (class-level @UseModule plus handler-level @ModuleManage run this guard twice per request), else call `getModuleAccessLevels`; no entry for module.id → existing "not accessible" ForbiddenException; requireManage and level not MANAGE → ForbiddenException with message `Module '<slug>' requires manage permission`; store `request.moduleAccessLevels` and keep setting `request.moduleAccessIds` (Set of keys). Export `ModuleManage(slug)` = applyDecorators(SetMetadata(MODULE_SLUG_KEY, slug), SetMetadata(MODULE_MANAGE_KEY, true), UseGuards(ModuleGuard)) with a German JSDoc: replaces `@Roles(ADMIN, SUPER_ADMIN)` for module-scoped configuration; usable on a handler inside a @UseModule controller or on a whole controller; admins pass via the D-03 short-circuit; never combine with @Roles on the same handler (global RolesGuard would still block managers); tenant/user/role only from the JWT (T-15-10). Update `module.guard.spec.ts` mocks from `getAccessibleModuleIds` to `getModuleAccessLevels` and add the behavior cases.
**Grant write side (L-01, L-02).** `CreateModuleGrantDto`: add optional `level?: ModuleGrantLevel` with `@IsOptional()` and `@IsEnum(ModuleGrantLevel)` (import from @prisma/client); doc comment: level is ignored on DELETE. New `apps/api/src/groups/dto/create-module-grant.dto.spec.ts` following `apps/api/src/custom-modules/dto/custom-module.dto.spec.ts` (plainToInstance + validate). `ModuleGrantsService.grant` accepts `level?: ModuleGrantLevel`; keep the existing check order (XOR, tenant cross-check, activation). Then find the existing row for the exact target with `tenantPrisma.moduleGrant.findFirst` (same where as the current P2002 branch): if it exists and a level was given that differs → `tenantPrisma.moduleGrant.update({ where: { id: existing.id }, data: { level } })`, log `Grant-Stufe geändert: tenant=… module=… <target> level=<level>`, return it; if it exists otherwise → return it unchanged (no level given never changes the level). If not, create with `level: level ?? 'USE'` and add `level=` to the existing log line; the P2002 branch applies the same rule. Replace the outdated class comment sentence about the record carrying no level (old D-04) with: since 261002-icv the row carries `level`; only this admin-only service sets it. Controller unchanged (stays admin-only, L-01). Extend `module-grants.service.spec.ts` with the grant cases.
**First converted handler.** In `kantine-datev.controller.ts` replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on `saveSettings` with `@ModuleManage('kantine-datev')`, remove now-unused `Role`/`Roles` imports, and update the header comment (settings: administrators and users with Freigabestufe Verwalten, 261002-icv). Update `kantine-datev.controller.spec.ts` (its ROLES_KEY expectation on saveSettings becomes the MODULE_MANAGE_KEY expectation).
**Web capability (L-05, L-08).** `apps/web/src/lib/api.ts`: add `canManage?: boolean` to `ApiModule`. New `apps/web/src/lib/use-module-capability.ts` exporting `useCanManageModule(moduleSlug: string): boolean | null`: null while `useAuthStore` user is null; true immediately for ADMIN/SUPER_ADMIN (mirrors the backend short-circuit, no fetch); otherwise one fetch of `${process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'}/modules/active` with `credentials: 'include'`, `cache: 'no-store'`, result true only if an entry has this slug AND `canManage === true`; non-ok or thrown → false; ignore results after unmount. German doc comment: display only, ModuleGuard is the binding check. In `kantine-datev/page.tsx` replace the `isAdmin` role check with `useCanManageModule('kantine-datev') === true` (settings tab + BillingTab hint), renaming the BillingTab prop `isAdmin` → `canManage`. Extend `kantine-datev.test.tsx`: stub global fetch (vi.stubGlobal, unstub in afterEach) answering `/modules/active` with `[{ slug: 'kantine-datev', canManage: true }]` or `canManage: false`, plus an ADMIN case asserting fetch was not called; existing ADMIN/USER cases must stay green.
Biome-lint the touched files (`pnpm exec biome lint <files>` from repo root), commit `feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/groups src/kantine-datev rls-coverage rls-access-inventory && pnpm --filter @tessera/web exec vitest run kantine-datev && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit</automated>
</verify>
<done>Migration applied locally (`prisma migrate status` up to date, drift check exit 0); listed api/web tests green incl. rls gates; both type-checks clean; a USER with a MANAGE grant passes `PUT /modules/kantine-datev/settings` guard logic (unit-proven) and sees the Einstellungen tab; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Convert remaining module-scoped admin handlers (proxmox, handelsware-datev, dkv-fleet) + their web pages and DKV access page</name>
<files>apps/api/src/proxmox/proxmox.controller.ts, apps/api/src/proxmox/proxmox-client.service.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.ts, apps/api/src/handelsware-datev/handelsware-datev.controller.spec.ts, apps/api/src/dkv/dkv.controller.ts, apps/api/src/module-registry/module-manage-handlers.spec.ts, apps/web/src/lib/module-access-actions.ts, apps/web/src/components/modules/module-access-gate.tsx, apps/web/src/components/modules/module-access-gate.test.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/page.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/components/ImportTab.tsx, apps/web/src/app/(portal)/modules/handelsware-datev/handelsware-datev.test.tsx, apps/web/src/app/(portal)/modules/proxmox/page.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/page.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.tsx, apps/web/src/app/(portal)/modules/proxmox/components/ServerCard.test.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.tsx, apps/web/src/app/(portal)/modules/proxmox/settings/components/ServerForm.test.tsx, apps/web/src/app/(portal)/modules/proxmox/proxmox-page-roles.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json</files>
<behavior>
- module-manage-handlers.spec (metadata, L-04/L-09): DkvController class has MODULE_SLUG_KEY 'dkv-fleet', MODULE_MANAGE_KEY true, guards metadata (GUARDS_METADATA from @nestjs/common/constants) containing ModuleGuard, and none of its prototype methods has ROLES_KEY; ProxmoxController create/update/remove/poll/test/testDraft have MODULE_MANAGE_KEY true + slug 'proxmox' and no ROLES_KEY, `list` has neither; KantineDatevController.saveSettings and HandelswareDatevController.saveSettings are manage-level; STAY ADMIN-ONLY: TendersController getSourceConfig/saveSourceConfig/pollNow have ROLES_KEY [ADMIN, SUPER_ADMIN] and no MODULE_MANAGE_KEY; ModuleGrantsController matrix/userAccess/create/remove and ModuleRegistryController activate/deactivate have ROLES_KEY [ADMIN, SUPER_ADMIN].
- Gate: slug 'dkv-fleet' with level 'manage' → children; 'use' → denied page with the manage-required body text; 'none' or thrown → standard denied text; other slugs keep calling checkModuleAccess exactly once (existing tests unchanged).
- Handelsware page: USER with canManage true sees the settings tab; false does not.
- Proxmox: USER with canManage true sees the manager controls (poll button / settings link / enabled form); USER with canManage false keeps today's read-only view; ADMIN/SUPER_ADMIN unchanged.
</behavior>
<action>
**API conversions (L-04, L-05).** `proxmox.controller.ts`: replace `@Roles(Role.ADMIN, Role.SUPER_ADMIN)` on create, update, remove, poll, test and testDraft with `@ModuleManage('proxmox')`; `GET servers` stays USE (class @UseModule); drop unused imports; update the header comment. `proxmox-client.service.ts`: comment only — the SSRF safeguard is now "administrator or a user the administrator explicitly granted Verwalten for the Proxmox module (`@ModuleManage('proxmox')`, 261002-icv)". `handelsware-datev.controller.ts`: `saveSettings` → `@ModuleManage('handelsware-datev')` (account routes are already USE-level, leave them), update header comment and `handelsware-datev.controller.spec.ts` (ROLES_KEY expectation → MODULE_MANAGE_KEY). `dkv.controller.ts`: today it has NO @UseModule and every one of its 11 handlers carries @Roles(ADMIN, SUPER_ADMIN) — i.e. DKV usage itself is admin-only. Per L-04 put `@ModuleManage('dkv-fleet')` on the class, remove all 11 per-handler @Roles plus the `Role`/`Roles` imports, and rewrite the header comment: whole module is Verwalten-level; USE-level users keep getting 403 exactly as before (not widened); the class guard additionally requires the dkv-fleet activation (the web page already required it). No route order changes anywhere (L-12).
**Leave admin-only (do not edit; list in SUMMARY with reason):** tenders `getSourceConfig`/`saveSourceConfig`/`pollNow` (platform-wide singleton poll config and upstream fetch for the whole installation, not module-per-company configuration); tenders `createRssFeed` scope 'platform' and `removeRssFeed` platform-feed branch (platform-wide feeds shown to every user of the installation); module-registry activate/deactivate and all module-grants routes (L-01); custom-modules shared entries (not a registry module, no @UseModule, sidebar entries for everyone); groups/user/ldap/settings (SMTP)/tenant/welcome-mail controllers (global administration). Confirm with `grep -rn "Roles(" apps/api/src --include=*.ts` that cert-manager, domaincheck and reminders have no admin-only handler; mention that in the SUMMARY.
New `apps/api/src/module-registry/module-manage-handlers.spec.ts` with the metadata assertions from behavior (if importing several controllers in one file proves problematic, split per controller next to it and adjust files list in the SUMMARY).
**Web (L-08).** `module-access-actions.ts`: add `getModuleAccessLevel(moduleSlug): Promise<'none' | 'use' | 'manage'>` (same cookie forwarding and fail-closed handling, reading `canManage` from /modules/active); make `checkModuleAccess` delegate (`!== 'none'`) with unchanged signature. `module-access-gate.tsx`: add `MANAGE_ONLY_MODULE_SLUGS = new Set(['dkv-fleet'])` with a German comment pointing at the class-level @ModuleManage on DkvController; for those slugs call getModuleAccessLevel ('manage' → children, 'use' → ModuleAccessDenied with body `t('accessDenied.manageRequiredBody')`, else the standard denied texts); all other slugs keep the current checkModuleAccess path. Extend `module-access-gate.test.tsx`. Handelsware: `useCanManageModule('handelsware-datev') === true` replaces isAdmin in page.tsx; rename ImportTab prop `isAdmin` → `canManage`. Proxmox: `useCanManageModule('proxmox')` in page.tsx and settings/page.tsx (settings page shows its existing loading placeholder while the value is null, the denied text when false); rename the `isAdmin` props of ServerCard and ServerForm to `canManage` and update their tests and code comments (e.g. the idle-text comment about the poll endpoint). Update `proxmox-page-roles.test.tsx` (stub fetch for USER cases: `[]` for read-only, `[{ slug: 'proxmox', canManage: true }]` for the new manager case) and add one handelsware manager case.
**Texts (de + en, formal "Sie", real umlauts, no "Mandant"/tenant in new wording, L-12):** `proxmox.settings.accessDeniedText` → „Diese Seite steht Administratoren und Benutzern zur Verfügung, die dieses Modul verwalten dürfen.“ / "This page is available to administrators and to users who may manage this module."; in `kantineDatev.notConfigured.user` and `handelswareDatev.notConfigured.user` replace only the leading „Ein Administrator muss“ with „Ein Administrator oder jemand, der dieses Modul verwalten darf, muss“ (rest verbatim; en: "An administrator or someone who may manage this module must …"); new `modules.accessDenied.manageRequiredBody` → „Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.“ / "This module is only available to users who may manage it. Please contact your administrator.".
Biome-lint touched files, commit `feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten` (attribution line). Do not push.
</action>
<verify>
<automated>pnpm --filter @tessera/api exec vitest run src/module-registry src/kantine-datev src/handelsware-datev src/proxmox src/dkv src/tenders src/groups && pnpm --filter @tessera/web exec vitest run proxmox handelsware-datev kantine-datev module-access umlaut && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && test -z "$(grep -nE '^\s*@Roles\(' apps/api/src/dkv/dkv.controller.ts apps/api/src/proxmox/proxmox.controller.ts apps/api/src/kantine-datev/kantine-datev.controller.ts apps/api/src/handelsware-datev/handelsware-datev.controller.ts)" && grep -qE '^\s*@Roles\(' apps/api/src/tenders/tenders.controller.ts</automated>
</verify>
<done>No decorator-level @Roles left in the four converted controllers, tenders keeps its @Roles on getSourceConfig/saveSourceConfig/pollNow (proven by the metadata spec); metadata spec proves converted vs. admin-only handlers; proxmox/handelsware/kantine pages and DKV gate follow canManage; tests and type-checks green; one commit, not pushed.</done>
</task>
<task type="auto" tdd="true">
<name>Task 3: Admin grant UI with level (matrix + user dialog), docs, changelog, full suites, local rebuild</name>
<files>apps/api/src/groups/module-grants.service.ts, apps/api/src/groups/module-grants.service.spec.ts, apps/web/src/app/(portal)/admin/modules/grants/page.tsx, apps/web/src/app/(portal)/admin/modules/grants/grants-matrix.test.tsx, apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx, apps/web/src/app/(portal)/admin/users/user-access-modal.test.tsx, apps/web/src/messages/de.json, apps/web/src/messages/en.json, docs/anleitung-administration.md, docs/anleitung-anwender.md, CHANGELOG.md</files>
<behavior>
- getMatrix: each grant item is { moduleId, groupId, level }.
- getUserAccess: each module row keeps { module, viaGroups, direct } and adds `directLevel` (level of the direct grant or null) and `manageViaGroups` (display names of the groups granting MANAGE, subset of viaGroups).
- Matrix: a granted cell shows a level select with Benutzen/Verwalten reflecting the response; choosing Verwalten POSTs /module-grants with { moduleId, groupId, level: 'MANAGE' }; a failed POST rolls the select back; ticking an empty cell POSTs without level and shows Benutzen.
- User dialog: direct grant row shows a level select; changing it POSTs { moduleId, userId, level }; a group chip granting MANAGE shows the „Verwalten“ marker.
</behavior>
<action>
**API read side for the UI (L-07).** In `module-grants.service.ts` `getMatrix`: add `level: true` to the group-grant select and return `level` per grant item. `getUserAccess`: select `{ moduleId: true, level: true }` for direct grants; group grants already include the row (use its `level`); add `directLevel` and `manageViaGroups` per row as in behavior, leaving existing keys untouched. Keep every access on the method's `tenantPrisma` (rls-access-inventory). Extend the spec.
**Matrix page (`admin/modules/grants/page.tsx`).** State becomes a Map cellKey → 'USE' | 'MANAGE'. For a granted cell render, next to the checkbox, a compact native select (options from `admin.groups.grants.levelUse` / `levelManage`, aria-label `levelSelectLabel` with module + group) styled like the page inputs and disabled while that cell is saving; on change POST `/module-grants` with `{ moduleId, groupId, level }` using the same optimistic update + rollback + error banner as `toggleGrant`. Ticking an empty cell keeps POSTing without level (server default USE) and stores USE locally; revoke unchanged. Below the table render `adminModules.grants.levelExplanation` and the rewritten `adminNote`. Extend `grants-matrix.test.tsx` (its existing fetch-stub pattern).
**User dialog (`UserAccessModal.tsx`).** Extend the row type with `directLevel` and `manageViaGroups`. Group chips whose name is in `manageViaGroups` show the suffix marker `t('manageMarker')`. When `direct` is true, show a level select next to the checkbox (option labels from `admin.groups.grants`, aria-label `t('directLevelLabel', { module, user })`); change POSTs `{ moduleId, userId, level }` with the existing optimistic/rollback pattern; ticking the checkbox sets `directLevel` 'USE' locally. Extend `user-access-modal.test.tsx`.
**Texts (de/en, formal "Sie", no "Mandant"/tenant, real umlauts):** `admin.groups.grants.levelUse` „Benutzen“ / "Use"; `admin.groups.grants.levelManage` „Verwalten“ / "Manage"; `admin.groups.grants.levelSelectLabel` „Stufe für {module} in Gruppe {group}“ / "Level for {module} in group {group}"; `admin.users.grants.directLevelLabel` „Stufe der direkten Freigabe von {module} für {user}“ / "Level of the direct grant of {module} for {user}"; `admin.users.grants.manageMarker` „Verwalten“ / "Manage"; `adminModules.grants.levelExplanation` „„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.“ (en equivalent); rewrite `adminModules.grants.adminNote` → „Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.“ / "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.".
**Docs (L-10, German, formal, new sentences without "Mandant").** `docs/anleitung-administration.md`: chapter 1 bullet on admin access (mention that the level Verwalten exists and admins implicitly have it); chapter 2 "Details zu Gruppen und Modulzugriff" (level select on the direct grant, Verwalten marker on group chips); chapter 5 new subsection "Freigabestufen: Benutzen und Verwalten" — what Verwalten unlocks per module (Kantinenabrechnung: Einstellungen; Handelsware: Einstellungen; Proxmox: Server anlegen, ändern, löschen, prüfen, sofort abrufen; DKV-Rechnung: das gesamte Modul, Benutzen allein reicht dort nicht), what stays admin-only (Freigaben erteilen und Stufe wählen, Module aktivieren, Benutzer, Gruppen, LDAP, SMTP, Willkommensmail, gemeinsame eigene Module, Ausschreibungs-Radar-Plattformeinstellungen: Abrufintervall, „Jetzt abrufen“, plattformweite RSS-Feeds), the higher level wins, existing grants are Benutzen; update the Freigaben-Matrix paragraph (level select per granted cell) and add a Fehlersuche row (user does not see the Einstellungen tab → grant is only Benutzen). `docs/anleitung-anwender.md`: after the Aktivieren/Freigeben paragraph a short paragraph on Verwalten; DKV-Rechnung section note (needs Verwalten); Proxmox server line and the Kantinenabrechnung/Handelsware "Einmalig einrichten" lines → "Administrator oder wer das Modul verwalten darf". `CHANGELOG.md` → under „## Unveröffentlicht“ / „### Neu“ one bullet in the existing user-facing style: two levels Benutzen (as before) and Verwalten; managers change the module's own settings (examples: Kantinenabrechnung, Handelsware, Proxmox-Server) without being administrators; admins choose the level per group in the Freigaben-Matrix or per user in the user details; existing grants stay Benutzen; the DKV-Rechnung module is available to administrators and to users with Verwalten.
**Finish.** Run full suites `pnpm --filter @tessera/api test` and `pnpm --filter @tessera/web test`, both type-checks, biome lint on every file touched in this plan. Rebuild the local stack from the repo root with `docker compose up -d --build api web`, then check `docker compose logs api --tail 120` for a clean start (no migration/Prisma error). No browser check (orchestrator does it), no push. Commit `feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog` (attribution line). In the SUMMARY list: converted handlers per controller, the admin-only handlers with reasons (from Task 2), the DKV behavior change (now additionally requires activation; USE-level users see the explanatory page), and test counts.
</action>
<verify>
<automated>pnpm --filter @tessera/api test && pnpm --filter @tessera/web test && pnpm --filter @tessera/api exec tsc --noEmit && pnpm --filter @tessera/web exec tsc --noEmit && node -e 'const de=require("./apps/web/src/messages/de.json"),en=require("./apps/web/src/messages/en.json");const ks=["admin.groups.grants.levelUse","admin.groups.grants.levelManage","admin.groups.grants.levelSelectLabel","admin.users.grants.directLevelLabel","admin.users.grants.manageMarker","adminModules.grants.levelExplanation","adminModules.grants.adminNote","modules.accessDenied.manageRequiredBody","proxmox.settings.accessDeniedText"];const g=(o,k)=>k.split(".").reduce((a,p)=>a&&a[p],o);for(const k of ks)for(const m of [de,en]){const v=g(m,k);if(typeof v!=="string"||/mandant|tenant/i.test(v)){console.error("bad key",k);process.exit(1)}}' && grep -q "Verwalten" CHANGELOG.md && docker compose ps --status running --services | grep -qx api && docker compose ps --status running --services | grep -qx web</automated>
</verify>
<done>Admins choose Benutzen/Verwalten per group cell and per direct user grant, lists show the level; full api + web suites, both type-checks and biome green; docs and changelog updated; api and web containers rebuilt and running with a clean api log; commit on main, not pushed; SUMMARY lists admin-only handlers with reasons.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser → API (module routes) | untrusted caller; identity/role/tenant only from the validated JWT |
| admin browser → POST /module-grants | the `level` field is client-supplied and decides future privileges |
| web UI capability display | `canManage` in the page is display only; ModuleGuard is binding |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-icv-01 | Elevation of Privilege | module-grants.controller.ts | high | mitigate | Grant/revoke routes keep @Roles(ADMIN, SUPER_ADMIN) (L-01); metadata spec in module-manage-handlers.spec.ts asserts it, so a manager cannot grant themselves or others |
| T-icv-02 | Elevation of Privilege | ModuleGuard / ModuleManage | high | mitigate | Level resolved server-side from ModuleGrant rows via getModuleAccessLevels using JWT userId/role/tenantId only; never from body/query; guard spec covers USE→403, MANAGE→ok, no grant→403 |
| T-icv-03 | Tampering | CreateModuleGrantDto.level | medium | mitigate | @IsOptional + @IsEnum(ModuleGrantLevel); DTO spec rejects 'ADMIN' and lowercase values; global whitelist ValidationPipe |
| T-icv-04 | Elevation of Privilege | cross-module scope | high | mitigate | MANAGE checked for the route's own slug's moduleId only; guard test: MANAGE on A → 403 on ModuleManage('b') |
| T-icv-05 | Elevation of Privilege | deactivated module | medium | mitigate | Levels intersected with active TenantModuleActivation (same as today); service test for inactive-module MANAGE grant |
| T-icv-06 | Elevation of Privilege | converted handlers still carrying @Roles or missing guard | high | mitigate | Verify gate: no decorator-level @Roles in the 4 converted controllers; metadata spec asserts MODULE_MANAGE_KEY + ModuleGuard on each converted handler/class |
| T-icv-07 | Elevation of Privilege | platform-wide tender settings, activation, users/groups/SMTP | high | mitigate | Left on @Roles(ADMIN, SUPER_ADMIN); metadata spec (tenders getSourceConfig/saveSourceConfig/pollNow, module-grants, activate/deactivate keep ROLES_KEY) plus the tenders @Roles presence gate prove nothing global was widened |
| T-icv-08 | Elevation of Privilege | DKV (dkv-fleet) | medium | mitigate | Whole controller @ModuleManage('dkv-fleet'): USE-level users stay at 403 as before (no silent widening); web gate shows explanatory page |
| T-icv-09 | Information Disclosure / SSRF | proxmox server addresses entered by managers | medium | accept | Proxmox targets are private by design (T-DHH-02, no address filter possible); the admin explicitly delegates via Verwalten; documented in proxmox-client.service.ts comment and admin docs |
| T-icv-10 | Repudiation | grant level changes | low | mitigate | Logger lines for create and level change include level=<level> (D-23 pattern, no audit table) |
| T-icv-11 | Tampering | repeated grant click downgrading MANAGE | low | mitigate | grant() never changes level when no level is sent; service test |
| T-icv-SC | Tampering | npm/pip/cargo installs | low | accept | No new packages in this plan; nothing to verify |
</threat_model>
<verification>
- Task verify commands above all pass; Task 3 runs the full api + web suites (includes rls-coverage, rls-access-inventory, umlaut-guard, migration-sql specs).
- `prisma migrate status` up to date locally; drift check exit 0.
- `docker compose ps` shows api and web running after rebuild; api log clean.
- Source coverage audit:
| Source item | Covered by |
|-------------|------------|
| GOAL: second grant level USE/MANAGE, managers change own module settings | Tasks 1-3 |
| L-01 admin-only grants/activation/global admin | Task 1 (controller untouched), Task 2 (metadata spec), T-icv-01/07 |
| L-02 users + groups, MANAGE wins | Task 1 (service + tests), Task 3 (UI both places) |
| L-03 admins implicit manage | Task 1 (short-circuit → MANAGE, hook short-circuit) |
| L-04 all module-scoped handlers, global ones listed, DKV converted | Task 1 (kantine), Task 2 (proxmox, handelsware, dkv, admin-only list) |
| L-05 reusable decorator/guard, canManage in /modules/active | Task 1 |
| L-06 migration default USE, RLS gates | Task 1 |
| L-07 admin grant UI level choice + display | Task 3 |
| L-08 module pages for admins AND managers | Task 1 (kantine), Task 2 (handelsware, proxmox, DKV gate) |
| L-09 tests + full suites + tsc + biome | Tasks 1-3 |
| L-10 CHANGELOG + docs | Task 3 |
| L-11 local migration + rebuild, no push | Task 1 (migrate), Task 3 (rebuild) |
| L-12 route order, no Mandant in texts | Task 2/3 text rules + node key check |
</verification>
<success_criteria>
- ModuleGrant has `level` (USE default); all existing grants are USE.
- `@ModuleManage(slug)` exists and is used by kantine-datev saveSettings, handelsware-datev saveSettings, six proxmox write handlers, and the whole DkvController; no @Roles remains on those handlers.
- GET /modules/active returns `canManage`; module pages show settings/controls for admins and managers only.
- Admin matrix and user dialog set and show the level.
- Full api + web test suites, both tsc runs and biome on touched files are green; local stack rebuilt; three commits on main, not pushed.
</success_criteria>
<output>
Create `.planning/quick/261002-icv-modul-freigabe-mit-stufe-verwalten-modul/261002-icv-SUMMARY.md` when done. It MUST contain a section "Bewusst nur für Administratoren" listing each handler kept admin-only with its reason, and a section on the DKV behavior change.
</output>
@@ -0,0 +1,147 @@
---
phase: quick-261002-icv
plan: 01
quick_id: 261002-icv
subsystem: module-grants
tags: [berechtigungen, freigabestufe, module-guard, prisma, nestjs, nextjs]
status: complete
completed: 2026-10-02
commits: 3
plan_head_before: b94d267584398be7ccf714954dbd71ce577ef301
plan_head_after: eaf2c4574afb41f0787eb17874b709bd0faf1a01
actuals:
tasks: 3
commits: 3
requires: []
provides:
- "ModuleGrant.level (USE/MANAGE), Bestand = USE"
- "ModuleAccessService.getModuleAccessLevels als einzige Auflösung für Zugriff und Stufe"
- "@ModuleManage(slug) am ModuleGuard"
- "GET /modules/active liefert canManage je Modul"
- "Web-Hook useCanManageModule(slug)"
affects: [kantine-datev, handelsware-datev, proxmox, dkv-fleet, admin-freigaben]
key-files:
created:
- apps/api/prisma/migrations/20261002140000_module_grant_level/migration.sql
- apps/api/src/groups/dto/create-module-grant.dto.spec.ts
- apps/api/src/module-registry/module-manage-handlers.spec.ts
- apps/web/src/lib/use-module-capability.ts
modified:
- apps/api/prisma/schema.prisma
- apps/api/src/module-registry/module-access.service.ts
- apps/api/src/module-registry/module.guard.ts
- apps/api/src/groups/module-grants.service.ts
- apps/api/src/groups/dto/create-module-grant.dto.ts
- apps/api/src/kantine-datev/kantine-datev.controller.ts
- apps/api/src/handelsware-datev/handelsware-datev.controller.ts
- apps/api/src/proxmox/proxmox.controller.ts
- apps/api/src/dkv/dkv.controller.ts
- apps/web/src/lib/module-access-actions.ts
- apps/web/src/components/modules/module-access-gate.tsx
- "apps/web/src/app/(portal)/admin/modules/grants/page.tsx"
- "apps/web/src/app/(portal)/admin/users/components/UserAccessModal.tsx"
decisions:
- "Stufe wird ausschließlich serverseitig aus ModuleGrant-Zeilen aufgelöst; MANAGE gewinnt bei mehreren Wegen."
- "Wiederholter Klick auf eine Matrix-Zelle (POST ohne level) ändert die Stufe nie."
- "DKV-Fleet wird als Ganzes Verwalten-Stufe, Benutzen-Stufe bekommt keinen API-Zugriff (wie bisher)."
---
# Quick 261002-icv: Modul-Freigabe mit Stufe Benutzen und Verwalten
Jede Modul-Freigabe (Gruppe oder einzelner Benutzer) hat jetzt eine Stufe: **Benutzen** (USE, Standard und Bestand) oder **Verwalten** (MANAGE, zusätzlich die eigenen Einstellungen dieses einen Moduls ändern). Die Stufe wird im Backend über den neuen Dekorator `@ModuleManage('<slug>')` am `ModuleGuard` erzwungen. Die Weboberfläche erfährt die wirksame Stufe aus `GET /modules/active` (`canManage`) und zeigt Einstellungen nur Administratoren und Verwaltern. Administratoren vergeben die Stufe in der Freigaben-Matrix (je Gruppe) und im Benutzer-Detaildialog (je Benutzer).
## Was gebaut wurde
**Datenbank (Task 1).** Migration `20261002140000_module_grant_level` (von Hand geschrieben): `CREATE TYPE "ModuleGrantLevel"` und `ADD COLUMN "level" ... NOT NULL DEFAULT 'USE'`. Lokal angewendet über die Container-IP; `prisma migrate status` aktuell, Drift-Prüfung (`migrate diff --exit-code`) Rückgabewert 0. Die vorhandenen vier Freigaben stehen lokal alle auf USE. Keine neue Tabelle, daher keine Änderung an RLS-Regeln; `rls-coverage` und `rls-access-inventory` grün.
**Zugriffsauflösung und Wächter (Task 1).**
- `ModuleAccessService.getModuleAccessLevels` ist die einzige Auflösung: ADMIN/SUPER_ADMIN bekommen auf jedem aktiven Modul MANAGE ohne Grant-Abfragen; andere Rollen Direkt- plus Gruppen-Grants, MANAGE gewinnt, geschnitten mit aktiven Aktivierungen (MANAGE auf deaktiviertem Modul zählt nicht). Alles über denselben `forTenant`-Klienten. `getAccessibleModuleIds` ist nur noch die Schlüsselmenge (Signatur unverändert), `findAccessibleModules` liefert `canManage`.
- `ModuleGuard` liest `MODULE_MANAGE_KEY`, nutzt pro Request `request.moduleAccessLevels` (Klassen-`@UseModule` plus Handler-`@ModuleManage` fragen den Dienst nur einmal) und wirft bei Stufe USE `Module '<slug>' requires manage permission`. MANAGE gilt nur für das Modul der Route.
- `CreateModuleGrantDto.level` (optional, `@IsEnum`). `ModuleGrantsService.grant`: ohne Stufe USE; vorhandene Freigabe wird nur bei ausdrücklich anderer Stufe geändert (Logzeile `Grant-Stufe geändert … level=…`), ein Wiederholungsklick stuft nie herab; P2002-Wettlauf wendet dieselbe Regel an.
**Umgestellte Handler (Tasks 1 und 2).**
| Controller | Auf `@ModuleManage` umgestellt |
|---|---|
| `KantineDatevController` | `saveSettings` (`kantine-datev`) |
| `HandelswareDatevController` | `saveSettings` (`handelsware-datev`) |
| `ProxmoxController` | `create`, `update`, `remove`, `poll`, `test`, `testDraft` (`proxmox`); `list` bleibt Benutzen |
| `DkvController` | ganze Klasse (`dkv-fleet`), alle 11 früheren `@Roles` entfernt |
In den vier Controllern steht kein dekoratorseitiges `@Roles` mehr.
**Web (Tasks 1 bis 3).** `useCanManageModule(slug)` (Admins sofort `true` ohne Abfrage, sonst eine Abfrage von `/modules/active`, Fehler = `false`). Kantinenabrechnung, Handelsware, Proxmox-Seite, Proxmox-Einstellungen, `ServerCard`/`ServerForm` (Props `isAdmin` zu `canManage`) und das Proxmox-Dashboard-Widget (Einstellungs-Link) folgen `canManage`. Neue Server-Funktion `getModuleAccessLevel`; `checkModuleAccess` delegiert. `ModuleAccessGate` kennt `MANAGE_ONLY_MODULE_SLUGS = {'dkv-fleet'}`. Matrix und Benutzerdialog haben ein Stufen-Auswahlfeld (optimistisch, Rücksprung bei Fehler); Gruppen, die Verwalten gewähren, sind im Dialog mit „Verwalten“ markiert. Die API liefert dafür `level` je Matrix-Eintrag sowie `directLevel` und `manageViaGroups` je Modulzeile.
**Texte, Doku, Changelog.** Neue Schlüssel in `de.json`/`en.json` (formales „Sie“, echte Umlaute, kein „Mandant“/Tenant in den neuen Formulierungen; per Skript geprüft). `docs/anleitung-administration.md` (Kapitel 1, 2, 5 mit neuem Abschnitt „Freigabestufen: Benutzen und Verwalten“, Fehlersuche), `docs/anleitung-anwender.md`, `CHANGELOG.md` (Unveröffentlicht, Neu).
## Bewusst nur für Administratoren
Diese Handler blieben unverändert auf `@Roles(ADMIN, SUPER_ADMIN)`; die Metadaten-Spec `module-manage-handlers.spec.ts` beweist das:
| Handler | Grund |
|---|---|
| `TendersController.getSourceConfig` | plattformweiter Singleton der Abrufeinstellungen für die ganze Installation, keine Konfiguration eines einzelnen Moduls je Firma |
| `TendersController.saveSourceConfig` | wie oben; ändert Abrufintervall und Quelle für alle |
| `TendersController.pollNow` | stößt den plattformweiten Abruf beim Datenanbieter an (Lastschalter, T-lvg-01) |
| `TendersController.createRssFeed` (Bereich „platform“), `removeRssFeed` (plattformweiter Zweig) | prüfen die Rolle inline; plattformweite RSS-Feeds sehen alle Benutzer der Installation. Nicht angefasst |
| `ModuleRegistryController.activate` / `deactivate` | Modul-Aktivierung ist Sache der Administratoren (L-01) |
| `ModuleGrantsController.matrix` / `userAccess` / `create` / `remove` | Freigaben vergeben und Stufe wählen bleibt Administratoren vorbehalten (L-01); sonst könnte sich ein Verwalter selbst Rechte geben (T-icv-01) |
| Benutzer-, Gruppen-, LDAP-, SMTP-, Willkommensmail-, Mandanten-Controller | globale Administration, nicht modulgebunden |
| Gemeinsame eigene Module (`custom-modules`) | kein Registry-Modul und kein `@UseModule`; Seitenleisten-Einträge für alle, Rollenprüfung im Dienst |
Geprüft mit `grep -rn "Roles(" apps/api/src`: `cert-manager`, `domaincheck` und `reminders` haben keinen Administrator-Handler, also nichts umzustellen.
## DKV-Verhalten (Änderung)
Vorher trug jeder der 11 DKV-Handler `@Roles(ADMIN, SUPER_ADMIN)`, das Modul war faktisch nur für Administratoren benutzbar, und der Controller hatte kein `@UseModule`. Jetzt trägt die ganze Klasse `@ModuleManage('dkv-fleet')`:
- Zugriff haben Administratoren und Benutzer mit Stufe Verwalten.
- Benutzer mit nur Benutzen bekommen weiterhin 403 von der API (nichts wurde aufgeweitet).
- Neu ist, dass der Wächter zusätzlich die Aktivierung von `dkv-fleet` für den Mandanten verlangt (die Webseite verlangte sie schon). Ein Administrator ohne Aktivierung wird jetzt von der API ebenfalls abgewiesen.
- `ModuleAccessGate` zeigt Benutzern mit nur Benutzen eine erklärende Zugriffsseite („Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen …“), sonst den Standardtext.
## Tests und Prüfungen (ehrlich)
- **API:** `pnpm --filter @tessera/api test`: 111 Dateien, 1911 Tests, alle grün (vorher in den Teilläufen u. a. `rls-coverage`, `rls-access-inventory`, `migration-sql`).
- **Web:** `pnpm --filter @tessera/web test`: 115 Dateien, 1234 Tests, alle grün (Vollauf vor einer reinen Umbenennung unbenutzter Testparameter; danach die betroffenen `admin`-Tests erneut grün, 7 Dateien, 96 Tests).
- **tsc:** `tsc --noEmit` in api und web ohne Fehler.
- **Biome:** `biome lint` auf allen berührten TS/TSX-Dateien ohne neue Meldungen. Zwei bereits vorhandene Warnungen bleiben bestehen und stammen nicht aus diesem Plan (`noAssignInExpressions` in `migration-sql.spec.ts`, `noArrayIndexKey` in `grants/page.tsx`).
- **Lokaler Stand:** `docker compose up -d --build api web` erfolgreich; api, web und db laufen; Log meldet `Nest application successfully started`, kein Migrations- oder Prisma-Fehler. Browserprüfung macht der Orchestrator.
## Abweichungen vom Plan
**1. [Rule 2 - Konsistenz] Proxmox-Dashboard-Widget auf `canManage` umgestellt**
- **Gefunden bei:** Task 2
- **Problem:** `proxmox-widget.tsx` blendete den Link „Zu den Einstellungen“ nur für Administratoren ein, obwohl Verwalter die Einstellungsseite jetzt nutzen dürfen. Die Datei stand nicht in der Dateiliste des Plans.
- **Fix:** `useCanManageModule('proxmox')` statt Rollenprüfung; bestehender Widget-Test blieb grün.
- **Commit:** c2ebc8d
**2. [Rule 1 - Typfehler] `applyToExisting` in `ModuleGrantsService.grant`** brauchte den Typ `ModuleGrant`, damit `tsc` die Spec akzeptiert. Behoben vor dem Commit von Task 1 (a222711).
Sonst: Plan wie geschrieben ausgeführt. Die Metadaten-Spec liegt wie geplant als eine Datei vor. Beim Handelsware-Test wurde eine Textprüfung (`/Ein Administrator muss zuerst/`) an den neuen Wortlaut angepasst, ebenso bei der Kantinenabrechnung.
## Bekannte Stubs
Keine.
## Threat Flags
Keine neuen Angriffsflächen außerhalb des Bedrohungsmodells des Plans. Hinweis zu T-icv-09: Wer für Proxmox „Verwalten“ erhält, darf Serveradressen eintragen (private Ziele per Design); das steht im Kommentar von `proxmox-client.service.ts` und im Administrationshandbuch.
## Commits (nicht gepusht)
- `a222711` feat(module-grants): Freigabestufe Verwalten – Datenbank, Zugriffsprüfung und Kantinen-Einstellungen
- `c2ebc8d` feat(module-grants): Proxmox, Handelsware und DKV mit Freigabestufe Verwalten
- `eaf2c45` feat(module-grants): Stufe Verwalten in Freigaben-Matrix und Benutzerdetails, Doku und Changelog
## Self-Check: PASSED
- Migration, `module.guard.ts`, `use-module-capability.ts`, `module-manage-handlers.spec.ts`, `create-module-grant.dto.spec.ts` vorhanden.
- Alle drei Commit-Hashes existieren auf `main` (`git rev-list --count` über das Ledger: 3).
- Keine unbeabsichtigten Löschungen in den Commits.
## Browser-Prüfung (Orchestrator, 02.10., lokal, dunkel)
- Testgruppe „Buchhaltung Test“ mit Testbenutzer (USER) per API angelegt; in der Freigaben-Matrix Kantinenabrechnung = Verwalten, Handelsware = Benutzen gesetzt, bleibt nach Neuladen erhalten.
- Als Testbenutzer: Kantine zeigt Reiter Einstellungen, Speichern klappt; Handelsware ohne Einstellungs-Reiter; API: Handelsware-Einstellungen PUT → 403, DKV ohne Freigabe → 403.
- Korrektur: Matrix zeigte Kategorie-Kennungen („accounting“) → jetzt Anzeigenamen (Finanzbuchhaltung, Fuhrpark, …).
- Testbenutzer und Testgruppe wieder gelöscht.
+7
View File
@@ -4,6 +4,13 @@ Diese Liste beschreibt in einfachen Worten, was sich von Version zu Version an T
## Unveröffentlicht ## Unveröffentlicht
### Neu
- Neue Gruppe „Finanzbuchhaltung“ in der Seitenleiste mit zwei Modulen. Beide aktiviert ein Administrator im Marktplatz; wer sie nutzen soll, bekommt zusätzlich die Freigabe.
- Kantinenabrechnung: Die CSV-Datei der Kantine hochladen (Excel-Export mit UTF-8 oder Windows-1252 ist beides in Ordnung). Tessera zeigt Zeilenzahl, Abrechnungsmonat und Gesamtbetrag, nennt fehlerhafte Zeilen mit Zeilennummer und weist auf unterschiedliche Abrechnungsmonate hin. Ist alles in Ordnung, laden Sie mit einem Klick die DATEV-Lohndatei herunter. Beraternummer, Mandantennummer und Lohnart trägt ein Administrator einmalig ein; bis dahin ist der Download gesperrt. Die hochgeladenen Daten werden nicht gespeichert.
- Handelsware: Eine Excel-Liste mit Handelswaren-Umsätzen hochladen. Tessera ordnet jedem Produkt sein Konto zu, markiert neue Produkte mit „neu“ und vergibt ihnen das nächste freie Gegenkonto. Das Buchungsdatum wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und lässt sich ändern. Neue Konten werden erst beim Herunterladen der Buchungsdatei gespeichert. Im Reiter „Konten“ pflegen Sie die Kontenliste, lesen sie aus einer CSV-Datei ein (ersetzt alle vorhandenen Konten, nach Rückfrage) und exportieren sie als CSV. Standard-Erlöskonto und Startwert für das Gegenkonto trägt ein Administrator einmalig ein.
- Modul-Freigaben haben jetzt zwei Stufen: „Benutzen“ (wie bisher) und „Verwalten“. Wer ein Modul verwalten darf, ändert dessen Einstellungen selbst, ohne Administrator zu sein, zum Beispiel in der Kantinenabrechnung, bei Handelsware und bei den Proxmox-Servern. Ein Administrator wählt die Stufe je Gruppe in der Freigaben-Matrix oder je Benutzer in den Benutzerdetails; bestehende Freigaben bleiben „Benutzen“. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Das Modul DKV-Rechnung steht Administratoren und Benutzern mit „Verwalten“ zur Verfügung.
## 1.9.2 – 2026-10-02 ## 1.9.2 – 2026-10-02
### Neu ### Neu
@@ -0,0 +1,46 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Kantinenabrechnung" (kantine-datev).
--
-- Zweck: eine neue Tabelle `KantineDatevConfig` mit den drei Nummern, die der
-- Administrator einmalig je Mandant hinterlegt (Beraternummer, Mandantennummer,
-- Lohnart). Eine Zeile je Mandant (Singleton, Vorbild `DkvModuleConfig`). Die
-- Felder sind Text, damit fuehrende Nullen erhalten bleiben, und haben
-- ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das Modul die
-- Verarbeitung. Die hochgeladene Kantinen-CSV wird nicht gespeichert.
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): die Tabelle
-- traegt `tenantId` und `tenant_isolation_policy` OHNE Benutzerdimension
-- (`USING ("tenantId" = current_tenant_id())`, Form aus `DkvModuleConfig`) —
-- Verwaltungsdaten des Mandanten, nicht persoenliche Daten eines Benutzers.
-- Keine `system_read_policy`: es gibt keinen Hintergrunddienst, der diese
-- Einstellungen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TABLE "KantineDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"beraterNr" TEXT,
"mandantNr" TEXT,
"lohnart" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "KantineDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "KantineDatevConfig_tenantId_key" ON "KantineDatevConfig"("tenantId");
CREATE INDEX "KantineDatevConfig_tenantId_idx" ON "KantineDatevConfig"("tenantId");
ALTER TABLE "KantineDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "KantineDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "KantineDatevConfig"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,70 @@
-- 261002-fm5 — Finanzbuchhaltung: Modul "Handelsware" (handelsware-datev).
--
-- Zweck: zwei neue Tabellen. `HandelswareDatevConfig` traegt die Einstellungen
-- des Mandanten (Standard-Erloeskonto fuer neue Konten, Startwert fuer die
-- Gegenkonto-Vergabe bei leerer Kontenliste) — eine Zeile je Mandant
-- (Singleton, Vorbild `DkvModuleConfig`/`KantineDatevConfig`). Beide Zahlen
-- haben ABSICHTLICH keinen Standardwert: solange sie leer sind, sperrt das
-- Modul die Verarbeitung. `HandelswareKonto` ist die Kontenliste (Produktname
-- -> Gegenkonto, Erloeskonto) — mehrere Zeilen je Mandant, der Name ist je
-- Mandant eindeutig, das Gegenkonto bewusst nicht (mehrere Produkte duerfen
-- auf dasselbe Gegenkonto laufen).
--
-- Von Hand geschrieben (Vorbild 20260923140000_proxmox_server), von Hand
-- gepflegter Kopfkommentar Pflicht bei jeder RLS-Migration in diesem Projekt.
--
-- Zeilenschutz (Pflicht — sonst schlaegt rls-coverage.spec.ts fehl): beide
-- Tabellen tragen `tenantId` und `tenant_isolation_policy` OHNE
-- Benutzerdimension (`USING ("tenantId" = current_tenant_id())`, Form aus
-- `DkvModuleConfig`) — Verwaltungsdaten des Mandanten, nicht persoenliche Daten
-- eines Benutzers. Keine `system_read_policy`: es gibt keinen Hintergrunddienst,
-- der diese Tabellen ueber alle Mandanten liest.
--
-- Rechte fuer die Anwendungsrolle tessera_app kommen automatisch ueber
-- ALTER DEFAULT PRIVILEGES aus 20260909130000_rls_app_role — hier nichts zu
-- tun.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken diese Regeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
-- 1) HandelswareDatevConfig
CREATE TABLE "HandelswareDatevConfig" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"erloeskonto" INTEGER,
"startGegenkonto" INTEGER,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareDatevConfig_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareDatevConfig_tenantId_key" ON "HandelswareDatevConfig"("tenantId");
CREATE INDEX "HandelswareDatevConfig_tenantId_idx" ON "HandelswareDatevConfig"("tenantId");
ALTER TABLE "HandelswareDatevConfig" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareDatevConfig" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareDatevConfig"
USING ("tenantId" = current_tenant_id());
-- 2) HandelswareKonto
CREATE TABLE "HandelswareKonto" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"gegenkonto" INTEGER NOT NULL,
"erloeskonto" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "HandelswareKonto_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "HandelswareKonto_tenantId_name_key" ON "HandelswareKonto"("tenantId", "name");
CREATE INDEX "HandelswareKonto_tenantId_idx" ON "HandelswareKonto"("tenantId");
ALTER TABLE "HandelswareKonto" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "HandelswareKonto" FORCE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation_policy ON "HandelswareKonto"
USING ("tenantId" = current_tenant_id());
@@ -0,0 +1,28 @@
-- 261002-icv — Freigabestufe fuer Modul-Freigaben: Benutzen (USE) und
-- Verwalten (MANAGE).
--
-- Zweck: jede Zeile in "ModuleGrant" bekommt eine Stufe. USE ist der Bestand
-- und der Standard (Modul oeffnen und benutzen). MANAGE erlaubt zusaetzlich,
-- die eigenen Einstellungen dieses einen Moduls zu aendern. Freigaben
-- erteilen, Module aktivieren und die uebrige Verwaltung bleiben
-- Administratoren vorbehalten (das erzwingt die Anwendung, nicht diese
-- Migration).
--
-- Bestandsdaten: durch den DEFAULT 'USE' werden alle vorhandenen Freigaben zu
-- USE — niemand gewinnt durch die Migration Rechte.
--
-- Von Hand geschrieben (Vorbild 20261002120000_kantine_datev_config).
--
-- Zeilenschutz: keine neue Tabelle. Die vorhandenen Regeln auf "ModuleGrant"
-- filtern Zeilen, nicht Spalten, und bleiben unveraendert — rls-coverage
-- braucht nichts. PostgreSQL gewaehrt USAGE auf neue Typen automatisch an
-- PUBLIC, die Anwendungsrolle tessera_app kann den Aufzaehlungstyp also
-- verwenden.
--
-- WICHTIG: wie alle bisherigen RLS-Migrationen wirken die Zeilenregeln erst,
-- wenn die Anwendung als Rolle ohne Umgehungsrecht verbindet (Schalter
-- heute AUS, siehe docs/mandantentrennung-datenbankrolle.md).
CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');
ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';
+63 -1
View File
@@ -140,12 +140,20 @@ model TenantModuleActivation {
// darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen // darf höchstens eine Gruppe die Standard-Markierung tragen, DB-erzwungen
// über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser // über einen partiellen Unique-Index in der Hand-SQL-Ergänzung dieser
// Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag). // Migration (Prisma 6.19 kennt keine partiellen Indizes ohne Preview-Flag).
// D-04: ModuleGrant trägt bewusst KEIN Rechtestufen-Feld — nur Zugriff an/aus. // D-04 (überholt durch 261002-icv): ModuleGrant trägt seit 261002-icv die Freigabestufe `level`.
enum MembershipSource { enum MembershipSource {
MANUAL MANUAL
LDAP LDAP
} }
// 261002-icv: Freigabestufe einer Modul-Freigabe. USE = Benutzen (Standard und
// Bestand), MANAGE = Verwalten (Modul benutzen UND dessen eigene Einstellungen
// ändern). Freigaben erteilen bleibt Administratoren vorbehalten.
enum ModuleGrantLevel {
USE
MANAGE
}
model Group { model Group {
id String @id @default(uuid()) id String @id @default(uuid())
tenantId String tenantId String
@@ -191,6 +199,10 @@ model ModuleGrant {
userId String? userId String?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) user User? @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
// 261002-icv: Freigabestufe; USE = Benutzen (Standard und Bestand),
// MANAGE = Verwalten — Modul benutzen und dessen eigene Einstellungen
// ändern; Freigaben erteilen bleibt Administratoren vorbehalten.
level ModuleGrantLevel @default(USE)
// Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante // Entweder-oder (Gruppe XOR Benutzer, D-04) + Duplikat-Schutz je Variante
// werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein // werden per hand-editierter migration.sql ergänzt — Prisma 6.19 hat kein
@@ -344,6 +356,56 @@ model DkvModuleConfig {
@@index([tenantId]) @@index([tenantId])
} }
// quick-261002-fm5: Kantinenabrechnung (Modul kantine-datev). Eine Zeile je
// Mandant (Singleton wie DkvModuleConfig). Die drei Nummern stehen als Text,
// damit fuehrende Nullen erhalten bleiben; sie haben bewusst KEINEN
// Standardwert — der Administrator hinterlegt sie einmalig, bis dahin ist die
// Verarbeitung gesperrt. Die hochgeladene CSV selbst wird nie gespeichert.
model KantineDatevConfig {
id String @id @default(uuid())
tenantId String @unique
beraterNr String?
mandantNr String?
lohnart String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
// quick-261002-fm5: Handelsware (Modul handelsware-datev). Einstellungen je
// Mandant (Singleton wie KantineDatevConfig): Standard-Erloeskonto fuer neue
// Konten und Startwert fuer die Gegenkonto-Vergabe bei leerer Kontenliste.
// Beide Zahlen haben bewusst KEINEN Standardwert — der Administrator hinterlegt
// sie einmalig, bis dahin ist die Verarbeitung gesperrt.
model HandelswareDatevConfig {
id String @id @default(uuid())
tenantId String @unique
erloeskonto Int?
startGegenkonto Int?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([tenantId])
}
// quick-261002-fm5: Kontenliste der Handelsware (Produktname -> Gegenkonto,
// Erloeskonto). Der Name ist je Mandant eindeutig; das Gegenkonto bewusst
// NICHT (mehrere Produkte duerfen auf dasselbe Gegenkonto laufen, wie in der
// Desktop-Vorlage). Keine Relation zu Tenant, Zeilenschutz nach ProxmoxServer.
model HandelswareKonto {
id String @id @default(uuid())
tenantId String
name String
gegenkonto Int
erloeskonto Int
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([tenantId, name])
@@index([tenantId])
}
// Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal- // Phase 14, Plan 03 (INGEST-05, CONFIG-02, D-06/D-07) — per-tenant portal-
// alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly // alert mailbox config, mirroring DkvModuleConfig's shape/pattern exactly
// (own tenantId @unique row, own encrypted creds — D-03: each module keeps // (own tenantId @unique row, own encrypted creds — D-03: each module keeps
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { decodeCsvText } from './decode-csv-text';
describe('decodeCsvText', () => {
it('liest gueltiges UTF-8 unveraendert', () => {
expect(decodeCsvText(Buffer.from('Müller;Straße', 'utf8'))).toBe('Müller;Straße');
});
it('entfernt ein UTF-8-BOM', () => {
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from('Name;Wert', 'utf8')]);
expect(decodeCsvText(buf)).toBe('Name;Wert');
});
it('faellt bei ungueltigem UTF-8 auf Windows-1252 zurueck (Umlaut)', () => {
expect(decodeCsvText(Buffer.from('Müller', 'latin1'))).toBe('Müller');
});
it('liest das Euro-Zeichen (0x80) in Windows-1252', () => {
expect(decodeCsvText(Buffer.from([0x31, 0x30, 0x80]))).toBe('10€');
});
});
@@ -0,0 +1,18 @@
/**
* Dekodiert hochgeladene CSV-Bytes zu Text (quick-261002-fm5).
*
* Excel und Warenwirtschaftssysteme liefern CSV entweder als UTF-8 (mit oder
* ohne Byte-Order-Mark) oder als Windows-1252. Zuerst wird streng als UTF-8
* gelesen: sind die Bytes kein gueltiges UTF-8 (typisch bei Umlauten in
* Windows-1252), faellt die Funktion auf Windows-1252 zurueck. `TextDecoder`
* verwirft ein fuehrendes BOM standardmaessig.
*
* Gemeinsam genutzt von Kantinenabrechnung und Handelsware.
*/
export function decodeCsvText(buffer: Buffer): string {
try {
return new TextDecoder('utf-8', { fatal: true }).decode(buffer);
} catch {
return new TextDecoder('windows-1252').decode(buffer);
}
}
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest';
import { decodeUploadFilename } from './decode-upload-filename';
describe('decodeUploadFilename', () => {
it('laesst ASCII-Namen unveraendert', () => {
expect(decodeUploadFilename('HWA 0326 Test.xlsx')).toBe('HWA 0326 Test.xlsx');
});
it('kehrt latin1-gelesenes UTF-8 um', () => {
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
expect(decodeUploadFilename(mojibake)).toBe('Käse 0326.xlsx');
});
it('laesst einen schon richtigen Namen mit Umlaut stehen', () => {
expect(decodeUploadFilename('Käse.xlsx')).toBe('Käse.xlsx');
});
it('laesst Namen mit Zeichen ueber 255 stehen', () => {
expect(decodeUploadFilename('Preis €.xlsx')).toBe('Preis €.xlsx');
});
});
@@ -0,0 +1,14 @@
/**
* Multer liefert `originalname` je nach Version als latin1-gelesene Bytes: ein
* UTF-8-Dateiname wie "Käse.xlsx" kommt als "Käse.xlsx" an. Diese Funktion
* kehrt das um, ohne einen schon richtigen Namen zu zerstoeren: ist der Name
* nicht aus latin1-Zeichen zusammengesetzt (Zeichen > 255) oder ergibt die
* Umkehrung kein gueltiges UTF-8, bleibt er unveraendert.
*/
export function decodeUploadFilename(name: string): string {
for (let i = 0; i < name.length; i++) {
if (name.charCodeAt(i) > 255) return name;
}
const converted = Buffer.from(name, 'latin1').toString('utf8');
return converted.includes('�') ? name : converted;
}
+4
View File
@@ -26,6 +26,8 @@ import { TenantGuard } from './tenant/tenant.guard';
import { TenantModule } from './tenant/tenant.module'; import { TenantModule } from './tenant/tenant.module';
import { TendersModule } from './tenders/tenders.module'; import { TendersModule } from './tenders/tenders.module';
import { UserModule } from './user/user.module'; import { UserModule } from './user/user.module';
import { HandelswareDatevModule } from './handelsware-datev/handelsware-datev.module';
import { KantineDatevModule } from './kantine-datev/kantine-datev.module';
import { ProxmoxModule } from './proxmox/proxmox.module'; import { ProxmoxModule } from './proxmox/proxmox.module';
import { CustomModulesModule } from './custom-modules/custom-modules.module'; import { CustomModulesModule } from './custom-modules/custom-modules.module';
import { RemindersModule } from './reminders/reminders.module'; import { RemindersModule } from './reminders/reminders.module';
@@ -55,6 +57,8 @@ import { RemindersModule } from './reminders/reminders.module';
TendersModule, TendersModule,
BugReportsModule, BugReportsModule,
ProxmoxModule, ProxmoxModule,
KantineDatevModule,
HandelswareDatevModule,
CustomModulesModule, CustomModulesModule,
RemindersModule, RemindersModule,
], ],
+12 -17
View File
@@ -15,8 +15,7 @@ import {
UseInterceptors, UseInterceptors,
} from '@nestjs/common'; } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
import { Role } from '@prisma/client'; import { ModuleManage } from '../module-registry/module.guard';
import { Roles } from '../auth/decorators/roles.decorator';
import type { import type {
AuthenticatedRequest, AuthenticatedRequest,
UploadedFileLike, UploadedFileLike,
@@ -29,11 +28,17 @@ import { DkvHistoryQueryDto } from './dto/dkv-history.dto';
import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto'; import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
/** /**
* DkvController — all /dkv/* routes, ADMIN-only (V4). * DkvController — all /dkv/* routes, manager level (V4, 261002-icv).
* *
* Every handler carries @Roles(Role.ADMIN, Role.SUPER_ADMIN). * The whole module is Verwalten-level: `@ModuleManage('dkv-fleet')` on the
* Global JwtAuthGuard enforces JWT authentication; RolesGuard enforces the * class replaces the former per-handler @Roles(ADMIN, SUPER_ADMIN). Access is
* @Roles decorator. No route is publicly accessible. * therefore limited to administrators and to users with the grant level
* "Verwalten" (MANAGE) on the dkv-fleet module. Users with only "Benutzen"
* (USE) keep getting 403 exactly as before — nothing was widened. The class
* guard additionally requires the dkv-fleet module to be active for the
* tenant (the web page already required that).
* Global JwtAuthGuard enforces JWT authentication; ModuleGuard enforces the
* grant level. No route is publicly accessible.
* *
* Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards). * Tenant extraction: `req.tenantId` set by TenantGuard (runs after auth guards).
* All operations are scoped to the authenticated tenant's data. * All operations are scoped to the authenticated tenant's data.
@@ -52,6 +57,7 @@ import { CreateVehicleDto, UpdateVehicleDto } from './dto/dkv-vehicle.dto';
* POST /dkv/vehicles/import — bulk-import from CSV upload * POST /dkv/vehicles/import — bulk-import from CSV upload
*/ */
@Controller('dkv') @Controller('dkv')
@ModuleManage('dkv-fleet')
export class DkvController { export class DkvController {
constructor( constructor(
private readonly dkvService: DkvService, private readonly dkvService: DkvService,
@@ -62,7 +68,6 @@ export class DkvController {
/** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */ /** GET /dkv/config — returns module config with username + hasPassword. 404 when not yet configured. */
@Get('config') @Get('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getConfig(@Req() req: AuthenticatedRequest) { async getConfig(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const config = await this.dkvService.getConfigForApi(tenantId); const config = await this.dkvService.getConfigForApi(tenantId);
@@ -79,7 +84,6 @@ export class DkvController {
* or stops the cron job if isActive is false. * or stops the cron job if isActive is false.
*/ */
@Put('config') @Put('config')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) { async saveConfig(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const result = await this.dkvService.saveConfig(tenantId, dto); const result = await this.dkvService.saveConfig(tenantId, dto);
@@ -98,7 +102,6 @@ export class DkvController {
/** POST /dkv/check-now — immediately run the inbox processing pipeline. */ /** POST /dkv/check-now — immediately run the inbox processing pipeline. */
@Post('check-now') @Post('check-now')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async checkNow(@Req() req: AuthenticatedRequest) { async checkNow(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.checkNow(tenantId); return this.dkvService.checkNow(tenantId);
@@ -109,7 +112,6 @@ export class DkvController {
* Used by the InboxConfigForm "Verbindung testen" button before saving. * Used by the InboxConfigForm "Verbindung testen" button before saving.
*/ */
@Post('test-connection') @Post('test-connection')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) { async testConnection(@Req() req: AuthenticatedRequest, @Body() dto: DkvConfigDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.testConnection(tenantId, dto); return this.dkvService.testConnection(tenantId, dto);
@@ -122,7 +124,6 @@ export class DkvController {
* T-07-06: pagination parameters validated by DkvHistoryQueryDto. * T-07-06: pagination parameters validated by DkvHistoryQueryDto.
*/ */
@Get('history') @Get('history')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) { async getHistory(@Req() req: AuthenticatedRequest, @Query() query: DkvHistoryQueryDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
const page = query.page ?? 1; const page = query.page ?? 1;
@@ -140,7 +141,6 @@ export class DkvController {
* containing path separators or non-whitelisted characters is rejected. * containing path separators or non-whitelisted characters is rejected.
*/ */
@Get('exports/:filename') @Get('exports/:filename')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async downloadExport( async downloadExport(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@Param('filename') filename: string, @Param('filename') filename: string,
@@ -168,7 +168,6 @@ export class DkvController {
/** GET /dkv/vehicles — list all vehicle master records for this tenant. */ /** GET /dkv/vehicles — list all vehicle master records for this tenant. */
@Get('vehicles') @Get('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async listVehicles(@Req() req: AuthenticatedRequest) { async listVehicles(@Req() req: AuthenticatedRequest) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.listVehicles(tenantId); return this.dkvService.listVehicles(tenantId);
@@ -176,7 +175,6 @@ export class DkvController {
/** POST /dkv/vehicles — create a new vehicle master record. */ /** POST /dkv/vehicles — create a new vehicle master record. */
@Post('vehicles') @Post('vehicles')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) { async createVehicle(@Req() req: AuthenticatedRequest, @Body() dto: CreateVehicleDto) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.createVehicle(tenantId, dto); return this.dkvService.createVehicle(tenantId, dto);
@@ -184,7 +182,6 @@ export class DkvController {
/** PUT /dkv/vehicles/:id — update an existing vehicle master record. */ /** PUT /dkv/vehicles/:id — update an existing vehicle master record. */
@Put('vehicles/:id') @Put('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async updateVehicle( async updateVehicle(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@Param('id') id: string, @Param('id') id: string,
@@ -196,7 +193,6 @@ export class DkvController {
/** DELETE /dkv/vehicles/:id — delete a vehicle master record. */ /** DELETE /dkv/vehicles/:id — delete a vehicle master record. */
@Delete('vehicles/:id') @Delete('vehicles/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) { async deleteVehicle(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this._requireTenant(req); const tenantId = this._requireTenant(req);
return this.dkvService.deleteVehicle(tenantId, id); return this.dkvService.deleteVehicle(tenantId, id);
@@ -213,7 +209,6 @@ export class DkvController {
* The controller reads `file.buffer.toString('utf-8')` and passes to DkvService. * The controller reads `file.buffer.toString('utf-8')` and passes to DkvService.
*/ */
@Post('vehicles/import') @Post('vehicles/import')
@Roles(Role.ADMIN, Role.SUPER_ADMIN)
@UseInterceptors(FileInterceptor('file', { @UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05) limits: { fileSize: 5 * 1024 * 1024 }, // 5 MB — generous for any realistic vehicle list (WR-05)
})) }))
@@ -0,0 +1,25 @@
import 'reflect-metadata';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, expect, it } from 'vitest';
import { CreateModuleGrantDto } from './create-module-grant.dto';
async function errorsFor(plain: Record<string, unknown>) {
const dto = plainToInstance(CreateModuleGrantDto, plain);
const errors = await validate(dto as object);
return errors.map((e) => e.property);
}
describe('CreateModuleGrantDto — Freigabestufe (261002-icv)', () => {
it('ohne level ist gültig', async () => {
expect(await errorsFor({ moduleId: 'm1', groupId: 'g1' })).toEqual([]);
});
it.each(['USE', 'MANAGE'])('level %s ist gültig', async (level) => {
expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toEqual([]);
});
it.each(['ADMIN', 'manage', 'use', '', 1])('level %j wird abgelehnt', async (level) => {
expect(await errorsFor({ moduleId: 'm1', userId: 'u1', level })).toContain('level');
});
});
@@ -1,4 +1,5 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; import { ModuleGrantLevel } from '@prisma/client';
import { IsEnum, IsNotEmpty, IsOptional, IsString } from 'class-validator';
/** /**
* DTO für Grant-Erstellung und -Entzug (PERM-03). * DTO für Grant-Erstellung und -Entzug (PERM-03).
@@ -21,4 +22,12 @@ export class CreateModuleGrantDto {
@IsString() @IsString()
@IsOptional() @IsOptional()
userId?: string; userId?: string;
/**
* Freigabestufe (261002-icv): 'USE' (Benutzen, Standard) oder 'MANAGE'
* (Verwalten). Beim Entzug (DELETE) wird das Feld ignoriert.
*/
@IsOptional()
@IsEnum(ModuleGrantLevel)
level?: ModuleGrantLevel;
} }
+14
View File
@@ -335,3 +335,17 @@ describe('add_group_internal_name_and_object_guid migration.sql (D-04)', () => {
expect(sql).not.toMatch(/ALTER TABLE .* (ENABLE|FORCE) ROW LEVEL SECURITY/); expect(sql).not.toMatch(/ALTER TABLE .* (ENABLE|FORCE) ROW LEVEL SECURITY/);
}); });
}); });
describe('module_grant_level migration.sql (261002-icv)', () => {
const sql = readMigrationSql('_module_grant_level');
it('legt den Aufzählungstyp ModuleGrantLevel mit USE und MANAGE an', () => {
expect(sql).toContain(`CREATE TYPE "ModuleGrantLevel" AS ENUM ('USE', 'MANAGE');`);
});
it('fügt die Spalte level mit Standard USE hinzu (Bestand wird USE)', () => {
expect(sql).toContain(
`ALTER TABLE "ModuleGrant" ADD COLUMN "level" "ModuleGrantLevel" NOT NULL DEFAULT 'USE';`,
);
});
});
@@ -124,6 +124,12 @@ function makeFakePrisma() {
findFirst: async ({ where }: any) => { findFirst: async ({ where }: any) => {
return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null; return findGrant(where.tenantId, where.moduleId, where.groupId, where.userId) ?? null;
}, },
update: async ({ where, data }: any) => {
const record = grants.get(where.id);
if (!record) throw new Error('not found');
Object.assign(record, data);
return record;
},
findMany: async ({ where }: any) => { findMany: async ({ where }: any) => {
let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId); let rows = Array.from(grants.values()).filter((g) => g.tenantId === where.tenantId);
@@ -351,6 +357,83 @@ describe('ModuleGrantsService.grant', () => {
}); });
}); });
describe('ModuleGrantsService.grant — Freigabestufe (261002-icv)', () => {
it('ohne Stufe wird mit USE angelegt', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
const result = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
expect(result.level).toBe('USE');
});
it('mit Stufe MANAGE wird mit MANAGE angelegt', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
const result = await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' });
expect(result.level).toBe('MANAGE');
});
it('bestehende USE-Freigabe plus Stufe MANAGE wird auf MANAGE angehoben und protokolliert', async () => {
const logSpy = vi.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
const first = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
const second = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
expect(second.id).toBe(first.id);
expect(second.level).toBe('MANAGE');
expect(prisma.__grantCount()).toBe(1);
expect(logSpy.mock.calls.map((c) => String(c[0])).join('\n')).toContain(
'Grant-Stufe geändert: tenant=t1 module=mod-1 group=g1 level=MANAGE',
);
logSpy.mockRestore();
});
it('bestehende MANAGE-Freigabe ohne Stufenangabe bleibt MANAGE (Wiederholungsklick stuft nie herab)', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
const again = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
expect(again.level).toBe('MANAGE');
});
it('bestehende MANAGE-Freigabe kann ausdrücklich auf USE gesetzt werden', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
const down = await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'USE' });
expect(down.level).toBe('USE');
});
it('P2002-Wettlauf mit Stufe: die Stufe wird angewendet, es bleibt eine Zeile', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
const [a, b] = await Promise.all([
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' }),
service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }),
]);
expect(a.groupId).toBe('g1');
expect(b.level).toBe('MANAGE');
expect(prisma.__grantCount()).toBe(1);
});
});
describe('ModuleGrantsService.revoke', () => { describe('ModuleGrantsService.revoke', () => {
it('entfernt einen bestehenden Grant', async () => { it('entfernt einen bestehenden Grant', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
@@ -412,7 +495,18 @@ describe('ModuleGrantsService.getMatrix', () => {
expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']); expect(matrix.modules.map((m: any) => m.id)).toEqual(['mod-a', 'mod-b']);
expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']); expect(matrix.groups.map((g: any) => g.name)).toEqual(['Alpha', 'Zeta']);
expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1' }]); expect(matrix.grants).toEqual([{ moduleId: 'mod-a', groupId: 'g1', level: 'USE' }]);
});
it('261002-icv: jedes Grant-Element trägt die Stufe', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' });
const matrix = await service.getMatrix('t1');
expect(matrix.grants).toEqual([{ moduleId: 'mod-1', groupId: 'g1', level: 'MANAGE' }]);
}); });
it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => { it('empty: ein Mandant ohne Gruppen liefert eine leere Gruppenliste und wirft nicht', async () => {
@@ -469,11 +563,32 @@ describe('ModuleGrantsService.getUserAccess', () => {
module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' }, module: { id: 'mod-1', category: 'ops', name: 'Modul Eins' },
viaGroups: ['Gruppe A'], viaGroups: ['Gruppe A'],
direct: false, direct: false,
directLevel: null,
manageViaGroups: [],
}, },
]); ]);
expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]); expect(result.groups).toEqual([{ id: 'g1', name: 'Gruppe A', source: 'MANUAL' }]);
}); });
it('261002-icv: directLevel zeigt die Stufe der Direkt-Freigabe, manageViaGroups nennt Gruppen mit Verwalten', async () => {
const prisma = makeFakePrisma();
seedBase(prisma);
prisma.__seedGroup({ id: 'g2', tenantId: 't1', name: 'Gruppe B' });
prisma.__seedMembership('g1', 'u1');
prisma.__seedMembership('g2', 'u1');
const service = new ModuleGrantsService(prisma as any);
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g1' });
await service.grant('t1', { moduleId: 'mod-1', groupId: 'g2', level: 'MANAGE' });
await service.grant('t1', { moduleId: 'mod-1', userId: 'u1', level: 'MANAGE' });
const row = (await service.getUserAccess('t1', 'u1')).modules[0];
expect(row.direct).toBe(true);
expect(row.directLevel).toBe('MANAGE');
expect([...row.viaGroups].sort()).toEqual(['Gruppe A', 'Gruppe B']);
expect(row.manageViaGroups).toEqual(['Gruppe B']);
});
it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => { it('adjacency: ein Direkt-Grant UND ein Gruppen-Grant auf dasselbe Modul erscheinen gleichzeitig, keiner verdrängt den anderen', async () => {
const prisma = makeFakePrisma(); const prisma = makeFakePrisma();
seedBase(prisma); seedBase(prisma);
+63 -23
View File
@@ -4,6 +4,7 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { type ModuleGrant, ModuleGrantLevel } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
import { prismaErrorCode } from '../prisma/prisma-error'; import { prismaErrorCode } from '../prisma/prisma-error';
@@ -21,8 +22,9 @@ import { prismaErrorCode } from '../prisma/prisma-error';
* über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine * über `this.logger`. Es entsteht bewusst keine Audit-Tabelle und keine
* Ansicht im Admin-UI. * Ansicht im Admin-UI.
* *
* D-04: der Datensatz trägt keine Rechtestufe, und dieser Service bietet * Seit 261002-icv trägt der Datensatz eine Freigabestufe `level` (Benutzen /
* keine Methode, die eine solche setzen könnte. * Verwalten); nur dieser ausschließlich Administratoren zugängliche Service
* setzt sie.
*/ */
@Injectable() @Injectable()
export class ModuleGrantsService { export class ModuleGrantsService {
@@ -82,9 +84,9 @@ export class ModuleGrantsService {
*/ */
async grant( async grant(
tenantId: string, tenantId: string,
data: { moduleId: string; groupId?: string; userId?: string }, data: { moduleId: string; groupId?: string; userId?: string; level?: ModuleGrantLevel },
) { ) {
const { moduleId, groupId, userId } = data; const { moduleId, groupId, userId, level } = data;
if ((groupId && userId) || (!groupId && !userId)) { if ((groupId && userId) || (!groupId && !userId)) {
throw new BadRequestException( throw new BadRequestException(
'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines', 'Ein Grant muss entweder eine groupId oder eine userId tragen, nicht beides und nicht keines',
@@ -119,6 +121,37 @@ export class ModuleGrantsService {
} }
const target = groupId ? `group=${groupId}` : `user=${userId}`; const target = groupId ? `group=${groupId}` : `user=${userId}`;
const targetWhere = {
tenantId,
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
};
// Besteht der Grant schon: nur eine ausdruecklich andere Stufe aendert
// ihn. Ohne Stufenangabe (erneuter Klick auf die Zelle) bleibt die
// vorhandene Stufe — ein Wiederholungsklick stuft nie herab (T-icv-11).
const applyToExisting = async (existing: ModuleGrant): Promise<ModuleGrant> => {
if (level && existing.level !== level) {
const updated = await tenantPrisma.moduleGrant.update({
where: { id: existing.id },
data: { level },
});
this.logger.log(
`Grant-Stufe geändert: tenant=${tenantId} module=${moduleId} ${target} level=${level}`,
);
return updated;
}
this.logger.log(
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
);
return existing;
};
const found = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere });
if (found) {
return applyToExisting(found);
}
try { try {
const created = await tenantPrisma.moduleGrant.create({ const created = await tenantPrisma.moduleGrant.create({
@@ -127,27 +160,18 @@ export class ModuleGrantsService {
moduleId, moduleId,
groupId: groupId ?? null, groupId: groupId ?? null,
userId: userId ?? null, userId: userId ?? null,
level: level ?? ModuleGrantLevel.USE,
}, },
}); });
this.logger.log( this.logger.log(
`Grant erteilt: tenant=${tenantId} module=${moduleId} ${target}`, `Grant erteilt: tenant=${tenantId} module=${moduleId} ${target} level=${created.level ?? level ?? ModuleGrantLevel.USE}`,
); );
return created; return created;
} catch (err: unknown) { } catch (err: unknown) {
if (prismaErrorCode(err) === 'P2002') { if (prismaErrorCode(err) === 'P2002') {
const existing = await tenantPrisma.moduleGrant.findFirst({ const existing = await tenantPrisma.moduleGrant.findFirst({ where: targetWhere });
where: {
tenantId,
moduleId,
groupId: groupId ?? null,
userId: userId ?? null,
},
});
if (existing) { if (existing) {
this.logger.log( return applyToExisting(existing);
`Grant bereits vorhanden (Doppelklick abgefangen): tenant=${tenantId} module=${moduleId} ${target}`,
);
return existing;
} }
} }
throw err; throw err;
@@ -202,7 +226,7 @@ export class ModuleGrantsService {
}), }),
tenantPrisma.moduleGrant.findMany({ tenantPrisma.moduleGrant.findMany({
where: { tenantId, groupId: { not: null } }, where: { tenantId, groupId: { not: null } },
select: { moduleId: true, groupId: true }, select: { moduleId: true, groupId: true, level: true },
}), }),
]); ]);
@@ -218,6 +242,7 @@ export class ModuleGrantsService {
grants: groupGrants.map((g) => ({ grants: groupGrants.map((g) => ({
moduleId: g.moduleId, moduleId: g.moduleId,
groupId: g.groupId, groupId: g.groupId,
level: g.level ?? ModuleGrantLevel.USE,
})), })),
}; };
} }
@@ -231,7 +256,8 @@ export class ModuleGrantsService {
* dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere * dadurch sichtbar. `modules` beantwortet je aktivem Modul die andere
* Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein * Frage (welche Gruppe gewährt dieses Modul, und besteht zusätzlich ein
* Direkt-Grant) und behält dafür je Eintrag exakt die Form * Direkt-Grant) und behält dafür je Eintrag exakt die Form
* { module, viaGroups, direct }. * { module, viaGroups, direct }; seit 261002-icv kommen `directLevel` und
* `manageViaGroups` hinzu (Anzeige der Freigabestufe).
* *
* Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide * Anzeigename mit Fallback (D-04, UI-SPEC Surface Contract 6): beide
* Projektionsstellen (viaGroups-Namen, groups[].name) liefern * Projektionsstellen (viaGroups-Namen, groups[].name) liefern
@@ -258,7 +284,7 @@ export class ModuleGrantsService {
}), }),
tenantPrisma.moduleGrant.findMany({ tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId }, where: { tenantId, userId },
select: { moduleId: true }, select: { moduleId: true, level: true },
}), }),
// Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird // Mandantengebunden seit 260909-jts (Aufgabe 3): der Kontext wird
// über denselben tenantPrisma wie die drei Abfragen oben gesetzt — // über denselben tenantPrisma wie die drei Abfragen oben gesetzt —
@@ -275,13 +301,23 @@ export class ModuleGrantsService {
}), }),
]); ]);
const directModuleIds = new Set(directGrants.map((g) => g.moduleId)); const directLevelByModule = new Map<string, ModuleGrantLevel>();
for (const g of directGrants) {
directLevelByModule.set(g.moduleId, g.level ?? ModuleGrantLevel.USE);
}
const groupNamesByModule = new Map<string, string[]>(); const groupNamesByModule = new Map<string, string[]>();
const manageGroupNamesByModule = new Map<string, string[]>();
for (const g of groupGrants) { for (const g of groupGrants) {
if (!g.group) continue; if (!g.group) continue;
const displayName = g.group.internalName ?? g.group.name;
const names = groupNamesByModule.get(g.moduleId) ?? []; const names = groupNamesByModule.get(g.moduleId) ?? [];
names.push(g.group.internalName ?? g.group.name); names.push(displayName);
groupNamesByModule.set(g.moduleId, names); groupNamesByModule.set(g.moduleId, names);
if (g.level === ModuleGrantLevel.MANAGE) {
const manageNames = manageGroupNamesByModule.get(g.moduleId) ?? [];
manageNames.push(displayName);
manageGroupNamesByModule.set(g.moduleId, manageNames);
}
} }
const modules = activations const modules = activations
@@ -304,7 +340,11 @@ export class ModuleGrantsService {
modules: modules.map((module) => ({ modules: modules.map((module) => ({
module, module,
viaGroups: groupNamesByModule.get(module.id) ?? [], viaGroups: groupNamesByModule.get(module.id) ?? [],
direct: directModuleIds.has(module.id), direct: directLevelByModule.has(module.id),
// 261002-icv: Stufe der Direkt-Freigabe (null ohne Direkt-Grant) und
// die Gruppen, die Verwalten gewähren (Teilmenge von viaGroups).
directLevel: directLevelByModule.get(module.id) ?? null,
manageViaGroups: manageGroupNamesByModule.get(module.id) ?? [],
})), })),
}; };
} }
@@ -0,0 +1,25 @@
import { Transform } from 'class-transformer';
import { IsInt, IsString, Length, Matches, Max, Min } from 'class-validator';
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value);
/** Anlegen und Aendern eines Kontos der Kontenliste (quick-261002-fm5). */
export class HandelswareAccountDto {
@Transform(trim)
@IsString({ message: 'Der Name muss angegeben werden' })
@Length(1, 120, { message: 'Der Name muss 1 bis 120 Zeichen lang sein' })
@Matches(/^[^\t\r\n]*$/, {
message: 'Der Name darf keine Tabulatoren oder Zeilenumbrüche enthalten',
})
name!: string;
@IsInt({ message: 'Das Gegenkonto muss eine ganze Zahl sein' })
@Min(1, { message: 'Das Gegenkonto muss mindestens 1 sein' })
@Max(999999999, { message: 'Das Gegenkonto darf höchstens 999999999 sein' })
gegenkonto!: number;
@IsInt({ message: 'Das Erlöskonto muss eine ganze Zahl sein' })
@Min(1, { message: 'Das Erlöskonto muss mindestens 1 sein' })
@Max(999999999, { message: 'Das Erlöskonto darf höchstens 999999999 sein' })
erloeskonto!: number;
}
@@ -0,0 +1,18 @@
import { IsInt, Max, Min } from 'class-validator';
/**
* Einstellungen der Handelsware (quick-261002-fm5): Standard-Erloeskonto fuer
* neue Konten und Startwert fuer die Gegenkonto-Vergabe. Ganze Zahlen,
* bewusst ohne Standardwert — der Administrator hinterlegt sie einmalig.
*/
export class HandelswareSettingsDto {
@IsInt({ message: 'Das Standard-Erlöskonto muss eine ganze Zahl sein' })
@Min(1, { message: 'Das Standard-Erlöskonto muss mindestens 1 sein' })
@Max(999999999, { message: 'Das Standard-Erlöskonto darf höchstens 999999999 sein' })
erloeskonto!: number;
@IsInt({ message: 'Der Startwert Gegenkonto muss eine ganze Zahl sein' })
@Min(1, { message: 'Der Startwert Gegenkonto muss mindestens 1 sein' })
@Max(999999999, { message: 'Der Startwert Gegenkonto darf höchstens 999999999 sein' })
startGegenkonto!: number;
}
@@ -0,0 +1,195 @@
import 'reflect-metadata';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
import { HandelswareDatevController, parseNewAccountsField } from './handelsware-datev.controller';
const proto = HandelswareDatevController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
function makeService() {
return {
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
preview: vi.fn(async (..._a: unknown[]) => ({})),
export: vi.fn(async (..._a: unknown[]) => ({})),
listAccounts: vi.fn(async (..._a: unknown[]) => []),
createAccount: vi.fn(async (..._a: unknown[]) => ({})),
updateAccount: vi.fn(async (..._a: unknown[]) => ({})),
deleteAccount: vi.fn(async (..._a: unknown[]) => ({})),
exportAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})),
importAccountsCsv: vi.fn(async (..._a: unknown[]) => ({})),
};
}
describe('HandelswareDatevController — Metadaten', () => {
it('haengt an modules/handelsware-datev und traegt @UseModule', () => {
expect(Reflect.getMetadata('path', HandelswareDatevController)).toBe(
'modules/handelsware-datev',
);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, HandelswareDatevController)).toBe(
'handelsware-datev',
);
});
it('PUT settings verlangt die Freigabestufe Verwalten, alles andere keine Routen-Rolle und kein Verwalten (261002-icv)', () => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('handelsware-datev');
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined();
for (const name of [
'getSettings',
'preview',
'export',
'listAccounts',
'createAccount',
'exportAccountsCsv',
'importAccountsCsv',
'updateAccount',
'deleteAccount',
]) {
expect(Reflect.getMetadata(ROLES_KEY, proto[name]), name).toBeUndefined();
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name]), name).toBeUndefined();
}
});
it('Pfade und Methoden', () => {
const route = (name: string) => [
Reflect.getMetadata('method', proto[name]),
Reflect.getMetadata('path', proto[name]),
];
// RequestMethod: GET 0, POST 1, PUT 2, DELETE 3
expect(route('getSettings')).toEqual([0, 'settings']);
expect(route('saveSettings')).toEqual([2, 'settings']);
expect(route('preview')).toEqual([1, 'preview']);
expect(route('export')).toEqual([1, 'export']);
expect(route('listAccounts')).toEqual([0, 'accounts']);
expect(route('createAccount')).toEqual([1, 'accounts']);
expect(route('exportAccountsCsv')).toEqual([0, 'accounts/export-csv']);
expect(route('importAccountsCsv')).toEqual([1, 'accounts/import-csv']);
expect(route('updateAccount')).toEqual([2, 'accounts/:id']);
expect(route('deleteAccount')).toEqual([3, 'accounts/:id']);
});
});
describe('HandelswareDatevController — Routen-Reihenfolge (statisch vor :id)', () => {
it('deklariert alle statischen Konten-Routen vor accounts/:id', () => {
const methods = Object.getOwnPropertyNames(HandelswareDatevController.prototype);
const idx = (name: string) => {
const i = methods.indexOf(name);
expect(i, `${name} fehlt`).toBeGreaterThanOrEqual(0);
return i;
};
const firstIdRoute = Math.min(idx('updateAccount'), idx('deleteAccount'));
for (const staticRoute of [
'listAccounts',
'createAccount',
'exportAccountsCsv',
'importAccountsCsv',
]) {
expect(idx(staticRoute), `${staticRoute} muss vor :id stehen`).toBeLessThan(firstIdRoute);
}
});
});
describe('HandelswareDatevController — Verhalten', () => {
it('reicht req.tenantId weiter und decodiert den Dateinamen', async () => {
const service = makeService();
const c = new HandelswareDatevController(service as any);
const mojibake = Buffer.from('Käse 0326.xlsx', 'utf8').toString('latin1');
const buffer = Buffer.from('x');
await c.preview(req('t1'), { buffer, originalname: mojibake } as any);
expect(service.preview).toHaveBeenCalledWith('t1', { buffer, originalname: 'Käse 0326.xlsx' });
await c.export(
req('t1'),
{ buffer, originalname: 'a.xlsx' } as any,
' 3103 ',
'[{"name":"A","gegenkonto":5}]',
);
expect(service.export).toHaveBeenCalledWith('t1', { buffer, originalname: 'a.xlsx' }, '3103', [
{ name: 'A', gegenkonto: 5 },
]);
await c.listAccounts(req('t1'));
await c.deleteAccount(req('t1'), 'x');
expect(service.listAccounts).toHaveBeenCalledWith('t1');
expect(service.deleteAccount).toHaveBeenCalledWith('t1', 'x');
});
it('antwortet ohne Datei mit 400', async () => {
const c = new HandelswareDatevController(makeService() as any);
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
await expect(c.importAccountsCsv(req('t1'), undefined)).rejects.toThrow(BadRequestException);
});
it('antwortet ohne Mandantenkontext mit 403', async () => {
const c = new HandelswareDatevController(makeService() as any);
await expect(c.listAccounts(req(undefined))).rejects.toThrow(ForbiddenException);
});
});
describe('parseNewAccountsField', () => {
it('akzeptiert eine Liste und leere Werte', () => {
expect(parseNewAccountsField('[{"name":"A","gegenkonto":1,"erloeskonto":2}]')).toEqual([
{ name: 'A', gegenkonto: 1 },
]);
expect(parseNewAccountsField(undefined)).toEqual([]);
expect(parseNewAccountsField('[]')).toEqual([]);
});
it.each([
'kein json',
'{"a":1}',
'[1]',
'[{"name":1,"gegenkonto":1}]',
'[{"name":"A","gegenkonto":"1"}]',
'[{"name":"A","gegenkonto":1.5}]',
'[null]',
])('lehnt %s ab', (raw) => {
expect(() => parseNewAccountsField(raw)).toThrow(BadRequestException);
});
it('lehnt mehr als 10 000 Eintraege ab', () => {
const big = JSON.stringify(
Array.from({ length: 10_001 }, (_, i) => ({ name: `n${i}`, gegenkonto: i })),
);
expect(() => parseNewAccountsField(big)).toThrow(BadRequestException);
});
});
describe('DTOs', () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
it('Einstellungen: nur ganze Zahlen 1 bis 999999999', async () => {
const run = (value: unknown) =>
pipe.transform(value, { type: 'body', metatype: HandelswareSettingsDto });
await expect(run({ erloeskonto: 5, startGegenkonto: 6 })).resolves.toBeDefined();
for (const bad of [
{ erloeskonto: 0, startGegenkonto: 6 },
{ erloeskonto: 5, startGegenkonto: 1000000000 },
{ erloeskonto: 1.5, startGegenkonto: 6 },
{ erloeskonto: '5', startGegenkonto: 6 },
{ erloeskonto: 5 },
]) {
await expect(run(bad)).rejects.toThrow(BadRequestException);
}
});
it('Konto: Name wird getrimmt, Tabulator im Namen und leerer Name werden abgelehnt', async () => {
const run = (value: unknown) =>
pipe.transform(value, { type: 'body', metatype: HandelswareAccountDto });
const ok: any = await run({ name: ' Kaffee ', gegenkonto: 1, erloeskonto: 2 });
expect(ok.name).toBe('Kaffee');
await expect(run({ name: 'a\tb', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
BadRequestException,
);
await expect(run({ name: ' ', gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
BadRequestException,
);
await expect(run({ name: 'x'.repeat(121), gegenkonto: 1, erloeskonto: 2 })).rejects.toThrow(
BadRequestException,
);
});
});
@@ -0,0 +1,172 @@
import {
BadRequestException,
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
Post,
Put,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { decodeUploadFilename } from '../accounting/decode-upload-filename';
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { HandelswareAccountDto } from './dto/handelsware-account.dto';
import { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
import { HandelswareDatevService } from './handelsware-datev.service';
const MAX_NEW_ACCOUNTS = 10_000;
/**
* Das Formularfeld `newAccounts` ist ein JSON-Text (Liste der von der Vorschau
* gemeldeten neuen Konten). Defensiv gelesen: gueltiges JSON, ein Feld, hoechstens
* 10 000 Eintraege, jeder mit Text-`name` und ganzzahligem `gegenkonto`.
*/
export function parseNewAccountsField(raw: unknown): { name: string; gegenkonto: number }[] {
const bad = () =>
new BadRequestException({
code: 'newAccountsInvalid',
message: 'Die Angaben zu den neuen Konten sind ungültig.',
});
if (raw === undefined || raw === null || raw === '') return [];
if (typeof raw !== 'string') throw bad();
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw bad();
}
if (!Array.isArray(parsed) || parsed.length > MAX_NEW_ACCOUNTS) throw bad();
return parsed.map((entry) => {
if (
typeof entry !== 'object' ||
entry === null ||
typeof (entry as { name?: unknown }).name !== 'string' ||
!Number.isInteger((entry as { gegenkonto?: unknown }).gegenkonto)
) {
throw bad();
}
const { name, gegenkonto } = entry as { name: string; gegenkonto: number };
return { name, gegenkonto };
});
}
/**
* `@UseModule('handelsware-datev')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId`. Die Einstellungen aendern
* Administratoren und Benutzer mit der Freigabestufe Verwalten
* (`@ModuleManage`, 261002-icv; T-FM5-02); die Kontenliste pflegen alle Benutzer mit
* Modulzugriff.
*
* REIHENFOLGE: alle statischen Routen (`accounts`, `accounts/export-csv`,
* `accounts/import-csv`) stehen VOR `accounts/:id` — sonst faengt `:id` sie ab
* (Unit-Tests sehen das nicht, `handelsware-datev.controller.spec.ts` prueft die
* Deklarationsreihenfolge).
*/
@Controller('modules/handelsware-datev')
@UseModule('handelsware-datev')
export class HandelswareDatevController {
constructor(private readonly service: HandelswareDatevService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('settings')
async getSettings(@Req() req: AuthenticatedRequest) {
return this.service.getSettings(this.requireTenantId(req));
}
@Put('settings')
@ModuleManage('handelsware-datev')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareSettingsDto) {
return this.service.saveSettings(this.requireTenantId(req), dto);
}
@Post('preview')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async preview(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.preview(tenantId, {
buffer: file.buffer,
originalname: decodeUploadFilename(file.originalname),
});
}
@Post('export')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async export(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
@Body('buchungsdatum') buchungsdatum?: string,
@Body('newAccounts') newAccounts?: string,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.export(
tenantId,
{ buffer: file.buffer, originalname: decodeUploadFilename(file.originalname) },
typeof buchungsdatum === 'string' ? buchungsdatum.trim() : '',
parseNewAccountsField(newAccounts),
);
}
@Get('accounts')
async listAccounts(@Req() req: AuthenticatedRequest) {
return this.service.listAccounts(this.requireTenantId(req));
}
@Post('accounts')
async createAccount(@Req() req: AuthenticatedRequest, @Body() dto: HandelswareAccountDto) {
return this.service.createAccount(this.requireTenantId(req), dto);
}
@Get('accounts/export-csv')
async exportAccountsCsv(@Req() req: AuthenticatedRequest) {
return this.service.exportAccountsCsv(this.requireTenantId(req));
}
@Post('accounts/import-csv')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 1024 * 1024 } }))
async importAccountsCsv(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.importAccountsCsv(tenantId, file.buffer);
}
@Put('accounts/:id')
async updateAccount(
@Req() req: AuthenticatedRequest,
@Param('id') id: string,
@Body() dto: HandelswareAccountDto,
) {
return this.service.updateAccount(this.requireTenantId(req), id, dto);
}
@Delete('accounts/:id')
async deleteAccount(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.service.deleteAccount(this.requireTenantId(req), id);
}
}
@@ -0,0 +1,31 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { HandelswareDatevController } from './handelsware-datev.controller';
import { seedHandelswareDatevModule } from './handelsware-datev.seed';
import { HandelswareDatevService } from './handelsware-datev.service';
/**
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
* DATEV-Buchungsdatei exportieren. Traegt sich beim Start in die
* Modulverwaltung ein; aktiviert wird per Marktplatz.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [HandelswareDatevController],
providers: [HandelswareDatevService],
})
export class HandelswareDatevModule implements OnModuleInit {
private readonly logger = new Logger(HandelswareDatevModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedHandelswareDatevModule(this.moduleRegistryService);
this.logger.log('Handelsware-DATEV module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed handelsware-datev module', error);
}
}
}
@@ -0,0 +1,22 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Traegt das Modul "Handelsware" in die Modulverwaltung ein (quick-261002-fm5).
* `isSystem: true` legt den Eintrag an, aktiviert ihn aber NICHT je Mandant —
* der Administrator aktiviert ueber den Marktplatz und erteilt die Freigabe.
*/
export async function seedHandelswareDatevModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'handelsware-datev',
name: 'Handelsware',
version: '1.0.0',
category: 'accounting',
description: {
de: 'Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei exportieren',
en: 'Map merchandise sales from Excel to revenue accounts and export a DATEV booking file',
},
isSystem: true,
});
}
@@ -0,0 +1,378 @@
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import * as XLSX from 'xlsx';
/**
* Zwei Klienten wie in favorites.service.spec.ts: `forTenant` und
* `withTenantTransaction` werden auf den Nachbau umgeleitet. Die Transaktion
* arbeitet auf einer KOPIE des Bestands und uebernimmt sie nur, wenn die
* Funktion ohne Fehler endet — so ist Alles-oder-nichts pruefbar.
*/
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((db: any, tenantId: string) => db.__bound(tenantId)),
withTenantTransaction: vi.fn((db: any, tenantId: string, fn: (tx: any) => any) =>
db.__transaction(tenantId, fn),
),
}));
import { HandelswareDatevService } from './handelsware-datev.service';
interface Konto {
id: string;
tenantId: string;
name: string;
gegenkonto: number;
erloeskonto: number;
}
function uniqueError() {
return Object.assign(new Error('Unique constraint failed'), { code: 'P2002' });
}
function makeDb(opts: {
config?: { erloeskonto: number | null; startGegenkonto: number | null } | null;
konten?: Konto[];
}) {
const state = {
config: opts.config === undefined ? { erloeskonto: 4711, startGegenkonto: 2000 } : opts.config,
konten: [...(opts.konten ?? [])],
writes: [] as string[],
seq: 100,
};
function client(tenantId: string, s: { konten: Konto[] }, record: (w: string) => void) {
const own = () => s.konten.filter((k) => k.tenantId === tenantId);
return {
handelswareDatevConfig: {
findUnique: vi.fn(async () => state.config),
upsert: vi.fn(async ({ create, update }: any) => {
record('config.upsert');
state.config = { ...(state.config ?? {}), ...update, ...create } as any;
return state.config;
}),
},
handelswareKonto: {
findMany: vi.fn(async () => [...own()].sort((a, b) => a.name.localeCompare(b.name))),
findFirst: vi.fn(async ({ where }: any) => own().find((k) => k.id === where.id) ?? null),
create: vi.fn(async ({ data }: any) => {
record('konto.create');
if (own().some((k) => k.name === data.name)) throw uniqueError();
const row = { id: `k${++state.seq}`, ...data };
s.konten.push(row);
return row;
}),
update: vi.fn(async ({ where, data }: any) => {
record('konto.update');
const row = s.konten.find((k) => k.id === where.id) as Konto;
if (data.name !== row.name && own().some((k) => k.name === data.name))
throw uniqueError();
Object.assign(row, data);
return row;
}),
delete: vi.fn(async ({ where }: any) => {
record('konto.delete');
s.konten.splice(
s.konten.findIndex((k) => k.id === where.id),
1,
);
}),
deleteMany: vi.fn(async () => {
record('konto.deleteMany');
const keep = s.konten.filter((k) => k.tenantId !== tenantId);
s.konten.length = 0;
s.konten.push(...keep);
}),
createMany: vi.fn(async ({ data }: any) => {
record('konto.createMany');
for (const d of data) {
if (own().some((k) => k.name === d.name)) throw uniqueError();
s.konten.push({ id: `k${++state.seq}`, ...d });
}
}),
},
};
}
const db: any = {
__state: state,
__bound: (tenantId: string) => client(tenantId, state, (w) => state.writes.push(w)),
__transaction: async (tenantId: string, fn: (tx: any) => any) => {
const copy = { konten: state.konten.map((k) => ({ ...k })) };
const txWrites: string[] = [];
const result = await fn(client(tenantId, copy, (w) => txWrites.push(w)));
state.konten = copy.konten;
state.writes.push(...txWrites.map((w) => `tx:${w}`));
return result;
},
};
return db;
}
function workbook(aoa: unknown[][]): Buffer {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}
const FILE = {
buffer: workbook([
['', '2026'],
['Kaffee', 12.5],
['Kakao', -3],
['Kakao', 1],
]),
originalname: 'HWA 0326 Test.xlsx',
};
const konto = (name: string, gegenkonto: number, erloeskonto = 4000): Konto => ({
id: `id-${name}`,
tenantId: 't1',
name,
gegenkonto,
erloeskonto,
});
describe('HandelswareDatevService — Vorschau', () => {
it('sperrt mit settingsMissing, solange Erloeskonto oder Startwert fehlen', async () => {
for (const config of [
null,
{ erloeskonto: 1, startGegenkonto: null },
{ erloeskonto: null, startGegenkonto: 1 },
]) {
const service = new HandelswareDatevService(makeDb({ config }));
const err: any = await service.preview('t1', FILE).catch((e) => e);
expect(err).toBeInstanceOf(BadRequestException);
expect(err.getResponse().code).toBe('settingsMissing');
}
});
it('liefert Zeilen, neue Konten, Datumsvorschlag und Dateinamen — und schreibt nichts', async () => {
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
const res = await new HandelswareDatevService(db).preview('t1', FILE);
expect(res.headerText).toBe('2026');
expect(res.suggestedBuchungsdatum).toBe('3103');
expect(res.exportFilename).toBe('HWA_0326.txt');
expect(res.rows.map((r) => [r.buchungstext, r.gegenkonto, r.isNew])).toEqual([
['Kaffee', 2010, false],
['Kakao', 2011, true],
['Kakao', 2011, true],
]);
expect(res.newAccounts).toEqual([{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }]);
expect(db.__state.writes).toEqual([]);
expect(db.__state.konten).toHaveLength(1);
});
it('meldet eine kaputte Datei als 400 invalidFile', async () => {
const service = new HandelswareDatevService(makeDb({}));
const err: any = await service
.preview('t1', { buffer: Buffer.from('xx'), originalname: 'a.xlsx' })
.catch((e) => e);
expect(err.getResponse().code).toBe('invalidFile');
});
it('gibt Zeilenfehler zurueck statt zu werfen', async () => {
const buffer = workbook([
['', 'X'],
['Kaffee', 'viel'],
]);
const res = await new HandelswareDatevService(makeDb({})).preview('t1', {
buffer,
originalname: 'a.xlsx',
});
expect(res.rowErrors).toHaveLength(1);
});
});
describe('HandelswareDatevService — Export', () => {
const submitted = [{ name: 'Kakao', gegenkonto: 2011 }];
it('speichert die neuen Konten erst beim Export, in der Transaktion, und liefert die TXT', async () => {
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
const res = await new HandelswareDatevService(db).export('t1', FILE, '3103', submitted);
expect(res.createdCount).toBe(1);
expect(res.filename).toBe('HWA_0326.txt');
expect(res.mimeType).toBe('text/plain;charset=utf-8');
expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe(
'\t2026\t\t\t\t\r\nKaffee\t12.50\tS\t2010\t3103\t4000\r\nKakao\t3.00\tH\t2011\t3103\t4711\r\nKakao\t1.00\tS\t2011\t3103\t4711\r\n',
);
expect(db.__state.konten.map((k: Konto) => k.name).sort()).toEqual(['Kaffee', 'Kakao']);
expect(db.__state.writes).toEqual(['tx:konto.createMany']);
});
it('409 accountsChanged, wenn sich die Liste seit der Vorschau geaendert hat — nichts gespeichert', async () => {
// Inzwischen gibt es schon ein Konto mit Gegenkonto 2011 -> neues Konto waere 2012.
const db = makeDb({ konten: [konto('Kaffee', 2010), konto('Saft', 2011)] });
const err: any = await new HandelswareDatevService(db)
.export('t1', FILE, '3103', submitted)
.catch((e) => e);
expect(err).toBeInstanceOf(ConflictException);
expect(err.getResponse().code).toBe('accountsChanged');
expect(err.getResponse().message).toBe(
'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.',
);
expect(db.__state.konten).toHaveLength(2);
expect(db.__state.writes).toEqual([]);
});
it('409, wenn der Client ein neues Konto verschweigt oder erfindet', async () => {
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
const service = new HandelswareDatevService(db);
await expect(service.export('t1', FILE, '3103', [])).rejects.toBeInstanceOf(ConflictException);
await expect(
service.export('t1', FILE, '3103', [...submitted, { name: 'Erfunden', gegenkonto: 9 }]),
).rejects.toBeInstanceOf(ConflictException);
expect(db.__state.konten).toHaveLength(1);
});
it('Wettlauf: Eindeutigkeit (P2002) beim Anlegen wird zu 409', async () => {
const db = makeDb({ konten: [konto('Kaffee', 2010)] });
const original = db.__transaction;
// Ein zweiter Export hat "Kakao" zwischen Berechnung und Speichern angelegt.
db.__transaction = (tenantId: string, fn: (tx: any) => any) =>
original(tenantId, (tx: any) => {
tx.handelswareKonto.createMany = async () => {
throw uniqueError();
};
return fn(tx);
});
const err: any = await new HandelswareDatevService(db)
.export('t1', FILE, '3103', submitted)
.catch((e) => e);
expect(err).toBeInstanceOf(ConflictException);
expect(err.getResponse().code).toBe('accountsChanged');
});
it('400 bei ungueltigem Buchungsdatum', async () => {
const err: any = await new HandelswareDatevService(makeDb({}))
.export('t1', FILE, '3102', submitted)
.catch((e) => e);
expect(err.getResponse().code).toBe('buchungsdatumInvalid');
});
it('400 bei Zeilenfehlern', async () => {
const buffer = workbook([
['', 'X'],
['Kaffee', 'viel'],
]);
const err: any = await new HandelswareDatevService(makeDb({}))
.export('t1', { buffer, originalname: 'a 0326.xlsx' }, '3103', [])
.catch((e) => e);
expect(err).toBeInstanceOf(BadRequestException);
expect(err.getResponse().code).toBe('rowErrors');
});
it('400 settingsMissing beim Export ohne Einstellungen', async () => {
const err: any = await new HandelswareDatevService(makeDb({ config: null }))
.export('t1', FILE, '3103', submitted)
.catch((e) => e);
expect(err.getResponse().code).toBe('settingsMissing');
});
});
describe('HandelswareDatevService — Kontenliste', () => {
it('legt an, sortiert nach Name und meldet doppelte Namen als 409 nameTaken', async () => {
const db = makeDb({});
const service = new HandelswareDatevService(db);
await service.createAccount('t1', { name: 'Tee', gegenkonto: 2, erloeskonto: 3 });
await service.createAccount('t1', { name: 'Kaffee', gegenkonto: 4, erloeskonto: 5 });
expect((await service.listAccounts('t1')).map((a) => a.name)).toEqual(['Kaffee', 'Tee']);
const err: any = await service
.createAccount('t1', { name: 'Tee', gegenkonto: 9, erloeskonto: 9 })
.catch((e) => e);
expect(err).toBeInstanceOf(ConflictException);
expect(err.getResponse().code).toBe('nameTaken');
});
it('aendert ein Konto; Namensklau ist 409; unbekannte id ist 404', async () => {
const db = makeDb({ konten: [konto('A', 1), konto('B', 2)] });
const service = new HandelswareDatevService(db);
const updated = await service.updateAccount('t1', 'id-A', {
name: 'A2',
gegenkonto: 7,
erloeskonto: 8,
});
expect(updated).toMatchObject({ name: 'A2', gegenkonto: 7 });
await expect(
service.updateAccount('t1', 'id-A', { name: 'B', gegenkonto: 1, erloeskonto: 1 }),
).rejects.toBeInstanceOf(ConflictException);
await expect(
service.updateAccount('t1', 'nope', { name: 'X', gegenkonto: 1, erloeskonto: 1 }),
).rejects.toBeInstanceOf(NotFoundException);
});
it('loescht ein Konto; unbekannte id ist 404', async () => {
const db = makeDb({ konten: [konto('A', 1)] });
const service = new HandelswareDatevService(db);
await expect(service.deleteAccount('t1', 'nope')).rejects.toBeInstanceOf(NotFoundException);
await expect(service.deleteAccount('t1', 'id-A')).resolves.toEqual({ deleted: true });
expect(db.__state.konten).toHaveLength(0);
});
it('CSV-Import ersetzt die Liste in EINER Transaktion (deleteMany + createMany)', async () => {
const db = makeDb({ konten: [konto('Alt', 1)] });
const res = await new HandelswareDatevService(db).importAccountsCsv(
't1',
Buffer.from('Name;Gegenkonto;Konto\nNeu1;10;20\nNeu2;11'),
);
expect(res).toEqual({ count: 2 });
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Neu1', 'Neu2']);
expect(db.__state.konten[1].erloeskonto).toBe(4711);
expect(db.__state.writes).toEqual(['tx:konto.deleteMany', 'tx:konto.createMany']);
});
it('CSV-Import mit einer ungueltigen Zeile aendert nichts', async () => {
const db = makeDb({ konten: [konto('Alt', 1)] });
const err: any = await new HandelswareDatevService(db)
.importAccountsCsv('t1', Buffer.from('Neu1;10;20\nNeu2;abc;20'))
.catch((e) => e);
expect(err).toBeInstanceOf(BadRequestException);
expect(err.getResponse().code).toBe('csvErrors');
expect(err.getResponse().errors).toHaveLength(1);
expect(db.__state.writes).toEqual([]);
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']);
});
it('CSV-Import: scheitert das Schreiben mittendrin, bleibt die alte Liste', async () => {
const db = makeDb({ konten: [konto('Alt', 1)] });
const original = db.__transaction;
db.__transaction = (tenantId: string, fn: (tx: any) => any) =>
original(tenantId, (tx: any) => {
tx.handelswareKonto.createMany = async () => {
throw new Error('Datenbank weg');
};
return fn(tx);
});
await expect(
new HandelswareDatevService(db).importAccountsCsv('t1', Buffer.from('Neu;1;2')),
).rejects.toThrow('Datenbank weg');
expect(db.__state.konten.map((k: Konto) => k.name)).toEqual(['Alt']);
});
it('CSV-Export liefert BOM-CSV als Base64', async () => {
const db = makeDb({ konten: [konto('Käse', 1, 2)] });
const res = await new HandelswareDatevService(db).exportAccountsCsv('t1');
expect(res.filename).toBe('Konten.csv');
expect(Buffer.from(res.content, 'base64').toString('utf8')).toBe('Käse;1;2\r\n');
});
});
describe('HandelswareDatevService — Einstellungen', () => {
it('configured nur, wenn beide Zahlen gesetzt sind', async () => {
expect(await new HandelswareDatevService(makeDb({ config: null })).getSettings('t1')).toEqual({
erloeskonto: null,
startGegenkonto: null,
configured: false,
});
expect((await new HandelswareDatevService(makeDb({})).getSettings('t1')).configured).toBe(true);
});
it('speichert per upsert', async () => {
const db = makeDb({ config: null });
const res = await new HandelswareDatevService(db).saveSettings('t1', {
erloeskonto: 5,
startGegenkonto: 6,
});
expect(res).toEqual({ erloeskonto: 5, startGegenkonto: 6, configured: true });
expect(db.__state.writes).toEqual(['config.upsert']);
});
});
@@ -0,0 +1,340 @@
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant, withTenantTransaction } from '../prisma/prisma-tenant.extension';
import type { HandelswareAccountDto } from './dto/handelsware-account.dto';
import type { HandelswareSettingsDto } from './dto/handelsware-settings.dto';
import type {
AccountEntry,
FileResponse,
HandelswareSettings,
HandelswareSettingsReady,
NewAccount,
PreviewResult,
} from './handelsware-datev.types';
import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv';
import {
assignAccounts,
calculateBuchungsdatum,
generateTxt,
getExportFilename,
isValidBuchungsdatum,
} from './handelsware-transform';
import { HandelswareFileError, parseHandelswareXlsx } from './handelsware-xlsx';
export interface UploadedWorkbook {
buffer: Buffer;
/** Bereits als UTF-8 dekodierter Dateiname. */
originalname: string;
}
export interface HandelswareSettingsResponse extends HandelswareSettings {
configured: boolean;
}
export interface AccountResponse extends AccountEntry {
id: string;
}
const MAX_ACCOUNTS_IMPORT = 10_000;
const SETTINGS_MISSING = {
code: 'settingsMissing',
message: 'Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt.',
};
const ACCOUNTS_CHANGED = {
code: 'accountsChanged',
message:
'Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.',
};
const NAME_TAKEN = {
code: 'nameTaken',
message: 'Ein Konto mit diesem Namen gibt es bereits.',
};
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === 'object' && error !== null && (error as { code?: unknown }).code === 'P2002'
);
}
function isReady(settings: HandelswareSettings | null): settings is HandelswareSettingsReady {
return Boolean(settings && settings.erloeskonto !== null && settings.startGegenkonto !== null);
}
/** Gleichheit der berechneten und der von der Vorschau gemeldeten neuen Konten (Name + Gegenkonto). */
function sameNewAccounts(
computed: NewAccount[],
submitted: { name: string; gegenkonto: number }[],
): boolean {
if (computed.length !== submitted.length) return false;
const byName = new Map(submitted.map((a) => [a.name, a.gegenkonto]));
if (byName.size !== submitted.length) return false;
return computed.every((a) => byName.get(a.name) === a.gegenkonto);
}
/**
* Handelsware (quick-261002-fm5): Excel-Umsaetze den Erloeskonten zuordnen,
* Kontenliste je Mandant pflegen, TXT fuer DATEV erzeugen. Alle
* Datenbankzugriffe mandantengebunden (`forTenant` bzw. eine gemeinsame
* `withTenantTransaction`); neue Konten werden NUR beim Export gespeichert,
* die Vorschau schreibt nie.
*/
@Injectable()
export class HandelswareDatevService {
constructor(private readonly prisma: PrismaService) {}
// --- Einstellungen -------------------------------------------------------
async getSettings(tenantId: string): Promise<HandelswareSettingsResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.handelswareDatevConfig.findUnique({ where: { tenantId } });
const settings: HandelswareSettings = {
erloeskonto: row?.erloeskonto ?? null,
startGegenkonto: row?.startGegenkonto ?? null,
};
return { ...settings, configured: isReady(settings) };
}
async saveSettings(
tenantId: string,
dto: HandelswareSettingsDto,
): Promise<HandelswareSettingsResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const data = { erloeskonto: dto.erloeskonto, startGegenkonto: dto.startGegenkonto };
await tenantPrisma.handelswareDatevConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return { ...data, configured: true };
}
// --- Kontenliste ---------------------------------------------------------
async listAccounts(tenantId: string): Promise<AccountResponse[]> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const rows = await tenantPrisma.handelswareKonto.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
});
return rows.map((r) => ({
id: r.id,
name: r.name,
gegenkonto: r.gegenkonto,
erloeskonto: r.erloeskonto,
}));
}
async createAccount(tenantId: string, dto: HandelswareAccountDto): Promise<AccountResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
try {
const row = await tenantPrisma.handelswareKonto.create({
data: {
tenantId,
name: dto.name,
gegenkonto: dto.gegenkonto,
erloeskonto: dto.erloeskonto,
},
});
return {
id: row.id,
name: row.name,
gegenkonto: row.gegenkonto,
erloeskonto: row.erloeskonto,
};
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN);
throw error;
}
}
async updateAccount(
tenantId: string,
id: string,
dto: HandelswareAccountDto,
): Promise<AccountResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException('Konto nicht gefunden');
try {
const row = await tenantPrisma.handelswareKonto.update({
where: { id },
data: { name: dto.name, gegenkonto: dto.gegenkonto, erloeskonto: dto.erloeskonto },
});
return {
id: row.id,
name: row.name,
gegenkonto: row.gegenkonto,
erloeskonto: row.erloeskonto,
};
} catch (error) {
if (isUniqueViolation(error)) throw new ConflictException(NAME_TAKEN);
throw error;
}
}
async deleteAccount(tenantId: string, id: string): Promise<{ deleted: true }> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const existing = await tenantPrisma.handelswareKonto.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException('Konto nicht gefunden');
await tenantPrisma.handelswareKonto.delete({ where: { id } });
return { deleted: true };
}
async exportAccountsCsv(tenantId: string): Promise<FileResponse> {
const accounts = await this.listAccounts(tenantId);
return {
filename: 'Konten.csv',
content: Buffer.from(generateKontenCsv(accounts), 'utf8').toString('base64'),
mimeType: 'text/csv;charset=utf-8',
};
}
/** Ersetzt die gesamte Kontenliste durch den CSV-Inhalt — alles oder nichts. */
async importAccountsCsv(tenantId: string, buffer: Buffer): Promise<{ count: number }> {
const settings = await this.getSettings(tenantId);
const { accounts, errors } = parseKontenCsv(buffer, settings.erloeskonto);
if (errors.length > 0) {
throw new BadRequestException({
code: 'csvErrors',
message: 'Die CSV-Datei enthält Fehler. Es wurde nichts geändert.',
errors,
});
}
if (accounts.length > MAX_ACCOUNTS_IMPORT) {
throw new BadRequestException({
code: 'tooManyRows',
message: `Die Datei enthält mehr als ${MAX_ACCOUNTS_IMPORT} Konten.`,
});
}
await withTenantTransaction(this.prisma, tenantId, async (tx) => {
await tx.handelswareKonto.deleteMany({ where: { tenantId } });
if (accounts.length > 0) {
await tx.handelswareKonto.createMany({
data: accounts.map((a) => ({ tenantId, ...a })),
});
}
});
return { count: accounts.length };
}
// --- Import / Export -----------------------------------------------------
private parseWorkbook(buffer: Buffer) {
try {
return parseHandelswareXlsx(buffer);
} catch (error) {
if (error instanceof HandelswareFileError) {
throw new BadRequestException({ code: error.code, message: error.message });
}
throw error;
}
}
/** Vorschau: liest die Datei, ordnet Konten zu — schreibt NICHTS in die Datenbank. */
async preview(tenantId: string, file: UploadedWorkbook): Promise<PreviewResult> {
const settings = await this.getSettings(tenantId);
if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING);
const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer);
const accounts = await this.listAccounts(tenantId);
const { rows, newAccounts } = assignAccounts(importRows, accounts, settings);
return {
headerText,
suggestedBuchungsdatum: calculateBuchungsdatum(file.originalname),
exportFilename: getExportFilename(file.originalname),
rows,
newAccounts,
rowErrors,
};
}
/**
* Export: berechnet die Zuordnung INNERHALB einer mandantengebundenen
* Transaktion neu und vergleicht mit den neuen Konten, die die Vorschau
* gemeldet hat (409 `accountsChanged`, wenn die Liste sich inzwischen
* geaendert hat). Nur dann werden die neuen Konten gespeichert — in derselben
* Transaktion, in der die Datei erzeugt wird.
*/
async export(
tenantId: string,
file: UploadedWorkbook,
buchungsdatum: string,
submittedNewAccounts: { name: string; gegenkonto: number }[],
): Promise<FileResponse & { createdCount: number }> {
if (!isValidBuchungsdatum(buchungsdatum)) {
throw new BadRequestException({
code: 'buchungsdatumInvalid',
message: 'Das Buchungsdatum muss als TTMM angegeben werden, zum Beispiel 3103.',
});
}
const { headerText, rows: importRows, rowErrors } = this.parseWorkbook(file.buffer);
if (rowErrors.length > 0) {
throw new BadRequestException({
code: 'rowErrors',
message: 'Die Datei enthält fehlerhafte Zeilen und kann nicht exportiert werden.',
errors: rowErrors,
});
}
if (importRows.length === 0) {
throw new BadRequestException({
code: 'noRows',
message: 'Die Datei enthält keine Datenzeilen.',
});
}
try {
return await withTenantTransaction(this.prisma, tenantId, async (tx) => {
const config = await tx.handelswareDatevConfig.findUnique({ where: { tenantId } });
const settings: HandelswareSettings = {
erloeskonto: config?.erloeskonto ?? null,
startGegenkonto: config?.startGegenkonto ?? null,
};
if (!isReady(settings)) throw new BadRequestException(SETTINGS_MISSING);
const stored: AccountEntry[] = await tx.handelswareKonto.findMany({
where: { tenantId },
orderBy: { name: 'asc' },
});
const { rows, newAccounts } = assignAccounts(importRows, stored, settings);
if (!sameNewAccounts(newAccounts, submittedNewAccounts)) {
throw new ConflictException(ACCOUNTS_CHANGED);
}
if (newAccounts.length > 0) {
await tx.handelswareKonto.createMany({
data: newAccounts.map((a) => ({
tenantId,
name: a.name,
gegenkonto: a.gegenkonto,
erloeskonto: a.erloeskonto,
})),
});
}
const txt = generateTxt(headerText, rows, buchungsdatum);
return {
filename: getExportFilename(file.originalname),
content: Buffer.from(txt, 'utf8').toString('base64'),
mimeType: 'text/plain;charset=utf-8',
createdCount: newAccounts.length,
};
});
} catch (error) {
// Zwei Exporte gleichzeitig: die Eindeutigkeit (Mandant, Name) faengt den Wettlauf.
if (isUniqueViolation(error)) throw new ConflictException(ACCOUNTS_CHANGED);
throw error;
}
}
}
@@ -0,0 +1,83 @@
/**
* Typen des Moduls Handelsware (quick-261002-fm5): Excel-Umsaetze den
* Erloeskonten zuordnen und als DATEV-Buchungsdatei (TXT) exportieren.
*/
/** Eine Zeile aus der hochgeladenen Excel-Datei. */
export interface ImportRow {
/** Zeile in der Excel-Datei (1-basiert) */
line: number;
buchungstext: string;
umsatz: number;
}
export type RowErrorCode = 'umsatzInvalid';
export interface RowError {
line: number;
code: RowErrorCode;
message: string;
}
/** Vorschauzeile (ohne Buchungsdatum — das tragen Vorschau und Export einmal fuer alle). */
export interface PreviewRow {
line: number;
buchungstext: string;
/** Betrag als Text, Punkt, genau 2 Nachkommastellen, ohne Vorzeichen */
umsatz: string;
sollHaben: 'S' | 'H';
gegenkonto: number;
erloeskonto: number;
/** true, wenn das Konto fuer dieses Produkt neu vergeben wurde */
isNew: boolean;
}
export interface AccountEntry {
name: string;
gegenkonto: number;
erloeskonto: number;
}
export type NewAccount = AccountEntry;
/** Einstellungen des Mandanten, wie in der Datenbank (leer = noch nicht hinterlegt). */
export interface HandelswareSettings {
erloeskonto: number | null;
startGegenkonto: number | null;
}
/** Vollstaendige Einstellungen — Voraussetzung fuer jede Verarbeitung. */
export interface HandelswareSettingsReady {
erloeskonto: number;
startGegenkonto: number;
}
export interface FileResponse {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}
export interface PreviewResult {
headerText: string;
suggestedBuchungsdatum: string;
exportFilename: string;
rows: PreviewRow[];
newAccounts: NewAccount[];
rowErrors: RowError[];
}
export type KontenCsvErrorCode =
| 'nameEmpty'
| 'nameTooLong'
| 'gegenkontoInvalid'
| 'erloeskontoInvalid'
| 'missingErloeskonto'
| 'duplicateName';
export interface KontenCsvError {
line: number;
code: KontenCsvErrorCode;
message: string;
}
@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import { generateKontenCsv, parseKontenCsv } from './handelsware-konten-csv';
const csv = (text: string, enc: BufferEncoding = 'utf8') => Buffer.from(text, enc);
describe('parseKontenCsv', () => {
it('liest CRLF und LF gleich', () => {
const a = parseKontenCsv(csv('Kaffee;2010;4000\r\nTee;2011;4001\r\n'), 4711);
const b = parseKontenCsv(csv('Kaffee;2010;4000\nTee;2011;4001'), 4711);
expect(a.accounts).toEqual(b.accounts);
expect(a.accounts).toHaveLength(2);
expect(a.errors).toEqual([]);
});
it('dekodiert Windows-1252 und UTF-8 mit BOM', () => {
expect(parseKontenCsv(csv('Käse;2010;4000', 'latin1'), null).accounts[0].name).toBe('Käse');
const bom = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), csv('Käse;2010;4000')]);
expect(parseKontenCsv(bom, null).accounts[0].name).toBe('Käse');
});
it('ueberspringt eine Kopfzeile, wenn die zweite Spalte keine Zahl ist', () => {
const r = parseKontenCsv(csv('Name;Gegenkonto;Konto\nKaffee;2010;4000'), null);
expect(r.accounts).toEqual([{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 }]);
expect(r.errors).toEqual([]);
});
it('nimmt fuer eine fehlende dritte Spalte das Standard-Erloeskonto', () => {
const r = parseKontenCsv(csv('Kaffee;2010'), 4711);
expect(r.accounts[0].erloeskonto).toBe(4711);
});
it('meldet missingErloeskonto, wenn das Standard-Erloeskonto leer ist', () => {
const r = parseKontenCsv(csv('Kaffee;2010'), null);
expect(r.errors).toEqual([expect.objectContaining({ line: 1, code: 'missingErloeskonto' })]);
});
it('meldet ungueltige Zahlen und leere Namen mit Zeilennummer', () => {
const r = parseKontenCsv(csv('ok;1;2\n;5;6\nx;abc;6\ny;5;-1\nz;0;6'), null);
expect(r.errors.map((e) => [e.line, e.code])).toEqual([
[2, 'nameEmpty'],
[3, 'gegenkontoInvalid'],
[4, 'erloeskontoInvalid'],
[5, 'gegenkontoInvalid'],
]);
});
it('meldet doppelte Namen', () => {
const r = parseKontenCsv(csv('Kaffee;1;2\nKaffee;3;4'), null);
expect(r.errors).toEqual([expect.objectContaining({ line: 2, code: 'duplicateName' })]);
});
it('meldet zu lange Namen', () => {
const r = parseKontenCsv(csv(`${'x'.repeat(121)};1;2`), null);
expect(r.errors[0].code).toBe('nameTooLong');
});
it('erlaubt Semikolons im Namen', () => {
const r = parseKontenCsv(csv('Tee; gruen;2010;4000'), null);
expect(r.accounts[0]).toEqual({ name: 'Tee; gruen', gegenkonto: 2010, erloeskonto: 4000 });
});
});
describe('generateKontenCsv', () => {
it('beginnt mit BOM, nutzt Semikolon und CRLF', () => {
const out = generateKontenCsv([
{ name: 'Käse', gegenkonto: 2010, erloeskonto: 4000 },
{ name: 'Tee', gegenkonto: 2011, erloeskonto: 4001 },
]);
expect(out.startsWith('')).toBe(true);
expect(out.slice(1)).toBe('Käse;2010;4000\r\nTee;2011;4001\r\n');
});
it.each([
'=SUMME(A1)',
'+1',
'-5 % Aktion',
'@cmd',
])('schuetzt %j mit einem Apostroph', (name) => {
const out = generateKontenCsv([{ name, gegenkonto: 1, erloeskonto: 2 }]);
expect(out.slice(1).startsWith(`'${name};`)).toBe(true);
});
it('Export und Import ergeben dieselbe Liste (Rundlauf)', () => {
const accounts = [
{ name: '=1+1', gegenkonto: 2010, erloeskonto: 4000 },
{ name: '-5 % Aktion', gegenkonto: 2011, erloeskonto: 4000 },
{ name: 'Käse', gegenkonto: 2012, erloeskonto: 4001 },
];
const back = parseKontenCsv(Buffer.from(generateKontenCsv(accounts), 'utf8'), null);
expect(back.errors).toEqual([]);
expect(back.accounts).toEqual(accounts);
});
it('leere Liste ergibt nur das BOM', () => {
expect(generateKontenCsv([])).toBe('');
});
});
@@ -0,0 +1,141 @@
import { decodeCsvText } from '../accounting/decode-csv-text';
import type { AccountEntry, KontenCsvError } from './handelsware-datev.types';
export const MAX_NAME_LENGTH = 120;
const MAX_ACCOUNT_NUMBER = 999_999_999;
/** Zeichen, mit denen Excel einen Zelltext als Formel liest. */
const FORMULA_TRIGGERS = ['=', '+', '-', '@'];
function parseAccountNumber(value: string): number | null {
if (!/^\d{1,9}$/.test(value)) return null;
const num = Number.parseInt(value, 10);
return num >= 1 && num <= MAX_ACCOUNT_NUMBER ? num : null;
}
/** Entfernt den Schutz-Apostroph, den `generateKontenCsv` vor Formelzeichen setzt. */
function stripFormulaGuard(name: string): string {
if (name.length > 1 && name[0] === "'" && FORMULA_TRIGGERS.includes(name[1])) {
return name.slice(1);
}
return name;
}
/**
* Liest eine Konten-CSV (Semikolon): Name;Gegenkonto;Konto. UTF-8 oder
* Windows-1252, CRLF oder LF. Eine Kopfzeile (zweite Spalte keine Zahl) wird
* uebersprungen. Fehlt die dritte Spalte, gilt das Standard-Erloeskonto. Der
* Name steht vor den letzten beiden Semikolons, darf also selbst Semikolons
* enthalten. Es wird alles geprueft; bei Fehlern ist `accounts` unbrauchbar.
*/
export function parseKontenCsv(
buffer: Buffer,
defaultErloeskonto: number | null,
): { accounts: AccountEntry[]; errors: KontenCsvError[] } {
const accounts: AccountEntry[] = [];
const errors: KontenCsvError[] = [];
const seen = new Set<string>();
const lines = decodeCsvText(buffer).split(/\r?\n/);
let firstContentLine = true;
for (let i = 0; i < lines.length; i++) {
const raw = lines[i];
if (raw.trim() === '') continue;
const line = i + 1;
const parts = raw.split(';');
let name: string;
let gegenText: string;
let kontoText: string;
if (parts.length >= 3) {
kontoText = parts[parts.length - 1].trim();
gegenText = parts[parts.length - 2].trim();
name = parts.slice(0, -2).join(';').trim();
} else {
name = (parts[0] ?? '').trim();
gegenText = (parts[1] ?? '').trim();
kontoText = '';
}
// Kopfzeile: nur als allererste Inhaltszeile, wenn die zweite Spalte keine Zahl ist.
if (firstContentLine) {
firstContentLine = false;
if (!/^\d+$/.test(gegenText)) continue;
}
name = stripFormulaGuard(name);
if (name === '') {
errors.push({ line, code: 'nameEmpty', message: 'Der Name fehlt.' });
continue;
}
if (name.length > MAX_NAME_LENGTH) {
errors.push({
line,
code: 'nameTooLong',
message: `Der Name ist länger als ${MAX_NAME_LENGTH} Zeichen.`,
});
continue;
}
const gegenkonto = parseAccountNumber(gegenText);
if (gegenkonto === null) {
errors.push({
line,
code: 'gegenkontoInvalid',
message: 'Das Gegenkonto muss eine ganze Zahl von 1 bis 999999999 sein.',
});
continue;
}
let erloeskonto: number | null;
if (kontoText === '') {
if (defaultErloeskonto === null) {
errors.push({
line,
code: 'missingErloeskonto',
message: 'Das Erlöskonto fehlt und es ist kein Standard-Erlöskonto hinterlegt.',
});
continue;
}
erloeskonto = defaultErloeskonto;
} else {
erloeskonto = parseAccountNumber(kontoText);
if (erloeskonto === null) {
errors.push({
line,
code: 'erloeskontoInvalid',
message: 'Das Erlöskonto muss eine ganze Zahl von 1 bis 999999999 sein.',
});
continue;
}
}
if (seen.has(name)) {
errors.push({
line,
code: 'duplicateName',
message: 'Der Name kommt in der Datei mehrfach vor.',
});
continue;
}
seen.add(name);
accounts.push({ name, gegenkonto, erloeskonto });
}
return { accounts, errors };
}
/**
* Konten-CSV fuer Excel: UTF-8 mit BOM (damit Umlaute stimmen), Semikolon,
* CRLF. Namen, die mit Formelzeichen beginnen, bekommen einen Apostroph
* davor (Schutz vor Formeleinschleusung, T-FM5-07); `parseKontenCsv` nimmt ihn
* wieder weg.
*/
export function generateKontenCsv(accounts: AccountEntry[]): string {
const lines = accounts.map((a) => {
const name = FORMULA_TRIGGERS.includes(a.name[0] ?? '') ? `'${a.name}` : a.name;
return `${name};${a.gegenkonto};${a.erloeskonto}`;
});
return `${lines.join('\r\n')}${lines.length > 0 ? '\r\n' : ''}`;
}
@@ -0,0 +1,142 @@
import { describe, expect, it } from 'vitest';
import type { ImportRow } from './handelsware-datev.types';
import {
assignAccounts,
calculateBuchungsdatum,
formatAmount,
generateTxt,
getExportFilename,
isValidBuchungsdatum,
} from './handelsware-transform';
// Neutrale Testwerte, keine Zahlen aus einem echten Kontenrahmen.
const SETTINGS = { erloeskonto: 4711, startGegenkonto: 2000 };
const row = (buchungstext: string, umsatz: number, line = 2): ImportRow => ({
line,
buchungstext,
umsatz,
});
describe('calculateBuchungsdatum', () => {
it.each([
['HWA 0326 Test.xlsx', '3103'],
['HWA 0226.xlsx', '2802'],
['x 0228.xlsx', '2902'],
['HWA 0426.xlsx', '3004'],
['HWA 0026.xlsx', ''],
['HWA 1326.xlsx', ''],
['HWA.xlsx', ''],
['HWA 12.xlsx', ''],
])('%s -> %j', (name, expected) => {
expect(calculateBuchungsdatum(name)).toBe(expected);
});
});
describe('isValidBuchungsdatum', () => {
it.each(['3103', '0101', '2902', '3012'])('akzeptiert %s', (v) => {
expect(isValidBuchungsdatum(v)).toBe(true);
});
it.each([
'3102',
'0013',
'0000',
'3204',
'abc',
'310',
'31033',
'3104',
'',
])('lehnt %j ab', (v) => {
expect(isValidBuchungsdatum(v)).toBe(false);
});
});
describe('formatAmount', () => {
it('Soll fuer positive Werte und Null, Haben fuer negative', () => {
expect(formatAmount(12.5)).toEqual({ formatted: '12.50', sollHaben: 'S' });
expect(formatAmount(0)).toEqual({ formatted: '0.00', sollHaben: 'S' });
expect(formatAmount(-3.456)).toEqual({ formatted: '3.46', sollHaben: 'H' });
});
});
describe('assignAccounts', () => {
const accounts = [
{ name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 },
{ name: 'Tee', gegenkonto: 2005, erloeskonto: 4001 },
];
it('bekannter Name bekommt sein Gegenkonto und Erloeskonto, isNew false', () => {
const r = assignAccounts([row('Kaffee', 5)], accounts, SETTINGS);
expect(r.rows[0]).toMatchObject({ gegenkonto: 2010, erloeskonto: 4000, isNew: false });
expect(r.newAccounts).toEqual([]);
});
it('unbekannte Namen: hoechstes Gegenkonto + 1, dann + 2, mit Standard-Erloeskonto', () => {
const r = assignAccounts([row('Kakao', 1), row('Saft', 2)], accounts, SETTINGS);
expect(r.newAccounts).toEqual([
{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 },
{ name: 'Saft', gegenkonto: 2012, erloeskonto: 4711 },
]);
expect(r.rows.map((x) => x.isNew)).toEqual([true, true]);
});
it('leere Liste: erstes neues Konto ist genau der Startwert, dann + 1', () => {
const r = assignAccounts([row('A', 1), row('B', 1)], [], SETTINGS);
expect(r.newAccounts.map((a) => a.gegenkonto)).toEqual([2000, 2001]);
});
it('derselbe unbekannte Name zweimal: ein neues Konto, beide Zeilen als neu', () => {
const r = assignAccounts(
[row('Kakao', 1, 2), row('Saft', 1, 3), row('Kakao', 2, 4)],
accounts,
SETTINGS,
);
expect(r.newAccounts.map((a) => a.name)).toEqual(['Kakao', 'Saft']);
expect(r.rows[2]).toMatchObject({ gegenkonto: 2011, isNew: true, line: 4 });
});
it('vergleicht Namen genau (Gross-/Kleinschreibung zaehlt)', () => {
const r = assignAccounts([row('kaffee', 1)], accounts, SETTINGS);
expect(r.rows[0].isNew).toBe(true);
});
it('formatiert Betrag und Soll/Haben je Zeile', () => {
const r = assignAccounts([row('Kaffee', -2.5)], accounts, SETTINGS);
expect(r.rows[0]).toMatchObject({ umsatz: '2.50', sollHaben: 'H' });
});
});
describe('generateTxt', () => {
const rows = assignAccounts([row('Müller Käse', 12.5), row('Tee', -3)], [], SETTINGS).rows;
const txt = generateTxt('2026', rows, '3103');
it('Kopfzeile: TAB Kopftext und vier weitere Tabs', () => {
expect(txt.split('\r\n')[0]).toBe('\t2026\t\t\t\t');
});
it('Datenzeilen: Text, Umsatz, S/H, Gegenkonto, TTMM, Erloeskonto', () => {
const lines = txt.split('\r\n');
expect(lines[1]).toBe('Müller Käse\t12.50\tS\t2000\t3103\t4711');
expect(lines[2]).toBe('Tee\t3.00\tH\t2001\t3103\t4711');
});
it('endet mit CRLF und enthaelt kein einzelnes LF', () => {
expect(txt.endsWith('\r\n')).toBe(true);
expect(txt.replace(/\r\n/g, '')).not.toContain('\n');
});
it('behaelt Umlaute bei UTF-8 bei', () => {
expect(Buffer.from(txt, 'utf8').toString('utf8')).toContain('Müller Käse');
});
});
describe('getExportFilename', () => {
it.each([
['HWA 0326 Test.xlsx', 'HWA_0326.txt'],
['HWA0326.xlsx', 'HWA_0326.txt'],
['Liste.xlsx', 'Handelsware_Export.txt'],
])('%s -> %s', (name, expected) => {
expect(getExportFilename(name)).toBe(expected);
});
});
@@ -0,0 +1,115 @@
import type {
AccountEntry,
HandelswareSettingsReady,
ImportRow,
NewAccount,
PreviewRow,
} from './handelsware-datev.types';
/**
* Buchungsdatum (TTMM) aus dem Dateinamen: die erste vierstellige Ziffernfolge
* ist MMYY, ergibt den letzten Tag dieses Monats.
* "HWA 0326 Test.xlsx" -> "3103". Ohne Treffer oder mit Monat ausserhalb 1-12: "".
*/
export function calculateBuchungsdatum(filename: string): string {
const match = filename.match(/(\d{2})(\d{2})/);
if (!match) return '';
const month = Number.parseInt(match[1], 10);
if (month < 1 || month > 12) return '';
const year = 2000 + Number.parseInt(match[2], 10);
const lastDay = new Date(year, month, 0).getDate();
return `${String(lastDay).padStart(2, '0')}${String(month).padStart(2, '0')}`;
}
const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** TTMM: vier Ziffern, Monat 1-12, Tag passend zum Monat (Februar bis 29). */
export function isValidBuchungsdatum(ttmm: string): boolean {
if (!/^\d{4}$/.test(ttmm)) return false;
const day = Number.parseInt(ttmm.slice(0, 2), 10);
const month = Number.parseInt(ttmm.slice(2, 4), 10);
if (month < 1 || month > 12) return false;
return day >= 1 && day <= DAYS_PER_MONTH[month - 1];
}
/** Betrag ohne Vorzeichen, Punkt, genau 2 Nachkommastellen; Soll fuer >= 0, Haben fuer < 0. */
export function formatAmount(value: number): { formatted: string; sollHaben: 'S' | 'H' } {
const sollHaben = value < 0 ? 'H' : 'S';
return { formatted: Math.abs(value).toFixed(2), sollHaben };
}
/**
* Ordnet jeder Zeile ihr Konto zu. Bekannte Produkte (genauer, gross-/
* kleinschreibungsabhaengiger Name) bekommen ihr Gegenkonto und Erloeskonto;
* unbekannte bekommen das naechste freie Gegenkonto (hoechstes vorhandenes + 1,
* bei leerer Liste genau der Startwert aus den Einstellungen) und das
* Standard-Erloeskonto. Dasselbe unbekannte Produkt mehrfach in einer Datei
* bekommt EIN neues Konto. `newAccounts` steht in der Reihenfolge des ersten
* Auftretens.
*/
export function assignAccounts(
importRows: ImportRow[],
accounts: AccountEntry[],
settings: HandelswareSettingsReady,
): { rows: PreviewRow[]; newAccounts: NewAccount[] } {
const known = new Map<string, AccountEntry>();
for (const account of accounts) known.set(account.name, account);
const created = new Map<string, NewAccount>();
const newAccounts: NewAccount[] = [];
let next =
accounts.length > 0
? Math.max(...accounts.map((a) => a.gegenkonto)) + 1
: settings.startGegenkonto;
const rows: PreviewRow[] = [];
for (const row of importRows) {
const { formatted, sollHaben } = formatAmount(row.umsatz);
let account = known.get(row.buchungstext);
let isNew = false;
if (!account) {
isNew = true;
account = created.get(row.buchungstext);
if (!account) {
account = { name: row.buchungstext, gegenkonto: next++, erloeskonto: settings.erloeskonto };
created.set(row.buchungstext, account);
newAccounts.push(account);
}
}
rows.push({
line: row.line,
buchungstext: row.buchungstext,
umsatz: formatted,
sollHaben,
gegenkonto: account.gegenkonto,
erloeskonto: account.erloeskonto,
isNew,
});
}
return { rows, newAccounts };
}
/**
* TXT-Datei fuer DATEV: Kopfzeile TAB Kopftext + 4 Tabs, dann je Zeile
* Text, Umsatz, S/H, Gegenkonto, Datum (TTMM), Erloeskonto — tabgetrennt, CRLF,
* die Datei endet mit CRLF. UTF-8 (offene Frage: DATEV erwartet oft ANSI).
*/
export function generateTxt(headerText: string, rows: PreviewRow[], buchungsdatum: string): string {
const lines: string[] = [`\t${headerText}\t\t\t\t`];
for (const row of rows) {
lines.push(
`${row.buchungstext}\t${row.umsatz}\t${row.sollHaben}\t${row.gegenkonto}\t${buchungsdatum}\t${row.erloeskonto}`,
);
}
return `${lines.join('\r\n')}\r\n`;
}
/** Dateiname des Exports: "HWA 0326 Test.xlsx" -> "HWA_0326.txt", sonst "Handelsware_Export.txt". */
export function getExportFilename(importFilename: string): string {
const match = importFilename.match(/(\w+)\s*(\d{4})/);
if (match) return `${match[1]}_${match[2]}.txt`;
return 'Handelsware_Export.txt';
}
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import * as XLSX from 'xlsx';
import {
HandelswareFileError,
MAX_DATA_ROWS,
parseHandelswareXlsx,
parseUmsatz,
} from './handelsware-xlsx';
/** Baut eine Arbeitsmappe aus einer Matrix (Zeile 1 = Kopf). */
function workbook(aoa: unknown[][]): Buffer {
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, XLSX.utils.aoa_to_sheet(aoa), 'Blatt1');
return XLSX.write(wb, { type: 'buffer', bookType: 'xlsx' }) as Buffer;
}
describe('parseUmsatz', () => {
it('uebernimmt Zahlen unveraendert', () => {
expect(parseUmsatz(12.5)).toBe(12.5);
expect(parseUmsatz(-3)).toBe(-3);
});
it('liest deutsche Texte', () => {
expect(parseUmsatz('1.234,56')).toBe(1234.56);
expect(parseUmsatz('-12,5')).toBe(-12.5);
expect(parseUmsatz(' 7,00 ')).toBe(7);
});
it('liest Text mit Punkt als Dezimalzeichen', () => {
expect(parseUmsatz('12.5')).toBe(12.5);
});
it.each(['', 'abc', '1,2,3', '12,5x', '--1', null, undefined, true, NaN])('lehnt %j ab', (v) => {
expect(parseUmsatz(v)).toBeNull();
});
});
describe('parseHandelswareXlsx', () => {
it('liest Kopftext aus B1 (Text oder Zahl) und die Zeilen ab Zeile 2', () => {
const textHeader = parseHandelswareXlsx(
workbook([
['', 'Marz'],
['Kaffee', 12.5],
]),
);
expect(textHeader.headerText).toBe('Marz');
const numberHeader = parseHandelswareXlsx(
workbook([
['', 2025],
['Kaffee', 1],
]),
);
expect(numberHeader.headerText).toBe('2025');
});
it('liest Zahlen und deutsche Texte als Umsatz und merkt sich die Zeilennummer', () => {
const r = parseHandelswareXlsx(
workbook([
['', 'X'],
['Kaffee', 12.5],
['Tee', '1.234,56'],
['Kakao', '-12,5'],
]),
);
expect(r.rows).toEqual([
{ line: 2, buchungstext: 'Kaffee', umsatz: 12.5 },
{ line: 3, buchungstext: 'Tee', umsatz: 1234.56 },
{ line: 4, buchungstext: 'Kakao', umsatz: -12.5 },
]);
expect(r.rowErrors).toEqual([]);
});
it('endet an der ersten Zeile, in der A und B leer sind', () => {
const r = parseHandelswareXlsx(workbook([['', 'X'], ['Kaffee', 1], [], ['Tee', 2]]));
expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']);
});
it('meldet nicht numerischen oder leeren Umsatz als Zeilenfehler', () => {
const r = parseHandelswareXlsx(
workbook([
['', 'X'],
['Kaffee', 'viel'],
['Tee', null],
['Kakao', 3],
]),
);
expect(r.rowErrors).toEqual([
expect.objectContaining({ line: 2, code: 'umsatzInvalid' }),
expect.objectContaining({ line: 3, code: 'umsatzInvalid' }),
]);
expect(r.rows).toHaveLength(1);
});
it('ueberspringt eine Zeile ohne Buchungstext, aber mit Wert (wie die Vorlage)', () => {
const r = parseHandelswareXlsx(
workbook([
['', 'X'],
['', 5],
['Kaffee', 1],
]),
);
expect(r.rows.map((x) => x.buchungstext)).toEqual(['Kaffee']);
expect(r.rowErrors).toEqual([]);
});
it('ersetzt Tabulatoren und Zeilenumbrueche im Text durch Leerzeichen', () => {
const r = parseHandelswareXlsx(
workbook([
['', 'Kopf\tText'],
['Kaf\tfee\nneu', 1],
]),
);
expect(r.headerText).toBe('Kopf Text');
expect(r.rows[0].buchungstext).toBe('Kaf fee neu');
});
it('wirft invalidFile bei Muelldaten', () => {
expect(() => parseHandelswareXlsx(Buffer.from('das ist keine Excel-Datei;1;2'))).toThrow(
HandelswareFileError,
);
try {
parseHandelswareXlsx(Buffer.from([1, 2, 3, 4, 5, 6]));
expect.unreachable();
} catch (e) {
expect((e as HandelswareFileError).code).toBe('invalidFile');
}
});
it('wirft invalidFile bei kaputtem ZIP', () => {
const broken = Buffer.concat([Buffer.from([0x50, 0x4b, 0x03, 0x04]), Buffer.from('kaputt')]);
expect(() => parseHandelswareXlsx(broken)).toThrow(HandelswareFileError);
});
it('wirft tooManyRows ab mehr als 10 000 Datenzeilen, nicht davor', () => {
const header = ['', 'X'];
const make = (n: number) =>
workbook([header, ...Array.from({ length: n }, (_, i) => [`P${i}`, 1])]);
expect(parseHandelswareXlsx(make(MAX_DATA_ROWS)).rows).toHaveLength(MAX_DATA_ROWS);
try {
parseHandelswareXlsx(make(MAX_DATA_ROWS + 1));
expect.unreachable();
} catch (e) {
expect((e as HandelswareFileError).code).toBe('tooManyRows');
}
});
});
@@ -0,0 +1,130 @@
import * as XLSX from 'xlsx';
import type { ImportRow, RowError } from './handelsware-datev.types';
/** Obergrenze der Datenzeilen je Datei (T-FM5-04). */
export const MAX_DATA_ROWS = 10_000;
export type HandelswareFileErrorCode = 'invalidFile' | 'tooManyRows';
export class HandelswareFileError extends Error {
constructor(
readonly code: HandelswareFileErrorCode,
message: string,
) {
super(message);
this.name = 'HandelswareFileError';
}
}
/** Tabulator und Zeilenumbrueche wuerden die Spalten der TXT-Datei zerreissen. */
export function sanitizeText(value: string): string {
return value.replace(/[\t\r\n]+/g, ' ').trim();
}
/**
* Wandelt einen Umsatzwert in eine Zahl: Zahl unveraendert, Text im deutschen
* Format ("1.234,56", "-12,5") oder mit Punkt ("12.5"). `null` bei allem, was
* keine Zahl ist.
*/
export function parseUmsatz(value: unknown): number | null {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
if (typeof value !== 'string') return null;
let text = value.replace(/\s/g, '');
if (text === '') return null;
if (text.includes(',')) {
// Deutsches Format: Punkte sind Tausendertrenner, das Komma ist das Dezimalzeichen.
text = text.replace(/\./g, '').replace(',', '.');
}
if (!/^-?\d+(\.\d+)?$/.test(text)) return null;
const num = Number(text);
return Number.isFinite(num) ? num : null;
}
/** Signatur einer xlsx-Datei (ZIP) oder einer alten xls-Datei (OLE2). */
function looksLikeWorkbook(buffer: Buffer): boolean {
if (buffer.length < 4) return false;
const zip = buffer[0] === 0x50 && buffer[1] === 0x4b;
const ole = buffer[0] === 0xd0 && buffer[1] === 0xcf && buffer[2] === 0x11 && buffer[3] === 0xe0;
return zip || ole;
}
function cellText(cell: XLSX.CellObject | undefined): string {
if (!cell || cell.v === undefined || cell.v === null) return '';
return String(cell.v);
}
/**
* Liest die Handelsware-Excel-Datei: Zelle B1 = Kopftext, ab Zeile 2 Spalte A =
* Buchungstext und Spalte B = Umsatz, bis A und B beide leer sind. Nur das erste
* Blatt, keine Formeln/HTML/Formatvorlagen (T-FM5-05), hoechstens
* `MAX_DATA_ROWS` Datenzeilen (T-FM5-04).
*
* Abweichung von der Vorlage: ein Umsatz, der keine Zahl ist, wurde dort still
* als 0 gebucht — hier wird er ein Zeilenfehler.
*/
export function parseHandelswareXlsx(buffer: Buffer): {
headerText: string;
rows: ImportRow[];
rowErrors: RowError[];
} {
if (!looksLikeWorkbook(buffer)) {
throw new HandelswareFileError('invalidFile', 'Die Datei ist keine gültige Excel-Datei.');
}
let sheet: XLSX.WorkSheet | undefined;
try {
const workbook = XLSX.read(buffer, {
type: 'buffer',
cellFormula: false,
cellHTML: false,
cellStyles: false,
// Zeile 1 (Kopf) + MAX_DATA_ROWS Datenzeilen + 1 Zeile, um "zu viele" zu erkennen.
sheetRows: MAX_DATA_ROWS + 2,
});
sheet = workbook.Sheets[workbook.SheetNames[0]];
} catch {
throw new HandelswareFileError(
'invalidFile',
'Die Datei konnte nicht als Excel-Datei gelesen werden.',
);
}
if (!sheet) {
throw new HandelswareFileError('invalidFile', 'Die Excel-Datei enthält kein Tabellenblatt.');
}
const headerText = sanitizeText(cellText(sheet.B1));
const rows: ImportRow[] = [];
const rowErrors: RowError[] = [];
for (let line = 2; ; line++) {
const textA = sanitizeText(cellText(sheet[`A${line}`]));
const rawB = sheet[`B${line}`]?.v;
const emptyB = rawB === undefined || rawB === null || String(rawB).trim() === '';
if (textA === '' && emptyB) break;
if (line - 1 > MAX_DATA_ROWS) {
throw new HandelswareFileError(
'tooManyRows',
`Die Datei enthält mehr als ${MAX_DATA_ROWS} Datenzeilen.`,
);
}
// Zeile ohne Buchungstext, aber mit Wert: uebersprungen (Verhalten der Vorlage).
if (textA === '') continue;
const umsatz = parseUmsatz(rawB);
if (umsatz === null) {
rowErrors.push({
line,
code: 'umsatzInvalid',
message: 'Der Umsatz ist keine gültige Zahl.',
});
continue;
}
rows.push({ line, buchungstext: textA, umsatz });
}
return { headerText, rows, rowErrors };
}
@@ -0,0 +1,24 @@
import { IsString, Matches } from 'class-validator';
/**
* Einstellungen der Kantinenabrechnung (quick-261002-fm5): drei Nummern, die
* der Administrator einmalig je Mandant hinterlegt. Nur Ziffern (1 bis 10),
* Text statt Zahl, damit fuehrende Nullen erhalten bleiben.
*/
export class KantineDatevSettingsDto {
@IsString({ message: 'Die Beraternummer muss angegeben werden' })
@Matches(/^\d{1,10}$/, {
message: 'Die Beraternummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
})
beraterNr!: string;
@IsString({ message: 'Die Mandantennummer muss angegeben werden' })
@Matches(/^\d{1,10}$/, {
message: 'Die Mandantennummer darf nur Ziffern enthalten (1 bis 10 Stellen)',
})
mandantNr!: string;
@IsString({ message: 'Die Lohnart muss angegeben werden' })
@Matches(/^\d{1,10}$/, { message: 'Die Lohnart darf nur Ziffern enthalten (1 bis 10 Stellen)' })
lohnart!: string;
}
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest';
import { parseKantinenCsv } from './kantine-csv.parser';
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
const ROW = '100;Muster, Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026';
describe('parseKantinenCsv', () => {
it('liefert bei CRLF und LF dieselben Zeilen', () => {
const lf = parseKantinenCsv([HEADER, ROW, ROW].join('\n'));
const crlf = parseKantinenCsv([HEADER, ROW, ROW].join('\r\n'));
expect(crlf.rows).toEqual(lf.rows);
expect(lf.rows).toHaveLength(2);
expect(lf.errors).toEqual([]);
});
it('ueberspringt leere Zeilen und merkt sich die echte Zeilennummer', () => {
const { rows } = parseKantinenCsv([HEADER, '', ROW, '', ROW, ''].join('\r\n'));
expect(rows.map((r) => r.line)).toEqual([3, 5]);
});
it('meldet einen Kopf mit zu wenigen Spalten mit Hinweis auf das Trennzeichen', () => {
const { rows, errors } = parseKantinenCsv('a,b,c\n1,2,3');
expect(rows).toEqual([]);
expect(errors).toHaveLength(1);
expect(errors[0]).toMatchObject({ row: 1, code: 'headerColumns' });
expect(errors[0].message).toContain('Ist das Trennzeichen korrekt (Semikolon)?');
});
it('meldet eine leere Datei als fehlenden Kopf', () => {
expect(parseKantinenCsv('').errors[0]).toMatchObject({ row: 1, code: 'headerMissing' });
});
it('meldet eine Datenzeile mit zu wenigen Spalten mit Zeilennummer und ueberspringt sie', () => {
const { rows, errors } = parseKantinenCsv([HEADER, ROW, '1;2;3', ROW].join('\n'));
expect(rows).toHaveLength(2);
expect(errors).toEqual([
expect.objectContaining({ row: 3, code: 'columnCount', field: 'zeile' }),
]);
});
it('trimmt Whitespace', () => {
const { rows } = parseKantinenCsv(
[HEADER, ` 100 ; Max ;1;1;1;0;0;0; 7,94 ;01.03.2026;31.03.2026`].join('\n'),
);
expect(rows[0].personalNr).toBe('100');
expect(rows[0].betrag).toBe('7,94');
});
});
@@ -0,0 +1,83 @@
import type { KantinenRawRow, ValidationError } from './kantine-datev.types';
/** Erwartete Anzahl der Spalten pro CSV-Zeile. */
const ERWARTETE_SPALTENANZAHL = 11;
/**
* Parst eine Kantinen-CSV (Semikolon-getrennt, bereits als Text dekodiert).
*
* - Erste Zeile ist der Kopf und wird uebersprungen
* - Leere Zeilen werden ignoriert
* - Whitespace wird getrimmt
* - Falsche Spaltenanzahl erzeugt einen Fehler mit Zeilennummer
*/
export function parseKantinenCsv(content: string): {
rows: KantinenRawRow[];
errors: ValidationError[];
} {
const rows: KantinenRawRow[] = [];
const errors: ValidationError[] = [];
// Unterstuetzt CRLF und LF.
const zeilen = content.split(/\r?\n/);
const headerZeile = zeilen[0]?.trim();
if (!headerZeile) {
errors.push({
row: 1,
field: 'header',
code: 'headerMissing',
message: 'Die Datei enthält keine Header-Zeile.',
});
return { rows, errors };
}
const headerSpalten = headerZeile.split(';').map((s) => s.trim());
if (headerSpalten.length < ERWARTETE_SPALTENANZAHL) {
errors.push({
row: 1,
field: 'header',
code: 'headerColumns',
message: `Header enthält nur ${headerSpalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}. Ist das Trennzeichen korrekt (Semikolon)?`,
});
return { rows, errors };
}
for (let i = 1; i < zeilen.length; i++) {
const zeile = zeilen[i]?.trim();
const zeilenNummer = i + 1;
if (!zeile) {
continue;
}
const spalten = zeile.split(';').map((s) => s.trim());
if (spalten.length < ERWARTETE_SPALTENANZAHL) {
errors.push({
row: zeilenNummer,
field: 'zeile',
code: 'columnCount',
message: `Zeile hat nur ${spalten.length} Spalten, erwartet werden ${ERWARTETE_SPALTENANZAHL}.`,
});
continue;
}
rows.push({
personalNr: spalten[0],
name: spalten[1],
menge: spalten[2],
ekPreis: spalten[3],
netto: spalten[4],
zuAbschlag: spalten[5],
mwst: spalten[6],
zuschuss: spalten[7],
betrag: spalten[8],
abrechnungVon: spalten[9],
abrechnungBis: spalten[10],
line: zeilenNummer,
});
}
return { rows, errors };
}
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import { validateKantinenData } from './kantine-csv.validator';
import type { KantinenRawRow } from './kantine-datev.types';
function row(over: Partial<KantinenRawRow> = {}): KantinenRawRow {
return {
personalNr: '100',
name: 'Max Muster',
menge: '1',
ekPreis: '1,00',
netto: '1,00',
zuAbschlag: '0',
mwst: '0',
zuschuss: '0',
betrag: '7,94',
abrechnungVon: '01.03.2026',
abrechnungBis: '31.03.2026',
...over,
};
}
describe('validateKantinenData', () => {
it('akzeptiert eine gueltige Zeile und bestimmt den Monat aus "bis"', () => {
const result = validateKantinenData([row()]);
expect(result.isValid).toBe(true);
expect(result.abrechnungsMonat).toBe('03/2026');
});
it('meldet nicht numerische und fehlende Personalnummern', () => {
const r = validateKantinenData([row({ personalNr: 'A12' }), row({ personalNr: '' })]);
expect(r.errors.map((e) => e.code)).toEqual(['personalNrNotNumeric', 'personalNrMissing']);
expect(r.errors[0].message).toBe('Personalnummer muss numerisch sein');
});
it('lehnt Betraege mit Tausenderpunkt oder Minus ab (wie die Vorlage)', () => {
const r = validateKantinenData([
row({ betrag: '1.234,56' }),
row({ betrag: '-5,00' }),
row({ betrag: '' }),
]);
expect(r.errors.map((e) => e.code)).toEqual(['betragFormat', 'betragFormat', 'betragMissing']);
});
it('prueft das Datumsformat', () => {
const r = validateKantinenData([row({ abrechnungVon: '2026-03-01', abrechnungBis: '' })]);
expect(r.errors.map((e) => e.code)).toEqual(['vonFormat', 'bisMissing']);
});
it('meldet von/bis in verschiedenen Monaten mit Zeile = Index + 2', () => {
const r = validateKantinenData([row(), row({ abrechnungVon: '28.02.2026' })]);
expect(r.errors).toEqual([
expect.objectContaining({
row: 3,
code: 'multiMonthRange',
field: 'abrechnungVon/abrechnungBis',
}),
]);
});
it('nimmt die echte Dateizeile, wenn der Parser sie mitliefert', () => {
const r = validateKantinenData([row({ personalNr: 'x', line: 9 })]);
expect(r.errors[0].row).toBe(9);
});
it('warnt bei zwei Abrechnungsmonaten und behaelt den ersten', () => {
const r = validateKantinenData([
row(),
row({ abrechnungVon: '01.04.2026', abrechnungBis: '30.04.2026' }),
]);
expect(r.isValid).toBe(true);
expect(r.abrechnungsMonat).toBe('03/2026');
expect(r.warnings).toHaveLength(1);
expect(r.warnings[0]).toMatchObject({
code: 'multipleMonths',
params: { months: ['03/2026', '04/2026'] },
});
expect(r.warnings[0].message).toBe(
'Verschiedene Abrechnungsmonate erkannt: 03/2026, 04/2026. Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
);
});
});
@@ -0,0 +1,138 @@
import type {
KantinenRawRow,
ValidationError,
ValidationResult,
ValidationWarning,
} from './kantine-datev.types';
/** Zahl im deutschen Format (Komma als Dezimaltrenner), wie in der Vorlage. */
export function isValidGermanNumber(value: string): boolean {
return /^\d+([,]\d+)?$/.test(value.trim());
}
/** Datum im Format TT.MM.JJJJ. */
function isValidDate(value: string): boolean {
return /^\d{2}\.\d{2}\.\d{4}$/.test(value.trim());
}
function extractMonthYear(dateStr: string): { month: string; year: string } | null {
const match = dateStr.trim().match(/^(\d{2})\.(\d{2})\.(\d{4})$/);
if (!match) return null;
return { month: match[2], year: match[3] };
}
/**
* Validiert die geparsten Kantinen-Zeilen (Regeln und Meldungen wie in der
* Desktop-Vorlage):
* 1. Personalnummer: vorhanden und numerisch
* 2. Betrag: vorhanden, deutsches Zahlenformat
* 3. Abrechnung von/bis: Format TT.MM.JJJJ
* 4. von und bis muessen im selben Monat liegen
* 5. Abrechnungsmonat kommt aus "Abrechnung bis" -> MM/YYYY
*/
export function validateKantinenData(rows: KantinenRawRow[]): ValidationResult {
const errors: ValidationError[] = [];
const warnings: ValidationWarning[] = [];
const detectedMonths = new Set<string>();
let abrechnungsMonat: string | null = null;
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
// Echte Dateizeile, falls bekannt; sonst 1-basiert + 1 fuer den Kopf.
const rowNum = row.line ?? i + 2;
if (!row.personalNr || row.personalNr.trim() === '') {
errors.push({
row: rowNum,
field: 'personalNr',
code: 'personalNrMissing',
message: 'Personalnummer fehlt',
});
} else if (!/^\d+$/.test(row.personalNr.trim())) {
errors.push({
row: rowNum,
field: 'personalNr',
code: 'personalNrNotNumeric',
message: 'Personalnummer muss numerisch sein',
});
}
if (!row.betrag || row.betrag.trim() === '') {
errors.push({ row: rowNum, field: 'betrag', code: 'betragMissing', message: 'Betrag fehlt' });
} else if (!isValidGermanNumber(row.betrag)) {
errors.push({
row: rowNum,
field: 'betrag',
code: 'betragFormat',
message: 'Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)',
});
}
if (!row.abrechnungVon || row.abrechnungVon.trim() === '') {
errors.push({
row: rowNum,
field: 'abrechnungVon',
code: 'vonMissing',
message: 'Abrechnung von fehlt',
});
} else if (!isValidDate(row.abrechnungVon)) {
errors.push({
row: rowNum,
field: 'abrechnungVon',
code: 'vonFormat',
message: 'Abrechnung von muss im Format TT.MM.JJJJ vorliegen',
});
}
if (!row.abrechnungBis || row.abrechnungBis.trim() === '') {
errors.push({
row: rowNum,
field: 'abrechnungBis',
code: 'bisMissing',
message: 'Abrechnung bis fehlt',
});
} else if (!isValidDate(row.abrechnungBis)) {
errors.push({
row: rowNum,
field: 'abrechnungBis',
code: 'bisFormat',
message: 'Abrechnung bis muss im Format TT.MM.JJJJ vorliegen',
});
}
const vonParsed = extractMonthYear(row.abrechnungVon);
const bisParsed = extractMonthYear(row.abrechnungBis);
if (vonParsed && bisParsed) {
if (vonParsed.month !== bisParsed.month || vonParsed.year !== bisParsed.year) {
errors.push({
row: rowNum,
field: 'abrechnungVon/abrechnungBis',
code: 'multiMonthRange',
message: 'Abrechnungszeitraum erstreckt sich über mehrere Monate',
});
}
}
if (bisParsed) {
detectedMonths.add(`${bisParsed.month}/${bisParsed.year}`);
}
}
if (detectedMonths.size === 1) {
abrechnungsMonat = [...detectedMonths][0];
} else if (detectedMonths.size > 1) {
const months = [...detectedMonths];
warnings.push({
code: 'multipleMonths',
message:
`Verschiedene Abrechnungsmonate erkannt: ${months.join(', ')}. ` +
'Alle Zeilen sollten im selben Abrechnungsmonat liegen.',
params: { months },
});
// Fallback wie in der Vorlage: der erste erkannte Monat.
abrechnungsMonat = months[0];
}
return { isValid: errors.length === 0, errors, warnings, abrechnungsMonat };
}
@@ -0,0 +1,87 @@
import 'reflect-metadata';
import { BadRequestException, ForbiddenException, ValidationPipe } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import { KantineDatevController } from './kantine-datev.controller';
const proto = KantineDatevController.prototype as any;
const req = (tenantId?: string) => ({ tenantId }) as any;
function makeService() {
return {
getSettings: vi.fn(async (..._a: unknown[]) => ({})),
saveSettings: vi.fn(async (..._a: unknown[]) => ({})),
preview: vi.fn(async (..._a: unknown[]) => ({})),
export: vi.fn(async (..._a: unknown[]) => ({})),
};
}
describe('KantineDatevController — Metadaten', () => {
it('haengt an modules/kantine-datev und traegt @UseModule', () => {
expect(Reflect.getMetadata('path', KantineDatevController)).toBe('modules/kantine-datev');
expect(Reflect.getMetadata(MODULE_SLUG_KEY, KantineDatevController)).toBe('kantine-datev');
});
it('PUT settings verlangt die Freigabestufe Verwalten und trägt keine Routen-Rolle (261002-icv)', () => {
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto.saveSettings)).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, proto.saveSettings)).toBe('kantine-datev');
expect(Reflect.getMetadata(ROLES_KEY, proto.saveSettings)).toBeUndefined();
});
it.each(['getSettings', 'preview', 'export'])(
'%s traegt weder Routen-Rolle noch Verwalten-Pflicht',
(name) => {
expect(Reflect.getMetadata(ROLES_KEY, proto[name])).toBeUndefined();
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, proto[name])).toBeUndefined();
},
);
});
describe('KantineDatevController — Verhalten', () => {
it('reicht req.tenantId und den Dateipuffer an den Dienst', async () => {
const service = makeService();
const c = new KantineDatevController(service as any);
const buffer = Buffer.from('x');
await c.getSettings(req('t1'));
await c.preview(req('t1'), { buffer } as any);
await c.export(req('t1'), { buffer } as any);
expect(service.getSettings).toHaveBeenCalledWith('t1');
expect(service.preview).toHaveBeenCalledWith('t1', buffer);
expect(service.export).toHaveBeenCalledWith('t1', buffer);
});
it('antwortet ohne Datei mit 400', async () => {
const c = new KantineDatevController(makeService() as any);
await expect(c.preview(req('t1'), undefined)).rejects.toThrow(BadRequestException);
await expect(c.export(req('t1'), undefined)).rejects.toThrow(BadRequestException);
});
it('antwortet ohne Mandantenkontext mit 403', async () => {
const c = new KantineDatevController(makeService() as any);
await expect(c.getSettings(req(undefined))).rejects.toThrow(ForbiddenException);
});
});
describe('KantineDatevSettingsDto', () => {
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const run = (value: unknown) =>
pipe.transform(value, { type: 'body', metatype: KantineDatevSettingsDto });
it('akzeptiert Ziffernketten (auch mit fuehrender Null)', async () => {
await expect(
run({ beraterNr: '0123456', mandantNr: '12345', lohnart: '1111' }),
).resolves.toBeDefined();
});
it.each([
[{ beraterNr: '12a', mandantNr: '1', lohnart: '1' }],
[{ beraterNr: '1', mandantNr: '', lohnart: '1' }],
[{ beraterNr: '1', mandantNr: '1', lohnart: '12345678901' }],
[{ beraterNr: '1', mandantNr: '1' }],
[{ beraterNr: 1, mandantNr: '1', lohnart: '1' }],
])('lehnt %j ab', async (body) => {
await expect(run(body)).rejects.toThrow(BadRequestException);
});
});
@@ -0,0 +1,77 @@
import {
BadRequestException,
Body,
Controller,
ForbiddenException,
Get,
Post,
Put,
Req,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { AuthenticatedRequest, UploadedFileLike } from '../auth/types/auth-user';
import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import { KantineDatevService } from './kantine-datev.service';
/**
* `@UseModule('kantine-datev')` auf Klassenebene — Aktivierung UND Freigabe.
* `tenantId` kommt ausschliesslich aus `req.tenantId` (TenantGuard). Lesen,
* Vorschau und Export stehen jedem Benutzer mit Modulzugriff offen; die
* Einstellungen aendern Administratoren und Benutzer mit der Freigabestufe
* Verwalten (`@ModuleManage`, 261002-icv; T-FM5-02). Hochgeladene Dateien
* bleiben im Arbeitsspeicher (multer-Standard), 5 MB Grenze (T-FM5-04).
* Keine `:id`-Routen in diesem Controller.
*/
@Controller('modules/kantine-datev')
@UseModule('kantine-datev')
export class KantineDatevController {
constructor(private readonly service: KantineDatevService) {}
private requireTenantId(req: AuthenticatedRequest): string {
const tenantId = req.tenantId;
if (!tenantId) {
throw new ForbiddenException('Kein Mandantenkontext');
}
return tenantId;
}
@Get('settings')
async getSettings(@Req() req: AuthenticatedRequest) {
return this.service.getSettings(this.requireTenantId(req));
}
@Put('settings')
@ModuleManage('kantine-datev')
async saveSettings(@Req() req: AuthenticatedRequest, @Body() dto: KantineDatevSettingsDto) {
return this.service.saveSettings(this.requireTenantId(req), dto);
}
@Post('preview')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async preview(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.preview(tenantId, file.buffer);
}
@Post('export')
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
async export(
@Req() req: AuthenticatedRequest,
@UploadedFile() file: UploadedFileLike | undefined,
) {
const tenantId = this.requireTenantId(req);
if (!file) {
throw new BadRequestException('Keine Datei hochgeladen');
}
return this.service.export(tenantId, file.buffer);
}
}
@@ -0,0 +1,31 @@
import { Logger, Module, OnModuleInit } from '@nestjs/common';
import { ModuleRegistryModule } from '../module-registry/module-registry.module';
import { ModuleRegistryService } from '../module-registry/module-registry.service';
import { KantineDatevController } from './kantine-datev.controller';
import { seedKantineDatevModule } from './kantine-datev.seed';
import { KantineDatevService } from './kantine-datev.service';
/**
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV pruefen und als
* DATEV-Lohn-ASCII-Datei exportieren. Traegt sich beim Start in die
* Modulverwaltung ein; aktiviert wird per Marktplatz.
*/
@Module({
imports: [ModuleRegistryModule],
controllers: [KantineDatevController],
providers: [KantineDatevService],
})
export class KantineDatevModule implements OnModuleInit {
private readonly logger = new Logger(KantineDatevModule.name);
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
async onModuleInit(): Promise<void> {
try {
await seedKantineDatevModule(this.moduleRegistryService);
this.logger.log('Kantine-DATEV module seeded in registry');
} catch (error) {
this.logger.error('Failed to seed kantine-datev module', error);
}
}
}
@@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
buildKantineExport,
KantineExportError,
processKantineCsv,
} from './kantine-datev.pipeline';
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
const ok = (nr: string, name: string, betrag: string, bis = '31.03.2026') =>
`${nr};${name};1;1,00;1,00;0;0;0;${betrag};01.03.2026;${bis}`;
function csv(lines: string[], eol = '\r\n'): string {
return [HEADER, ...lines].join(eol);
}
describe('processKantineCsv', () => {
it('liest Windows-1252 mit Umlauten und CRLF', () => {
const buf = Buffer.from(
csv([ok('100', 'Müller, Jürgen', '7,94'), ok('200', 'Köhler', '51,5')]),
'latin1',
);
const p = processKantineCsv(buf, SETTINGS);
expect(p.rowCount).toBe(2);
expect(p.abrechnungsMonat).toBe('03/2026');
expect(p.totalCents).toBe(794 + 5150);
expect(p.errors).toEqual([]);
expect(p.canExport).toBe(true);
expect(p.blockedReason).toBeNull();
});
it('liest UTF-8 mit BOM und LF gleich', () => {
const body = csv([ok('100', 'Müller', '7,94')], '\n');
const buf = Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(body, 'utf8')]);
const p = processKantineCsv(buf, SETTINGS);
expect(p.rowCount).toBe(1);
expect(p.errors).toEqual([]);
});
it('zaehlt in der Summe nur Zeilen mit gueltigem Betrag', () => {
const p = processKantineCsv(
Buffer.from(csv([ok('100', 'A', '7,94'), ok('101', 'B', 'abc')])),
SETTINGS,
);
expect(p.totalCents).toBe(794);
expect(p.errors).toEqual([expect.objectContaining({ row: 3, code: 'betragFormat' })]);
expect(p.canExport).toBe(false);
expect(p.blockedReason).toBe('errors');
});
it('meldet zwei Monate als Warnung, nicht als Fehler', () => {
const p = processKantineCsv(
Buffer.from(csv([ok('100', 'A', '1,00'), `101;B;1;1;1;0;0;0;1,00;01.04.2026;30.04.2026`])),
SETTINGS,
);
expect(p.warnings).toHaveLength(1);
expect(p.errors).toEqual([]);
expect(p.abrechnungsMonat).toBe('03/2026');
});
it('meldet eine Datei ohne Datenzeilen mit noRows', () => {
const p = processKantineCsv(Buffer.from(csv([])), SETTINGS);
expect(p.rowCount).toBe(0);
expect(p.errors.map((e) => e.code)).toEqual(['noRows']);
expect(p.canExport).toBe(false);
});
it('sperrt ohne Einstellungen mit settingsMissing', () => {
const p = processKantineCsv(Buffer.from(csv([ok('100', 'A', '1,00')])), null);
expect(p.canExport).toBe(false);
expect(p.blockedReason).toBe('settingsMissing');
});
it('gibt keine Zeileninhalte in der Vorschau zurueck', () => {
const p = processKantineCsv(Buffer.from(csv([ok('100', 'Geheimname', 'x')])), SETTINGS);
expect(JSON.stringify(p)).not.toContain('Geheimname');
});
});
describe('buildKantineExport', () => {
it('erzeugt Dateiname und Base64-Inhalt', () => {
const r = buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), SETTINGS);
expect(r.filename).toBe('LuG_1234567_12345_03_2026.sic');
expect(r.mimeType).toBe('text/plain');
expect(Buffer.from(r.content, 'base64').toString('utf8')).toBe(
'1234567\t12345\t03/2026\t\t\t\t\t\t\t\t\r\n\t100\t\t1111\t-7.94\t\t\t\t\t\t\r\n',
);
});
it('verweigert den Export bei Fehlern', () => {
expect(() => buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS)).toThrow(
KantineExportError,
);
try {
buildKantineExport(Buffer.from(csv([ok('x', 'A', '7,94')])), SETTINGS);
} catch (e) {
expect((e as KantineExportError).code).toBe('hasErrors');
}
});
it('verweigert den Export ohne Einstellungen', () => {
try {
buildKantineExport(Buffer.from(csv([ok('100', 'A', '7,94')])), null);
expect.unreachable();
} catch (e) {
expect((e as KantineExportError).code).toBe('settingsMissing');
}
});
});
@@ -0,0 +1,134 @@
import { decodeCsvText } from '../accounting/decode-csv-text';
import { parseKantinenCsv } from './kantine-csv.parser';
import { isValidGermanNumber, validateKantinenData } from './kantine-csv.validator';
import {
buildKantineExportFilename,
generateDatevOutput,
qualityCheck,
transformToDatevRecords,
} from './kantine-datev.transformer';
import type {
FileResponse,
KantineDatevSettings,
KantinenRawRow,
KantinePreview,
ValidationError,
ValidationResult,
} from './kantine-datev.types';
/**
* Verarbeitungskette der Kantinenabrechnung (quick-261002-fm5):
* dekodieren -> parsen -> validieren -> Summe -> Vorschau bzw. Export.
*
* Reine Funktionen ohne Datenbank, Datei oder Protokollausgabe: hochgeladene
* Zeilen (Namen, Personalnummern) verlassen den Arbeitsspeicher nie.
*/
/** Grund, warum ein Export abgelehnt wurde. */
export type KantineExportErrorCode = 'settingsMissing' | 'hasErrors' | 'qualityCheckFailed';
export class KantineExportError extends Error {
constructor(
readonly code: KantineExportErrorCode,
message: string,
readonly errors: ValidationError[] = [],
) {
super(message);
this.name = 'KantineExportError';
}
}
interface Analysis {
rows: KantinenRawRow[];
errors: ValidationError[];
validation: ValidationResult;
}
function analyze(buffer: Buffer): Analysis {
const text = decodeCsvText(buffer);
const parsed = parseKantinenCsv(text);
const validation = validateKantinenData(parsed.rows);
const errors = [...parsed.errors, ...validation.errors].sort((a, b) => a.row - b.row);
if (parsed.rows.length === 0 && parsed.errors.length === 0) {
errors.push({
row: 1,
field: 'datei',
code: 'noRows',
message: 'Die Datei enthält keine Datenzeilen.',
});
}
return { rows: parsed.rows, errors, validation };
}
/** Summe der Betraege in Cent; nur Zeilen, deren Betrag das Format besteht. */
function sumCents(rows: KantinenRawRow[]): number {
let total = 0;
for (const row of rows) {
if (!isValidGermanNumber(row.betrag)) continue;
total += Math.round(Number(row.betrag.trim().replace(',', '.')) * 100);
}
return total;
}
export function processKantineCsv(
buffer: Buffer,
settings: KantineDatevSettings | null,
): KantinePreview {
const { rows, errors, validation } = analyze(buffer);
let blockedReason: KantinePreview['blockedReason'] = null;
if (!settings) {
blockedReason = 'settingsMissing';
} else if (errors.length > 0) {
blockedReason = 'errors';
}
return {
rowCount: rows.length,
abrechnungsMonat: validation.abrechnungsMonat,
totalCents: sumCents(rows),
errors,
warnings: validation.warnings,
canExport: blockedReason === null,
blockedReason,
};
}
export function buildKantineExport(
buffer: Buffer,
settings: KantineDatevSettings | null,
): FileResponse {
if (!settings) {
throw new KantineExportError(
'settingsMissing',
'Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.',
);
}
const { rows, errors, validation } = analyze(buffer);
if (errors.length > 0 || !validation.abrechnungsMonat) {
throw new KantineExportError(
'hasErrors',
'Die Datei enthält Fehler und kann nicht exportiert werden.',
errors,
);
}
const records = transformToDatevRecords(rows);
const output = generateDatevOutput(records, validation.abrechnungsMonat, settings);
const check = qualityCheck(output);
if (!check.passed) {
throw new KantineExportError(
'qualityCheckFailed',
`Qualitätsprüfung fehlgeschlagen: ${check.errors.join('; ')}`,
);
}
return {
filename: buildKantineExportFilename(settings, validation.abrechnungsMonat),
content: Buffer.from(output, 'utf8').toString('base64'),
mimeType: 'text/plain',
};
}
@@ -0,0 +1,23 @@
import { ModuleRegistryService } from '../module-registry/module-registry.service';
/**
* Traegt das Modul "Kantinenabrechnung" in die Modulverwaltung ein
* (quick-261002-fm5). `isSystem: true` legt den Eintrag an, aktiviert ihn aber
* NICHT je Mandant — der Administrator aktiviert ueber den Marktplatz und
* erteilt die Freigabe.
*/
export async function seedKantineDatevModule(
moduleRegistryService: ModuleRegistryService,
): Promise<void> {
await moduleRegistryService.seedModule({
slug: 'kantine-datev',
name: 'Kantinenabrechnung',
version: '1.0.0',
category: 'accounting',
description: {
de: 'Kantinen-CSV prüfen und als DATEV-Lohndatei (ASCII) für die Gehaltsabrechnung exportieren',
en: 'Check canteen CSV files and export them as a DATEV payroll ASCII file',
},
isSystem: true,
});
}
@@ -0,0 +1,83 @@
import { BadRequestException } from '@nestjs/common';
import { describe, expect, it, vi } from 'vitest';
vi.mock('../prisma/prisma-tenant.extension', () => ({
forTenant: vi.fn((p: unknown) => p),
}));
import { forTenant } from '../prisma/prisma-tenant.extension';
import { KantineDatevService } from './kantine-datev.service';
const HEADER = 'PersNr;Name;Menge;EK;Netto;ZuAb;MwSt;Zuschuss;Betrag;Von;Bis';
const GOOD = Buffer.from(
[HEADER, '100;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\r\n'),
);
function setup(row: Record<string, string | null> | null = null) {
const kantineDatevConfig = {
findUnique: vi.fn(async () => row),
upsert: vi.fn(async ({ create }: any) => create),
};
return { prisma: { kantineDatevConfig }, kantineDatevConfig };
}
describe('KantineDatevService — Einstellungen', () => {
it('liefert ohne Zeile leere Werte und configured=false', async () => {
const { prisma } = setup(null);
const res = await new KantineDatevService(prisma as any).getSettings('t1');
expect(res).toEqual({ beraterNr: null, mandantNr: null, lohnart: null, configured: false });
expect(forTenant).toHaveBeenCalledWith(prisma, 't1');
});
it('configured=false, solange ein Feld fehlt', async () => {
const { prisma } = setup({ beraterNr: '1', mandantNr: null, lohnart: '3' });
expect((await new KantineDatevService(prisma as any).getSettings('t1')).configured).toBe(false);
});
it('speichert per upsert auf tenantId (aus dem Argument)', async () => {
const { prisma, kantineDatevConfig } = setup();
const res = await new KantineDatevService(prisma as any).saveSettings('t1', {
beraterNr: '1234567',
mandantNr: '12345',
lohnart: '1111',
});
expect(kantineDatevConfig.upsert).toHaveBeenCalledWith(
expect.objectContaining({ where: { tenantId: 't1' } }),
);
expect(res.configured).toBe(true);
});
});
describe('KantineDatevService — Vorschau und Export', () => {
it('Vorschau ohne Einstellungen sperrt mit settingsMissing', async () => {
const { prisma } = setup(null);
const p = await new KantineDatevService(prisma as any).preview('t1', GOOD);
expect(p.blockedReason).toBe('settingsMissing');
expect(p.rowCount).toBe(1);
});
it('Export ohne Einstellungen -> 400 mit code settingsMissing', async () => {
const { prisma } = setup(null);
const err: any = await new KantineDatevService(prisma as any)
.export('t1', GOOD)
.catch((e) => e);
expect(err).toBeInstanceOf(BadRequestException);
expect(err.getResponse().code).toBe('settingsMissing');
});
it('Export mit Fehlern -> 400 mit code hasErrors und Fehlerliste', async () => {
const { prisma } = setup({ beraterNr: '1', mandantNr: '2', lohnart: '3' });
const bad = Buffer.from(
[HEADER, 'x;Max;1;1,00;1,00;0;0;0;7,94;01.03.2026;31.03.2026'].join('\n'),
);
const err: any = await new KantineDatevService(prisma as any).export('t1', bad).catch((e) => e);
expect(err.getResponse().code).toBe('hasErrors');
expect(err.getResponse().errors).toHaveLength(1);
});
it('Export mit Einstellungen liefert Datei', async () => {
const { prisma } = setup({ beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' });
const res = await new KantineDatevService(prisma as any).export('t1', GOOD);
expect(res.filename).toBe('LuG_1234567_12345_03_2026.sic');
});
});
@@ -0,0 +1,84 @@
import { BadRequestException, Injectable, UnprocessableEntityException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension';
import type { KantineDatevSettingsDto } from './dto/kantine-datev-settings.dto';
import {
buildKantineExport,
KantineExportError,
processKantineCsv,
} from './kantine-datev.pipeline';
import type { FileResponse, KantineDatevSettings, KantinePreview } from './kantine-datev.types';
export interface KantineSettingsResponse {
beraterNr: string | null;
mandantNr: string | null;
lohnart: string | null;
configured: boolean;
}
/**
* Kantinenabrechnung (quick-261002-fm5). Die Einstellungen liegen je Mandant
* in `KantineDatevConfig` (mandantengebunden); die hochgeladene CSV wird nur
* im Arbeitsspeicher verarbeitet und weder gespeichert noch protokolliert.
*/
@Injectable()
export class KantineDatevService {
constructor(private readonly prisma: PrismaService) {}
async getSettings(tenantId: string): Promise<KantineSettingsResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const row = await tenantPrisma.kantineDatevConfig.findUnique({ where: { tenantId } });
const beraterNr = row?.beraterNr ?? null;
const mandantNr = row?.mandantNr ?? null;
const lohnart = row?.lohnart ?? null;
return {
beraterNr,
mandantNr,
lohnart,
configured: Boolean(beraterNr && mandantNr && lohnart),
};
}
async saveSettings(
tenantId: string,
dto: KantineDatevSettingsDto,
): Promise<KantineSettingsResponse> {
const tenantPrisma = forTenant(this.prisma, tenantId);
const data = { beraterNr: dto.beraterNr, mandantNr: dto.mandantNr, lohnart: dto.lohnart };
await tenantPrisma.kantineDatevConfig.upsert({
where: { tenantId },
create: { tenantId, ...data },
update: data,
});
return { ...data, configured: true };
}
private async loadConfigured(tenantId: string): Promise<KantineDatevSettings | null> {
const s = await this.getSettings(tenantId);
if (!s.configured || !s.beraterNr || !s.mandantNr || !s.lohnart) return null;
return { beraterNr: s.beraterNr, mandantNr: s.mandantNr, lohnart: s.lohnart };
}
async preview(tenantId: string, buffer: Buffer): Promise<KantinePreview> {
return processKantineCsv(buffer, await this.loadConfigured(tenantId));
}
async export(tenantId: string, buffer: Buffer): Promise<FileResponse> {
const settings = await this.loadConfigured(tenantId);
try {
return buildKantineExport(buffer, settings);
} catch (error) {
if (error instanceof KantineExportError) {
if (error.code === 'qualityCheckFailed') {
throw new UnprocessableEntityException({ code: error.code, message: error.message });
}
throw new BadRequestException({
code: error.code,
message: error.message,
errors: error.errors,
});
}
throw error;
}
}
}
@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest';
import {
buildKantineExportFilename,
generateDatevOutput,
qualityCheck,
transformBetrag,
} from './kantine-datev.transformer';
const SETTINGS = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111' };
describe('transformBetrag', () => {
it('macht den Betrag negativ mit Punkt und zwei Nachkommastellen', () => {
expect(transformBetrag('7,94')).toBe('-7.94');
expect(transformBetrag('51,5')).toBe('-51.50');
expect(transformBetrag('0,00')).toBe('-0.00');
});
});
describe('generateDatevOutput', () => {
const out = generateDatevOutput(
[
{ personalNr: '100', betrag: '-7.94' },
{ personalNr: '200', betrag: '-51.50' },
],
'03/2026',
SETTINGS,
);
it('schreibt den Kopf aus den Einstellungen mit 8 leeren Spalten', () => {
const lines = out.split('\r\n');
expect(lines[0]).toBe('1234567\t12345\t03/2026\t\t\t\t\t\t\t\t');
});
it('schreibt Detailzeilen mit Lohnart und Betrag', () => {
const lines = out.split('\r\n');
expect(lines[1]).toBe('\t100\t\t1111\t-7.94\t\t\t\t\t\t');
expect(lines[2]).toBe('\t200\t\t1111\t-51.50\t\t\t\t\t\t');
});
it('hat in jeder Zeile 11 Spalten, CRLF und ein abschliessendes CRLF', () => {
expect(out.endsWith('\r\n')).toBe(true);
expect(out.replace(/\r\n/g, '')).not.toContain('\n');
for (const line of out.split('\r\n').slice(0, -1)) {
expect(line.split('\t')).toHaveLength(11);
}
});
it('besteht die Qualitaetspruefung', () => {
expect(qualityCheck(out)).toEqual({ passed: true, errors: [] });
});
});
describe('qualityCheck', () => {
it('lehnt eine Datei nur mit LF ab', () => {
const r = qualityCheck('a\tb\t\t\t\t\t\t\t\t\t\n');
expect(r.passed).toBe(false);
expect(r.errors).toContain('Datei endet nicht mit CRLF');
expect(r.errors).toContain('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
});
it('lehnt eine Zeile mit 10 Spalten ab', () => {
const r = qualityCheck('a\t\t\t\t\t\t\t\t\t\r\n');
expect(r.passed).toBe(false);
expect(r.errors[0]).toBe('Zeile 1: 10 Spalten gefunden, 11 erwartet');
});
it('lehnt einen positiven Betrag ab', () => {
const r = qualityCheck(`k\t\t\t\t\t\t\t\t\t\t\r\n\t1\t\t1111\t7.94\t\t\t\t\t\t\r\n`);
expect(r.passed).toBe(false);
expect(r.errors[0]).toContain('Betrag "7.94" ist nicht im Format -X.XX');
});
});
describe('buildKantineExportFilename', () => {
it('folgt LuG_<Berater>_<Mandant>_<MM>_<YYYY>.sic', () => {
expect(buildKantineExportFilename(SETTINGS, '03/2026')).toBe('LuG_1234567_12345_03_2026.sic');
});
});
@@ -0,0 +1,111 @@
import type { DatevRecord, KantineDatevSettings, KantinenRawRow } from './kantine-datev.types';
const SPALTEN_ANZAHL = 11;
const TAB = '\t';
const CRLF = '\r\n';
/**
* Transformiert einen Betrag gemaess DATEV-Regel: Komma -> Punkt, immer
* negativ, exakt 2 Nachkommastellen. "7,94" -> "-7.94", "51,5" -> "-51.50".
*/
export function transformBetrag(betrag: string): string {
const cleaned = betrag.trim().replace(',', '.');
const num = parseFloat(cleaned);
const absValue = Math.abs(num);
return `-${absValue.toFixed(2)}`;
}
export function transformToDatevRecords(rows: KantinenRawRow[]): DatevRecord[] {
return rows.map((row) => ({
personalNr: row.personalNr.trim(),
betrag: transformBetrag(row.betrag),
}));
}
/**
* Generiert die komplette DATEV-Lohn-ASCII-Datei.
*
* Kopf: Beraternr TAB Mandantennr TAB MM/YYYY + 8 leere Spalten
* Detail: TAB PersonalNr TAB TAB Lohnart TAB -Betrag + 6 leere Spalten
*
* Exakt 11 Spalten je Zeile, CRLF-Zeilenenden, die Datei endet mit CRLF.
* Berater-, Mandantennummer und Lohnart kommen aus den Einstellungen des
* Mandanten, nicht aus festen Werten.
*/
export function generateDatevOutput(
records: DatevRecord[],
abrechnungsMonat: string,
settings: KantineDatevSettings,
): string {
const lines: string[] = [];
lines.push(
[settings.beraterNr, settings.mandantNr, abrechnungsMonat, '', '', '', '', '', '', '', ''].join(
TAB,
),
);
for (const record of records) {
lines.push(
['', record.personalNr, '', settings.lohnart, record.betrag, '', '', '', '', '', ''].join(
TAB,
),
);
}
return lines.join(CRLF) + CRLF;
}
/**
* Qualitaetspruefung der erzeugten Ausgabe: 11 Spalten je Zeile, nur CRLF,
* Datei endet mit CRLF, alle Betraege negativ mit 2 Nachkommastellen.
*/
export function qualityCheck(output: string): { passed: boolean; errors: string[] } {
const errors: string[] = [];
if (!output.endsWith(CRLF)) {
errors.push('Datei endet nicht mit CRLF');
}
const ohneCarriageReturn = output.replace(/\r\n/g, '');
if (ohneCarriageReturn.includes('\n')) {
errors.push('Datei enthaelt einzelne LF-Zeilenenden (nur CRLF erlaubt)');
}
const zeilen = output.split(CRLF);
const inhaltZeilen = zeilen.slice(0, -1);
if (inhaltZeilen.length === 0) {
errors.push('Datei enthaelt keine Zeilen');
return { passed: false, errors };
}
for (let i = 0; i < inhaltZeilen.length; i++) {
const spalten = inhaltZeilen[i].split(TAB);
if (spalten.length !== SPALTEN_ANZAHL) {
errors.push(`Zeile ${i + 1}: ${spalten.length} Spalten gefunden, ${SPALTEN_ANZAHL} erwartet`);
}
}
const betragRegex = /^-\d+\.\d{2}$/;
for (let i = 1; i < inhaltZeilen.length; i++) {
const spalten = inhaltZeilen[i].split(TAB);
const betrag = spalten[4];
if (betrag && !betragRegex.test(betrag)) {
errors.push(
`Zeile ${i + 1}: Betrag "${betrag}" ist nicht im Format -X.XX (negativ, 2 Nachkommastellen)`,
);
}
}
return { passed: errors.length === 0, errors };
}
/** Dateiname des Downloads: LuG_<Beraternr>_<Mandantennr>_<MM>_<YYYY>.sic */
export function buildKantineExportFilename(
settings: KantineDatevSettings,
abrechnungsMonat: string,
): string {
const [monat, jahr] = abrechnungsMonat.split('/');
return `LuG_${settings.beraterNr}_${settings.mandantNr}_${monat}_${jahr}.sic`;
}
@@ -0,0 +1,93 @@
/**
* Typen der Kantinenabrechnung (quick-261002-fm5). Portiert aus der
* Desktop-Vorlage; jeder Fehler und jede Warnung traegt zusaetzlich eine
* stabile Kennung (`code`), damit die Oberflaeche den Text uebersetzen kann,
* waehrend `message` den deutschen Originaltext behaelt.
*/
/** Rohe CSV-Zeile nach dem Parsen. */
export interface KantinenRawRow {
personalNr: string;
name: string;
menge: string;
ekPreis: string;
netto: string;
zuAbschlag: string;
mwst: string;
zuschuss: string;
betrag: string;
abrechnungVon: string;
abrechnungBis: string;
/** 1-basierte Zeilennummer in der Datei (fuer Fehlermeldungen). */
line?: number;
}
/** Validierter und transformierter Datensatz. */
export interface DatevRecord {
personalNr: string;
/** z. B. "-51.50" (immer negativ, Punkt, 2 Nachkommastellen) */
betrag: string;
}
export type KantineErrorCode =
| 'headerMissing'
| 'headerColumns'
| 'columnCount'
| 'personalNrMissing'
| 'personalNrNotNumeric'
| 'betragMissing'
| 'betragFormat'
| 'vonMissing'
| 'vonFormat'
| 'bisMissing'
| 'bisFormat'
| 'multiMonthRange'
| 'noRows';
export interface ValidationError {
row: number;
field: string;
code: KantineErrorCode;
message: string;
}
export interface ValidationWarning {
code: 'multipleMonths';
message: string;
params: { months: string[] };
}
export interface ValidationResult {
isValid: boolean;
errors: ValidationError[];
warnings: ValidationWarning[];
/** Format "MM/YYYY" */
abrechnungsMonat: string | null;
}
/** Nummern, die der Administrator je Mandant hinterlegt. */
export interface KantineDatevSettings {
beraterNr: string;
mandantNr: string;
lohnart: string;
}
export type KantineBlockedReason = 'settingsMissing' | 'errors';
export interface KantinePreview {
rowCount: number;
abrechnungsMonat: string | null;
/** Summe der gueltigen Betraege in Cent. */
totalCents: number;
errors: ValidationError[];
warnings: ValidationWarning[];
canExport: boolean;
blockedReason: KantineBlockedReason | null;
}
export interface FileResponse {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}
@@ -30,8 +30,8 @@ const BOUND_MODEL_NAMES = ['tenantModuleActivation', 'moduleGrant'];
function makeFakePrisma(opts: { function makeFakePrisma(opts: {
activations?: { moduleId: string }[]; activations?: { moduleId: string }[];
directGrants?: { moduleId: string }[]; directGrants?: { moduleId: string; level?: string }[];
groupGrants?: { moduleId: string }[]; groupGrants?: { moduleId: string; level?: string }[];
} = {}) { } = {}) {
const activations = opts.activations ?? []; const activations = opts.activations ?? [];
const directGrants = opts.directGrants ?? []; const directGrants = opts.directGrants ?? [];
@@ -250,6 +250,108 @@ describe('ModuleAccessService.getAccessibleModuleIds — USER (Grant-Auflösung,
}); });
}); });
describe('ModuleAccessService.getModuleAccessLevels — Freigabestufe (261002-icv)', () => {
it('Direkt-Grant USE: Map {mod-1: USE}', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1', level: 'USE' }],
});
const service = new ModuleAccessService(prisma as any);
const result = await service.getModuleAccessLevels('t1', 'user-1', 'USER');
expect(result).toEqual(new Map([['mod-1', 'USE']]));
});
it('Direkt-Grant USE plus Gruppen-Grant MANAGE auf dasselbe Modul: MANAGE gewinnt (L-02)', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1', level: 'USE' }],
groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
});
it('Gruppen-Grant USE plus Direkt-Grant MANAGE: ebenfalls MANAGE (Reihenfolge egal)', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
groupGrants: [{ moduleId: 'mod-1', level: 'USE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
});
it('MANAGE nur über eine Gruppe: MANAGE', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
groupGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('MANAGE');
});
it('MANAGE-Grant auf ein deaktiviertes Modul: fehlt in der Map (T-icv-05)', async () => {
const prisma = makeFakePrisma({
activations: [],
directGrants: [{ moduleId: 'mod-1', level: 'MANAGE' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).has('mod-1')).toBe(false);
});
it.each(['ADMIN', 'SUPER_ADMIN'] as const)(
'%s: jedes aktive Modul mit MANAGE, ohne Grant-Abfragen (L-03)',
async (role) => {
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }] });
const service = new ModuleAccessService(prisma as any);
const result = await service.getModuleAccessLevels('t1', 'a-1', role);
expect(result).toEqual(new Map([['mod-1', 'MANAGE'], ['mod-2', 'MANAGE']]));
expect(prisma.moduleGrant.findMany).not.toHaveBeenCalled();
},
);
it('ohne Grants: leere Map', async () => {
const prisma = makeFakePrisma({ activations: [{ moduleId: 'mod-1' }] });
const service = new ModuleAccessService(prisma as any);
expect(await service.getModuleAccessLevels('t1', 'user-1', 'USER')).toEqual(new Map());
});
it('Zeilen ohne level-Feld (alte Mocks) zählen als USE', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }],
directGrants: [{ moduleId: 'mod-1' }],
});
const service = new ModuleAccessService(prisma as any);
expect((await service.getModuleAccessLevels('t1', 'user-1', 'USER')).get('mod-1')).toBe('USE');
});
it('findAccessibleModules liefert canManage je Zeile', async () => {
const prisma = makeFakePrisma({
activations: [{ moduleId: 'mod-1' }, { moduleId: 'mod-2' }],
directGrants: [
{ moduleId: 'mod-1', level: 'MANAGE' },
{ moduleId: 'mod-2', level: 'USE' },
],
});
const service = new ModuleAccessService(prisma as any);
const rows = await service.findAccessibleModules('t1', 'user-1', 'USER');
expect(rows.find((r: any) => r.id === 'mod-1')?.canManage).toBe(true);
expect(rows.find((r: any) => r.id === 'mod-2')?.canManage).toBe(false);
});
});
describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => { describe('ModuleAccessService.findAccessibleModules — ordering (PERM-04)', () => {
it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => { it('sortiert die zugänglichen Module deterministisch nach Namen aufsteigend', async () => {
const prisma = makeFakePrisma({ const prisma = makeFakePrisma({
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Role } from '@prisma/client'; import { ModuleGrantLevel, Role } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { forTenant } from '../prisma/prisma-tenant.extension'; import { forTenant } from '../prisma/prisma-tenant.extension';
@@ -10,37 +10,44 @@ import { forTenant } from '../prisma/prisma-tenant.extension';
* `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API * `getAccessibleModuleIds` auf, damit Sidebar, Modulseiten und API
* niemals auseinanderdriften können — genau das Sicherheitsloch, das * niemals auseinanderdriften können — genau das Sicherheitsloch, das
* D-01 strukturell verhindert. * D-01 strukturell verhindert.
*
* Seit 261002-icv traegt jede Freigabe eine Stufe (Benutzen / Verwalten).
* `getModuleAccessLevels` ist die einzige Aufloesung fuer Zugriff UND Stufe;
* `getAccessibleModuleIds` ist nur noch deren Schluesselmenge.
*/ */
@Injectable() @Injectable()
export class ModuleAccessService { export class ModuleAccessService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
/** /**
* Berechnet die Menge der moduleIds, auf die dieser Benutzer Zugriff hat. * Berechnet je Modul, auf das dieser Benutzer Zugriff hat, die wirksame
* Freigabestufe (261002-icv). Einzige Aufloesung fuer Zugriff UND Stufe.
* *
* D-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Prüfung — sie erhalten alle * D-03/L-03: ADMIN/SUPER_ADMIN umgehen jede Grant-Pruefung — sie erhalten
* mandantenweit aktiven Module ihres eigenen Mandanten. Diese Rolle * alle aktiven Module ihres eigenen Mandanten mit Stufe MANAGE. Diese Rolle
* kommt ausschließlich aus dem JWT (Aufrufer), nie aus Body/Params — * kommt ausschliesslich aus dem JWT (Aufrufer), nie aus Body/Params —
* der Kurzschluss kann daher keine Module eines fremden Mandanten * der Kurzschluss kann daher keine Module eines fremden Mandanten
* liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10). * liefern, weil `tenantId` ebenfalls aus dem JWT stammt (T-15-10).
* *
* Für alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants * Fuer alle anderen Rollen (USER): Vereinigungsmenge aus Direkt-Grants
* und Grants über Gruppenmitgliedschaften, geschnitten mit den * und Grants ueber Gruppenmitgliedschaften, geschnitten mit den
* mandantenweit aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes * aktiven Modulen (D-02 — ein Grant auf ein deaktiviertes Modul gewaehrt
* Modul gewährt keinen Zugriff). Die Gruppen-Query ist eine einzige * keinen Zugriff, auch keine Verwaltungsstufe). Besteht Zugriff ueber
* verschachtelte Prisma-Query (`group: { memberships: { some: { userId } } }`) * mehrere Wege, gilt die hoehere Stufe (MANAGE gewinnt, L-02); ein Wert
* statt einer Schleife über die Gruppen des Benutzers — sonst entsteht * ausser 'MANAGE' (z. B. eine Zeile ohne `level`) zaehlt als USE. Die
* ein N+1 pro geschütztem Endpoint. * Gruppen-Query ist eine einzige verschachtelte Prisma-Query statt einer
* Schleife ueber die Gruppen des Benutzers — sonst entsteht ein N+1 pro
* geschuetztem Endpoint.
* *
* Rein lesend, kein Caching über Request-Grenzen hinweg (D-09) — ein * Rein lesend, kein Caching ueber Request-Grenzen hinweg (D-09) — ein
* Freigabe-Entzug wirkt bei der nächsten Anfrage. * Freigabe-Entzug wirkt bei der naechsten Anfrage.
*/ */
async getAccessibleModuleIds( async getModuleAccessLevels(
tenantId: string, tenantId: string,
userId: string, userId: string,
role: Role, role: Role,
): Promise<Set<string>> { ): Promise<Map<string, ModuleGrantLevel>> {
// EIN gebundener Klient fuer alle vier mandantengebundenen Zugriffe // EIN gebundener Klient fuer alle mandantengebundenen Zugriffe
// dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge) // dieser Methode (Kurzschlusszweig, Direktweg, Gruppenweg, Schnittmenge)
// — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die // — nicht ein Klient je Modellzugriff (260910-exd, Aufgabe 2). Die
// bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen: // bestehenden `where`-Filter mit tenantId bleiben ZUSAETZLICH stehen:
@@ -61,46 +68,80 @@ export class ModuleAccessService {
where: { tenantId, isActive: true }, where: { tenantId, isActive: true },
select: { moduleId: true }, select: { moduleId: true },
}); });
return new Set(activations.map((a: { moduleId: string }) => a.moduleId)); return new Map(
activations.map((a: { moduleId: string }) => [a.moduleId, ModuleGrantLevel.MANAGE]),
);
} }
const [direct, viaGroup] = await Promise.all([ const [direct, viaGroup] = await Promise.all([
tenantPrisma.moduleGrant.findMany({ tenantPrisma.moduleGrant.findMany({
where: { tenantId, userId }, where: { tenantId, userId },
select: { moduleId: true }, select: { moduleId: true, level: true },
}), }),
tenantPrisma.moduleGrant.findMany({ tenantPrisma.moduleGrant.findMany({
where: { tenantId, group: { memberships: { some: { userId } } } }, where: { tenantId, group: { memberships: { some: { userId } } } },
select: { moduleId: true }, select: { moduleId: true, level: true },
}), }),
]); ]);
const grantedIds = [...direct, ...viaGroup].map((g: { moduleId: string }) => g.moduleId);
if (grantedIds.length === 0) { const granted = new Map<string, ModuleGrantLevel>();
return new Set(); for (const g of [...direct, ...viaGroup] as Array<{
moduleId: string;
level?: ModuleGrantLevel;
}>) {
const level =
g.level === ModuleGrantLevel.MANAGE ? ModuleGrantLevel.MANAGE : ModuleGrantLevel.USE;
if (level === ModuleGrantLevel.MANAGE || !granted.has(g.moduleId)) {
granted.set(g.moduleId, level);
}
}
if (granted.size === 0) {
return new Map();
} }
const activations = await tenantPrisma.tenantModuleActivation.findMany({ const activations = await tenantPrisma.tenantModuleActivation.findMany({
where: { where: {
tenantId, tenantId,
isActive: true, isActive: true,
moduleId: { in: grantedIds }, moduleId: { in: [...granted.keys()] },
}, },
select: { moduleId: true }, select: { moduleId: true },
}); });
return new Set(activations.map((a: { moduleId: string }) => a.moduleId)); const result = new Map<string, ModuleGrantLevel>();
for (const a of activations as Array<{ moduleId: string }>) {
const level = granted.get(a.moduleId);
if (level) result.set(a.moduleId, level);
}
return result;
}
/**
* Menge der moduleIds, auf die dieser Benutzer Zugriff hat — die
* Schluesselmenge von `getModuleAccessLevels` (Signatur unveraendert,
* Verbraucher: Guard-Altpfade, Katalog, Dashboard).
*/
async getAccessibleModuleIds(
tenantId: string,
userId: string,
role: Role,
): Promise<Set<string>> {
const levels = await this.getModuleAccessLevels(tenantId, userId, role);
return new Set(levels.keys());
} }
/** /**
* Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer * Lädt die vollständigen Module-Datensätze, auf die dieser Benutzer
* Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die * Zugriff hat, sortiert nach Name. Bedient `GET /modules/active` — die
* explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg * explizite Sortierung hält die Sidebar-Reihenfolge über Aufrufe hinweg
* stabil. * stabil. Jede Zeile traegt `canManage` (261002-icv): wahr bei Freigabestufe
* Verwalten (Administratoren: immer) — nur zur Anzeige, bindend bleibt der
* ModuleGuard.
*/ */
async findAccessibleModules(tenantId: string, userId: string, role: Role) { async findAccessibleModules(tenantId: string, userId: string, role: Role) {
const accessibleIds = await this.getAccessibleModuleIds(tenantId, userId, role); const levels = await this.getModuleAccessLevels(tenantId, userId, role);
if (accessibleIds.size === 0) { if (levels.size === 0) {
return []; return [];
} }
@@ -111,10 +152,14 @@ export class ModuleAccessService {
// Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer // Tabelle eine Regel gibt — dann verschwaende der gesamte Katalog fuer
// jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als // jeden Mandanten. Diese Bedingung steht hier als Bedingung, nicht als
// heute beobachtbare Tatsache. // heute beobachtbare Tatsache.
return this.prisma.module.findMany({ const rows = await this.prisma.module.findMany({
where: { id: { in: [...accessibleIds] } }, where: { id: { in: [...levels.keys()] } },
orderBy: { name: 'asc' }, orderBy: { name: 'asc' },
}); });
return rows.map((row) => ({
...row,
canManage: levels.get(row.id) === ModuleGrantLevel.MANAGE,
}));
} }
/** /**
@@ -0,0 +1,100 @@
import 'reflect-metadata';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { Role } from '@prisma/client';
import { describe, expect, it } from 'vitest';
import { ROLES_KEY } from '../auth/decorators/roles.decorator';
import { DkvController } from '../dkv/dkv.controller';
import { ModuleGrantsController } from '../groups/module-grants.controller';
import { HandelswareDatevController } from '../handelsware-datev/handelsware-datev.controller';
import { KantineDatevController } from '../kantine-datev/kantine-datev.controller';
import { ProxmoxController } from '../proxmox/proxmox.controller';
import { TendersController } from '../tenders/tenders.controller';
import { ModuleRegistryController } from './module-registry.controller';
import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard } from './module.guard';
/**
* Metadaten-Beweis für die Freigabestufe Verwalten (261002-icv, L-04/L-09):
* welche Handler auf `@ModuleManage` umgestellt wurden und welche bewusst
* Administratoren vorbehalten bleiben (T-icv-01/06/07/08). Reine Metadaten —
* kein Nest-Start, keine Datenbank.
*/
const ADMIN_ONLY = [Role.ADMIN, Role.SUPER_ADMIN];
function methodsOf(controller: { prototype: object }): string[] {
return Object.getOwnPropertyNames(controller.prototype).filter(
(name) => name !== 'constructor' && typeof (controller.prototype as any)[name] === 'function',
);
}
function handler(controller: { prototype: object }, name: string) {
return (controller.prototype as any)[name];
}
function expectManage(controller: { prototype: object }, name: string, slug: string) {
const fn = handler(controller, name);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn), `${name} MODULE_MANAGE_KEY`).toBe(true);
expect(Reflect.getMetadata(MODULE_SLUG_KEY, fn), `${name} MODULE_SLUG_KEY`).toBe(slug);
expect(Reflect.getMetadata(GUARDS_METADATA, fn), `${name} guards`).toContain(ModuleGuard);
expect(Reflect.getMetadata(ROLES_KEY, fn), `${name} ROLES_KEY`).toBeUndefined();
}
function expectAdminOnly(controller: { prototype: object }, name: string) {
const fn = handler(controller, name);
expect(Reflect.getMetadata(ROLES_KEY, fn), `${name} ROLES_KEY`).toEqual(ADMIN_ONLY);
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn), `${name} MODULE_MANAGE_KEY`).toBeUndefined();
}
describe('Umgestellte Handler (Verwalten)', () => {
it('DkvController: ganze Klasse Verwalten, kein Handler trägt @Roles', () => {
expect(Reflect.getMetadata(MODULE_SLUG_KEY, DkvController)).toBe('dkv-fleet');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, DkvController)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, DkvController)).toContain(ModuleGuard);
const names = methodsOf(DkvController).filter((n) => Reflect.hasMetadata('path', handler(DkvController, n)));
expect(names.length).toBe(11);
for (const name of names) {
expect(Reflect.getMetadata(ROLES_KEY, handler(DkvController, name)), name).toBeUndefined();
}
});
it.each(['create', 'update', 'remove', 'poll', 'test', 'testDraft'])(
'ProxmoxController.%s verlangt Verwalten für proxmox',
(name) => {
expectManage(ProxmoxController, name, 'proxmox');
},
);
it('ProxmoxController.list bleibt auf Benutzen-Ebene', () => {
const fn = handler(ProxmoxController, 'list');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, fn)).toBeUndefined();
expect(Reflect.getMetadata(ROLES_KEY, fn)).toBeUndefined();
});
it('KantineDatevController.saveSettings und HandelswareDatevController.saveSettings verlangen Verwalten', () => {
expectManage(KantineDatevController, 'saveSettings', 'kantine-datev');
expectManage(HandelswareDatevController, 'saveSettings', 'handelsware-datev');
});
});
describe('Bewusst nur für Administratoren (T-icv-01, T-icv-07)', () => {
it.each(['getSourceConfig', 'saveSourceConfig', 'pollNow'])(
'TendersController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(TendersController, name);
},
);
it.each(['matrix', 'userAccess', 'create', 'remove'])(
'ModuleGrantsController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleGrantsController, name);
},
);
it.each(['activate', 'deactivate'])(
'ModuleRegistryController.%s bleibt @Roles(ADMIN, SUPER_ADMIN)',
(name) => {
expectAdminOnly(ModuleRegistryController, name);
},
);
});
@@ -1,6 +1,7 @@
import { ForbiddenException } from '@nestjs/common'; import { ForbiddenException } from '@nestjs/common';
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { ModuleGuard } from './module.guard'; import { MODULE_MANAGE_KEY, MODULE_SLUG_KEY, ModuleGuard, ModuleManage } from './module.guard';
/** /**
* ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus * ModuleGuard.canActivate — deckt die vollständige Behavior-Liste aus
@@ -18,26 +19,28 @@ function makeContext(request: any) {
} as any; } as any;
} }
function makeReflector(slug: string | undefined) { function makeReflector(slug: string | undefined, manage = false) {
return { getAllAndOverride: vi.fn(() => slug) } as any; return {
getAllAndOverride: vi.fn((key: string) => (key === MODULE_MANAGE_KEY ? manage : slug)),
} as any;
} }
describe('ModuleGuard.canActivate', () => { describe('ModuleGuard.canActivate', () => {
it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => { it('gibt true zurück und ruft keinen Service auf, wenn kein @UseModule-Slug in den Metadaten steht', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any; const moduleRegistryService = { findBySlug: vi.fn() } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector(undefined), moduleRegistryService, moduleAccessService);
const result = await guard.canActivate(makeContext({})); const result = await guard.canActivate(makeContext({}));
expect(result).toBe(true); expect(result).toBe(true);
expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled(); expect(moduleRegistryService.findBySlug).not.toHaveBeenCalled();
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled();
}); });
it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => { it('wirft ForbiddenException("No tenant context"), wenn weder request.tenantId noch request.user.tenantId gesetzt sind', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any; const moduleRegistryService = { findBySlug: vi.fn() } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow( await expect(guard.canActivate(makeContext({ user: {} }))).rejects.toThrow(
@@ -47,7 +50,7 @@ describe('ModuleGuard.canActivate', () => {
it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => { it('wirft ForbiddenException("No user context"), wenn tenantId gesetzt ist, aber userId/role fehlen', async () => {
const moduleRegistryService = { findBySlug: vi.fn() } as any; const moduleRegistryService = { findBySlug: vi.fn() } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
await expect( await expect(
@@ -57,7 +60,7 @@ describe('ModuleGuard.canActivate', () => {
it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => { it('wirft ForbiddenException bei unbekanntem Slug (findBySlug liefert null)', async () => {
const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any; const moduleRegistryService = { findBySlug: vi.fn().mockResolvedValue(null) } as any;
const moduleAccessService = { getAccessibleModuleIds: vi.fn() } as any; const moduleAccessService = { getModuleAccessLevels: vi.fn() } as any;
const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('unknown-slug'), moduleRegistryService, moduleAccessService);
await expect( await expect(
@@ -65,7 +68,7 @@ describe('ModuleGuard.canActivate', () => {
makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }), makeContext({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } }),
), ),
).rejects.toThrow(ForbiddenException); ).rejects.toThrow(ForbiddenException);
expect(moduleAccessService.getAccessibleModuleIds).not.toHaveBeenCalled(); expect(moduleAccessService.getModuleAccessLevels).not.toHaveBeenCalled();
}); });
it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => { it('USER ohne Grant auf ein aktives Modul: wirft ForbiddenException', async () => {
@@ -73,7 +76,7 @@ describe('ModuleGuard.canActivate', () => {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any; } as any;
const moduleAccessService = { const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any; } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
@@ -89,7 +92,7 @@ describe('ModuleGuard.canActivate', () => {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any; } as any;
const moduleAccessService = { const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set(['mod-1'])), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['mod-1', 'MANAGE']])),
} as any; } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } }; const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
@@ -97,16 +100,16 @@ describe('ModuleGuard.canActivate', () => {
const result = await guard.canActivate(makeContext(request)); const result = await guard.canActivate(makeContext(request));
expect(result).toBe(true); expect(result).toBe(true);
expect(moduleAccessService.getAccessibleModuleIds).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN'); expect(moduleAccessService.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
}); });
it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => { it('USER mit Zugriff: gibt true zurück und legt das Ergebnis auf request.moduleAccessIds ab (Per-Request-Memoisierung, D-09)', async () => {
const moduleRegistryService = { const moduleRegistryService = {
findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }), findBySlug: vi.fn().mockResolvedValue({ id: 'mod-1', slug: 'domaincheck' }),
} as any; } as any;
const accessibleIds = new Set(['mod-1']); const accessibleIds = new Map([['mod-1', 'USE']]);
const moduleAccessService = { const moduleAccessService = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(accessibleIds), getModuleAccessLevels: vi.fn().mockResolvedValue(accessibleIds),
} as any; } as any;
const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService); const guard = new ModuleGuard(makeReflector('domaincheck'), moduleRegistryService, moduleAccessService);
const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } }; const request = { tenantId: 't1', user: { id: 'user-1', role: 'USER' } };
@@ -114,7 +117,8 @@ describe('ModuleGuard.canActivate', () => {
const result = await guard.canActivate(makeContext(request)); const result = await guard.canActivate(makeContext(request));
expect(result).toBe(true); expect(result).toBe(true);
expect((request as any).moduleAccessIds).toBe(accessibleIds); expect((request as any).moduleAccessLevels).toBe(accessibleIds);
expect([...(request as any).moduleAccessIds]).toEqual(['mod-1']);
}); });
/** /**
@@ -141,7 +145,7 @@ describe('ModuleGuard.canActivate', () => {
// Fall A: der Benutzer hat tatsächlich keine Freigabe. // Fall A: der Benutzer hat tatsächlich keine Freigabe.
const moduleAccessServiceGenuinelyEmpty = { const moduleAccessServiceGenuinelyEmpty = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any; } as any;
const guardA = new ModuleGuard( const guardA = new ModuleGuard(
makeReflector('domaincheck'), makeReflector('domaincheck'),
@@ -153,7 +157,7 @@ describe('ModuleGuard.canActivate', () => {
// einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge — // einer ungebunden gebliebenen Abfrage) trotzdem eine leere Menge —
// aus Sicht des Wächters nicht von Fall A zu unterscheiden. // aus Sicht des Wächters nicht von Fall A zu unterscheiden.
const moduleAccessServiceQueryFoundNothing = { const moduleAccessServiceQueryFoundNothing = {
getAccessibleModuleIds: vi.fn().mockResolvedValue(new Set()), getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()),
} as any; } as any;
const guardB = new ModuleGuard( const guardB = new ModuleGuard(
makeReflector('domaincheck'), makeReflector('domaincheck'),
@@ -183,3 +187,71 @@ describe('ModuleGuard.canActivate', () => {
).toBe(messageA); ).toBe(messageA);
}); });
}); });
describe('ModuleGuard — Freigabestufe (261002-icv)', () => {
const registry = {
findBySlug: vi.fn().mockImplementation(async (slug: string) => ({ id: `id-${slug}`, slug })),
} as any;
const userRequest = () => ({ tenantId: 't1', user: { id: 'user-1', role: 'USER' } });
it('@UseModule-Route + USE: erlaubt', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any;
const guard = new ModuleGuard(makeReflector('a'), registry, access);
expect(await guard.canActivate(makeContext(userRequest()))).toBe(true);
});
it('@ModuleManage-Route + USE: ForbiddenException mit Hinweis auf Verwalten', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'USE']])) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(
"Module 'a' requires manage permission",
);
});
it('@ModuleManage-Route + MANAGE: erlaubt', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
expect(await guard.canActivate(makeContext(userRequest()))).toBe(true);
});
it('Administrator (MANAGE auf allen aktiven Modulen): erlaubt', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
const request = { tenantId: 't1', user: { id: 'admin-1', role: 'ADMIN' } };
expect(await guard.canActivate(makeContext(request))).toBe(true);
expect(access.getModuleAccessLevels).toHaveBeenCalledWith('t1', 'admin-1', 'ADMIN');
});
it('@ModuleManage-Route ohne Freigabe: ForbiddenException (nicht zugänglich)', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map()) } as any;
const guard = new ModuleGuard(makeReflector('a', true), registry, access);
await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(
"Module 'a' is not accessible for this user",
);
});
it('MANAGE auf Modul a gewährt nichts auf Modul b (T-icv-04)', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const guard = new ModuleGuard(makeReflector('b', true), registry, access);
await expect(guard.canActivate(makeContext(userRequest()))).rejects.toThrow(ForbiddenException);
});
it('ein zweiter Lauf auf demselben Request nutzt request.moduleAccessLevels und fragt den Dienst nicht erneut', async () => {
const access = { getModuleAccessLevels: vi.fn().mockResolvedValue(new Map([['id-a', 'MANAGE']])) } as any;
const request = userRequest();
await new ModuleGuard(makeReflector('a'), registry, access).canActivate(makeContext(request));
await new ModuleGuard(makeReflector('a', true), registry, access).canActivate(makeContext(request));
expect(access.getModuleAccessLevels).toHaveBeenCalledTimes(1);
});
it('ModuleManage(slug) setzt Slug, Manage-Schlüssel und den ModuleGuard', () => {
class Probe {
@ModuleManage('probe')
handler() {}
}
const handler = Probe.prototype.handler;
expect(Reflect.getMetadata(MODULE_SLUG_KEY, handler)).toBe('probe');
expect(Reflect.getMetadata(MODULE_MANAGE_KEY, handler)).toBe(true);
expect(Reflect.getMetadata(GUARDS_METADATA, handler)).toContain(ModuleGuard);
});
});
+66 -10
View File
@@ -8,6 +8,7 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { ModuleGrantLevel } from '@prisma/client';
import { ModuleAccessService } from './module-access.service'; import { ModuleAccessService } from './module-access.service';
import { ModuleRegistryService } from './module-registry.service'; import { ModuleRegistryService } from './module-registry.service';
@@ -16,6 +17,12 @@ import { ModuleRegistryService } from './module-registry.service';
*/ */
export const MODULE_SLUG_KEY = 'moduleSlug'; export const MODULE_SLUG_KEY = 'moduleSlug';
/**
* Metadata key set by @ModuleManage(): the route needs the Freigabestufe
* Verwalten (MANAGE) for the module, not just access (261002-icv).
*/
export const MODULE_MANAGE_KEY = 'moduleManage';
/** /**
* Guard that checks whether the requesting user has access to the module * Guard that checks whether the requesting user has access to the module
* identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER * identified by its slug — Aktivierung UND (Rolle ODER Direkt-Grant ODER
@@ -28,6 +35,13 @@ export const MODULE_SLUG_KEY = 'moduleSlug';
* T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst * T-15-03: Ohne `@UseModule(slug)`-Metadaten gibt der Guard bewusst
* `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue * `true` zurück (Durchsetzung hängt am Dekorator) — jeder neue
* Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3). * Modul-Controller MUSS `@UseModule` tragen (Projektregel seit Phase 3).
*
* 261002-icv: Trägt die Route zusätzlich `@ModuleManage(slug)`, genügt
* Zugriff allein nicht — die wirksame Freigabestufe muss Verwalten sein
* (Administratoren erfüllen das über den Kurzschluss in
* `getModuleAccessLevels`). Die Stufe wird serverseitig aus den ModuleGrant-
* Zeilen aufgelöst, nie aus Body/Query (T-icv-02), und nur für das Modul
* der Route (T-icv-04).
*/ */
@Injectable() @Injectable()
export class ModuleGuard implements CanActivate { export class ModuleGuard implements CanActivate {
@@ -71,22 +85,37 @@ export class ModuleGuard implements CanActivate {
); );
} }
const accessibleModuleIds = await this.moduleAccessService.getAccessibleModuleIds( const requireManage =
tenantId, this.reflector.getAllAndOverride<boolean>(MODULE_MANAGE_KEY, [
userId, context.getHandler(),
role, context.getClass(),
); ]) === true;
if (!accessibleModuleIds.has(module.id)) { // Per-Request-Memoisierung (D-09): ein Klassen-@UseModule plus ein
// Handler-@ModuleManage lassen diesen Guard zweimal pro Request laufen;
// die Aufloesung bezahlt nur der erste Lauf. Ueber Request-Grenzen
// hinweg wird nichts zwischengespeichert.
const levels: Map<string, ModuleGrantLevel> =
request.moduleAccessLevels instanceof Map
? request.moduleAccessLevels
: await this.moduleAccessService.getModuleAccessLevels(tenantId, userId, role);
const level = levels.get(module.id);
if (!level) {
throw new ForbiddenException( throw new ForbiddenException(
`Module '${moduleSlug}' is not accessible for this user`, `Module '${moduleSlug}' is not accessible for this user`,
); );
} }
// Per-Request-Memoisierung (D-09): ein nachfolgender Handler im if (requireManage && level !== ModuleGrantLevel.MANAGE) {
// selben Request bezahlt die Auflösung nicht ein zweites Mal. Über throw new ForbiddenException(
// Request-Grenzen hinweg wird nichts zwischengespeichert. `Module '${moduleSlug}' requires manage permission`,
request.moduleAccessIds = accessibleModuleIds; );
}
request.moduleAccessLevels = levels;
request.moduleAccessIds = new Set(levels.keys());
return true; return true;
} }
@@ -107,3 +136,30 @@ export function UseModule(slug: string) {
UseGuards(ModuleGuard), UseGuards(ModuleGuard),
); );
} }
/**
* Decorator fuer modul-eigene Konfiguration: verlangt Zugriff auf das Modul
* UND die Freigabestufe Verwalten (261002-icv). Ersetzt
* `@Roles(ADMIN, SUPER_ADMIN)` fuer Handler, die nur dieses eine Modul
* konfigurieren.
*
* Verwendbar auf einem Handler innerhalb eines `@UseModule`-Controllers oder
* auf einem ganzen Controller. Administratoren bestehen ueber den
* D-03-Kurzschluss (sie loesen auf allen aktiven Modulen zu MANAGE auf).
*
* Niemals zusammen mit `@Roles` am selben Handler: der globale RolesGuard
* wuerde Verwalter trotzdem sperren. Mandant, Benutzer und Rolle stammen
* ausschliesslich aus dem JWT (T-15-10).
*
* Usage:
* @ModuleManage('kantine-datev')
* @Put('settings')
* saveSettings(...) { ... }
*/
export function ModuleManage(slug: string) {
return applyDecorators(
SetMetadata(MODULE_SLUG_KEY, slug),
SetMetadata(MODULE_MANAGE_KEY, true),
UseGuards(ModuleGuard),
);
}
@@ -30,7 +30,9 @@ import type { ProxmoxErrorKind } from './proxmox.types';
* Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen * Keine SSRF-Adresspruefung wie `isPublicHttpUrl`: Proxmox-Server stehen
* per Definition im privaten Netz, eine solche Pruefung wuerde jede reale * per Definition im privaten Netz, eine solche Pruefung wuerde jede reale
* Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur * Adresse blockieren (T-DHH-02). Die Absicherung ist stattdessen, dass nur
* ein Administrator (`@Roles(ADMIN, SUPER_ADMIN)`) Adressen eintragen darf * ein Administrator oder ein Benutzer, dem der Administrator ausdruecklich
* die Freigabestufe Verwalten fuer das Proxmox-Modul gegeben hat
* (`@ModuleManage('proxmox')`, 261002-icv), Adressen eintragen darf
* — siehe Bedrohungsmodell T-DHH-02 im Plan. * — siehe Bedrohungsmodell T-DHH-02 im Plan.
*/ */
+12 -12
View File
@@ -9,10 +9,8 @@ import {
Put, Put,
Req, Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Role } from '@prisma/client';
import { Roles } from '../auth/decorators/roles.decorator';
import type { AuthenticatedRequest } from '../auth/types/auth-user'; import type { AuthenticatedRequest } from '../auth/types/auth-user';
import { UseModule } from '../module-registry/module.guard'; import { ModuleManage, UseModule } from '../module-registry/module.guard';
import { import {
CreateProxmoxServerDto, CreateProxmoxServerDto,
TestProxmoxServerDto, TestProxmoxServerDto,
@@ -26,9 +24,11 @@ import { ProxmoxService } from './proxmox.service';
* `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt * `domaincheck.controller.ts`) — Aktivierung UND Freigabe. `tenantId` kommt
* ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus * ausschliesslich aus `req.tenantId` (gesetzt vom `TenantGuard`), nie aus
* Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit * Body oder Query. Lesen (`GET servers`) steht jedem Benutzer mit
* Modulzugriff offen; Schreiben (`POST servers`, `POST servers/test`, * Modulzugriff offen; Schreiben (`POST servers`, `PUT`/`DELETE servers/:id`,
* `POST servers/:id/poll`, `POST servers/:id/test`) zusaetzlich * `POST servers/test`, `POST servers/:id/poll`, `POST servers/:id/test`)
* `@Roles(ADMIN, SUPER_ADMIN)` (T-DHH-05). `servers/test` (statisch, zwei * zusaetzlich `@ModuleManage('proxmox')` — Administratoren und Benutzer mit
* der Freigabestufe Verwalten (261002-icv, vorher `@Roles(ADMIN,
* SUPER_ADMIN)`; T-DHH-05). `servers/test` (statisch, zwei
* Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich * Segmente) und `servers/:id/test` (drei Segmente) ueberschneiden sich
* nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine * nicht — beide POST, aber unterschiedliche Segmentzahl, deshalb keine
* Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen). * Reihenfolge-Abhaengigkeit (anders als `GET :id` vs. statische Routen).
@@ -55,7 +55,7 @@ export class ProxmoxController {
} }
@Post('servers') @Post('servers')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('proxmox')
async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) { async create(@Req() req: AuthenticatedRequest, @Body() dto: CreateProxmoxServerDto) {
const tenantId = this.requireTenantId(req); const tenantId = this.requireTenantId(req);
const created = await this.proxmoxService.createServer(tenantId, dto); const created = await this.proxmoxService.createServer(tenantId, dto);
@@ -65,7 +65,7 @@ export class ProxmoxController {
} }
@Put('servers/:id') @Put('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('proxmox')
async update( async update(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@Param('id') id: string, @Param('id') id: string,
@@ -78,7 +78,7 @@ export class ProxmoxController {
} }
@Delete('servers/:id') @Delete('servers/:id')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('proxmox')
async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) { async remove(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
const tenantId = this.requireTenantId(req); const tenantId = this.requireTenantId(req);
const deleted = await this.proxmoxService.deleteServer(tenantId, id); const deleted = await this.proxmoxService.deleteServer(tenantId, id);
@@ -87,7 +87,7 @@ export class ProxmoxController {
} }
@Post('servers/:id/poll') @Post('servers/:id/poll')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('proxmox')
async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) { async poll(@Req() req: AuthenticatedRequest, @Param('id') id: string) {
return this.proxmoxService.pollServer(this.requireTenantId(req), id); return this.proxmoxService.pollServer(this.requireTenantId(req), id);
} }
@@ -101,7 +101,7 @@ export class ProxmoxController {
* OHNE den Zwischenlagerstand zu ueberschreiben. * OHNE den Zwischenlagerstand zu ueberschreiben.
*/ */
@Post('servers/:id/test') @Post('servers/:id/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('proxmox')
async test( async test(
@Req() req: AuthenticatedRequest, @Req() req: AuthenticatedRequest,
@Param('id') id: string, @Param('id') id: string,
@@ -115,7 +115,7 @@ export class ProxmoxController {
* noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle. * noch keinen gespeicherten Server, `dto` ist deshalb die einzige Quelle.
*/ */
@Post('servers/test') @Post('servers/test')
@Roles(Role.ADMIN, Role.SUPER_ADMIN) @ModuleManage('proxmox')
async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) { async testDraft(@Req() req: AuthenticatedRequest, @Body() dto: TestProxmoxServerDto) {
this.requireTenantId(req); this.requireTenantId(req);
return this.proxmoxService.testDraftConnection(dto); return this.proxmoxService.testDraftConnection(dto);
@@ -17,11 +17,15 @@ const messages: Record<string, Record<string, unknown>> = {
emptyModulesLink: 'Zu Module', emptyModulesLink: 'Zu Module',
adminNote: adminNote:
'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.', 'ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.',
levelExplanation: '„Benutzen“: Das Modul öffnen. „Verwalten“: zusätzlich die Einstellungen ändern.',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.', saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
noSearchResults: 'Kein Treffer für "{search}" — weder bei den Modulen noch bei den Gruppen.', noSearchResults: 'Kein Treffer für "{search}" — weder bei den Modulen noch bei den Gruppen.',
}, },
'admin.groups.grants': { 'admin.groups.grants': {
matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}', matrixCheckboxLabel: '{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}',
levelUse: 'Benutzen',
levelManage: 'Verwalten',
levelSelectLabel: 'Stufe für {module} in Gruppe {group}',
}, },
'adminModules.activationDialog': { 'adminModules.activationDialog': {
title: 'Modul aktivieren: {module}', title: 'Modul aktivieren: {module}',
@@ -321,6 +325,121 @@ describe('AdminModuleGrantsPage (Permission-Matrix)', () => {
}); });
}); });
describe('AdminModuleGrantsPage — Freigabestufe (261002-icv)', () => {
beforeEach(() => {
stubAdmin();
});
const levelMatrix = {
...mockMatrix,
grants: [
{ moduleId: 'm1', groupId: 'g1', level: 'MANAGE' },
{ moduleId: 'm2', groupId: 'g1', level: 'USE' },
],
};
it('zeigt bei freigegebenen Zellen ein Stufenfeld mit der gelieferten Stufe, bei leeren Zellen keins', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) })),
);
render(<AdminModuleGrantsPage />);
const manage = (await screen.findByLabelText(
'Stufe für Ausschreibungs-Radar in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
expect(manage.value).toBe('MANAGE');
const use = screen.getByLabelText('Stufe für DKV Flotte in Gruppe Alle Benutzer') as HTMLSelectElement;
expect(use.value).toBe('USE');
expect(screen.queryByLabelText('Stufe für Ausschreibungs-Radar in Gruppe Buchhaltung')).toBeNull();
});
it('Wechsel auf Verwalten sendet POST mit level MANAGE', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const select = (await screen.findByLabelText(
'Stufe für DKV Flotte in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({
moduleId: 'm2',
groupId: 'g1',
level: 'MANAGE',
});
});
expect(select.value).toBe('MANAGE');
});
it('ein fehlgeschlagener Stufenwechsel setzt das Auswahlfeld zurück', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const select = (await screen.findByLabelText(
'Stufe für DKV Flotte in Gruppe Alle Benutzer',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => expect(screen.getByText(/500: boom/)).toBeInTheDocument());
expect(select.value).toBe('USE');
});
it('Ankreuzen einer leeren Zelle sendet POST ohne level und zeigt Benutzen', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) });
});
vi.stubGlobal('fetch', fetchMock);
render(<AdminModuleGrantsPage />);
const box = await screen.findByLabelText('Ausschreibungs-Radar für Gruppe Buchhaltung freigeben');
await userEvent.click(box);
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({ moduleId: 'm1', groupId: 'g2' });
});
const select = (await screen.findByLabelText(
'Stufe für Ausschreibungs-Radar in Gruppe Buchhaltung',
)) as HTMLSelectElement;
expect(select.value).toBe('USE');
});
it('zeigt die Erklärung der Stufen unter der Matrix', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelMatrix) })),
);
render(<AdminModuleGrantsPage />);
expect(await screen.findByText(/„Verwalten“: zusätzlich die Einstellungen ändern/)).toBeInTheDocument();
});
});
describe('ActivateModuleDialog', () => { describe('ActivateModuleDialog', () => {
it('renders all three actions (cancel, configure later, grant now)', async () => { it('renders all three actions (cancel, configure later, grant now)', async () => {
vi.stubGlobal( vi.stubGlobal(
@@ -4,6 +4,7 @@ import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useAuthStore } from '@/lib/stores/auth-store';
import { useCategoryLabel } from '@/lib/use-category-label';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
@@ -19,9 +20,13 @@ interface Group {
internalName?: string | null; internalName?: string | null;
} }
type GrantLevel = 'USE' | 'MANAGE';
interface GrantPair { interface GrantPair {
moduleId: string; moduleId: string;
groupId: string; groupId: string;
/** Freigabestufe (261002-icv); fehlt sie, gilt Benutzen. */
level?: GrantLevel;
} }
function cellKey(moduleId: string, groupId: string): string { function cellKey(moduleId: string, groupId: string): string {
@@ -35,19 +40,22 @@ function cellKey(moduleId: string, groupId: string): string {
* einmal GET /module-grants/matrix (Module, Gruppen, bestehende * einmal GET /module-grants/matrix (Module, Gruppen, bestehende
* Gruppen-Grants in einer Antwort) und schaltet jede Zelle sofort um * Gruppen-Grants in einer Antwort) und schaltet jede Zelle sofort um
* (optimistisches UI, Rücksprung bei Fehler -- identisches Verhalten zu * (optimistisches UI, Rücksprung bei Fehler -- identisches Verhalten zu
* AdminModulesPage.toggleModule). * AdminModulesPage.toggleModule). Seit 261002-icv trägt jede freigegebene
* Zelle eine Stufe (Benutzen / Verwalten), die per Auswahlfeld gewechselt
* wird.
*/ */
export default function AdminModuleGrantsPage() { export default function AdminModuleGrantsPage() {
const t = useTranslations('adminModules.grants'); const t = useTranslations('adminModules.grants');
const tGrantLabel = useTranslations('admin.groups.grants'); const tGrantLabel = useTranslations('admin.groups.grants');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const categoryLabel = useCategoryLabel();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN'; const hasAccess = currentUser?.role === 'ADMIN' || currentUser?.role === 'SUPER_ADMIN';
const [modules, setModules] = useState<Module[]>([]); const [modules, setModules] = useState<Module[]>([]);
const [groups, setGroups] = useState<Group[]>([]); const [groups, setGroups] = useState<Group[]>([]);
const [grants, setGrants] = useState<Set<string>>(new Set()); const [grants, setGrants] = useState<Map<string, GrantLevel>>(new Map());
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
@@ -63,7 +71,11 @@ export default function AdminModuleGrantsPage() {
await res.json(); await res.json();
setModules(data.modules); setModules(data.modules);
setGroups(data.groups); setGroups(data.groups);
setGrants(new Set(data.grants.map((g) => cellKey(g.moduleId, g.groupId)))); setGrants(
new Map(
data.grants.map((g) => [cellKey(g.moduleId, g.groupId), g.level ?? 'USE'] as const),
),
);
} }
} catch { } catch {
// silently fail -- same precedent as the other admin list pages // silently fail -- same precedent as the other admin list pages
@@ -87,21 +99,22 @@ export default function AdminModuleGrantsPage() {
// Optimistic toggle -- rolled back below on any failure so the UI never // Optimistic toggle -- rolled back below on any failure so the UI never
// shows a grant the database does not have (T-15-25). // shows a grant the database does not have (T-15-25).
const previousLevel = grants.get(key);
setGrants((prev) => { setGrants((prev) => {
const next = new Set(prev); const next = new Map(prev);
if (currentlyGranted) { if (currentlyGranted) {
next.delete(key); next.delete(key);
} else { } else {
next.add(key); next.set(key, 'USE');
} }
return next; return next;
}); });
const rollback = () => { const rollback = () => {
setGrants((prev) => { setGrants((prev) => {
const next = new Set(prev); const next = new Map(prev);
if (currentlyGranted) { if (currentlyGranted) {
next.add(key); next.set(key, previousLevel ?? 'USE');
} else { } else {
next.delete(key); next.delete(key);
} }
@@ -130,6 +143,37 @@ export default function AdminModuleGrantsPage() {
} }
}; };
// Stufe einer bereits freigegebenen Zelle wechseln (261002-icv): POST mit
// `level` — der Server hebt/senkt die Stufe der bestehenden Freigabe.
// Optimistisch, bei Fehler Rücksprung auf die vorherige Stufe.
const changeLevel = async (moduleId: string, groupId: string, level: GrantLevel) => {
const key = cellKey(moduleId, groupId);
const previous = grants.get(key) ?? 'USE';
if (previous === level) return;
setTogglingKey(key);
setError(null);
setGrants((prev) => new Map(prev).set(key, level));
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, groupId, level }),
});
if (!res.ok) {
setGrants((prev) => new Map(prev).set(key, previous));
const body = await res.text().catch(() => '');
setError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
setGrants((prev) => new Map(prev).set(key, previous));
setError(String(err));
} finally {
setTogglingKey(null);
}
};
const searchLower = search.trim().toLowerCase(); const searchLower = search.trim().toLowerCase();
// Both axes are searched independently, then combined with the following // Both axes are searched independently, then combined with the following
@@ -249,7 +293,7 @@ export default function AdminModuleGrantsPage() {
colSpan={filteredGroups.length + 1} colSpan={filteredGroups.length + 1}
className="bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground" className="bg-muted/50 px-4 py-2 text-xs font-medium text-muted-foreground"
> >
{category} {categoryLabel(category)}
</td> </td>
</tr> </tr>
{mods.map((mod) => ( {mods.map((mod) => (
@@ -263,10 +307,11 @@ export default function AdminModuleGrantsPage() {
{filteredGroups.map((g) => { {filteredGroups.map((g) => {
const key = cellKey(mod.id, g.id); const key = cellKey(mod.id, g.id);
const isGranted = grants.has(key); const isGranted = grants.has(key);
const level = grants.get(key) ?? 'USE';
const isToggling = togglingKey === key; const isToggling = togglingKey === key;
return ( return (
<td key={g.id} className="px-4 py-3 text-center"> <td key={g.id} className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center"> <span className="relative inline-flex items-center justify-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={isGranted} checked={isGranted}
@@ -283,6 +328,23 @@ export default function AdminModuleGrantsPage() {
})} })}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input"
/> />
{isGranted && (
<select
value={level}
disabled={isToggling}
onChange={(e) =>
changeLevel(mod.id, g.id, e.target.value as GrantLevel)
}
aria-label={tGrantLabel('levelSelectLabel', {
module: mod.name,
group: g.name,
})}
className="h-7 rounded-md border border-input bg-background px-1 text-xs"
>
<option value="USE">{tGrantLabel('levelUse')}</option>
<option value="MANAGE">{tGrantLabel('levelManage')}</option>
</select>
)}
{isToggling && ( {isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /> <span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)} )}
@@ -299,6 +361,7 @@ export default function AdminModuleGrantsPage() {
</div> </div>
)} )}
<p className="text-xs text-muted-foreground">{t('levelExplanation')}</p>
<p className="text-xs text-muted-foreground">{t('adminNote')}</p> <p className="text-xs text-muted-foreground">{t('adminNote')}</p>
</> </>
)} )}
@@ -11,10 +11,16 @@ interface ModuleSummary {
category: string; category: string;
} }
type GrantLevel = 'USE' | 'MANAGE';
interface ModuleAccessRow { interface ModuleAccessRow {
module: ModuleSummary; module: ModuleSummary;
viaGroups: string[]; viaGroups: string[];
direct: boolean; direct: boolean;
/** Stufe der Direkt-Freigabe (261002-icv); null ohne Direkt-Freigabe. */
directLevel?: GrantLevel | null;
/** Namen der Gruppen, die Verwalten gewähren (Teilmenge von viaGroups). */
manageViaGroups?: string[];
} }
interface MembershipChip { interface MembershipChip {
@@ -37,7 +43,9 @@ interface UserAccessModalProps {
/** /**
* Benutzer-Detail-Zugriff (D-16, Surface Contract 3): Gruppenmitgliedschaften * Benutzer-Detail-Zugriff (D-16, Surface Contract 3): Gruppenmitgliedschaften
* (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und * (read-only -- bearbeitet wird ausschliesslich unter /admin/groups) und
* Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle. * Modul-Zugriff mit geerbten Gruppen plus einem Direkt-Toggle. Seit 261002-icv
* trägt die Direkt-Freigabe eine Stufe (Benutzen / Verwalten, Auswahlfeld),
* und Gruppen, die Verwalten gewähren, sind in der Liste markiert.
* *
* Lädt einmal GET /module-grants/users/:userId -- eine Antwort, zwei * Lädt einmal GET /module-grants/users/:userId -- eine Antwort, zwei
* Abschnitte: die Gruppenmitgliedschafts-Chips kommen direkt aus den * Abschnitte: die Gruppenmitgliedschafts-Chips kommen direkt aus den
@@ -49,6 +57,7 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
const t = useTranslations('admin.users.grants'); const t = useTranslations('admin.users.grants');
const tCommon = useTranslations('common'); const tCommon = useTranslations('common');
const tMembers = useTranslations('admin.groups.members'); const tMembers = useTranslations('admin.groups.members');
const tLevel = useTranslations('admin.groups.grants');
const [rows, setRows] = useState<ModuleAccessRow[] | null>(null); const [rows, setRows] = useState<ModuleAccessRow[] | null>(null);
const [groups, setGroups] = useState<MembershipChip[] | null>(null); const [groups, setGroups] = useState<MembershipChip[] | null>(null);
@@ -84,19 +93,28 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => { const toggleDirect = async (moduleId: string, currentlyDirect: boolean) => {
setTogglingModuleId(moduleId); setTogglingModuleId(moduleId);
setSaveError(null); setSaveError(null);
const previousLevel = rows?.find((r) => r.module.id === moduleId)?.directLevel ?? null;
// Optimistic toggle -- rolled back below on failure (T-15-25): the UI // Optimistic toggle -- rolled back below on failure (T-15-25): the UI
// must never show a grant the database does not have. // must never show a grant the database does not have.
setRows((prev) => setRows((prev) =>
prev prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: !currentlyDirect } : r)) ? prev.map((r) =>
r.module.id === moduleId
? { ...r, direct: !currentlyDirect, directLevel: currentlyDirect ? null : 'USE' }
: r,
)
: prev, : prev,
); );
const rollback = () => { const rollback = () => {
setRows((prev) => setRows((prev) =>
prev prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, direct: currentlyDirect } : r)) ? prev.map((r) =>
r.module.id === moduleId
? { ...r, direct: currentlyDirect, directLevel: previousLevel }
: r,
)
: prev, : prev,
); );
}; };
@@ -122,6 +140,41 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
} }
}; };
// Stufe der Direkt-Freigabe wechseln (261002-icv): POST mit `level`,
// optimistisch, bei Fehler Rücksprung auf die vorherige Stufe.
const changeDirectLevel = async (moduleId: string, level: GrantLevel) => {
const previous = rows?.find((r) => r.module.id === moduleId)?.directLevel ?? 'USE';
if (previous === level) return;
setTogglingModuleId(moduleId);
setSaveError(null);
const setLevel = (value: GrantLevel) =>
setRows((prev) =>
prev
? prev.map((r) => (r.module.id === moduleId ? { ...r, directLevel: value } : r))
: prev,
);
setLevel(level);
try {
const res = await fetch(`${API_URL}/module-grants`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ moduleId, userId, level }),
});
if (!res.ok) {
setLevel(previous);
const body = await res.text().catch(() => '');
setSaveError(`${res.status}: ${body || t('saveError')}`);
}
} catch (err) {
setLevel(previous);
setSaveError(String(err));
} finally {
setTogglingModuleId(null);
}
};
return ( return (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
@@ -217,13 +270,18 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground" className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground"
> >
{name} {name}
{row.manageViaGroups?.includes(name) && (
<span className="ml-1 font-medium text-foreground">
· {t('manageMarker')}
</span>
)}
</span> </span>
))} ))}
</span> </span>
)} )}
</td> </td>
<td className="px-4 py-3 text-center"> <td className="px-4 py-3 text-center">
<span className="relative inline-flex items-center justify-center"> <span className="relative inline-flex items-center justify-center gap-2">
<input <input
type="checkbox" type="checkbox"
checked={row.direct} checked={row.direct}
@@ -239,6 +297,23 @@ export function UserAccessModal({ userId, username, onClose }: UserAccessModalPr
})} })}
className="h-4 w-4 rounded border-input" className="h-4 w-4 rounded border-input"
/> />
{row.direct && (
<select
value={row.directLevel ?? 'USE'}
disabled={isToggling}
onChange={(e) =>
changeDirectLevel(row.module.id, e.target.value as GrantLevel)
}
aria-label={t('directLevelLabel', {
module: row.module.name,
user: username,
})}
className="h-7 rounded-md border border-input bg-background px-1 text-xs"
>
<option value="USE">{tLevel('levelUse')}</option>
<option value="MANAGE">{tLevel('levelManage')}</option>
</select>
)}
{isToggling && ( {isToggling && (
<span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" /> <span className="absolute -right-4 inline-block h-3 w-3 animate-spin rounded-full border-2 border-muted-foreground border-t-transparent" />
)} )}
@@ -22,6 +22,12 @@ const messages: Record<string, Record<string, unknown>> = {
directCheckboxLabel: directCheckboxLabel:
'{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}', '{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}',
saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.', saveError: 'Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.',
directLevelLabel: 'Stufe der direkten Freigabe von {module} für {user}',
manageMarker: 'Verwalten',
},
'admin.groups.grants': {
levelUse: 'Benutzen',
levelManage: 'Verwalten',
}, },
'admin.groups.members': { 'admin.groups.members': {
sourceManual: 'Manuell', sourceManual: 'Manuell',
@@ -285,3 +291,90 @@ describe('UserAccessModal', () => {
expect(screen.getByText('LDAP')).toBeInTheDocument(); expect(screen.getByText('LDAP')).toBeInTheDocument();
}); });
}); });
describe('UserAccessModal — Freigabestufe (261002-icv)', () => {
const levelAccess = {
groups: [{ id: 'g1', name: 'Alle Benutzer', source: 'MANUAL' as const }],
modules: [
{
module: { id: 'm1', name: 'Proxmox', category: 'infrastructure' },
viaGroups: ['Alle Benutzer'],
direct: false,
directLevel: null,
manageViaGroups: ['Alle Benutzer'],
},
{
module: { id: 'm2', name: 'DKV Flotte', category: 'fleet' },
viaGroups: [],
direct: true,
directLevel: 'USE' as const,
manageViaGroups: [],
},
],
};
it('zeigt bei der Direkt-Freigabe ein Stufenfeld und wechselt die Stufe per POST mit level', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) });
});
vi.stubGlobal('fetch', fetchMock);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
const select = (await screen.findByLabelText(
'Stufe der direkten Freigabe von DKV Flotte für Maxi Musterfrau',
)) as HTMLSelectElement;
expect(select.value).toBe('USE');
// Ohne Direkt-Freigabe kein Stufenfeld.
expect(
screen.queryByLabelText('Stufe der direkten Freigabe von Proxmox für Maxi Musterfrau'),
).toBeNull();
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => {
const post = fetchMock.mock.calls.find(([, init]) => init?.method === 'POST');
expect(post).toBeTruthy();
expect(JSON.parse(String(post?.[1]?.body))).toEqual({
moduleId: 'm2',
userId: 'u1',
level: 'MANAGE',
});
});
expect(select.value).toBe('MANAGE');
});
it('setzt die Stufe bei einem Fehler zurück und zeigt die Meldung', async () => {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method === 'POST') {
return Promise.resolve({ ok: false, status: 500, text: () => Promise.resolve('boom') });
}
return Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) });
});
vi.stubGlobal('fetch', fetchMock);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
const select = (await screen.findByLabelText(
'Stufe der direkten Freigabe von DKV Flotte für Maxi Musterfrau',
)) as HTMLSelectElement;
await userEvent.selectOptions(select, 'MANAGE');
await waitFor(() => expect(screen.getByText(/500: boom/)).toBeInTheDocument());
expect(select.value).toBe('USE');
});
it('markiert eine Gruppe, die Verwalten gewährt, mit „Verwalten“', async () => {
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(levelAccess) })),
);
render(<UserAccessModal userId="u1" username="Maxi Musterfrau" onClose={vi.fn()} />);
expect(await screen.findByText(/· Verwalten/)).toBeInTheDocument();
});
});
@@ -0,0 +1,461 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useRef, useState } from 'react';
import { downloadBase64 } from '@/lib/download-base64';
import {
createAccount,
deleteAccount,
exportAccountsCsv,
type HandelswareAccount,
HandelswareRequestError,
type HandelswareSettings,
importAccountsCsv,
listAccounts,
updateAccount,
} from '@/lib/handelsware-datev-api';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-2 py-1.5 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const BUTTON_CLASS =
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const SECONDARY_BUTTON_CLASS =
'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted disabled:cursor-not-allowed disabled:opacity-50';
const LINK_BUTTON_CLASS =
'text-sm font-medium text-foreground underline underline-offset-2 hover:opacity-80';
interface Draft {
name: string;
gegenkonto: string;
erloeskonto: string;
}
const EMPTY_DRAFT: Draft = { name: '', gegenkonto: '', erloeskonto: '' };
function parseNumber(text: string): number | null {
if (!/^\d{1,9}$/.test(text.trim())) return null;
const n = Number.parseInt(text.trim(), 10);
return n >= 1 ? n : null;
}
interface ImportLineError {
line: number;
code: string;
message: string;
}
/**
* Reiter "Konten": Kontenliste anzeigen, anlegen, bearbeiten, loeschen (mit
* Rueckfrage), CSV importieren (ersetzt ALLE Konten, nach Rueckfrage) und
* exportieren. Alle Benutzer mit Modulzugriff duerfen die Liste pflegen.
*/
export function AccountsTab({
settings,
reloadKey,
}: {
settings: HandelswareSettings | null;
reloadKey: number;
}) {
const t = useTranslations('handelswareDatev');
const [accounts, setAccounts] = useState<HandelswareAccount[] | null>(null);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
const [importErrors, setImportErrors] = useState<ImportLineError[]>([]);
const [newDraft, setNewDraft] = useState<Draft>(EMPTY_DRAFT);
const [editId, setEditId] = useState<string | null>(null);
const [editDraft, setEditDraft] = useState<Draft>(EMPTY_DRAFT);
const [deleteId, setDeleteId] = useState<string | null>(null);
const [pendingImport, setPendingImport] = useState<File | null>(null);
const [busy, setBusy] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const errorText = useCallback(
(error: unknown): string => {
if (error instanceof HandelswareRequestError) {
return error.code && t.has(`errors.${error.code}`)
? t(`errors.${error.code}`)
: error.message;
}
return t('errors.request');
},
[t],
);
// reloadKey: der Import-Reiter zaehlt hoch, wenn ein Export neue Konten gespeichert hat.
// biome-ignore lint/correctness/useExhaustiveDependencies: reloadKey ist der gewollte Ausloeser.
useEffect(() => {
let cancelled = false;
listAccounts()
.then((list) => {
if (!cancelled) setAccounts(list);
})
.catch((error) => {
if (!cancelled) setMessage({ kind: 'error', text: errorText(error) });
});
return () => {
cancelled = true;
};
}, [reloadKey, errorText]);
// Das Standard-Erloeskonto des Mandanten erleichtert das Anlegen (Vorbelegung, kein Festwert).
const defaultErloeskonto = settings?.erloeskonto != null ? String(settings.erloeskonto) : '';
const toInput = (draft: Draft) => {
const gegenkonto = parseNumber(draft.gegenkonto);
const erloeskonto = parseNumber(draft.erloeskonto || defaultErloeskonto);
return { name: draft.name.trim(), gegenkonto, erloeskonto };
};
const handleAdd = async (event: React.FormEvent) => {
event.preventDefault();
const { name, gegenkonto, erloeskonto } = toInput(newDraft);
if (name === '') return setMessage({ kind: 'error', text: t('accounts.nameRequired') });
if (gegenkonto === null || erloeskonto === null) {
return setMessage({ kind: 'error', text: t('accounts.invalidNumber') });
}
setBusy(true);
setMessage(null);
try {
const created = await createAccount({ name, gegenkonto, erloeskonto });
setAccounts((list) =>
[...(list ?? []), created].sort((a, b) => a.name.localeCompare(b.name)),
);
setNewDraft(EMPTY_DRAFT);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setBusy(false);
}
};
const startEdit = (account: HandelswareAccount) => {
setEditId(account.id);
setEditDraft({
name: account.name,
gegenkonto: String(account.gegenkonto),
erloeskonto: String(account.erloeskonto),
});
setDeleteId(null);
setMessage(null);
};
const handleSaveEdit = async () => {
if (!editId) return;
const { name, gegenkonto, erloeskonto } = toInput(editDraft);
if (name === '') return setMessage({ kind: 'error', text: t('accounts.nameRequired') });
if (gegenkonto === null || erloeskonto === null) {
return setMessage({ kind: 'error', text: t('accounts.invalidNumber') });
}
setBusy(true);
setMessage(null);
try {
const updated = await updateAccount(editId, { name, gegenkonto, erloeskonto });
setAccounts((list) =>
(list ?? [])
.map((a) => (a.id === updated.id ? updated : a))
.sort((a, b) => a.name.localeCompare(b.name)),
);
setEditId(null);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setBusy(false);
}
};
const handleDelete = async (id: string) => {
setBusy(true);
setMessage(null);
try {
await deleteAccount(id);
setAccounts((list) => (list ?? []).filter((a) => a.id !== id));
setDeleteId(null);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
} finally {
setBusy(false);
}
};
const handleExportCsv = async () => {
setMessage(null);
try {
const result = await exportAccountsCsv();
downloadBase64(result.filename, result.content, result.mimeType);
} catch (error) {
setMessage({ kind: 'error', text: errorText(error) });
}
};
const handleConfirmImport = async () => {
if (!pendingImport) return;
setBusy(true);
setMessage(null);
setImportErrors([]);
try {
const result = await importAccountsCsv(pendingImport);
setMessage({ kind: 'ok', text: t('accounts.importDone', { count: result.count }) });
setAccounts(await listAccounts());
} catch (error) {
if (error instanceof HandelswareRequestError && Array.isArray(error.details)) {
setImportErrors(error.details as ImportLineError[]);
setMessage({ kind: 'error', text: t('accounts.importErrors') });
} else {
setMessage({ kind: 'error', text: errorText(error) });
}
} finally {
setPendingImport(null);
setBusy(false);
}
};
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileInputRef}
type="file"
accept=".csv,text/csv"
className="hidden"
data-testid="accounts-csv-input"
onChange={(e) => {
const picked = e.target.files?.[0];
if (picked) {
setPendingImport(picked);
setMessage(null);
setImportErrors([]);
}
e.target.value = '';
}}
/>
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={() => fileInputRef.current?.click()}
disabled={busy}
>
{t('accounts.importCsv')}
</button>
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={handleExportCsv}
disabled={busy}
>
{t('accounts.exportCsv')}
</button>
<span className="text-xs text-muted-foreground">{t('accounts.csvHint')}</span>
</div>
{pendingImport && (
<div
role="alertdialog"
aria-label={t('accounts.importReplace')}
className="space-y-3 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground"
>
<p>
{t('accounts.importConfirm', {
count: accounts?.length ?? 0,
file: pendingImport.name,
})}
</p>
<div className="flex gap-2">
<button
type="button"
className={BUTTON_CLASS}
onClick={handleConfirmImport}
disabled={busy}
>
{t('accounts.importReplace')}
</button>
<button
type="button"
className={SECONDARY_BUTTON_CLASS}
onClick={() => setPendingImport(null)}
disabled={busy}
>
{t('accounts.cancel')}
</button>
</div>
</div>
)}
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
{importErrors.length > 0 && (
<ul className="space-y-0.5 text-sm text-destructive">
{importErrors.map((e) => (
<li key={`${e.line}-${e.code}`}>
{t('import.rowErrorLine', { line: e.line })}:{' '}
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
</li>
))}
</ul>
)}
<form
onSubmit={handleAdd}
className="grid gap-2 sm:grid-cols-[1fr_8rem_8rem_auto]"
noValidate
>
<input
aria-label={t('accounts.name')}
placeholder={t('accounts.namePlaceholder')}
maxLength={120}
value={newDraft.name}
onChange={(e) => setNewDraft({ ...newDraft, name: e.target.value })}
className={INPUT_CLASS}
/>
<input
aria-label={t('accounts.counter')}
inputMode="numeric"
maxLength={9}
value={newDraft.gegenkonto}
onChange={(e) => setNewDraft({ ...newDraft, gegenkonto: e.target.value.trim() })}
className={INPUT_CLASS}
/>
<input
aria-label={t('accounts.revenue')}
inputMode="numeric"
maxLength={9}
value={newDraft.erloeskonto || defaultErloeskonto}
onChange={(e) => setNewDraft({ ...newDraft, erloeskonto: e.target.value.trim() })}
className={INPUT_CLASS}
/>
<button type="submit" className={BUTTON_CLASS} disabled={busy}>
{t('accounts.add')}
</button>
</form>
{accounts === null ? (
<p className="text-sm text-muted-foreground">{t('loading')}</p>
) : accounts.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('accounts.empty')}</p>
) : (
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-left text-sm">
<thead className="bg-muted text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('accounts.name')}</th>
<th className="px-3 py-2 font-medium">{t('accounts.counter')}</th>
<th className="px-3 py-2 font-medium">{t('accounts.revenue')}</th>
<th className="px-3 py-2 font-medium">{t('accounts.actions')}</th>
</tr>
</thead>
<tbody>
{accounts.map((account) =>
editId === account.id ? (
<tr key={account.id} className="border-t border-border">
<td className="px-3 py-1.5">
<input
aria-label={t('accounts.name')}
maxLength={120}
value={editDraft.name}
onChange={(e) => setEditDraft({ ...editDraft, name: e.target.value })}
className={INPUT_CLASS}
/>
</td>
<td className="px-3 py-1.5">
<input
aria-label={t('accounts.counter')}
inputMode="numeric"
maxLength={9}
value={editDraft.gegenkonto}
onChange={(e) =>
setEditDraft({ ...editDraft, gegenkonto: e.target.value.trim() })
}
className={INPUT_CLASS}
/>
</td>
<td className="px-3 py-1.5">
<input
aria-label={t('accounts.revenue')}
inputMode="numeric"
maxLength={9}
value={editDraft.erloeskonto}
onChange={(e) =>
setEditDraft({ ...editDraft, erloeskonto: e.target.value.trim() })
}
className={INPUT_CLASS}
/>
</td>
<td className="space-x-3 px-3 py-1.5">
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={handleSaveEdit}
disabled={busy}
>
{t('accounts.save')}
</button>
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => setEditId(null)}
>
{t('accounts.cancel')}
</button>
</td>
</tr>
) : (
<tr key={account.id} className="border-t border-border">
<td className="px-3 py-1.5">{account.name}</td>
<td className="px-3 py-1.5 tabular-nums">{account.gegenkonto}</td>
<td className="px-3 py-1.5 tabular-nums">{account.erloeskonto}</td>
<td className="px-3 py-1.5">
{deleteId === account.id ? (
<span className="flex flex-wrap items-center gap-3">
<span>{t('accounts.deleteConfirm', { name: account.name })}</span>
<button
type="button"
className="text-sm font-medium text-destructive underline underline-offset-2"
onClick={() => handleDelete(account.id)}
disabled={busy}
>
{t('accounts.deleteYes')}
</button>
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => setDeleteId(null)}
>
{t('accounts.cancel')}
</button>
</span>
) : (
<span className="space-x-3">
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => startEdit(account)}
>
{t('accounts.edit')}
</button>
<button
type="button"
className={LINK_BUTTON_CLASS}
onClick={() => {
setDeleteId(account.id);
setEditId(null);
}}
>
{t('accounts.delete')}
</button>
</span>
)}
</td>
</tr>
),
)}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,320 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useState } from 'react';
import { FileDropArea } from '@/components/accounting/file-drop-area';
import { downloadBase64 } from '@/lib/download-base64';
import {
exportHandelsware,
type HandelswarePreview,
HandelswareRequestError,
type HandelswareSettings,
isValidBuchungsdatum,
previewHandelsware,
} from '@/lib/handelsware-datev-api';
const INPUT_CLASS =
'w-28 rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const BUTTON_CLASS =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const SECONDARY_BUTTON_CLASS =
'rounded-md border border-border px-3 py-1.5 text-sm font-medium text-foreground hover:bg-muted';
/** Anzeigegrenze der Vorschautabelle — die Datei selbst wird vollstaendig verarbeitet. */
const MAX_VISIBLE_ROWS = 1000;
const amountFormat = new Intl.NumberFormat('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
function toCents(amount: string): number {
return Math.round(Number(amount) * 100);
}
type Message = { kind: 'error' | 'ok'; text: string; code?: string | null };
/**
* Reiter "Import": Excel-Datei hochladen, Vorschau mit Kontenzuordnung,
* Buchungsdatum (TTMM) bearbeiten, TXT herunterladen. Neue Konten werden erst
* beim Herunterladen gespeichert (die API vergleicht dazu die Liste neu).
*/
export function ImportTab({
settings,
canManage,
onOpenSettings,
onAccountsChanged,
}: {
settings: HandelswareSettings | null;
canManage: boolean;
onOpenSettings: () => void;
onAccountsChanged: () => void;
}) {
const t = useTranslations('handelswareDatev');
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<HandelswarePreview | null>(null);
const [buchungsdatum, setBuchungsdatum] = useState('');
const [checking, setChecking] = useState(false);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState<Message | null>(null);
const requestMessage = useCallback(
(error: unknown): Message => {
if (error instanceof HandelswareRequestError) {
const text =
error.code && t.has(`errors.${error.code}`) ? t(`errors.${error.code}`) : error.message;
return { kind: 'error', text, code: error.code };
}
return { kind: 'error', text: t('errors.request') };
},
[t],
);
const loadPreview = useCallback(
async (selected: File, keepDate: boolean) => {
setChecking(true);
try {
const result = await previewHandelsware(selected);
setPreview(result);
if (!keepDate) setBuchungsdatum(result.suggestedBuchungsdatum);
return true;
} catch (error) {
setPreview(null);
setMessage(requestMessage(error));
return false;
} finally {
setChecking(false);
}
},
[requestMessage],
);
const handleFile = async (selected: File) => {
setFile(selected);
setPreview(null);
setMessage(null);
await loadPreview(selected, false);
};
const handleClear = () => {
setFile(null);
setPreview(null);
setMessage(null);
setBuchungsdatum('');
};
const handleReload = async () => {
if (!file) return;
setMessage(null);
await loadPreview(file, true);
};
const dateValid = isValidBuchungsdatum(buchungsdatum);
const canDownload =
Boolean(preview) &&
(preview?.rows.length ?? 0) > 0 &&
(preview?.rowErrors.length ?? 0) === 0 &&
dateValid &&
!downloading &&
!checking;
const handleDownload = async () => {
if (!file || !preview) return;
setDownloading(true);
setMessage(null);
try {
const result = await exportHandelsware(file, buchungsdatum, preview.newAccounts);
downloadBase64(result.filename, result.content, result.mimeType);
setMessage({
kind: 'ok',
text: t('import.downloaded', { filename: result.filename, count: result.createdCount }),
});
onAccountsChanged();
// Die neuen Konten sind jetzt gespeichert — Vorschau ohne "neu"-Markierung nachladen.
await loadPreview(file, true);
} catch (error) {
setMessage(requestMessage(error));
} finally {
setDownloading(false);
}
};
const notConfigured = settings !== null && !settings.configured;
const blockedBySettings = notConfigured || message?.code === 'settingsMissing';
let debit = 0;
let credit = 0;
for (const row of preview?.rows ?? []) {
if (row.sollHaben === 'S') debit += toCents(row.umsatz);
else credit += toCents(row.umsatz);
}
const visibleRows = preview?.rows.slice(0, MAX_VISIBLE_ROWS) ?? [];
return (
<div className="space-y-5">
{blockedBySettings && (
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
<p>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{canManage && (
<button
type="button"
onClick={onOpenSettings}
className="font-medium underline underline-offset-2"
>
{t('notConfigured.adminAction')}
</button>
)}
</div>
)}
<FileDropArea
accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
file={file}
onFile={handleFile}
onClear={handleClear}
placeholder={t('import.dropPlaceholder')}
hint={t('import.dropFormats')}
clearLabel={t('import.clear')}
/>
{checking && <p className="text-sm text-muted-foreground">{t('import.checking')}</p>}
{preview && (
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-[auto_1fr]">
<div className="space-y-1">
<label
htmlFor="handelsware-buchungsdatum"
className="text-sm font-medium text-foreground"
>
{t('import.date')}
</label>
<input
id="handelsware-buchungsdatum"
type="text"
inputMode="numeric"
maxLength={4}
autoComplete="off"
value={buchungsdatum}
aria-invalid={!dateValid}
onChange={(e) => setBuchungsdatum(e.target.value.trim())}
className={INPUT_CLASS}
/>
{!dateValid && <p className="text-xs text-destructive">{t('import.dateInvalid')}</p>}
<p className="text-xs text-muted-foreground">{t('import.dateHint')}</p>
</div>
<dl className="space-y-1 text-sm">
<div className="flex gap-2">
<dt className="text-muted-foreground">{t('import.headerText')}:</dt>
<dd className="text-foreground">{preview.headerText || '—'}</dd>
</div>
<div className="flex gap-2">
<dt className="text-muted-foreground">{t('import.exportFilename')}:</dt>
<dd className="text-foreground">{preview.exportFilename}</dd>
</div>
</dl>
</div>
{preview.rowErrors.length > 0 && (
<section className="space-y-1">
<h2 className="text-sm font-semibold text-destructive">
{t('import.rowErrorsTitle')}
</h2>
<ul className="space-y-0.5 text-sm text-destructive">
{preview.rowErrors.map((e) => (
<li key={`${e.line}-${e.code}`}>
{t('import.rowErrorLine', { line: e.line })}:{' '}
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
</li>
))}
</ul>
</section>
)}
{preview.rows.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('import.noRows')}</p>
) : (
<>
<p className="text-sm text-foreground">
{preview.newAccounts.length > 0
? t('import.newSummary', { count: preview.newAccounts.length })
: t('import.noNewAccounts')}
</p>
<div className="max-h-[28rem] overflow-auto rounded-md border border-border">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-muted text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('import.columns.text')}</th>
<th className="px-3 py-2 text-right font-medium">
{t('import.columns.amount')}
</th>
<th className="px-3 py-2 font-medium">{t('import.columns.sh')}</th>
<th className="px-3 py-2 font-medium">{t('import.columns.counter')}</th>
<th className="px-3 py-2 font-medium">{t('import.columns.date')}</th>
<th className="px-3 py-2 font-medium">{t('import.columns.revenue')}</th>
</tr>
</thead>
<tbody>
{visibleRows.map((row) => (
<tr key={row.line} className="border-t border-border">
<td className="px-3 py-1.5">
{row.buchungstext}
{row.isNew && (
<span className="ml-2 rounded bg-primary px-1.5 py-0.5 text-xs font-medium text-primary-foreground">
{t('import.newBadge')}
</span>
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums">{row.umsatz}</td>
<td className="px-3 py-1.5">{row.sollHaben}</td>
<td className="px-3 py-1.5 tabular-nums">{row.gegenkonto}</td>
<td className="px-3 py-1.5 tabular-nums">{buchungsdatum}</td>
<td className="px-3 py-1.5 tabular-nums">{row.erloeskonto}</td>
</tr>
))}
</tbody>
</table>
</div>
{preview.rows.length > MAX_VISIBLE_ROWS && (
<p className="text-xs text-muted-foreground">
{t('import.truncated', { shown: MAX_VISIBLE_ROWS, total: preview.rows.length })}
</p>
)}
<p className="text-sm text-muted-foreground">
{t('import.totals', {
debit: amountFormat.format(debit / 100),
credit: amountFormat.format(credit / 100),
})}
</p>
</>
)}
<button
type="button"
className={BUTTON_CLASS}
disabled={!canDownload}
onClick={handleDownload}
>
{downloading ? t('import.downloading') : t('import.download')}
</button>
</div>
)}
{message && (
<div className="space-y-2">
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
{message.code === 'accountsChanged' && file && (
<button type="button" className={SECONDARY_BUTTON_CLASS} onClick={handleReload}>
{t('import.reloadPreview')}
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,121 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import {
HandelswareRequestError,
type HandelswareSettings,
saveHandelswareSettings,
} from '@/lib/handelsware-datev-api';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const BUTTON_CLASS =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
function toNumber(text: string): number | null {
if (!/^\d{1,9}$/.test(text)) return null;
const n = Number.parseInt(text, 10);
return n >= 1 ? n : null;
}
/** Einstellungen der Handelsware — nur fuer Administratoren sichtbar (Reiter). */
export function SettingsTab({
settings,
onSaved,
}: {
settings: HandelswareSettings | null;
onSaved: (s: HandelswareSettings) => void;
}) {
const t = useTranslations('handelswareDatev');
const [erloeskonto, setErloeskonto] = useState('');
const [startGegenkonto, setStartGegenkonto] = useState('');
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
useEffect(() => {
if (settings) {
setErloeskonto(settings.erloeskonto === null ? '' : String(settings.erloeskonto));
setStartGegenkonto(settings.startGegenkonto === null ? '' : String(settings.startGegenkonto));
}
}, [settings]);
const handleSave = async (event: React.FormEvent) => {
event.preventDefault();
setTouched(true);
const e = toNumber(erloeskonto);
const s = toNumber(startGegenkonto);
if (e === null || s === null) return;
setSaving(true);
setMessage(null);
try {
onSaved(await saveHandelswareSettings({ erloeskonto: e, startGegenkonto: s }));
setMessage({ kind: 'ok', text: t('settings.saved') });
} catch (error) {
setMessage({
kind: 'error',
text: error instanceof HandelswareRequestError ? error.message : t('errors.request'),
});
} finally {
setSaving(false);
}
};
const fields = [
{
id: 'erloeskonto',
label: t('settings.erloeskonto'),
help: t('settings.erloeskontoHelp'),
value: erloeskonto,
set: setErloeskonto,
},
{
id: 'startGegenkonto',
label: t('settings.startGegenkonto'),
help: t('settings.startGegenkontoHelp'),
value: startGegenkonto,
set: setStartGegenkonto,
},
];
return (
<form onSubmit={handleSave} className="max-w-md space-y-4" noValidate>
<p className="text-sm text-muted-foreground">{t('settings.intro')}</p>
{fields.map((f) => {
const showError = touched && toNumber(f.value) === null;
return (
<div key={f.id} className="space-y-1">
<label htmlFor={`handelsware-${f.id}`} className="text-sm font-medium text-foreground">
{f.label}
</label>
<input
id={`handelsware-${f.id}`}
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={9}
value={f.value}
aria-invalid={showError}
onChange={(e) => f.set(e.target.value.trim())}
className={INPUT_CLASS}
/>
<p className="text-xs text-muted-foreground">{f.help}</p>
{showError && <p className="text-xs text-destructive">{t('settings.numberInvalid')}</p>}
</div>
);
})}
<button type="submit" className={BUTTON_CLASS} disabled={saving}>
{saving ? t('settings.saving') : t('settings.save')}
</button>
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
</form>
);
}
@@ -0,0 +1,403 @@
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { HandelswareAccount, HandelswarePreview } from '@/lib/handelsware-datev-api';
import de from '@/messages/de.json';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const api = {
getHandelswareSettings: vi.fn(),
saveHandelswareSettings: vi.fn(),
previewHandelsware: vi.fn(),
exportHandelsware: vi.fn(),
listAccounts: vi.fn(),
createAccount: vi.fn(),
updateAccount: vi.fn(),
deleteAccount: vi.fn(),
importAccountsCsv: vi.fn(),
exportAccountsCsv: vi.fn(),
};
// Echte Fehlerklasse und echte TTMM-Pruefung, nur die Netzwerkaufrufe sind ersetzt.
vi.mock('@/lib/handelsware-datev-api', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/handelsware-datev-api')>();
return {
...actual,
getHandelswareSettings: (...a: unknown[]) => api.getHandelswareSettings(...a),
saveHandelswareSettings: (...a: unknown[]) => api.saveHandelswareSettings(...a),
previewHandelsware: (...a: unknown[]) => api.previewHandelsware(...a),
exportHandelsware: (...a: unknown[]) => api.exportHandelsware(...a),
listAccounts: (...a: unknown[]) => api.listAccounts(...a),
createAccount: (...a: unknown[]) => api.createAccount(...a),
updateAccount: (...a: unknown[]) => api.updateAccount(...a),
deleteAccount: (...a: unknown[]) => api.deleteAccount(...a),
importAccountsCsv: (...a: unknown[]) => api.importAccountsCsv(...a),
exportAccountsCsv: (...a: unknown[]) => api.exportAccountsCsv(...a),
};
});
const mockDownload = vi.fn();
vi.mock('@/lib/download-base64', () => ({
downloadBase64: (...a: unknown[]) => mockDownload(...a),
}));
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
function mockUser(role: 'SUPER_ADMIN' | 'ADMIN' | 'USER') {
mockAuthStore.mockImplementation((selector: (s: { user: { role: string } }) => unknown) =>
selector({ user: { role } }),
);
}
import { HandelswareRequestError } from '@/lib/handelsware-datev-api';
import HandelswareDatevPage from './page';
const CONFIGURED = { erloeskonto: 4711, startGegenkonto: 2000, configured: true };
const EMPTY = { erloeskonto: null, startGegenkonto: null, configured: false };
const PREVIEW: HandelswarePreview = {
headerText: '2026',
suggestedBuchungsdatum: '3103',
exportFilename: 'HWA_0326.txt',
rows: [
{
line: 2,
buchungstext: 'Kaffee',
umsatz: '12.50',
sollHaben: 'S',
gegenkonto: 2010,
erloeskonto: 4000,
isNew: false,
},
{
line: 3,
buchungstext: 'Kakao',
umsatz: '3.00',
sollHaben: 'H',
gegenkonto: 2011,
erloeskonto: 4711,
isNew: true,
},
],
newAccounts: [{ name: 'Kakao', gegenkonto: 2011, erloeskonto: 4711 }],
rowErrors: [],
};
const ACCOUNTS: HandelswareAccount[] = [
{ id: 'a1', name: 'Kaffee', gegenkonto: 2010, erloeskonto: 4000 },
{ id: 'a2', name: 'Tee', gegenkonto: 2005, erloeskonto: 4001 },
];
const xlsx = () => new File(['x'], 'HWA 0326 Test.xlsx');
async function upload(file = xlsx()) {
const input = (await screen.findByTestId('file-drop-input')) as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
}
const downloadButton = () =>
screen.getByRole('button', { name: 'Buchungsdatei herunterladen' }) as HTMLButtonElement;
/** Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv). */
const mockFetch = vi.fn();
function stubActiveModules(entries: unknown[]) {
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
}
beforeEach(() => {
mockUser('USER');
api.getHandelswareSettings.mockResolvedValue(CONFIGURED);
api.listAccounts.mockResolvedValue(ACCOUNTS);
stubActiveModules([{ slug: 'handelsware-datev', canManage: false }]);
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
for (const m of [...Object.values(api), mockDownload, mockAuthStore]) m.mockReset();
});
describe('HandelswareDatevPage — Import', () => {
it('zeigt die Vorschau mit allen Spalten, "neu"-Markierung und Zusammenfassung', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
render(<HandelswareDatevPage />);
await upload();
const table = await screen.findByRole('table');
for (const col of ['Buchungstext', 'Umsatz', 'S/H', 'Gegenkonto', 'Datum', 'Erlöskonto']) {
expect(within(table).getByRole('columnheader', { name: col })).toBeTruthy();
}
const kakao = within(table).getByText('Kakao').closest('tr') as HTMLElement;
expect(within(kakao).getByText('neu')).toBeTruthy();
expect(within(kakao).getByText('H')).toBeTruthy();
const kaffee = within(table).getByText('Kaffee').closest('tr') as HTMLElement;
expect(within(kaffee).queryByText('neu')).toBeNull();
expect(screen.getByText('Ein neues Konto wird beim Herunterladen gespeichert')).toBeTruthy();
expect(screen.getByText('Summe Soll 12,50, Summe Haben 3,00')).toBeTruthy();
});
it('füllt das Buchungsdatum vor, die Datumsspalte folgt der Änderung, ungültig sperrt den Download', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
render(<HandelswareDatevPage />);
await upload();
const input = (await screen.findByLabelText('Buchungsdatum (TTMM)')) as HTMLInputElement;
expect(input.value).toBe('3103');
await waitFor(() => expect(downloadButton().disabled).toBe(false));
fireEvent.change(input, { target: { value: '1503' } });
expect(within(screen.getByRole('table')).getAllByText('1503')).toHaveLength(2);
fireEvent.change(input, { target: { value: '3102' } });
expect(screen.getByText(/Bitte das Datum als TTMM angeben/)).toBeTruthy();
expect(downloadButton().disabled).toBe(true);
});
it('Download sendet dieselbe Datei, das bearbeitete Datum und die neuen Konten, dann downloadBase64', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
api.exportHandelsware.mockResolvedValue({
filename: 'HWA_0326.txt',
content: 'QUJD',
mimeType: 'text/plain;charset=utf-8',
createdCount: 1,
});
render(<HandelswareDatevPage />);
const file = xlsx();
await upload(file);
const input = (await screen.findByLabelText('Buchungsdatum (TTMM)')) as HTMLInputElement;
fireEvent.change(input, { target: { value: '3003' } });
await waitFor(() => expect(downloadButton().disabled).toBe(false));
fireEvent.click(downloadButton());
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('HWA_0326.txt', 'QUJD', 'text/plain;charset=utf-8'),
);
expect(api.exportHandelsware).toHaveBeenCalledWith(file, '3003', PREVIEW.newAccounts);
expect(await screen.findByText(/Ein neues Konto wurde gespeichert/)).toBeTruthy();
// Vorschau wird nach dem Speichern neu geladen (Konten sind jetzt bekannt).
await waitFor(() => expect(api.previewHandelsware).toHaveBeenCalledTimes(2));
});
it('bei 409 accountsChanged: Serverhinweis und Knopf zum Neuladen der Vorschau', async () => {
api.previewHandelsware.mockResolvedValue(PREVIEW);
api.exportHandelsware.mockRejectedValue(
new HandelswareRequestError(
409,
'accountsChanged',
'Die Kontenliste wurde inzwischen geändert.',
),
);
render(<HandelswareDatevPage />);
await upload();
await waitFor(() => expect(downloadButton().disabled).toBe(false));
fireEvent.click(downloadButton());
expect(await screen.findByText(/Die Kontenliste wurde inzwischen geändert/)).toBeTruthy();
expect(mockDownload).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Vorschau neu laden' }));
await waitFor(() => expect(api.previewHandelsware).toHaveBeenCalledTimes(2));
});
it('Administrator sieht bei fehlenden Einstellungen den Hinweis mit Weg zu den Einstellungen', async () => {
mockUser('ADMIN');
api.getHandelswareSettings.mockResolvedValue(EMPTY);
render(<HandelswareDatevPage />);
expect(await screen.findByText(/noch nicht hinterlegt/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' }));
const field = (await screen.findByLabelText('Standard-Erlöskonto')) as HTMLInputElement;
expect(field.value).toBe('');
expect((screen.getByLabelText('Startwert Gegenkonto') as HTMLInputElement).value).toBe('');
});
it('normaler Benutzer sieht "Ein Administrator muss zuerst …" und keinen Einstellungen-Reiter', async () => {
api.getHandelswareSettings.mockResolvedValue(EMPTY);
render(<HandelswareDatevPage />);
expect(await screen.findByText(/muss zuerst Standard-Erlöskonto/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('listet fehlerhafte Zeilen mit Zeilennummer und sperrt den Download', async () => {
api.previewHandelsware.mockResolvedValue({
...PREVIEW,
rowErrors: [{ line: 5, code: 'umsatzInvalid', message: 'x' }],
});
render(<HandelswareDatevPage />);
await upload();
expect(await screen.findByText(/Zeile 5: Der Umsatz ist keine gültige Zahl/)).toBeTruthy();
expect(downloadButton().disabled).toBe(true);
});
});
describe('HandelswareDatevPage — Konten', () => {
async function openAccounts() {
render(<HandelswareDatevPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Konten' }));
await screen.findByText('Tee');
}
it('listet die Konten', async () => {
await openAccounts();
expect(screen.getByText('Kaffee')).toBeTruthy();
expect(screen.getByText('2005')).toBeTruthy();
});
it('legt ein Konto an (Erlöskonto aus den Einstellungen vorbelegt)', async () => {
api.createAccount.mockResolvedValue({
id: 'a3',
name: 'Saft',
gegenkonto: 2020,
erloeskonto: 4711,
});
await openAccounts();
fireEvent.change(screen.getAllByLabelText('Name')[0], { target: { value: 'Saft' } });
fireEvent.change(screen.getAllByLabelText('Gegenkonto')[0], { target: { value: '2020' } });
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
await waitFor(() =>
expect(api.createAccount).toHaveBeenCalledWith({
name: 'Saft',
gegenkonto: 2020,
erloeskonto: 4711,
}),
);
expect(await screen.findByText('Saft')).toBeTruthy();
});
it('bearbeitet ein Konto', async () => {
api.updateAccount.mockResolvedValue({
id: 'a2',
name: 'Tee',
gegenkonto: 2999,
erloeskonto: 4001,
});
await openAccounts();
const row = screen.getByText('Tee').closest('tr') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: 'Bearbeiten' }));
const editRow = screen.getByDisplayValue('Tee').closest('tr') as HTMLElement;
fireEvent.change(within(editRow).getByLabelText('Gegenkonto'), { target: { value: '2999' } });
fireEvent.click(within(editRow).getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.updateAccount).toHaveBeenCalledWith('a2', {
name: 'Tee',
gegenkonto: 2999,
erloeskonto: 4001,
}),
);
expect(await screen.findByText('2999')).toBeTruthy();
});
it('löscht erst nach Rückfrage', async () => {
api.deleteAccount.mockResolvedValue({ deleted: true });
await openAccounts();
const row = screen.getByText('Tee').closest('tr') as HTMLElement;
fireEvent.click(within(row).getByRole('button', { name: 'Löschen' }));
expect(api.deleteAccount).not.toHaveBeenCalled();
expect(screen.getByText('Konto „Tee“ wirklich löschen?')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Ja, löschen' }));
await waitFor(() => expect(api.deleteAccount).toHaveBeenCalledWith('a2'));
await waitFor(() => expect(screen.queryByText('Tee')).toBeNull());
});
it('CSV-Import fragt vorher "Alle 2 vorhandenen Konten werden … ersetzt" und ruft erst nach Bestätigung', async () => {
api.importAccountsCsv.mockResolvedValue({ count: 5 });
await openAccounts();
const csv = new File(['a;1;2'], 'konten.csv');
fireEvent.change(screen.getByTestId('accounts-csv-input'), { target: { files: [csv] } });
expect(
screen.getByText(
/Alle 2 vorhandenen Konten werden durch den Inhalt der Datei „konten\.csv“ ersetzt/,
),
).toBeTruthy();
expect(api.importAccountsCsv).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole('button', { name: 'Konten ersetzen' }));
await waitFor(() => expect(api.importAccountsCsv).toHaveBeenCalledWith(csv));
expect(await screen.findByText('5 Konten wurden importiert.')).toBeTruthy();
});
it('CSV-Import mit Fehlern zeigt die Zeilen', async () => {
api.importAccountsCsv.mockRejectedValue(
new HandelswareRequestError(400, 'csvErrors', 'x', [
{ line: 3, code: 'gegenkontoInvalid', message: 'y' },
]),
);
await openAccounts();
fireEvent.change(screen.getByTestId('accounts-csv-input'), {
target: { files: [new File(['x'], 'k.csv')] },
});
fireEvent.click(screen.getByRole('button', { name: 'Konten ersetzen' }));
expect(await screen.findByText(/Zeile 3: Das Gegenkonto muss eine ganze Zahl/)).toBeTruthy();
});
it('CSV-Export lädt die Datei herunter', async () => {
api.exportAccountsCsv.mockResolvedValue({
filename: 'Konten.csv',
content: 'QQ==',
mimeType: 'text/csv;charset=utf-8',
});
await openAccounts();
fireEvent.click(screen.getByRole('button', { name: 'CSV exportieren' }));
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('Konten.csv', 'QQ==', 'text/csv;charset=utf-8'),
);
});
});
describe('HandelswareDatevPage — Freigabestufe Verwalten (261002-icv)', () => {
it('Benutzer mit canManage sieht den Einstellungen-Reiter', async () => {
stubActiveModules([{ slug: 'handelsware-datev', canManage: true }]);
render(<HandelswareDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
});
it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => {
render(<HandelswareDatevPage />);
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
await screen.findByRole('button', { name: 'Konten' });
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
});
describe('HandelswareDatevPage — Einstellungen', () => {
it('Reiter nur für Administratoren, Eingaben validiert, Speichern ruft die API', async () => {
mockUser('SUPER_ADMIN');
api.getHandelswareSettings.mockResolvedValue(EMPTY);
api.saveHandelswareSettings.mockResolvedValue(CONFIGURED);
render(<HandelswareDatevPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
fireEvent.change(await screen.findByLabelText('Standard-Erlöskonto'), {
target: { value: 'abc' },
});
fireEvent.change(screen.getByLabelText('Startwert Gegenkonto'), { target: { value: '2000' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(
await screen.findByText('Bitte eine ganze Zahl von 1 bis 999999999 eingeben.'),
).toBeTruthy();
expect(api.saveHandelswareSettings).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('Standard-Erlöskonto'), { target: { value: '4711' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(api.saveHandelswareSettings).toHaveBeenCalledWith({
erloeskonto: 4711,
startGegenkonto: 2000,
}),
);
expect(await screen.findByText('Die Einstellungen wurden gespeichert.')).toBeTruthy();
});
});
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function HandelswareDatevLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="handelsware-datev">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,75 @@
'use client';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { getHandelswareSettings, type HandelswareSettings } from '@/lib/handelsware-datev-api';
import { useCanManageModule } from '@/lib/use-module-capability';
import { AccountsTab } from './components/AccountsTab';
import { ImportTab } from './components/ImportTab';
import { SettingsTab } from './components/SettingsTab';
type TabId = 'import' | 'accounts' | 'settings';
/**
* Handelsware (quick-261002-fm5): Excel-Umsaetze Erloeskonten zuordnen und als
* DATEV-Buchungsdatei herunterladen. Reiter Import / Konten / Einstellungen
* (letzterer fuer Administratoren und Benutzer mit der Freigabestufe Verwalten). Nach einem Export zaehlt `accountsVersion`
* hoch, damit der Reiter "Konten" die neu gespeicherten Konten nachlaedt.
*/
export default function HandelswareDatevPage() {
const t = useTranslations('handelswareDatev');
// Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
const canManage = useCanManageModule('handelsware-datev') === true;
const [tab, setTab] = useState<TabId>('import');
const [settings, setSettings] = useState<HandelswareSettings | null>(null);
const [accountsVersion, setAccountsVersion] = useState(0);
useEffect(() => {
let cancelled = false;
getHandelswareSettings()
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch(() => {
// Ohne Einstellungen bleibt der Hinweis aus; die API sperrt trotzdem sauber.
});
return () => {
cancelled = true;
};
}, []);
const tabs: { id: TabId; label: string }[] = [
{ id: 'import', label: t('tabs.import') },
{ id: 'accounts', label: t('tabs.accounts') },
];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !canManage ? 'import' : tab;
return (
<div className="mx-auto max-w-5xl space-y-6 p-3 sm:p-6">
<PageHeader
moduleSlug="handelsware-datev"
title={t('title')}
description={t('description')}
/>
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'import' && (
<ImportTab
settings={settings}
canManage={canManage}
onOpenSettings={() => setTab('settings')}
onAccountsChanged={() => setAccountsVersion((v) => v + 1)}
/>
)}
{activeTab === 'accounts' && (
<AccountsTab settings={settings} reloadKey={accountsVersion} />
)}
{activeTab === 'settings' && <SettingsTab settings={settings} onSaved={setSettings} />}
</div>
</div>
);
}
@@ -0,0 +1,258 @@
import {
cleanup,
fireEvent,
render as rtlRender,
screen,
waitFor,
within,
} from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import de from '@/messages/de.json';
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
const mockGetSettings = vi.fn();
const mockSaveSettings = vi.fn();
const mockPreview = vi.fn();
const mockExport = vi.fn();
vi.mock('@/lib/kantine-datev-api', () => ({
getKantineSettings: (...a: unknown[]) => mockGetSettings(...a),
saveKantineSettings: (...a: unknown[]) => mockSaveSettings(...a),
previewKantineCsv: (...a: unknown[]) => mockPreview(...a),
exportKantineCsv: (...a: unknown[]) => mockExport(...a),
KantineRequestError: class extends Error {
constructor(
readonly status: number,
readonly code: string | null,
message: string,
) {
super(message);
}
},
}));
const mockDownload = vi.fn();
vi.mock('@/lib/download-base64', () => ({
downloadBase64: (...a: unknown[]) => mockDownload(...a),
}));
const mockAuthStore = vi.fn();
vi.mock('@/lib/stores/auth-store', () => ({
useAuthStore: (selector: (state: unknown) => unknown) => mockAuthStore(selector),
}));
function mockUser(role: 'SUPER_ADMIN' | 'ADMIN' | 'USER') {
mockAuthStore.mockImplementation((selector: (s: { user: { role: string } }) => unknown) =>
selector({ user: { role } }),
);
}
import KantineDatevPage from './page';
const CONFIGURED = { beraterNr: '1234567', mandantNr: '12345', lohnart: '1111', configured: true };
const EMPTY = { beraterNr: null, mandantNr: null, lohnart: null, configured: false };
const GOOD_PREVIEW = {
rowCount: 3,
abrechnungsMonat: '03/2026',
totalCents: 12345,
errors: [],
warnings: [],
canExport: true,
blockedReason: null,
};
function csvFile() {
return new File(['x'], 'kantine.csv', { type: 'text/csv' });
}
async function upload(file = csvFile()) {
const input = (await screen.findByTestId('file-drop-input')) as HTMLInputElement;
fireEvent.change(input, { target: { files: [file] } });
}
/** Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv). */
const mockFetch = vi.fn();
function stubActiveModules(entries: unknown[]) {
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
}
beforeEach(() => {
mockUser('USER');
mockGetSettings.mockResolvedValue(CONFIGURED);
stubActiveModules([{ slug: 'kantine-datev', canManage: false }]);
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
for (const m of [
mockGetSettings,
mockSaveSettings,
mockPreview,
mockExport,
mockDownload,
mockAuthStore,
]) {
m.mockReset();
}
});
describe('KantineDatevPage — nicht eingerichtet', () => {
it('Administrator sieht Hinweis mit Weg zu den Einstellungen und das Formular', async () => {
mockUser('ADMIN');
mockGetSettings.mockResolvedValue(EMPTY);
render(<KantineDatevPage />);
expect(await screen.findByText(/noch nicht hinterlegt/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Zu den Einstellungen' }));
const berater = (await screen.findByLabelText('Beraternummer')) as HTMLInputElement;
expect(berater.value).toBe('');
expect(screen.getByLabelText('Mandantennummer')).toBeTruthy();
expect(screen.getByLabelText('Lohnart')).toBeTruthy();
});
it('normaler Benutzer sieht den Administrator-Hinweis und keinen Einstellungen-Reiter', async () => {
mockGetSettings.mockResolvedValue(EMPTY);
render(<KantineDatevPage />);
expect(await screen.findByText(/muss zuerst Beraternummer/)).toBeTruthy();
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
});
describe('KantineDatevPage — Freigabestufe Verwalten (261002-icv)', () => {
it('Benutzer mit canManage sieht den Einstellungen-Reiter', async () => {
stubActiveModules([{ slug: 'kantine-datev', canManage: true }]);
render(<KantineDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
});
it('Benutzer ohne canManage sieht keinen Einstellungen-Reiter', async () => {
render(<KantineDatevPage />);
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
await screen.findByText('Kantinenabrechnung');
expect(screen.queryByRole('button', { name: 'Einstellungen' })).toBeNull();
});
it('Administrator sieht den Reiter ohne jede Abfrage von /modules/active', async () => {
mockUser('ADMIN');
render(<KantineDatevPage />);
expect(await screen.findByRole('button', { name: 'Einstellungen' })).toBeTruthy();
expect(mockFetch).not.toHaveBeenCalled();
});
});
describe('KantineDatevPage — Abrechnung', () => {
it('zeigt nach dem Hochladen Zeilen, Abrechnungsmonat und Gesamtbetrag', async () => {
mockPreview.mockResolvedValue(GOOD_PREVIEW);
render(<KantineDatevPage />);
await upload();
expect(await screen.findByText('03/2026')).toBeTruthy();
expect(screen.getByText('3')).toBeTruthy();
expect(screen.getByText(/123,45/)).toBeTruthy();
expect(screen.getByText('Die hochgeladenen Daten werden nicht gespeichert.')).toBeTruthy();
});
it('listet Warnungen und Fehler mit Zeilennummer, Download gesperrt', async () => {
mockPreview.mockResolvedValue({
...GOOD_PREVIEW,
canExport: false,
blockedReason: 'errors',
warnings: [
{
code: 'multipleMonths',
message: 'x',
params: { months: ['03/2026', '04/2026'] },
},
],
errors: [{ row: 7, field: 'betrag', code: 'betragFormat', message: 'Betrag muss ...' }],
});
render(<KantineDatevPage />);
await upload();
const table = await screen.findByRole('table');
const row = within(table).getByText('7').closest('tr') as HTMLElement;
expect(within(row).getByText('Betrag')).toBeTruthy();
expect(within(row).getByText(/deutschen Zahlenformat/)).toBeTruthy();
expect(
screen.getByText(/Verschiedene Abrechnungsmonate erkannt: 03\/2026, 04\/2026/),
).toBeTruthy();
expect(
(screen.getByRole('button', { name: 'DATEV-Datei herunterladen' }) as HTMLButtonElement)
.disabled,
).toBe(true);
});
it('sperrt den Download, solange nichts hinterlegt ist', async () => {
mockGetSettings.mockResolvedValue(EMPTY);
mockPreview.mockResolvedValue({
...GOOD_PREVIEW,
canExport: false,
blockedReason: 'settingsMissing',
});
render(<KantineDatevPage />);
await upload();
const button = (await screen.findByRole('button', {
name: 'DATEV-Datei herunterladen',
})) as HTMLButtonElement;
expect(button.disabled).toBe(true);
});
it('Klick auf Download ruft den Export mit derselben Datei und danach downloadBase64', async () => {
mockPreview.mockResolvedValue(GOOD_PREVIEW);
mockExport.mockResolvedValue({
filename: 'LuG_1_2_03_2026.sic',
content: 'QUJD',
mimeType: 'text/plain',
});
render(<KantineDatevPage />);
const file = csvFile();
await upload(file);
const button = (await screen.findByRole('button', {
name: 'DATEV-Datei herunterladen',
})) as HTMLButtonElement;
await waitFor(() => expect(button.disabled).toBe(false));
fireEvent.click(button);
await waitFor(() =>
expect(mockDownload).toHaveBeenCalledWith('LuG_1_2_03_2026.sic', 'QUJD', 'text/plain'),
);
expect(mockExport).toHaveBeenCalledWith(file);
expect(await screen.findByText(/LuG_1_2_03_2026\.sic/)).toBeTruthy();
});
});
describe('KantineDatevPage — Einstellungen speichern', () => {
it('lehnt Buchstaben vor dem Senden ab und speichert Ziffern', async () => {
mockUser('ADMIN');
mockGetSettings.mockResolvedValue(EMPTY);
mockSaveSettings.mockResolvedValue(CONFIGURED);
render(<KantineDatevPage />);
fireEvent.click(await screen.findByRole('button', { name: 'Einstellungen' }));
fireEvent.change(await screen.findByLabelText('Beraternummer'), { target: { value: '12a' } });
fireEvent.change(screen.getByLabelText('Mandantennummer'), { target: { value: '12345' } });
fireEvent.change(screen.getByLabelText('Lohnart'), { target: { value: '1111' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
expect(await screen.findByText('Bitte nur Ziffern eingeben (1 bis 10 Stellen).')).toBeTruthy();
expect(mockSaveSettings).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText('Beraternummer'), { target: { value: '1234567' } });
fireEvent.click(screen.getByRole('button', { name: 'Speichern' }));
await waitFor(() =>
expect(mockSaveSettings).toHaveBeenCalledWith({
beraterNr: '1234567',
mandantNr: '12345',
lohnart: '1111',
}),
);
expect(await screen.findByText('Die Einstellungen wurden gespeichert.')).toBeTruthy();
});
});
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react';
import { ModuleAccessGate } from '@/components/modules/module-access-gate';
export default function KantineDatevLayout({ children }: { children: ReactNode }) {
return <ModuleAccessGate moduleSlug="kantine-datev">{children}</ModuleAccessGate>;
}
@@ -0,0 +1,364 @@
'use client';
import { useTranslations } from 'next-intl';
import { useCallback, useEffect, useState } from 'react';
import { FileDropArea } from '@/components/accounting/file-drop-area';
import { TabBar } from '@/components/accounting/tab-bar';
import { PageHeader } from '@/components/layout/page-header';
import { downloadBase64 } from '@/lib/download-base64';
import {
exportKantineCsv,
getKantineSettings,
type KantinePreview,
KantineRequestError,
type KantineSettings,
previewKantineCsv,
saveKantineSettings,
} from '@/lib/kantine-datev-api';
import { useCanManageModule } from '@/lib/use-module-capability';
type TabId = 'billing' | 'settings';
const INPUT_CLASS =
'w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring';
const BUTTON_CLASS =
'rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 transition-opacity disabled:cursor-not-allowed disabled:opacity-50';
const euro = new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' });
/**
* Kantinenabrechnung (quick-261002-fm5): Kantinen-CSV hochladen, pruefen und
* als DATEV-Lohndatei herunterladen. Die Datei bleibt im Browser (State) und
* wird fuer Vorschau UND Export an die API geschickt — die API speichert nichts.
*/
export default function KantineDatevPage() {
const t = useTranslations('kantineDatev');
// Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
const canManage = useCanManageModule('kantine-datev') === true;
const [tab, setTab] = useState<TabId>('billing');
const [settings, setSettings] = useState<KantineSettings | null>(null);
const [settingsError, setSettingsError] = useState(false);
useEffect(() => {
let cancelled = false;
getKantineSettings()
.then((s) => {
if (!cancelled) setSettings(s);
})
.catch(() => {
if (!cancelled) setSettingsError(true);
});
return () => {
cancelled = true;
};
}, []);
const tabs: { id: TabId; label: string }[] = [{ id: 'billing', label: t('tabs.billing') }];
if (canManage) tabs.push({ id: 'settings', label: t('tabs.settings') });
const activeTab = tab === 'settings' && !canManage ? 'billing' : tab;
return (
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="kantine-datev" title={t('title')} description={t('description')} />
<TabBar tabs={tabs} active={activeTab} onChange={setTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'billing' ? (
<BillingTab
settings={settings}
settingsError={settingsError}
canManage={canManage}
onOpenSettings={() => setTab('settings')}
/>
) : (
<SettingsTab settings={settings} onSaved={setSettings} />
)}
</div>
</div>
);
}
function BillingTab({
settings,
settingsError,
canManage,
onOpenSettings,
}: {
settings: KantineSettings | null;
settingsError: boolean;
canManage: boolean;
onOpenSettings: () => void;
}) {
const t = useTranslations('kantineDatev');
const [file, setFile] = useState<File | null>(null);
const [preview, setPreview] = useState<KantinePreview | null>(null);
const [checking, setChecking] = useState(false);
const [downloading, setDownloading] = useState(false);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
const requestError = useCallback(
(error: unknown): string => {
if (error instanceof KantineRequestError) {
if (error.code && t.has(`errors.${error.code}`)) return t(`errors.${error.code}`);
return error.message;
}
return t('errors.request');
},
[t],
);
const handleFile = async (selected: File) => {
setFile(selected);
setPreview(null);
setMessage(null);
setChecking(true);
try {
setPreview(await previewKantineCsv(selected));
} catch (error) {
setMessage({ kind: 'error', text: requestError(error) });
} finally {
setChecking(false);
}
};
const handleClear = () => {
setFile(null);
setPreview(null);
setMessage(null);
};
const handleDownload = async () => {
if (!file) return;
setDownloading(true);
setMessage(null);
try {
const result = await exportKantineCsv(file);
downloadBase64(result.filename, result.content, result.mimeType);
setMessage({ kind: 'ok', text: t('download.done', { filename: result.filename }) });
} catch (error) {
setMessage({ kind: 'error', text: requestError(error) });
} finally {
setDownloading(false);
}
};
const notConfigured = settings !== null && !settings.configured;
const canDownload = Boolean(preview?.canExport) && !downloading && !checking;
return (
<div className="space-y-5">
{settingsError && <p className="text-sm text-destructive">{t('errors.request')}</p>}
{notConfigured && (
<div className="space-y-2 rounded-md border border-status-warn/40 bg-status-warn/10 px-4 py-3 text-sm text-foreground">
<p>{canManage ? t('notConfigured.admin') : t('notConfigured.user')}</p>
{canManage && (
<button
type="button"
onClick={onOpenSettings}
className="font-medium underline underline-offset-2"
>
{t('notConfigured.adminAction')}
</button>
)}
</div>
)}
<FileDropArea
accept=".csv,text/csv"
file={file}
onFile={handleFile}
onClear={handleClear}
placeholder={t('dropZone.placeholder')}
hint={t('dropZone.formats')}
clearLabel={t('dropZone.clear')}
/>
<p className="text-xs text-muted-foreground">{t('noStorage')}</p>
{checking && <p className="text-sm text-muted-foreground">{t('checking')}</p>}
{preview && (
<div className="space-y-5">
<section aria-label={t('summary.title')}>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<SummaryItem label={t('summary.rows')} value={String(preview.rowCount)} />
<SummaryItem
label={t('summary.month')}
value={preview.abrechnungsMonat ?? t('summary.noMonth')}
/>
<SummaryItem
label={t('summary.total')}
value={euro.format(preview.totalCents / 100)}
/>
</dl>
</section>
{preview.warnings.length > 0 && (
<section className="space-y-1">
<h2 className="text-sm font-semibold text-foreground">{t('warnings.title')}</h2>
<ul className="space-y-1 text-sm text-status-warn-fg">
{preview.warnings.map((w) => (
<li key={`${w.code}-${w.message}`}>
{w.code === 'multipleMonths' && w.params?.months
? t('warnings.multipleMonths', { months: w.params.months.join(', ') })
: w.message}
</li>
))}
</ul>
</section>
)}
{preview.errors.length > 0 && (
<section className="space-y-2">
<h2 className="text-sm font-semibold text-destructive">{t('errors.title')}</h2>
<div className="overflow-x-auto rounded-md border border-border">
<table className="w-full text-left text-sm">
<thead className="bg-muted/50 text-xs text-muted-foreground">
<tr>
<th className="px-3 py-2 font-medium">{t('errors.line')}</th>
<th className="px-3 py-2 font-medium">{t('errors.field')}</th>
<th className="px-3 py-2 font-medium">{t('errors.message')}</th>
</tr>
</thead>
<tbody>
{preview.errors.map((e) => (
<tr key={`${e.row}-${e.field}-${e.code}`} className="border-t border-border">
<td className="px-3 py-2 tabular-nums">{e.row}</td>
<td className="px-3 py-2">
{t.has(`fields.${e.field}`) ? t(`fields.${e.field}`) : e.field}
</td>
<td className="px-3 py-2">
{t.has(`errors.${e.code}`) ? t(`errors.${e.code}`) : e.message}
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
)}
<button
type="button"
className={BUTTON_CLASS}
disabled={!canDownload}
onClick={handleDownload}
>
{downloading ? t('download.busy') : t('download.button')}
</button>
</div>
)}
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
</div>
);
}
function SummaryItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5 text-lg font-semibold text-foreground">{value}</dd>
</div>
);
}
function SettingsTab({
settings,
onSaved,
}: {
settings: KantineSettings | null;
onSaved: (s: KantineSettings) => void;
}) {
const t = useTranslations('kantineDatev');
const [beraterNr, setBeraterNr] = useState('');
const [mandantNr, setMandantNr] = useState('');
const [lohnart, setLohnart] = useState('');
const [touched, setTouched] = useState(false);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState<{ kind: 'error' | 'ok'; text: string } | null>(null);
// Gespeicherte Werte einmal ins Formular uebernehmen, sobald sie geladen sind.
useEffect(() => {
if (settings) {
setBeraterNr(settings.beraterNr ?? '');
setMandantNr(settings.mandantNr ?? '');
setLohnart(settings.lohnart ?? '');
}
}, [settings]);
const valid = (v: string) => /^\d{1,10}$/.test(v);
const allValid = valid(beraterNr) && valid(mandantNr) && valid(lohnart);
const handleSave = async (event: React.FormEvent) => {
event.preventDefault();
setTouched(true);
if (!allValid) return;
setSaving(true);
setMessage(null);
try {
const saved = await saveKantineSettings({ beraterNr, mandantNr, lohnart });
onSaved(saved);
setMessage({ kind: 'ok', text: t('settings.saved') });
} catch (error) {
setMessage({
kind: 'error',
text: error instanceof KantineRequestError ? error.message : t('errors.request'),
});
} finally {
setSaving(false);
}
};
const fields = [
{ id: 'beraterNr', label: t('settings.beraterNr'), value: beraterNr, set: setBeraterNr },
{ id: 'mandantNr', label: t('settings.mandantNr'), value: mandantNr, set: setMandantNr },
{ id: 'lohnart', label: t('settings.lohnart'), value: lohnart, set: setLohnart },
];
return (
<form onSubmit={handleSave} className="max-w-md space-y-4" noValidate>
<p className="text-sm text-muted-foreground">{t('settings.intro')}</p>
{fields.map((f) => {
const showError = touched && !valid(f.value);
return (
<div key={f.id} className="space-y-1">
<label htmlFor={`kantine-${f.id}`} className="text-sm font-medium text-foreground">
{f.label}
</label>
<input
id={`kantine-${f.id}`}
type="text"
inputMode="numeric"
autoComplete="off"
maxLength={10}
value={f.value}
aria-invalid={showError}
onChange={(e) => f.set(e.target.value.trim())}
className={INPUT_CLASS}
/>
{showError && <p className="text-xs text-destructive">{t('settings.digitsOnly')}</p>}
</div>
);
})}
<button type="submit" className={BUTTON_CLASS} disabled={saving}>
{saving ? t('settings.saving') : t('settings.save')}
</button>
{message && (
<p
role={message.kind === 'error' ? 'alert' : 'status'}
className={`text-sm ${message.kind === 'error' ? 'text-destructive' : 'text-status-ok-fg'}`}
>
{message.text}
</p>
)}
</form>
);
}
@@ -5,6 +5,8 @@ import { describe, expect, it } from 'vitest';
import CertManagerLayout from './cert-manager/layout'; import CertManagerLayout from './cert-manager/layout';
import DkvFleetLayout from './dkv-fleet/layout'; import DkvFleetLayout from './dkv-fleet/layout';
import DomaincheckLayout from './domaincheck/layout'; import DomaincheckLayout from './domaincheck/layout';
import HandelswareDatevLayout from './handelsware-datev/layout';
import KantineDatevLayout from './kantine-datev/layout';
import TenderRadarLayout from './tender-radar/layout'; import TenderRadarLayout from './tender-radar/layout';
/** /**
@@ -38,6 +40,8 @@ describe('module layouts — ModuleAccessGate slug wiring (T-e8k-01, T-e8k-03)',
['dkv-fleet', DkvFleetLayout], ['dkv-fleet', DkvFleetLayout],
['domaincheck', DomaincheckLayout], ['domaincheck', DomaincheckLayout],
['tender-radar', TenderRadarLayout], ['tender-radar', TenderRadarLayout],
['kantine-datev', KantineDatevLayout],
['handelsware-datev', HandelswareDatevLayout],
] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => { ] as const)('%s/layout.tsx passes moduleSlug="%s" and forwards children', (expectedSlug, Layout) => {
const element = Layout({ children: placeholderChild }); const element = Layout({ children: placeholderChild });
@@ -74,9 +74,9 @@ function makeServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServer {
const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false }); const UNPOLLED = makeStatus({ lastPolledAt: null, lastOkAt: null, reachable: false });
async function card(server: ProxmoxServer, isAdmin?: boolean) { async function card(server: ProxmoxServer, canManage?: boolean) {
const { ServerCard } = await import('./ServerCard'); const { ServerCard } = await import('./ServerCard');
renderDe(<ServerCard server={server} isAdmin={isAdmin} now={NOW} />); renderDe(<ServerCard server={server} canManage={canManage} now={NOW} />);
return screen.getByTestId('server-card'); return screen.getByTestId('server-card');
} }
@@ -392,7 +392,7 @@ describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument(); expect(within(el).queryByTestId('last-polled')).not.toBeInTheDocument();
}); });
it('isAdmin={false}: automatischer Hinweis ohne Knopfverweis', async () => { it('canManage={false}: automatischer Hinweis ohne Knopfverweis', async () => {
const el = await card(makeServer({ status: UNPOLLED }), false); const el = await card(makeServer({ status: UNPOLLED }), false);
expect( expect(
@@ -403,7 +403,7 @@ describe('ServerCard — noch nicht abgefragt (260923-le6)', () => {
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument(); expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
}); });
it('isAdmin weggelassen: verhaelt sich wie isAdmin={false} (sichere Vorgabe)', async () => { it('canManage weggelassen: verhaelt sich wie canManage={false} (sichere Vorgabe)', async () => {
const el = await card(makeServer({ status: UNPOLLED })); const el = await card(makeServer({ status: UNPOLLED }));
expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument(); expect(within(el).queryByText(/Jetzt aktualisieren/)).not.toBeInTheDocument();
@@ -351,7 +351,7 @@ function errorMessage(t: Translator, kind: ProxmoxErrorKind | null): string {
interface ServerCardProps { interface ServerCardProps {
server: ProxmoxServer; server: ProxmoxServer;
isAdmin?: boolean; canManage?: boolean;
/** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */ /** Bezugszeitpunkt fuer relative Zeitangaben (ms); die Seite reicht einen tickenden Wert durch. */
now?: number; now?: number;
} }
@@ -363,7 +363,7 @@ interface ServerCardProps {
* („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das * („offline & verwaist“) zeigt KEINE alten Messwerte mehr — auch wenn das
* Zwischenlager noch welche hat. * Zwischenlager noch welche hat.
*/ */
export function ServerCard({ server, isAdmin = false, now = Date.now() }: ServerCardProps) { export function ServerCard({ server, canManage = false, now = Date.now() }: ServerCardProps) {
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
const locale = useLocale(); const locale = useLocale();
const health = serverHealth(server, now); const health = serverHealth(server, now);
@@ -377,7 +377,7 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
body = ( body = (
<div className="space-y-1 text-sm" data-testid="orphan-notice"> <div className="space-y-1 text-sm" data-testid="orphan-notice">
<p className="text-foreground">{t('card.orphanText')}</p> <p className="text-foreground">{t('card.orphanText')}</p>
{isAdmin && ( {canManage && (
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{t('card.orphanAdminHint')}{' '} {t('card.orphanAdminHint')}{' '}
<Link <Link
@@ -391,12 +391,12 @@ export function ServerCard({ server, isAdmin = false, now = Date.now() }: Server
</div> </div>
); );
} else if (health === 'idle') { } else if (health === 'idle') {
// Nicht-Admins sehen den Knopf nicht (der Poll-Endpunkt verlangt // Wer nicht verwalten darf, sieht den Knopf nicht (der Poll-Endpunkt
// ADMIN/SUPER_ADMIN) und bekommen deshalb den Text ohne Knopfverweis // verlangt Verwalten fuer das Proxmox-Modul, 261002-icv) und bekommt
// (260923-le6). // deshalb den Text ohne Knopfverweis (260923-le6).
body = ( body = (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isAdmin {canManage
? t('card.notPolledYet', { refreshLabel: t('card.refresh') }) ? t('card.notPolledYet', { refreshLabel: t('card.refresh') })
: t('card.notPolledYetAutomatic')} : t('card.notPolledYetAutomatic')}
</p> </p>
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import Link from 'next/link'; import Link from 'next/link';
import { PageHeader } from '@/components/layout/page-header'; import { PageHeader } from '@/components/layout/page-header';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useCanManageModule } from '@/lib/use-module-capability';
import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api'; import { listServers, pollServer, type ProxmoxServer } from '@/lib/proxmox-api';
import { HealthBar } from '@/components/proxmox/HealthBar'; import { HealthBar } from '@/components/proxmox/HealthBar';
import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status'; import { sortServersByHealth, summarizeHealth } from '@/components/proxmox/proxmox-status';
@@ -60,13 +60,14 @@ function SkeletonCard() {
* Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand * Aufbau: Kopf, Gesundheitsbalken, Kartenraster sortiert nach Zustand
* (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“ * (down, warn, ok, idle, orphan, darin `position`). „Jetzt aktualisieren“
* loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint * loest je Server eine Abfrage aus und laedt danach neu; der Knopf erscheint
* nur fuer Admins, weil `POST servers/:id/poll` `@Roles(ADMIN, SUPER_ADMIN)` * nur fuer Administratoren und Benutzer mit der Freigabestufe Verwalten, weil
* verlangt (260923-le6). * `POST servers/:id/poll` `@ModuleManage('proxmox')` verlangt (260923-le6,
* 261002-icv).
*/ */
export default function ProxmoxPage() { export default function ProxmoxPage() {
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
const user = useAuthStore((s) => s.user); // Administratoren und Benutzer mit der Freigabestufe Verwalten (261002-icv).
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; const canManage = useCanManageModule('proxmox') === true;
const [servers, setServers] = useState<ProxmoxServer[] | null>(null); const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -112,7 +113,7 @@ export default function ProxmoxPage() {
title={t('title')} title={t('title')}
description={t('description')} description={t('description')}
actions={ actions={
isAdmin ? ( canManage ? (
<> <>
<Link href="/modules/proxmox/settings" className="btn btn-subtle"> <Link href="/modules/proxmox/settings" className="btn btn-subtle">
{t('card.settingsNav')} {t('card.settingsNav')}
@@ -171,7 +172,7 @@ export default function ProxmoxPage() {
<line x1="7" y1="16.5" x2="7.01" y2="16.5" /> <line x1="7" y1="16.5" x2="7.01" y2="16.5" />
</svg> </svg>
<p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p> <p className="max-w-md text-sm text-muted-foreground">{t('emptyState')}</p>
{isAdmin && ( {canManage && (
<Link <Link
href="/modules/proxmox/settings" href="/modules/proxmox/settings"
className="text-sm font-medium text-foreground hover:underline" className="text-sm font-medium text-foreground hover:underline"
@@ -191,7 +192,7 @@ export default function ProxmoxPage() {
<ul className="gap-4 lg:columns-2"> <ul className="gap-4 lg:columns-2">
{sorted.map((server) => ( {sorted.map((server) => (
<li key={server.id} className="mb-4 min-w-0 break-inside-avoid"> <li key={server.id} className="mb-4 min-w-0 break-inside-avoid">
<ServerCard server={server} isAdmin={isAdmin} now={now} /> <ServerCard server={server} canManage={canManage} now={now} />
</li> </li>
))} ))}
</ul> </ul>
@@ -1,7 +1,7 @@
import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react'; import { cleanup, render as rtlRender, screen, waitFor, within } from '@testing-library/react';
import { NextIntlClientProvider } from 'next-intl'; import { NextIntlClientProvider } from 'next-intl';
import type { ReactElement } from 'react'; import type { ReactElement } from 'react';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { ProxmoxServer } from '@/lib/proxmox-api'; import type { ProxmoxServer } from '@/lib/proxmox-api';
import de from '@/messages/de.json'; import de from '@/messages/de.json';
@@ -79,8 +79,22 @@ function makeUnpolledServer(overrides: Partial<ProxmoxServer> = {}): ProxmoxServ
} as ProxmoxServer; } as ProxmoxServer;
} }
// Antwort von GET /modules/active für den Fähigkeits-Hook (261002-icv):
// Standard ist "keine Verwalten-Freigabe".
const mockFetch = vi.fn();
function stubActiveModules(entries: unknown[]) {
mockFetch.mockResolvedValue({ ok: true, json: async () => entries });
}
beforeEach(() => {
stubActiveModules([]);
vi.stubGlobal('fetch', mockFetch);
});
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
vi.unstubAllGlobals();
mockFetch.mockReset();
mockListServers.mockReset(); mockListServers.mockReset();
mockPollServer.mockReset(); mockPollServer.mockReset();
mockAuthStore.mockReset(); mockAuthStore.mockReset();
@@ -104,6 +118,38 @@ describe('ProxmoxPage role gating (260923-le6)', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('Rolle USER mit canManage (Verwalten): Knopf, Einstellungen-Verweis und Admin-Text sichtbar (261002-icv)', async () => {
mockUser({ role: 'USER' });
stubActiveModules([{ slug: 'proxmox', canManage: true }]);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
expect(await screen.findByRole('button', { name: 'Jetzt aktualisieren' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Einstellungen' })).toHaveAttribute(
'href',
'/modules/proxmox/settings',
);
});
it('Rolle USER mit canManage false: bleibt schreibgeschützt', async () => {
mockUser({ role: 'USER' });
stubActiveModules([{ slug: 'proxmox', canManage: false }]);
mockListServers.mockResolvedValue([makeUnpolledServer()]);
const { default: ProxmoxPage } = await import('./page');
render(<ProxmoxPage />);
await screen.findByText('pve-1');
await waitFor(() => expect(mockFetch).toHaveBeenCalled());
expect(screen.queryByRole('button', { name: 'Jetzt aktualisieren' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'Einstellungen' })).not.toBeInTheDocument();
});
it('kein Benutzer geladen (user: null): kein Knopf', async () => { it('kein Benutzer geladen (user: null): kein Knopf', async () => {
mockUser(null); mockUser(null);
mockListServers.mockResolvedValue([makeUnpolledServer()]); mockListServers.mockResolvedValue([makeUnpolledServer()]);
@@ -88,7 +88,7 @@ const EXISTING_SERVER = {
describe('ServerForm', () => { describe('ServerForm', () => {
it('bei Typ pmg erscheint die Auswahl "API-Token" gar nicht', async () => { it('bei Typ pmg erscheint die Auswahl "API-Token" gar nicht', async () => {
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement; const authSelect = screen.getByLabelText('Zugangsart') as HTMLSelectElement;
const options = [...authSelect.options].map((o) => o.value); const options = [...authSelect.options].map((o) => o.value);
@@ -98,7 +98,7 @@ describe('ServerForm', () => {
it('bei pve/pbs mit Token erscheinen Token-Kennung und -Geheimnis; bei Passwort Benutzer und Passwort', async () => { it('bei pve/pbs mit Token erscheinen Token-Kennung und -Geheimnis; bei Passwort Benutzer und Passwort', async () => {
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const }; const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={pveServer} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument(); expect(screen.getByLabelText('Token-Kennung')).toBeInTheDocument();
expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument(); expect(screen.getByLabelText('Token-Geheimnis')).toBeInTheDocument();
@@ -113,7 +113,7 @@ describe('ServerForm', () => {
it('ein gespeichertes Geheimnis wird nie im Klartext angezeigt — das Feld ist leer', async () => { it('ein gespeichertes Geheimnis wird nie im Klartext angezeigt — das Feld ist leer', async () => {
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement; const passwordInput = screen.getByLabelText('Passwort') as HTMLInputElement;
expect(passwordInput.value).toBe(''); expect(passwordInput.value).toBe('');
@@ -123,7 +123,7 @@ describe('ServerForm', () => {
mockUpdateServer.mockResolvedValue(EXISTING_SERVER); mockUpdateServer.mockResolvedValue(EXISTING_SERVER);
const onSaved = vi.fn(); const onSaved = vi.fn();
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={onSaved} onCancel={vi.fn()} />); render(<ServerForm server={EXISTING_SERVER} canManage onSaved={onSaved} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Speichern')); fireEvent.click(screen.getByText('Speichern'));
@@ -134,7 +134,7 @@ describe('ServerForm', () => {
it('der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf "pruefen" mit Hinweistext', async () => { it('der Schalter fuer die Zertifikatspruefung steht beim Anlegen auf "pruefen" mit Hinweistext', async () => {
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={null} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement; const checkbox = screen.getByLabelText('Zertifikat prüfen') as HTMLInputElement;
expect(checkbox.checked).toBe(true); expect(checkbox.checked).toBe(true);
@@ -152,7 +152,7 @@ describe('ServerForm', () => {
rawSample: null, rawSample: null,
}); });
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen')); fireEvent.click(screen.getByText('Verbindung testen'));
@@ -168,7 +168,7 @@ describe('ServerForm', () => {
rawSample: null, rawSample: null,
}); });
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen')); fireEvent.click(screen.getByText('Verbindung testen'));
@@ -190,7 +190,7 @@ describe('ServerForm', () => {
rawSample: null, rawSample: null,
}); });
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={null} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={null} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.change(screen.getByLabelText('Adresse'), { fireEvent.change(screen.getByLabelText('Adresse'), {
target: { value: 'https://pve.neu:8006' }, target: { value: 'https://pve.neu:8006' },
@@ -211,7 +211,7 @@ describe('ServerForm', () => {
rawSample: null, rawSample: null,
}); });
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={EXISTING_SERVER} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={EXISTING_SERVER} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
// EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten. // EXISTING_SERVER wurde MIT Zertifikatspruefung gespeichert — im Formular jetzt abschalten.
fireEvent.click(screen.getByLabelText('Zertifikat prüfen')); fireEvent.click(screen.getByLabelText('Zertifikat prüfen'));
@@ -233,7 +233,7 @@ describe('ServerForm', () => {
}); });
const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const }; const pveServer = { ...EXISTING_SERVER, productType: 'pve' as const, authMethod: 'token' as const };
const { ServerForm } = await import('./ServerForm'); const { ServerForm } = await import('./ServerForm');
render(<ServerForm server={pveServer} isAdmin onSaved={vi.fn()} onCancel={vi.fn()} />); render(<ServerForm server={pveServer} canManage onSaved={vi.fn()} onCancel={vi.fn()} />);
fireEvent.click(screen.getByText('Verbindung testen')); fireEvent.click(screen.getByText('Verbindung testen'));
@@ -60,7 +60,7 @@ function serverToForm(server: ProxmoxServer | null): FormState {
interface ServerFormProps { interface ServerFormProps {
server: ProxmoxServer | null; server: ProxmoxServer | null;
isAdmin: boolean; canManage: boolean;
onSaved: (server: ProxmoxServer) => void; onSaved: (server: ProxmoxServer) => void;
onCancel: () => void; onCancel: () => void;
} }
@@ -72,7 +72,7 @@ interface ServerFormProps {
* Geheimnis wird nie im Klartext angezeigt: das Feld ist leer, ein leer * Geheimnis wird nie im Klartext angezeigt: das Feld ist leer, ein leer
* gelassenes Feld laesst den gespeicherten Wert unveraendert. * gelassenes Feld laesst den gespeicherten Wert unveraendert.
*/ */
export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormProps) { export function ServerForm({ server, canManage, onSaved, onCancel }: ServerFormProps) {
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
const [form, setForm] = useState<FormState>(() => serverToForm(server)); const [form, setForm] = useState<FormState>(() => serverToForm(server));
const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server); const [savedServer, setSavedServer] = useState<ProxmoxServer | null>(server);
@@ -189,7 +189,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
type="text" type="text"
className={inputCls} className={inputCls}
value={form.name} value={form.name}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('name', e.target.value)} onChange={(e) => update('name', e.target.value)}
/> />
</div> </div>
@@ -202,7 +202,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-product-type" id="proxmox-product-type"
className={inputCls} className={inputCls}
value={form.productType} value={form.productType}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)} onChange={(e) => handleProductTypeChange(e.target.value as ProxmoxProductType)}
> >
<option value="pve">{t('settings.productTypePve')}</option> <option value="pve">{t('settings.productTypePve')}</option>
@@ -221,7 +221,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder="https://pve.intern:8006" placeholder="https://pve.intern:8006"
className={inputCls} className={inputCls}
value={form.baseUrl} value={form.baseUrl}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('baseUrl', e.target.value)} onChange={(e) => update('baseUrl', e.target.value)}
/> />
</div> </div>
@@ -234,7 +234,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-auth-method" id="proxmox-auth-method"
className={inputCls} className={inputCls}
value={form.authMethod} value={form.authMethod}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('authMethod', e.target.value as ProxmoxAuthMethod)} onChange={(e) => update('authMethod', e.target.value as ProxmoxAuthMethod)}
> >
{/* D-03: PMG kennt keinen API-Token — die Auswahl bietet ihn bei diesem Typ gar nicht erst an. */} {/* D-03: PMG kennt keinen API-Token — die Auswahl bietet ihn bei diesem Typ gar nicht erst an. */}
@@ -255,7 +255,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder="root@pam!tessera" placeholder="root@pam!tessera"
className={inputCls} className={inputCls}
value={form.tokenId} value={form.tokenId}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('tokenId', e.target.value)} onChange={(e) => update('tokenId', e.target.value)}
/> />
</div> </div>
@@ -269,7 +269,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''} placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls} className={inputCls}
value={form.tokenSecret} value={form.tokenSecret}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('tokenSecret', e.target.value)} onChange={(e) => update('tokenSecret', e.target.value)}
/> />
</div> </div>
@@ -286,7 +286,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder="admin@pam" placeholder="admin@pam"
className={inputCls} className={inputCls}
value={form.username} value={form.username}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('username', e.target.value)} onChange={(e) => update('username', e.target.value)}
/> />
</div> </div>
@@ -300,7 +300,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''} placeholder={savedServer ? t('settings.secretUnchangedPlaceholder') : ''}
className={inputCls} className={inputCls}
value={form.password} value={form.password}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('password', e.target.value)} onChange={(e) => update('password', e.target.value)}
/> />
</div> </div>
@@ -318,7 +318,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
max={1440} max={1440}
className={inputCls} className={inputCls}
value={form.pollIntervalMin} value={form.pollIntervalMin}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('pollIntervalMin', e.target.value)} onChange={(e) => update('pollIntervalMin', e.target.value)}
/> />
</div> </div>
@@ -329,7 +329,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-tls-reject" id="proxmox-tls-reject"
type="checkbox" type="checkbox"
checked={form.tlsRejectUnauthorized} checked={form.tlsRejectUnauthorized}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)} onChange={(e) => update('tlsRejectUnauthorized', e.target.checked)}
/> />
{t('settings.tlsRejectLabel')} {t('settings.tlsRejectLabel')}
@@ -343,7 +343,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
id="proxmox-active" id="proxmox-active"
type="checkbox" type="checkbox"
checked={form.isActive} checked={form.isActive}
disabled={!isAdmin} disabled={!canManage}
onChange={(e) => update('isActive', e.target.checked)} onChange={(e) => update('isActive', e.target.checked)}
/> />
{t('settings.activeLabel')} {t('settings.activeLabel')}
@@ -362,7 +362,7 @@ export function ServerForm({ server, isAdmin, onSaved, onCancel }: ServerFormPro
</p> </p>
)} )}
{isAdmin && ( {canManage && (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<button <button
type="button" type="button"
@@ -2,7 +2,7 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useCanManageModule } from '@/lib/use-module-capability';
import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api'; import { deleteServer, listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { ServerForm } from './components/ServerForm'; import { ServerForm } from './components/ServerForm';
@@ -48,17 +48,18 @@ function DeleteDialog({ name, isDeleting, onConfirm, onCancel }: DeleteDialogPro
} }
/** /**
* Moduleinstellungen (Aufgabe 5) — ADMINISTRATION ONLY. Die Rollenpruefung * Moduleinstellungen (Aufgabe 5) — fuer Administratoren und Benutzer mit der
* hier ist reine Anzeige (Ladezustand solange die Rolle unbekannt ist, * Freigabestufe Verwalten (261002-icv). Die Pruefung hier ist reine Anzeige
* damit die Verwaltungsteile fuer einen normalen Benutzer nie kurz * (Ladezustand solange die Faehigkeit unbekannt ist, damit die
* aufblitzen) — der verbindliche Riegel liegt serverseitig * Verwaltungsteile fuer einen normalen Benutzer nie kurz aufblitzen) — der
* (`@Roles(ADMIN, SUPER_ADMIN)` auf jedem Schreibweg, Vorbild * verbindliche Riegel liegt serverseitig (`@ModuleManage('proxmox')` auf
* `tender-radar/settings/page.tsx`). * jedem Schreibweg).
*/ */
export default function ProxmoxSettingsPage() { export default function ProxmoxSettingsPage() {
const t = useTranslations('proxmox'); const t = useTranslations('proxmox');
const user = useAuthStore((s) => s.user); // null solange unklar: Verwaltungsteile blitzen nie kurz auf (261002-icv).
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; const canManageOrNull = useCanManageModule('proxmox');
const canManage = canManageOrNull === true;
const [servers, setServers] = useState<ProxmoxServer[] | null>(null); const [servers, setServers] = useState<ProxmoxServer[] | null>(null);
const [editingId, setEditingId] = useState<string | 'new' | null>(null); const [editingId, setEditingId] = useState<string | 'new' | null>(null);
@@ -93,7 +94,7 @@ export default function ProxmoxSettingsPage() {
} }
}; };
if (user === null) { if (canManageOrNull === null) {
return ( return (
<div className="mx-auto max-w-2xl p-6"> <div className="mx-auto max-w-2xl p-6">
<div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" /> <div className="mb-6 h-8 w-64 animate-pulse rounded bg-muted" />
@@ -102,7 +103,7 @@ export default function ProxmoxSettingsPage() {
); );
} }
if (!isAdmin) { if (!canManage) {
return ( return (
<div className="mx-auto max-w-2xl p-6"> <div className="mx-auto max-w-2xl p-6">
<h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1> <h1 className="mb-4 text-2xl font-semibold tracking-tight">{t('settings.title')}</h1>
@@ -127,7 +128,7 @@ export default function ProxmoxSettingsPage() {
{editingId === 'new' && ( {editingId === 'new' && (
<ServerForm <ServerForm
server={null} server={null}
isAdmin={isAdmin} canManage={canManage}
onSaved={handleSaved} onSaved={handleSaved}
onCancel={() => setEditingId(null)} onCancel={() => setEditingId(null)}
/> />
@@ -143,7 +144,7 @@ export default function ProxmoxSettingsPage() {
<li key={server.id}> <li key={server.id}>
<ServerForm <ServerForm
server={server} server={server}
isAdmin={isAdmin} canManage={canManage}
onSaved={handleSaved} onSaved={handleSaved}
onCancel={() => setEditingId(null)} onCancel={() => setEditingId(null)}
/> />
@@ -0,0 +1,97 @@
'use client';
import { useRef, useState } from 'react';
interface FileDropAreaProps {
onFile: (file: File) => void;
onClear: () => void;
accept: string;
file: File | null;
/** Text in der leeren Flaeche. */
placeholder: string;
/** Zweite Zeile (erlaubte Formate). */
hint?: string;
/** Beschriftung des Entfernen-Knopfs (fuer Screenreader). */
clearLabel: string;
disabled?: boolean;
}
/**
* Ablageflaeche fuer eine einzelne Datei (Finanzbuchhaltungs-Module,
* quick-261002-fm5). Eigenstaendig, ohne Texte aus einem anderen Modul —
* Bauform wie die Ablage des Zertifikat-Managers: echte Schaltflaeche fuer
* Klick UND Ziehen, Entfernen als Geschwister daneben.
*/
export function FileDropArea({
onFile,
onClear,
accept,
file,
placeholder,
hint,
clearLabel,
disabled = false,
}: FileDropAreaProps) {
const inputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
return (
<div>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
data-testid="file-drop-input"
onChange={(e) => {
const picked = e.target.files?.[0];
if (picked) onFile(picked);
e.target.value = '';
}}
/>
<div className="relative">
<button
type="button"
disabled={disabled}
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
const dropped = e.dataTransfer.files?.[0];
if (dropped && !disabled) onFile(dropped);
}}
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
}`}
>
{file ? (
<span className="text-sm text-foreground">
{file.name}{' '}
<span className="text-muted-foreground">({(file.size / 1024).toFixed(1)} KB)</span>
</span>
) : (
<span className="block space-y-1">
<span className="block text-sm text-muted-foreground">{placeholder}</span>
{hint && <span className="block text-xs text-muted-foreground">{hint}</span>}
</span>
)}
</button>
{file && (
<button
type="button"
aria-label={clearLabel}
onClick={onClear}
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
)}
</div>
</div>
);
}
@@ -0,0 +1,30 @@
'use client';
interface TabBarProps<T extends string> {
tabs: { id: T; label: string }[];
active: T;
onChange: (id: T) => void;
}
/** Reiterleiste der Finanzbuchhaltungs-Module — Muster Zertifikat-Manager. */
export function TabBar<T extends string>({ tabs, active, onChange }: TabBarProps<T>) {
return (
<nav className="flex gap-6 overflow-x-auto border-b border-border">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
aria-current={active === tab.id ? 'page' : undefined}
className={`pb-2 text-sm font-medium transition-colors ${
active === tab.id
? 'border-b-2 border-primary-strong font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</nav>
);
}
@@ -21,7 +21,7 @@ import { updateWidgetConfig } from '@/lib/dashboard-api';
// Die manuelle Abfrage (POST .../poll) gehoert der Modulseite und darf hier // Die manuelle Abfrage (POST .../poll) gehoert der Modulseite und darf hier
// nie auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen aus. // nie auftauchen — sonst loeste jede Kachel je Benutzer Live-Abfragen aus.
import { listServers, type ProxmoxServer } from '@/lib/proxmox-api'; import { listServers, type ProxmoxServer } from '@/lib/proxmox-api';
import { useAuthStore } from '@/lib/stores/auth-store'; import { useCanManageModule } from '@/lib/use-module-capability';
import { import {
healthSummary, healthSummary,
type KeyFigure, type KeyFigure,
@@ -71,8 +71,8 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
const t = useTranslations('widgets'); const t = useTranslations('widgets');
const tp = useTranslations('proxmox'); const tp = useTranslations('proxmox');
const locale = useLocale(); const locale = useLocale();
const user = useAuthStore((s) => s.user); // Einstellungs-Link auch fuer Benutzer mit der Freigabestufe Verwalten (261002-icv).
const isAdmin = user?.role === 'ADMIN' || user?.role === 'SUPER_ADMIN'; const canManage = useCanManageModule('proxmox') === true;
// Lokaler Zustand aus `config` initialisiert (wie `viewMode` bei den // Lokaler Zustand aus `config` initialisiert (wie `viewMode` bei den
// Favoriten); Aenderungen gehen per updateWidgetConfig an den Server. // Favoriten); Aenderungen gehen per updateWidgetConfig an den Server.
@@ -244,7 +244,7 @@ export function ProxmoxWidget({ instanceId, config, isEditMode }: WidgetProps) {
if (servers.length === 0) { if (servers.length === 0) {
const settingsHint = const settingsHint =
isAdmin && canManage &&
(isEditMode ? ( (isEditMode ? (
<span className="text-sm font-medium text-foreground">{tp('card.settingsLink')}</span> <span className="text-sm font-medium text-foreground">{tp('card.settingsLink')}</span>
) : ( ) : (
@@ -25,6 +25,8 @@ vi.mock('next-intl/server', () => ({
const translations: Record<string, string> = { const translations: Record<string, string> = {
'accessDenied.title': 'Kein Zugriff auf dieses Modul', 'accessDenied.title': 'Kein Zugriff auf dieses Modul',
'accessDenied.body': 'Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator.', 'accessDenied.body': 'Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator.',
'accessDenied.manageRequiredBody':
'Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.',
'accessDenied.backToDashboard': 'Zur Startseite', 'accessDenied.backToDashboard': 'Zur Startseite',
}; };
return translations[key] ?? key; return translations[key] ?? key;
@@ -102,4 +104,51 @@ describe('ModuleAccessGate — server access gate (D-07, PERM-04)', () => {
expect(checkModuleAccess).toHaveBeenCalledTimes(1); expect(checkModuleAccess).toHaveBeenCalledTimes(1);
expect(checkModuleAccess).toHaveBeenCalledWith('tender-radar'); expect(checkModuleAccess).toHaveBeenCalledWith('tender-radar');
}); });
describe('Verwalten-pflichtige Module (dkv-fleet, 261002-icv)', () => {
const renderGate = async (level: 'none' | 'use' | 'manage' | Error) => {
const checkModuleAccess = vi.fn();
const getModuleAccessLevel =
level instanceof Error
? vi.fn().mockRejectedValue(level)
: vi.fn().mockResolvedValue(level);
vi.doMock('@/lib/module-access-actions', () => ({ checkModuleAccess, getModuleAccessLevel }));
const { ModuleAccessGate } = await import('./module-access-gate');
const element = await ModuleAccessGate({
moduleSlug: 'dkv-fleet',
children: <div data-testid="module-children">children</div>,
});
render(element);
return { checkModuleAccess, getModuleAccessLevel };
};
it('manage: zeigt die Kinder', async () => {
await renderGate('manage');
expect(screen.getByTestId('module-children')).toBeInTheDocument();
});
it('use: zeigt die erklärende Seite mit dem Verwalten-Hinweis, keine Kinder', async () => {
await renderGate('use');
expect(screen.getByText(/nur Benutzern zur Verfügung, die es verwalten dürfen/)).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('none: zeigt den Standardtext', async () => {
await renderGate('none');
expect(screen.getByText(/keine Freigabe/)).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('Fehler beim Abruf: Standardtext, keine Kinder (fail closed)', async () => {
await renderGate(new Error('network error'));
expect(screen.getByText(/keine Freigabe/)).toBeInTheDocument();
expect(screen.queryByTestId('module-children')).not.toBeInTheDocument();
});
it('nutzt getModuleAccessLevel und nicht checkModuleAccess', async () => {
const { checkModuleAccess, getModuleAccessLevel } = await renderGate('manage');
expect(getModuleAccessLevel).toHaveBeenCalledWith('dkv-fleet');
expect(checkModuleAccess).not.toHaveBeenCalled();
});
});
}); });
@@ -1,8 +1,16 @@
import { checkModuleAccess } from '@/lib/module-access-actions'; import { checkModuleAccess, getModuleAccessLevel } from '@/lib/module-access-actions';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { ModuleAccessDenied } from './module-access-denied'; import { ModuleAccessDenied } from './module-access-denied';
/**
* Module, die als Ganzes Verwalten-Stufe verlangen (261002-icv): die API
* traegt dort `@ModuleManage` auf der ganzen Klasse (DkvController). Benutzer
* mit nur "Benutzen" bekaemen von der API ohnehin 403 — die Seite zeigt ihnen
* stattdessen eine erklaerende Zugriffsseite.
*/
const MANAGE_ONLY_MODULE_SLUGS = new Set(['dkv-fleet']);
interface ModuleAccessGateProps { interface ModuleAccessGateProps {
moduleSlug: string; moduleSlug: string;
children: ReactNode; children: ReactNode;
@@ -29,6 +37,26 @@ interface ModuleAccessGateProps {
* missing. * missing.
*/ */
export async function ModuleAccessGate({ moduleSlug, children }: ModuleAccessGateProps) { export async function ModuleAccessGate({ moduleSlug, children }: ModuleAccessGateProps) {
if (MANAGE_ONLY_MODULE_SLUGS.has(moduleSlug)) {
let level: 'none' | 'use' | 'manage' = 'none';
try {
level = await getModuleAccessLevel(moduleSlug);
} catch {
level = 'none';
}
if (level === 'manage') {
return children;
}
const tm = await getTranslations('modules');
return (
<ModuleAccessDenied
title={tm('accessDenied.title')}
body={level === 'use' ? tm('accessDenied.manageRequiredBody') : tm('accessDenied.body')}
backToDashboard={tm('accessDenied.backToDashboard')}
/>
);
}
let hasAccess = false; let hasAccess = false;
try { try {
@@ -43,6 +43,20 @@ const GLYPHS: Record<ModuleIconId, ReactNode> = {
<line x1="6" x2="6.01" y1="18" y2="18" /> <line x1="6" x2="6.01" y1="18" y2="18" />
</> </>
), ),
utensils: (
<>
<path d="M3 2v7c0 1.1.9 2 2 2h4a2 2 0 0 0 2-2V2" />
<path d="M7 2v20" />
<path d="M21 15V2a5 5 0 0 0-5 5v6c0 1.1.9 2 2 2h3Zm0 0v7" />
</>
),
'shopping-bag': (
<>
<path d="M16 10a4 4 0 0 1-8 0" />
<path d="M3.103 6.034h17.794" />
<path d="M3.4 5.467a2 2 0 0 0-.4 1.2V20a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6.667a2 2 0 0 0-.4-1.2l-2-2.667A2 2 0 0 0 17 2H7a2 2 0 0 0-1.6.8z" />
</>
),
tile: ( tile: (
<> <>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /> <path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
+70
View File
@@ -0,0 +1,70 @@
/**
* Gemeinsame Anfragehilfen der Finanzbuchhaltungs-Module (Kantinenabrechnung,
* Handelsware; quick-261002-fm5). `credentials: 'include'` fuer Cookie-Auth,
* `NEXT_PUBLIC_API_URL` als Basis — Muster `custom-modules-api.ts`.
*/
export const ACCOUNTING_API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** Dateiantwort der API (Base64) — wie beim Zertifikat-Manager. */
export interface FileResponse {
filename: string;
content: string;
mimeType: string;
}
/**
* Fehler mit HTTP-Status, maschinenlesbarer Kennung (`code`, falls die API
* eine liefert) und deutscher Servermeldung.
*/
export class AccountingRequestError extends Error {
constructor(
readonly status: number,
readonly code: string | null,
message: string,
readonly details: unknown = null,
) {
super(message);
this.name = 'AccountingRequestError';
}
}
async function failure(res: Response): Promise<AccountingRequestError> {
let message = `Request failed (${res.status})`;
let code: string | null = null;
let details: unknown = null;
try {
const body = await res.json();
const raw = body?.message;
if (Array.isArray(raw)) message = raw.join(' ');
else if (typeof raw === 'string') message = raw;
if (typeof body?.code === 'string') code = body.code;
details = body?.errors ?? null;
} catch {
// Antwort ohne JSON-Koerper — Standardmeldung bleibt.
}
return new AccountingRequestError(res.status, code, message, details);
}
export async function accountingRequest<T>(
path: string,
init: { method?: string; json?: unknown; form?: FormData } = {},
): Promise<T> {
const headers: Record<string, string> = {};
let body: BodyInit | undefined;
if (init.form) {
body = init.form;
} else if (init.json !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(init.json);
}
const res = await fetch(`${ACCOUNTING_API_URL}${path}`, {
method: init.method ?? 'GET',
credentials: 'include',
headers,
body,
});
if (!res.ok) throw await failure(res);
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
+2
View File
@@ -19,6 +19,8 @@ export interface ApiModule {
icon?: string; icon?: string;
version: string; version: string;
isSystem: boolean; isSystem: boolean;
/** Freigabestufe Verwalten (261002-icv) — nur Anzeige, bindend bleibt die API. */
canManage?: boolean;
} }
/** /**
+22
View File
@@ -0,0 +1,22 @@
/**
* Startet den Download einer Base64-kodierten Datei im Browser (quick-261002-fm5).
*
* Clientseitiger Blob-Download: Object-URL plus Anker mit `download`-Attribut.
* Genau dieser Weg wird vom Desktop-Client seit 1.9.2 als Datei gespeichert —
* deshalb keine Tauri-spezifische Sonderbehandlung. Gleicher Koerper wie
* `downloadBase64` im Zertifikat-Manager.
*/
export function downloadBase64(filename: string, content: string, mimeType: string): void {
const bytes = atob(content);
const byteArray = new Uint8Array(bytes.length);
for (let i = 0; i < bytes.length; i++) {
byteArray[i] = bytes.charCodeAt(i);
}
const blob = new Blob([byteArray], { type: mimeType });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
+143
View File
@@ -0,0 +1,143 @@
/**
* Handelsware — API-Client (quick-261002-fm5). Konsumiert
* `/modules/handelsware-datev`. Die Excel-Datei geht als Multipart-Feld `file`;
* beim Export zusaetzlich `buchungsdatum` und `newAccounts` (JSON-Text der von
* der Vorschau gemeldeten neuen Konten) — die API liest alle Zeilen erneut aus
* derselben Datei, damit die TXT nicht von der Arbeitsmappe abweichen kann.
*/
import { accountingRequest, type FileResponse } from '@/lib/accounting-request';
export type { FileResponse } from '@/lib/accounting-request';
export { AccountingRequestError as HandelswareRequestError } from '@/lib/accounting-request';
const BASE = '/modules/handelsware-datev';
export interface HandelswareSettings {
erloeskonto: number | null;
startGegenkonto: number | null;
configured: boolean;
}
export interface HandelswareSettingsInput {
erloeskonto: number;
startGegenkonto: number;
}
export interface HandelswareAccount {
id: string;
name: string;
gegenkonto: number;
erloeskonto: number;
}
export interface HandelswareAccountInput {
name: string;
gegenkonto: number;
erloeskonto: number;
}
export interface NewAccount {
name: string;
gegenkonto: number;
erloeskonto: number;
}
export interface PreviewRow {
line: number;
buchungstext: string;
umsatz: string;
sollHaben: 'S' | 'H';
gegenkonto: number;
erloeskonto: number;
isNew: boolean;
}
export interface RowError {
line: number;
code: string;
message: string;
}
export interface HandelswarePreview {
headerText: string;
suggestedBuchungsdatum: string;
exportFilename: string;
rows: PreviewRow[];
newAccounts: NewAccount[];
rowErrors: RowError[];
}
export type HandelswareExportResult = FileResponse & { createdCount: number };
const DAYS_PER_MONTH = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
/** TTMM: vier Ziffern, Monat 1-12, Tag passend zum Monat (Februar bis 29) — wie die API. */
export function isValidBuchungsdatum(ttmm: string): boolean {
if (!/^\d{4}$/.test(ttmm)) return false;
const day = Number.parseInt(ttmm.slice(0, 2), 10);
const month = Number.parseInt(ttmm.slice(2, 4), 10);
if (month < 1 || month > 12) return false;
return day >= 1 && day <= DAYS_PER_MONTH[month - 1];
}
function fileForm(file: File): FormData {
const form = new FormData();
form.append('file', file);
return form;
}
export function getHandelswareSettings(): Promise<HandelswareSettings> {
return accountingRequest(`${BASE}/settings`);
}
export function saveHandelswareSettings(
input: HandelswareSettingsInput,
): Promise<HandelswareSettings> {
return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input });
}
export function previewHandelsware(file: File): Promise<HandelswarePreview> {
return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) });
}
export function exportHandelsware(
file: File,
buchungsdatum: string,
newAccounts: NewAccount[],
): Promise<HandelswareExportResult> {
const form = fileForm(file);
form.append('buchungsdatum', buchungsdatum);
form.append('newAccounts', JSON.stringify(newAccounts));
return accountingRequest(`${BASE}/export`, { method: 'POST', form });
}
export function listAccounts(): Promise<HandelswareAccount[]> {
return accountingRequest(`${BASE}/accounts`);
}
export function createAccount(input: HandelswareAccountInput): Promise<HandelswareAccount> {
return accountingRequest(`${BASE}/accounts`, { method: 'POST', json: input });
}
export function updateAccount(
id: string,
input: HandelswareAccountInput,
): Promise<HandelswareAccount> {
return accountingRequest(`${BASE}/accounts/${encodeURIComponent(id)}`, {
method: 'PUT',
json: input,
});
}
export function deleteAccount(id: string): Promise<{ deleted: true }> {
return accountingRequest(`${BASE}/accounts/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
export function importAccountsCsv(file: File): Promise<{ count: number }> {
return accountingRequest(`${BASE}/accounts/import-csv`, { method: 'POST', form: fileForm(file) });
}
export function exportAccountsCsv(): Promise<FileResponse> {
return accountingRequest(`${BASE}/accounts/export-csv`);
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Kantinenabrechnung — API-Client (quick-261002-fm5). Konsumiert
* `/modules/kantine-datev`. Hochgeladene Dateien gehen als Multipart-Feld
* `file`; die API speichert sie nicht.
*/
import { accountingRequest, type FileResponse } from '@/lib/accounting-request';
export type { FileResponse } from '@/lib/accounting-request';
export { AccountingRequestError as KantineRequestError } from '@/lib/accounting-request';
const BASE = '/modules/kantine-datev';
export interface KantineSettings {
beraterNr: string | null;
mandantNr: string | null;
lohnart: string | null;
configured: boolean;
}
export interface KantineSettingsInput {
beraterNr: string;
mandantNr: string;
lohnart: string;
}
export interface KantineIssue {
row: number;
field: string;
code: string;
message: string;
}
export interface KantineWarning {
code: string;
message: string;
params?: { months?: string[] };
}
export interface KantinePreview {
rowCount: number;
abrechnungsMonat: string | null;
totalCents: number;
errors: KantineIssue[];
warnings: KantineWarning[];
canExport: boolean;
blockedReason: 'settingsMissing' | 'errors' | null;
}
function fileForm(file: File): FormData {
const form = new FormData();
form.append('file', file);
return form;
}
export function getKantineSettings(): Promise<KantineSettings> {
return accountingRequest(`${BASE}/settings`);
}
export function saveKantineSettings(input: KantineSettingsInput): Promise<KantineSettings> {
return accountingRequest(`${BASE}/settings`, { method: 'PUT', json: input });
}
export function previewKantineCsv(file: File): Promise<KantinePreview> {
return accountingRequest(`${BASE}/preview`, { method: 'POST', form: fileForm(file) });
}
export function exportKantineCsv(file: File): Promise<FileResponse> {
return accountingRequest(`${BASE}/export`, { method: 'POST', form: fileForm(file) });
}
+33 -15
View File
@@ -5,24 +5,27 @@ import { cookies } from 'next/headers';
const API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; const API_URL = process.env.API_INTERNAL_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/** /**
* Server-side module access check for the module page route (D-07, PERM-04). * Server-side Freigabestufe fuer ein Modul (261002-icv): `'none'` (kein
* Zugriff), `'use'` (Benutzen) oder `'manage'` (Verwalten).
* *
* Reads the session cookie, forwards it to `GET /modules/active` (the same * Liest den Session-Cookie, leitet ihn an `GET /modules/active` weiter (die
* ModuleAccessService.getAccessibleModuleIds resolution ModuleGuard and the * gleiche ModuleAccessService-Aufloesung, die ModuleGuard und Sidebar nutzen
* sidebar use — D-01), and checks whether `moduleSlug` is present in the * — D-01) und wertet `canManage` des Eintrags aus. Spiegelt
* response. Mirrors `fetchCurrentUser()` in auth-actions.ts exactly: same * `fetchCurrentUser()` in auth-actions.ts: gleiche Cookie-Weitergabe,
* cookie-forwarding, `credentials: 'include'`, `cache: 'no-store'`. * `credentials: 'include'`, `cache: 'no-store'`.
* *
* Fails closed (T-15-29): a missing session cookie, a non-ok API response, * Fails closed (T-15-29): fehlender Cookie, nicht-ok-Antwort oder ein
* or a thrown network error all resolve to `false`. A broken network path * Netzwerkfehler ergeben `'none'`. Ein kaputter Netzwerkpfad darf nie
* must never open access. * Zugriff oeffnen.
*/ */
export async function checkModuleAccess(moduleSlug: string): Promise<boolean> { export async function getModuleAccessLevel(
moduleSlug: string,
): Promise<'none' | 'use' | 'manage'> {
const cookieStore = await cookies(); const cookieStore = await cookies();
const session = cookieStore.get('session')?.value; const session = cookieStore.get('session')?.value;
if (!session) { if (!session) {
return false; return 'none';
} }
try { try {
@@ -35,12 +38,27 @@ export async function checkModuleAccess(moduleSlug: string): Promise<boolean> {
}); });
if (!response.ok) { if (!response.ok) {
return false; return 'none';
} }
const modules: Array<{ slug: string }> = await response.json(); const modules: Array<{ slug: string; canManage?: boolean }> = await response.json();
return modules.some((module) => module.slug === moduleSlug); const entry = modules.find((module) => module.slug === moduleSlug);
if (!entry) {
return 'none';
}
return entry.canManage === true ? 'manage' : 'use';
} catch { } catch {
return false; return 'none';
} }
} }
/**
* Server-side module access check for the module page route (D-07, PERM-04).
*
* Wahr, sobald das Modul in `GET /modules/active` vorkommt — die gleiche
* Aufloesung wie ModuleGuard und Sidebar (D-01). Delegiert seit 261002-icv an
* `getModuleAccessLevel` (unveraenderte Signatur). Fails closed (T-15-29).
*/
export async function checkModuleAccess(moduleSlug: string): Promise<boolean> {
return (await getModuleAccessLevel(moduleSlug)) !== 'none';
}
+3 -1
View File
@@ -7,7 +7,7 @@
* (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`). * (`--tile`, `--tile-foreground`, `--primary`, `--primary-foreground`).
*/ */
export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'tile'; export type ModuleIconId = 'radar' | 'fuel' | 'certificate' | 'globe' | 'server' | 'utensils' | 'shopping-bag' | 'tile';
const ICONS: Record<string, ModuleIconId> = { const ICONS: Record<string, ModuleIconId> = {
'tender-radar': 'radar', 'tender-radar': 'radar',
@@ -15,6 +15,8 @@ const ICONS: Record<string, ModuleIconId> = {
'cert-manager': 'certificate', 'cert-manager': 'certificate',
domaincheck: 'globe', domaincheck: 'globe',
proxmox: 'server', proxmox: 'server',
'kantine-datev': 'utensils',
'handelsware-datev': 'shopping-bag',
}; };
/** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */ /** Symbol eines Moduls; unbekannte Module bekommen das allgemeine Kachel-Symbol. */
+12
View File
@@ -59,6 +59,18 @@ export const MODULE_REGISTRY: Record<string, ModuleRegistryEntry> = {
{ ssr: false }, { ssr: false },
), ),
}, },
'kantine-datev': {
component: dynamic(
() => import('@/app/(portal)/modules/kantine-datev/page'),
{ ssr: false },
),
},
'handelsware-datev': {
component: dynamic(
() => import('@/app/(portal)/modules/handelsware-datev/page'),
{ ssr: false },
),
},
}; };
/** /**
+2
View File
@@ -29,6 +29,8 @@ const MODULE_TITLE_KEYS: Record<string, string> = {
'cert-manager': 'certManager.title', 'cert-manager': 'certManager.title',
'dkv-fleet': 'dkvFleet.pageTitle', 'dkv-fleet': 'dkvFleet.pageTitle',
'tender-radar': 'tenderRadar.page.title', 'tender-radar': 'tenderRadar.page.title',
'kantine-datev': 'kantineDatev.title',
'handelsware-datev': 'handelswareDatev.title',
}; };
/** /**
+56
View File
@@ -0,0 +1,56 @@
'use client';
import { useEffect, useState } from 'react';
import { useAuthStore } from '@/lib/stores/auth-store';
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
/**
* Darf der angemeldete Benutzer die Einstellungen dieses Moduls ändern
* (Freigabestufe Verwalten, 261002-icv)?
*
* Rückgabe: `null` solange noch unklar (kein Benutzer geladen bzw. Abfrage
* läuft), sonst `true`/`false`. Administratoren und Super-Administratoren
* sind sofort `true` — das spiegelt den Kurzschluss im Backend, es gibt
* dafür keine Abfrage. Alle anderen fragen einmal `GET /modules/active` ab
* und sind nur `true`, wenn der Eintrag dieses Moduls `canManage === true`
* trägt; ein Fehler zählt als `false`.
*
* Reine Anzeigehilfe: Welche Schaltflächen sichtbar sind, entscheidet nichts
* über die Berechtigung — bindend ist allein der ModuleGuard der API.
*/
export function useCanManageModule(moduleSlug: string): boolean | null {
const role = useAuthStore((s) => s.user?.role ?? null);
const hasUser = useAuthStore((s) => s.user !== null && s.user !== undefined);
const isAdmin = role === 'ADMIN' || role === 'SUPER_ADMIN';
const [fetched, setFetched] = useState<boolean | null>(null);
useEffect(() => {
if (!hasUser || isAdmin) return;
let cancelled = false;
(async () => {
try {
const response = await fetch(`${API_URL}/modules/active`, {
credentials: 'include',
cache: 'no-store',
});
if (!response.ok) {
if (!cancelled) setFetched(false);
return;
}
const modules = (await response.json()) as Array<{ slug: string; canManage?: boolean }>;
const entry = Array.isArray(modules) ? modules.find((m) => m.slug === moduleSlug) : null;
if (!cancelled) setFetched(entry?.canManage === true);
} catch {
if (!cancelled) setFetched(false);
}
})();
return () => {
cancelled = true;
};
}, [hasUser, isAdmin, moduleSlug]);
if (!hasUser) return null;
if (isAdmin) return true;
return fetched;
}
+192 -3
View File
@@ -553,6 +553,8 @@
"noInheritance": "–", "noInheritance": "–",
"noActiveModules": "Für diesen Mandanten sind keine Module aktiviert.", "noActiveModules": "Für diesen Mandanten sind keine Module aktiviert.",
"directCheckboxLabel": "{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}", "directCheckboxLabel": "{module} direkt für {user} {granted, select, true {freigeben} other {entziehen}}",
"directLevelLabel": "Stufe der direkten Freigabe von {module} für {user}",
"manageMarker": "Verwalten",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen." "saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen."
}, },
"errors": { "errors": {
@@ -730,7 +732,10 @@
"body": "Diese Gruppe hat {memberCount, plural, one {# Mitglied} other {# Mitglieder}} und {grantCount, plural, one {# Modul-Freigabe} other {# Modul-Freigaben}}. Betroffene Benutzer verlieren den Zugriff, sofern sie ihn nicht anderweitig haben. Diese Aktion kann nicht rückgängig gemacht werden." "body": "Diese Gruppe hat {memberCount, plural, one {# Mitglied} other {# Mitglieder}} und {grantCount, plural, one {# Modul-Freigabe} other {# Modul-Freigaben}}. Betroffene Benutzer verlieren den Zugriff, sofern sie ihn nicht anderweitig haben. Diese Aktion kann nicht rückgängig gemacht werden."
}, },
"grants": { "grants": {
"matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}" "matrixCheckboxLabel": "{module} für Gruppe {group} {granted, select, true {freigeben} other {entziehen}}",
"levelUse": "Benutzen",
"levelManage": "Verwalten",
"levelSelectLabel": "Stufe für {module} in Gruppe {group}"
} }
}, },
"customModules": { "customModules": {
@@ -860,7 +865,8 @@
"searchPlaceholder": "Module oder Gruppen durchsuchen...", "searchPlaceholder": "Module oder Gruppen durchsuchen...",
"emptyModules": "Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.", "emptyModules": "Es sind noch keine Module für diesen Mandanten aktiviert. Aktiviere zuerst ein Modul unter Module.",
"emptyModulesLink": "Zu Module", "emptyModulesLink": "Zu Module",
"adminNote": "ADMIN und SUPER_ADMIN haben immer Zugriff auf alle aktiven Module — diese Matrix betrifft nur die Rolle USER.", "levelExplanation": "„Benutzen“: Das Modul öffnen und damit arbeiten. „Verwalten“: zusätzlich die Einstellungen dieses Moduls ändern. Freigaben vergeben und Module aktivieren dürfen weiterhin nur Administratoren. Hat jemand über mehrere Wege Zugriff, gilt die höhere Stufe.",
"adminNote": "Administratoren haben immer Zugriff auf alle aktiven Module und dürfen deren Einstellungen ändern – diese Matrix betrifft nur Benutzer ohne Administratorrechte.",
"saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.", "saveError": "Freigabe konnte nicht gespeichert werden. Bitte erneut versuchen.",
"noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen." "noSearchResults": "Kein Treffer für „{search}\" — weder bei den Modulen noch bei den Gruppen."
}, },
@@ -924,6 +930,7 @@
"accessDenied": { "accessDenied": {
"title": "Kein Zugriff auf dieses Modul", "title": "Kein Zugriff auf dieses Modul",
"body": "Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator.", "body": "Sie haben für dieses Modul keine Freigabe. Wenden Sie sich an Ihren Administrator.",
"manageRequiredBody": "Dieses Modul steht nur Benutzern zur Verfügung, die es verwalten dürfen. Wenden Sie sich an Ihren Administrator.",
"backToDashboard": "Zur Startseite" "backToDashboard": "Zur Startseite"
} }
}, },
@@ -1011,7 +1018,7 @@
}, },
"settings": { "settings": {
"title": "Proxmox — Einstellungen", "title": "Proxmox — Einstellungen",
"accessDeniedText": "Diese Seite steht nur Administratoren zur Verfügung.", "accessDeniedText": "Diese Seite steht Administratoren und Benutzern zur Verfügung, die dieses Modul verwalten dürfen.",
"addServer": "Server hinzufügen", "addServer": "Server hinzufügen",
"noServers": "Noch kein Server eingetragen.", "noServers": "Noch kein Server eingetragen.",
"nameLabel": "Name", "nameLabel": "Name",
@@ -1618,12 +1625,194 @@
"loadError": "Die eigenen Module konnten nicht geladen werden." "loadError": "Die eigenen Module konnten nicht geladen werden."
} }
}, },
"kantineDatev": {
"title": "Kantinenabrechnung",
"description": "Kantinen-CSV prüfen und als DATEV-Lohndatei für die Gehaltsabrechnung herunterladen.",
"tabs": {
"billing": "Abrechnung",
"settings": "Einstellungen"
},
"loading": "Wird geladen …",
"dropZone": {
"placeholder": "CSV-Datei hierher ziehen oder klicken",
"formats": ".csv, Semikolon-getrennt, UTF-8 oder Windows-1252",
"clear": "Datei entfernen"
},
"noStorage": "Die hochgeladenen Daten werden nicht gespeichert.",
"checking": "Datei wird geprüft …",
"notConfigured": {
"admin": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt. Ohne diese Angaben kann keine DATEV-Datei erstellt werden.",
"adminAction": "Zu den Einstellungen",
"user": "Ein Administrator oder jemand, der dieses Modul verwalten darf, muss zuerst Beraternummer, Mandantennummer und Lohnart hinterlegen."
},
"summary": {
"title": "Ergebnis der Prüfung",
"rows": "Zeilen",
"month": "Abrechnungsmonat",
"total": "Gesamtbetrag",
"noMonth": "nicht erkannt"
},
"warnings": {
"title": "Hinweise",
"multipleMonths": "Verschiedene Abrechnungsmonate erkannt: {months}. Alle Zeilen sollten im selben Abrechnungsmonat liegen."
},
"errors": {
"title": "Fehler in der Datei",
"line": "Zeile",
"field": "Feld",
"message": "Meldung",
"headerMissing": "Die Datei enthält keine Kopfzeile.",
"headerColumns": "Die Kopfzeile hat zu wenige Spalten. Ist das Trennzeichen korrekt (Semikolon)?",
"columnCount": "Die Zeile hat zu wenige Spalten.",
"personalNrMissing": "Personalnummer fehlt",
"personalNrNotNumeric": "Personalnummer muss numerisch sein",
"betragMissing": "Betrag fehlt",
"betragFormat": "Betrag muss im deutschen Zahlenformat vorliegen (Komma als Dezimaltrenner)",
"vonMissing": "Abrechnung von fehlt",
"vonFormat": "Abrechnung von muss im Format TT.MM.JJJJ vorliegen",
"bisMissing": "Abrechnung bis fehlt",
"bisFormat": "Abrechnung bis muss im Format TT.MM.JJJJ vorliegen",
"multiMonthRange": "Abrechnungszeitraum erstreckt sich über mehrere Monate",
"noRows": "Die Datei enthält keine Datenzeilen.",
"settingsMissing": "Beraternummer, Mandantennummer und Lohnart sind noch nicht hinterlegt.",
"hasErrors": "Die Datei enthält Fehler und kann nicht exportiert werden.",
"qualityCheckFailed": "Die erzeugte Datei hat die Qualitätsprüfung nicht bestanden.",
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut."
},
"fields": {
"header": "Kopfzeile",
"zeile": "Zeile",
"datei": "Datei",
"personalNr": "Personalnummer",
"betrag": "Betrag",
"abrechnungVon": "Abrechnung von",
"abrechnungBis": "Abrechnung bis",
"abrechnungVon/abrechnungBis": "Abrechnungszeitraum"
},
"download": {
"button": "DATEV-Datei herunterladen",
"busy": "Datei wird erstellt …",
"done": "Die Datei {filename} wurde heruntergeladen."
},
"settings": {
"intro": "Diese Angaben stehen im Kopf und in den Zeilen der DATEV-Datei. Sie gelten für alle Benutzer und werden nur einmal hinterlegt.",
"beraterNr": "Beraternummer",
"mandantNr": "Mandantennummer",
"lohnart": "Lohnart",
"digitsOnly": "Bitte nur Ziffern eingeben (1 bis 10 Stellen).",
"save": "Speichern",
"saving": "Wird gespeichert …",
"saved": "Die Einstellungen wurden gespeichert."
}
},
"handelswareDatev": {
"title": "Handelsware",
"description": "Handelswaren-Umsätze aus Excel den Erlöskonten zuordnen und als DATEV-Buchungsdatei herunterladen.",
"tabs": {
"import": "Import",
"accounts": "Konten",
"settings": "Einstellungen"
},
"loading": "Wird geladen …",
"notConfigured": {
"admin": "Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt. Ohne diese Angaben können keine Konten zugeordnet werden.",
"adminAction": "Zu den Einstellungen",
"user": "Ein Administrator oder jemand, der dieses Modul verwalten darf, muss zuerst Standard-Erlöskonto und Startwert Gegenkonto hinterlegen."
},
"import": {
"dropPlaceholder": "Excel-Datei hierher ziehen oder klicken",
"dropFormats": ".xlsx, Buchungstext in Spalte A, Umsatz in Spalte B, Kopftext in Zelle B1",
"clear": "Datei entfernen",
"checking": "Datei wird geprüft …",
"headerText": "Kopftext",
"exportFilename": "Dateiname des Downloads",
"date": "Buchungsdatum (TTMM)",
"dateHint": "Wird aus dem Dateinamen abgeleitet (letzter Tag des Monats) und kann geändert werden.",
"dateInvalid": "Bitte das Datum als TTMM angeben, zum Beispiel 3103 für den 31.03.",
"columns": {
"text": "Buchungstext",
"amount": "Umsatz",
"sh": "S/H",
"counter": "Gegenkonto",
"date": "Datum",
"revenue": "Erlöskonto"
},
"newBadge": "neu",
"newSummary": "{count, plural, one {Ein neues Konto wird beim Herunterladen gespeichert} other {# neue Konten werden beim Herunterladen gespeichert}}",
"noNewAccounts": "Alle Produkte haben bereits ein Konto.",
"totals": "Summe Soll {debit}, Summe Haben {credit}",
"truncated": "Angezeigt werden die ersten {shown} von {total} Zeilen. Die Datei enthält alle Zeilen.",
"noRows": "Die Datei enthält keine Datenzeilen.",
"rowErrorsTitle": "Fehlerhafte Zeilen",
"rowErrorLine": "Zeile {line}",
"download": "Buchungsdatei herunterladen",
"downloading": "Datei wird erstellt …",
"downloaded": "Die Datei {filename} wurde heruntergeladen. {count, plural, =0 {Es wurden keine neuen Konten gespeichert.} one {Ein neues Konto wurde gespeichert.} other {# neue Konten wurden gespeichert.}}",
"reloadPreview": "Vorschau neu laden"
},
"accounts": {
"name": "Name",
"counter": "Gegenkonto",
"revenue": "Erlöskonto",
"actions": "Aktionen",
"empty": "Es sind noch keine Konten vorhanden.",
"add": "Hinzufügen",
"edit": "Bearbeiten",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"deleteConfirm": "Konto „{name}“ wirklich löschen?",
"deleteYes": "Ja, löschen",
"namePlaceholder": "Produktname",
"importCsv": "CSV importieren",
"exportCsv": "CSV exportieren",
"importConfirm": "{count, plural, =0 {Die leere Kontenliste wird} one {Das vorhandene Konto wird} other {Alle # vorhandenen Konten werden}} durch den Inhalt der Datei „{file}“ ersetzt. Fortfahren?",
"importReplace": "Konten ersetzen",
"importDone": "{count, plural, one {Ein Konto wurde importiert.} other {# Konten wurden importiert.}}",
"importErrors": "Die Datei enthält Fehler, es wurde nichts geändert:",
"csvHint": "CSV-Format: Name;Gegenkonto;Konto (Semikolon, UTF-8 oder Windows-1252).",
"invalidNumber": "Gegenkonto und Erlöskonto müssen ganze Zahlen sein.",
"nameRequired": "Bitte einen Namen angeben."
},
"settings": {
"intro": "Diese Angaben gelten für alle Benutzer und werden nur einmal hinterlegt.",
"erloeskonto": "Standard-Erlöskonto",
"erloeskontoHelp": "Wird neuen Konten zugeordnet.",
"startGegenkonto": "Startwert Gegenkonto",
"startGegenkontoHelp": "Erste Nummer, die vergeben wird, wenn die Kontenliste leer ist.",
"numberInvalid": "Bitte eine ganze Zahl von 1 bis 999999999 eingeben.",
"save": "Speichern",
"saving": "Wird gespeichert …",
"saved": "Die Einstellungen wurden gespeichert."
},
"errors": {
"settingsMissing": "Standard-Erlöskonto und Startwert Gegenkonto sind noch nicht hinterlegt.",
"invalidFile": "Die Datei ist keine gültige Excel-Datei.",
"tooManyRows": "Die Datei enthält zu viele Datenzeilen (höchstens 10000).",
"rowErrors": "Die Datei enthält fehlerhafte Zeilen und kann nicht exportiert werden.",
"noRows": "Die Datei enthält keine Datenzeilen.",
"buchungsdatumInvalid": "Das Buchungsdatum muss als TTMM angegeben werden, zum Beispiel 3103.",
"accountsChanged": "Die Kontenliste wurde inzwischen geändert. Bitte laden Sie die Datei erneut, um die Vorschau zu aktualisieren.",
"nameTaken": "Ein Konto mit diesem Namen gibt es bereits.",
"umsatzInvalid": "Der Umsatz ist keine gültige Zahl.",
"csvErrors": "Die CSV-Datei enthält Fehler. Es wurde nichts geändert.",
"newAccountsInvalid": "Die Angaben zu den neuen Konten sind ungültig.",
"nameEmpty": "Der Name fehlt.",
"nameTooLong": "Der Name ist zu lang (höchstens 120 Zeichen).",
"gegenkontoInvalid": "Das Gegenkonto muss eine ganze Zahl von 1 bis 999999999 sein.",
"erloeskontoInvalid": "Das Erlöskonto muss eine ganze Zahl von 1 bis 999999999 sein.",
"missingErloeskonto": "Das Erlöskonto fehlt und es ist kein Standard-Erlöskonto hinterlegt.",
"duplicateName": "Der Name kommt in der Datei mehrfach vor.",
"request": "Die Anfrage ist fehlgeschlagen. Bitte versuchen Sie es erneut."
}
},
"moduleCategories": { "moduleCategories": {
"domain-tools": "Domains", "domain-tools": "Domains",
"security-tools": "Sicherheit", "security-tools": "Sicherheit",
"fleet": "Fuhrpark", "fleet": "Fuhrpark",
"infrastructure": "Infrastruktur", "infrastructure": "Infrastruktur",
"procurement": "Beschaffung", "procurement": "Beschaffung",
"accounting": "Finanzbuchhaltung",
"custom-modules": "Eigene Module" "custom-modules": "Eigene Module"
} }
} }
+192 -3
View File
@@ -553,6 +553,8 @@
"noInheritance": "–", "noInheritance": "–",
"noActiveModules": "No modules are activated for this tenant.", "noActiveModules": "No modules are activated for this tenant.",
"directCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} directly for {user}", "directCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} directly for {user}",
"directLevelLabel": "Level of the direct grant of {module} for {user}",
"manageMarker": "Manage",
"saveError": "Could not save grant. Please try again." "saveError": "Could not save grant. Please try again."
}, },
"errors": { "errors": {
@@ -730,7 +732,10 @@
"body": "This group has {memberCount, plural, one {# member} other {# members}} and {grantCount, plural, one {# module grant} other {# module grants}}. Affected users will lose access unless they have it another way. This action cannot be undone." "body": "This group has {memberCount, plural, one {# member} other {# members}} and {grantCount, plural, one {# module grant} other {# module grants}}. Affected users will lose access unless they have it another way. This action cannot be undone."
}, },
"grants": { "grants": {
"matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}" "matrixCheckboxLabel": "{granted, select, true {Grant} other {Revoke}} {module} for group {group}",
"levelUse": "Use",
"levelManage": "Manage",
"levelSelectLabel": "Level for {module} in group {group}"
} }
}, },
"customModules": { "customModules": {
@@ -860,7 +865,8 @@
"searchPlaceholder": "Search modules or groups...", "searchPlaceholder": "Search modules or groups...",
"emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.", "emptyModules": "No modules are activated for this tenant yet. Activate a module under Modules first.",
"emptyModulesLink": "Go to Modules", "emptyModulesLink": "Go to Modules",
"adminNote": "ADMIN and SUPER_ADMIN always have access to all active modules — this matrix only applies to the USER role.", "levelExplanation": "“Use”: open the module and work with it. “Manage”: additionally change the settings of this module. Only administrators may grant access and activate modules. If someone has access through several routes, the higher level applies.",
"adminNote": "Administrators always have access to all active modules and may change their settings — this matrix only affects users without administrator rights.",
"saveError": "Could not save grant. Please try again.", "saveError": "Could not save grant. Please try again.",
"noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups." "noSearchResults": "No match for \"{search}\" — neither in the modules nor in the groups."
}, },
@@ -924,6 +930,7 @@
"accessDenied": { "accessDenied": {
"title": "No Access to This Module", "title": "No Access to This Module",
"body": "You do not have access to this module. Please contact your administrator.", "body": "You do not have access to this module. Please contact your administrator.",
"manageRequiredBody": "This module is only available to users who may manage it. Please contact your administrator.",
"backToDashboard": "Back to Dashboard" "backToDashboard": "Back to Dashboard"
} }
}, },
@@ -1011,7 +1018,7 @@
}, },
"settings": { "settings": {
"title": "Proxmox — Settings", "title": "Proxmox — Settings",
"accessDeniedText": "This page is only available to administrators.", "accessDeniedText": "This page is available to administrators and to users who may manage this module.",
"addServer": "Add server", "addServer": "Add server",
"noServers": "No server configured yet.", "noServers": "No server configured yet.",
"nameLabel": "Name", "nameLabel": "Name",
@@ -1618,12 +1625,194 @@
"loadError": "The custom modules could not be loaded." "loadError": "The custom modules could not be loaded."
} }
}, },
"kantineDatev": {
"title": "Canteen billing",
"description": "Check canteen CSV files and download them as a DATEV payroll file for the salary run.",
"tabs": {
"billing": "Billing",
"settings": "Settings"
},
"loading": "Loading …",
"dropZone": {
"placeholder": "Drag a CSV file here or click",
"formats": ".csv, semicolon-separated, UTF-8 or Windows-1252",
"clear": "Remove file"
},
"noStorage": "The uploaded data is not stored.",
"checking": "Checking file …",
"notConfigured": {
"admin": "Consultant number, client number and wage type have not been set yet. Without them no DATEV file can be created.",
"adminAction": "Go to settings",
"user": "An administrator or someone who may manage this module has to set the consultant number, client number and wage type first."
},
"summary": {
"title": "Check result",
"rows": "Rows",
"month": "Billing month",
"total": "Total amount",
"noMonth": "not detected"
},
"warnings": {
"title": "Notes",
"multipleMonths": "Different billing months detected: {months}. All rows should be in the same billing month."
},
"errors": {
"title": "Errors in the file",
"line": "Line",
"field": "Field",
"message": "Message",
"headerMissing": "The file has no header line.",
"headerColumns": "The header has too few columns. Is the separator correct (semicolon)?",
"columnCount": "The line has too few columns.",
"personalNrMissing": "Personnel number is missing",
"personalNrNotNumeric": "Personnel number must be numeric",
"betragMissing": "Amount is missing",
"betragFormat": "Amount must be in German number format (comma as decimal separator)",
"vonMissing": "Billing from is missing",
"vonFormat": "Billing from must be in the format DD.MM.YYYY",
"bisMissing": "Billing to is missing",
"bisFormat": "Billing to must be in the format DD.MM.YYYY",
"multiMonthRange": "The billing period spans several months",
"noRows": "The file contains no data rows.",
"settingsMissing": "Consultant number, client number and wage type have not been set yet.",
"hasErrors": "The file contains errors and cannot be exported.",
"qualityCheckFailed": "The generated file failed the quality check.",
"request": "The request failed. Please try again."
},
"fields": {
"header": "Header",
"zeile": "Line",
"datei": "File",
"personalNr": "Personnel number",
"betrag": "Amount",
"abrechnungVon": "Billing from",
"abrechnungBis": "Billing to",
"abrechnungVon/abrechnungBis": "Billing period"
},
"download": {
"button": "Download DATEV file",
"busy": "Creating file …",
"done": "The file {filename} has been downloaded."
},
"settings": {
"intro": "These values appear in the header and the lines of the DATEV file. They apply to all users and only have to be set once.",
"beraterNr": "Consultant number",
"mandantNr": "Client number",
"lohnart": "Wage type",
"digitsOnly": "Please enter digits only (1 to 10 digits).",
"save": "Save",
"saving": "Saving …",
"saved": "The settings have been saved."
}
},
"handelswareDatev": {
"title": "Merchandise",
"description": "Map merchandise sales from Excel to revenue accounts and download them as a DATEV booking file.",
"tabs": {
"import": "Import",
"accounts": "Accounts",
"settings": "Settings"
},
"loading": "Loading …",
"notConfigured": {
"admin": "The default revenue account and the starting counter account have not been set yet. Without them no accounts can be assigned.",
"adminAction": "Go to settings",
"user": "An administrator or someone who may manage this module has to set the default revenue account and the starting counter account first."
},
"import": {
"dropPlaceholder": "Drag an Excel file here or click",
"dropFormats": ".xlsx, booking text in column A, amount in column B, header text in cell B1",
"clear": "Remove file",
"checking": "Checking file …",
"headerText": "Header text",
"exportFilename": "Download file name",
"date": "Booking date (DDMM)",
"dateHint": "Derived from the file name (last day of the month) and can be changed.",
"dateInvalid": "Please enter the date as DDMM, for example 3103 for 31 March.",
"columns": {
"text": "Booking text",
"amount": "Amount",
"sh": "D/C",
"counter": "Counter account",
"date": "Date",
"revenue": "Revenue account"
},
"newBadge": "new",
"newSummary": "{count, plural, one {One new account will be saved when you download} other {# new accounts will be saved when you download}}",
"noNewAccounts": "All products already have an account.",
"totals": "Total debit {debit}, total credit {credit}",
"truncated": "Showing the first {shown} of {total} rows. The file contains all rows.",
"noRows": "The file contains no data rows.",
"rowErrorsTitle": "Faulty rows",
"rowErrorLine": "Row {line}",
"download": "Download booking file",
"downloading": "Creating file …",
"downloaded": "The file {filename} has been downloaded. {count, plural, =0 {No new accounts were saved.} one {One new account was saved.} other {# new accounts were saved.}}",
"reloadPreview": "Reload preview"
},
"accounts": {
"name": "Name",
"counter": "Counter account",
"revenue": "Revenue account",
"actions": "Actions",
"empty": "There are no accounts yet.",
"add": "Add",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"deleteConfirm": "Really delete account “{name}”?",
"deleteYes": "Yes, delete",
"namePlaceholder": "Product name",
"importCsv": "Import CSV",
"exportCsv": "Export CSV",
"importConfirm": "{count, plural, =0 {The empty account list is} one {The existing account is} other {All # existing accounts are}} replaced by the content of the file “{file}”. Continue?",
"importReplace": "Replace accounts",
"importDone": "{count, plural, one {One account was imported.} other {# accounts were imported.}}",
"importErrors": "The file contains errors, nothing was changed:",
"csvHint": "CSV format: Name;Counter account;Account (semicolon, UTF-8 or Windows-1252).",
"invalidNumber": "Counter account and revenue account must be whole numbers.",
"nameRequired": "Please enter a name."
},
"settings": {
"intro": "These values apply to all users and only have to be set once.",
"erloeskonto": "Default revenue account",
"erloeskontoHelp": "Assigned to new accounts.",
"startGegenkonto": "Starting counter account",
"startGegenkontoHelp": "First number handed out when the account list is empty.",
"numberInvalid": "Please enter a whole number from 1 to 999999999.",
"save": "Save",
"saving": "Saving …",
"saved": "The settings have been saved."
},
"errors": {
"settingsMissing": "The default revenue account and the starting counter account have not been set yet.",
"invalidFile": "The file is not a valid Excel file.",
"tooManyRows": "The file contains too many data rows (10000 at most).",
"rowErrors": "The file contains faulty rows and cannot be exported.",
"noRows": "The file contains no data rows.",
"buchungsdatumInvalid": "The booking date must be given as DDMM, for example 3103.",
"accountsChanged": "The account list has changed in the meantime. Please upload the file again to refresh the preview.",
"nameTaken": "An account with this name already exists.",
"umsatzInvalid": "The amount is not a valid number.",
"csvErrors": "The CSV file contains errors. Nothing was changed.",
"newAccountsInvalid": "The details of the new accounts are invalid.",
"nameEmpty": "The name is missing.",
"nameTooLong": "The name is too long (120 characters at most).",
"gegenkontoInvalid": "The counter account must be a whole number from 1 to 999999999.",
"erloeskontoInvalid": "The revenue account must be a whole number from 1 to 999999999.",
"missingErloeskonto": "The revenue account is missing and no default revenue account is set.",
"duplicateName": "The name appears more than once in the file.",
"request": "The request failed. Please try again."
}
},
"moduleCategories": { "moduleCategories": {
"domain-tools": "Domains", "domain-tools": "Domains",
"security-tools": "Security", "security-tools": "Security",
"fleet": "Fleet", "fleet": "Fleet",
"infrastructure": "Infrastructure", "infrastructure": "Infrastructure",
"procurement": "Procurement", "procurement": "Procurement",
"accounting": "Financial accounting",
"custom-modules": "Custom modules" "custom-modules": "Custom modules"
} }
} }

Some files were not shown because too many files have changed in this diff Show More