diff --git a/.env.example b/.env.example index 0e64b8b..1b79651 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,21 @@ -# Beispielwerte OHNE Geheimnisse. Echte Werte liegen in /etc/kundencenter/*.env (chmod 600). +# Vorlage OHNE Geheimnisse. Echte Werte nach /etc/kundencenter/db.env und /etc/kundencenter/app.env (chmod 600, root). +# Wichtig: Kommentare nur in eigenen Zeilen, nie hinter einem Wert. + +# ---- /etc/kundencenter/db.env ---- DB_HOST=127.0.0.1 DB_PORT=3306 DB_NAME=kundencenter DB_USER=kundencenter DB_PASSWORD= -KC_SECRET_KEY= # 32 Byte base64 (openssl rand -base64 32); Verschluesselung von TOTP-Secrets -KC_BASE_URL=http://localhost:4101 + +# ---- /etc/kundencenter/app.env ---- +# Master-Schlüssel für 2FA- und Zugangsdaten-Verschlüsselung: openssl rand -base64 32 +# Unbedingt sichern – ohne ihn sind verschlüsselte Daten nach einer Wiederherstellung unlesbar. +KC_SECRET_KEY= +# Exakt die Adresse, unter der das Kundencenter im Browser aufgerufen wird (Origin-Prüfung gegen CSRF) +KC_BASE_URL=https://kundencenter.example.de KC_API_PORT=4100 -KC_NODE_ENV=development -# optional: SMTP (sonst wird nur im Mail-Protokoll abgelegt) -SMTP_HOST= -SMTP_PORT=587 -SMTP_USER= -SMTP_PASSWORD= -SMTP_FROM= -# optional: Discord (sonst bleibt der Bot deaktiviert) -DISCORD_BOT_TOKEN= -DISCORD_GUILD_ID= -DISCORD_ADMIN_CHANNEL_ID= -DISCORD_STAFF_USER_IDS= # kommagetrennt, dürfen /kc-Befehle nutzen +KC_NODE_ENV=production +# Kennung des Programms "Kundencenter" beim Lizenzsystem (kein Geheimnis, in jeder Installation gleich). +# Den eigenen Lizenzschlüssel trägt man danach in der Oberfläche ein (Einstellungen → Lizenz). +LICENSE_PROGRAM_KEY=da78e720-7180-4c44-84dd-50bfc83fe1ef diff --git a/.gitignore b/.gitignore index 7d263c1..d2607f9 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ dist !.env.example *.log *.tsbuildinfo +.pnpm-store/ diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..c169cb6 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,18 @@ +# Nutzungsbedingungen KC@FlessingLabs + +Copyright © 2026 FlessingLabs. Alle Rechte vorbehalten. + +Der Quellcode von KC@FlessingLabs („Software“) ist öffentlich einsehbar. Er ist **keine** Open-Source-Software. + +1. **Testmodus:** Die Software darf ohne Lizenz kostenlos installiert und im Testmodus betrieben werden + (1 Personal-Konto, 2 Kunden, ohne lizenzpflichtige Zusatzfunktionen). +2. **Lizenz:** Der Betrieb über die Grenzen des Testmodus hinaus und die Nutzung der lizenzpflichtigen Funktionen + (u. a. Discord-Bot, E-Mail-Posteingang, DATEV-Export, Backups auf externe Ziele) setzen eine gültige Lizenz von + FlessingLabs voraus. Umfang und Laufzeit ergeben sich aus der jeweiligen Lizenz. +3. **Nicht gestattet** sind das Umgehen, Entfernen oder Verändern der Lizenzprüfung sowie die Weitergabe, der Verkauf + oder das Anbieten der Software oder veränderter Fassungen als eigenes Produkt oder Dienst für Dritte. +4. **Eigene Anpassungen** für den Betrieb der eigenen Installation sind erlaubt. +5. **Gewährleistung:** Die Software wird ohne Gewähr bereitgestellt, soweit gesetzlich zulässig. Für Datensicherung + ist der Betreiber selbst verantwortlich. + +Lizenzen und Fragen: https://flessinglabs.com diff --git a/README.md b/README.md index b067876..9f37bb4 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,106 @@ -# Kundencenter +# KC@FlessingLabs – Kundencenter -Modulares Kundencenter für Hosting, Server und Lizenzen (Modularer Monolith, TypeScript). +Selbst gehostetes Kundencenter für Hosting-Anbieter, Agenturen und Software-Hersteller: Kunden, Produkte, Bestellungen, +Verträge, Rechnungen, Support-Tickets und Lizenzen in einer Oberfläche – mit Anbindung an KeyHelp und an das +FlessingLabs-Lizenzsystem. Modularer Monolith in TypeScript (Fastify, Next.js, MariaDB). | Prozess | Pfad | Port (nur 127.0.0.1) | Aufgabe | |---|---|---|---| -| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Policy, Audit | -| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot | -| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, Proxy `/api/*` → API | +| `kc-api` | `apps/api` | 4100 | Fastify-API (`/v1/*`), Module, Rechte, Audit | +| `kc-worker` | `apps/worker` | 4102 (Health) | Persistente Aufträge, Discord-Bot, E-Mail-Posteingang | +| `kc-web` | `apps/web` | 4101 | Next.js-Oberfläche, leitet `/api/*` an die API weiter | -## Stand (Grundsystem) -Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Passwort-Reset, Einladungen, Benutzerverwaltung (Mitarbeiterrollen), -Kunden/Organisationen anlegen und verwalten, Rechnungsanschrift, Audit-Protokoll mit Hash-Kette, persistente Job-Queue, Discord-Bot (optional). -Produkte (versioniert), Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Verträge mit Kündigung/Verlängerung, Provisionierung über Connectoren (`docs/produkte-bestellungen-vertraege.md`), Connector-Framework (`docs/connector-vertrag.md`). -Support-Tickets (je Kunde, mit internen Notizen für Personal, Dateianhänge als Bild/PDF, Discord-Benachrichtigung). -Rechnungen (`docs/rechnungen.md`): Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF-Erzeugung, Firmenstammdaten unter Einstellungen → Firma. -Noch **nicht** vorhanden: Zahlungsanbieter-Anbindung, automatische wiederkehrende Rechnungsstellung, E-Mail-Versand von Rechnungen, Selbstregistrierung, Plesk-Connector, Domain-Registrierung über eine Registrar-API (aktuell manuell über die Domain-Aufstellung). +## Funktionen +- **Konten und Sicherheit:** Login mit Passwort + TOTP, Wiederherstellungscodes, Sitzungsverwaltung, Einladungen, Rollen für Personal und Kunden, Audit-Protokoll mit Hash-Kette. +- **Kunden:** Privat- und Geschäftskunden, Rechnungsanschrift, Übernahme aus KeyHelp, Kundenansicht („Als Kunde ansehen“). +- **Produkte, Bestellungen, Verträge:** versionierte Produkte, Bestellungen mit Freigabe und unveränderlichem Preis-Snapshot, Laufzeiten, Kündigung, automatische Verlängerung mit Erinnerung (`docs/produkte-bestellungen-vertraege.md`). +- **Bereitstellung über Connectoren:** KeyHelp (Hosting-Konten, Domains, Postfächer, Datenbanken) und Lizenzsystem (`docs/connector-vertrag.md`, `docs/connector-keyhelp.md`). +- **Lizenzverwaltung:** Übersicht, Vergabe mit Vertrag oder ohne Berechnung (Lizenz, Test, Add-on), Geräte freigeben, Sperren/Verlängern/Widerrufen, Limits und Funktionsumfang; Kunden sehen ihre Lizenzen und geben eigene Geräte frei. +- **Rechnungen:** Entwurf → Ausstellen (unveränderlich) → Bezahlt/Storno, PDF, DATEV-Export (`docs/rechnungen.md`). +- **Support:** Tickets je Kunde mit internen Notizen und Anhängen, E-Mail-Posteingang (IMAP), Discord-Bot mit eigenem Kanal je Ticket. +- **Betrieb:** verschlüsselte tägliche Backups mit automatischem Wiederherstellungstest (`docs/betrieb-backup-restore.md`). -## Modularität -API-Module liegen in `apps/api/src/modules/` und implementieren `KcModule` (`core/module.ts`). -Sie werden in `modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander. -Neue Fähigkeiten (Connectoren, Rechnungen, Tickets) kommen als weitere Module hinzu. +## Testmodus und Lizenz +Ohne Lizenzschlüssel läuft das Kundencenter im **Testmodus**: -## Konfiguration -Geheimnisse liegen **nicht** im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600): `db.env`, `app.env`, `discord.env`, optional SMTP in `app.env`. -Vorlage: `.env.example`. Wichtig: `KC_BASE_URL` muss exakt der Browser-URL entsprechen (Origin-Prüfung gegen CSRF). +| | Testmodus | Mit Lizenz | +|---|---|---| +| Personal-Konten | 1 | laut Lizenz | +| Kunden | 2 | laut Lizenz | +| Discord-Bot, E-Mail-Posteingang (IMAP), DATEV-Export, Backups auf externe Ziele | – | ✓ | + +Alles andere ist im Testmodus voll nutzbar. Eine Lizenz gibt es bei FlessingLabs (https://flessinglabs.com); den Schlüssel +unter **Einstellungen → Lizenz** eintragen, er wird regelmäßig bei licensing.flessinglabs.com geprüft (7 Tage offline +möglich). Läuft eine Lizenz ab, gilt wieder der Testmodus – bestehende Daten bleiben vollständig erhalten und nutzbar, +nur Neuanlagen über die Grenzen hinaus und die Zusatzfunktionen pausieren. Nutzungsbedingungen: `LICENSE.md`. + +## Installation (Debian/Ubuntu) +Voraussetzungen: Node.js ≥ 22, pnpm 10, MariaDB ≥ 10.6, ein Reverse-Proxy mit HTTPS (Apache, nginx, Caddy …), +für Backups `gpg` und `rclone`. + +```bash +# 1. Benutzer und Code +useradd --system --home-dir /var/lib/kundencenter --shell /usr/sbin/nologin kundencenter +git clone https://forge.flessinghome.de/flessinglabs/kundencenter.git /srv/kundencenter +chown -R kundencenter: /srv/kundencenter +cd /srv/kundencenter && sudo -u kundencenter pnpm install --frozen-lockfile && sudo -u kundencenter pnpm build + +# 2. Datenbank (Zeichensatz utf8mb4 / utf8mb4_unicode_ci ist wichtig) +mysql -e "CREATE DATABASE kundencenter CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + CREATE USER 'kundencenter'@'localhost' IDENTIFIED BY ''; + GRANT ALL ON kundencenter.* TO 'kundencenter'@'localhost'; + GRANT ALL ON kundencenter_restoretest.* TO 'kundencenter'@'localhost';" + +# 3. Konfiguration (Vorlage: .env.example; Kommentare nie hinter einen Wert schreiben) +install -d -m 700 /etc/kundencenter +install -d -o kundencenter -m 755 /var/lib/kundencenter/requests +install -d -o kundencenter -m 750 /var/lib/kundencenter/ticket-attachments +install -d -m 700 /var/backups/kundencenter +# /etc/kundencenter/db.env und /etc/kundencenter/app.env anlegen, chmod 600 + +# 4. Schema und erster Superadmin (Passwort wird abgefragt) +cd /srv/kundencenter && pnpm migrate +pnpm cli create-superadmin --email=admin@example.de --name="Vorname Nachname" + +# 5. Dienste +cp ops/systemd/kc-* /etc/systemd/system/ && systemctl daemon-reload +systemctl enable --now kc-api kc-web kc-worker kc-backup.timer kc-restore-test.timer kc-backup-request.path +``` + +Reverse-Proxy: alles auf `http://127.0.0.1:4101` weiterleiten und `X-Forwarded-Proto: https` setzen. Beispiel Apache: +```apache +ProxyPass / http://127.0.0.1:4101/ +ProxyPassReverse / http://127.0.0.1:4101/ +RequestHeader set X-Forwarded-Proto "https" +``` +Danach anmelden, 2FA einrichten, unter **Einstellungen** Firma, E-Mail (SMTP) und Backup-Passwort hinterlegen. + +## Update +```bash +cd /srv/kundencenter && systemctl start kc-backup # vorher sichern +sudo -u kundencenter git pull && sudo -u kundencenter pnpm install --frozen-lockfile && sudo -u kundencenter pnpm build +pnpm migrate && systemctl restart kc-api kc-web kc-worker +``` ## Betrieb ```bash systemctl status kc-api kc-worker kc-web # Dienste (Autostart, Restart=always) journalctl -u kc-api -f # Logs -pnpm install && pnpm build # Update: danach chown -R kundencenter und systemctl restart kc-* -pnpm migrate # Migrationen (migrations/*.sql, einmalig je Datei) -pnpm cli create-superadmin --email=… --name=… --password=… +pnpm cli reset-password --email=… # Passwort zurücksetzen ``` Health: `GET :4100/v1/health`, `GET :4100/v1/ready`, `GET :4102/health`. - -## Tests -`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf neu erstellt). -Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette/Maskierung. +Geheimnisse liegen nie im Repo, sondern in `/etc/kundencenter/*.env` (chmod 600). `KC_BASE_URL` muss exakt der Browser-Adresse entsprechen. ## Discord-Bot -In `/etc/kundencenter/discord.env` setzen: `DISCORD_BOT_TOKEN`, `DISCORD_GUILD_ID`, `DISCORD_ADMIN_CHANNEL_ID`, `DISCORD_STAFF_USER_IDS` (kommagetrennt), dann `systemctl restart kc-worker`. -Befehle: `/kc-status`, `/kc-kunde suche:` (nur für freigegebene Discord-User, Antworten nur für sie sichtbar). In Kanäle gehen nur Ereignisse ohne personenbezogene Daten (z. B. „Neuer Kunde angelegt: K-10001“). +Unter **Einstellungen → Discord** Bot-Token, Server-ID und Kanäle eintragen (Anleitung auf der Seite). Mit einer +Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal, sichtbar nur für die Support-Rollen und den Kunden. +Kunden und Personal verknüpfen ihr Discord-Konto unter „Mein Konto“. Erfordert eine Lizenz. -## Backup / Restore (Datenbank) -```bash -set -a; . /etc/kundencenter/db.env; set +a -MYSQL_PWD=$DB_PASSWORD mysqldump -h127.0.0.1 -u$DB_USER --single-transaction --routines $DB_NAME | gzip > kundencenter-$(date +%F).sql.gz -gunzip -c kundencenter-DATUM.sql.gz | MYSQL_PWD=$DB_PASSWORD mysql -h127.0.0.1 -u$DB_USER $DB_NAME # Restore in leere DB -``` -Automatisiertes, verschlüsseltes Backup mit Restore-Test ist noch offen (siehe Plane). +## Modularität +API-Module liegen in `apps/api/src/modules/` und implementieren `KcModule` (`core/module.ts`). Sie werden in +`modules/index.ts` eingetragen, registrieren Routen und Rechte und sprechen nur über `core/*` und Jobs miteinander. +Neue Anbieter kommen als Connector unter `packages/connector-*` hinzu (`docs/connector-vertrag.md`). + +## Tests +`cd apps/api && pnpm test` – Integrationstests gegen eine **separate** Datenbank `kundencenter_test` (wird bei jedem Lauf +neu erstellt). Abgedeckt: Login, Sperre, CSRF, 2FA-Pflicht, Sitzungen, Kundenanlage, Mandantentrennung, Rechte, Audit-Kette. diff --git a/apps/api/src/cli/index.ts b/apps/api/src/cli/index.ts index 0971736..9207405 100644 --- a/apps/api/src/cli/index.ts +++ b/apps/api/src/cli/index.ts @@ -3,18 +3,65 @@ import { hash } from '@node-rs/argon2'; import '../core/config.js'; import { pool, one, run } from '../core/db.js'; import { audit } from '../core/audit.js'; +import { assertStaffCapacity } from '../core/license.js'; const [cmd, ...args] = process.argv.slice(2); const opt = (n: string) => args.find((a) => a.startsWith(`--${n}=`))?.slice(n.length + 3); +const ARGON = { memoryCost: 19456, timeCost: 2, parallelism: 1 }; + +/** Passwort ohne Echo abfragen (wie `passwd`): landet nie in argv, `ps aux` oder der Shell-History. */ +async function promptHidden(question: string): Promise { + process.stdout.write(question); + const stdin = process.stdin; + if (!stdin.isTTY) throw new Error('Kein interaktives Terminal verfügbar. Für nicht-interaktive Aufrufe stattdessen KC_CLI_PASSWORD setzen (Umgebungsvariable, nicht als Argument).'); + return new Promise((resolve, reject) => { + stdin.resume(); stdin.setRawMode(true); + let input = ''; + const onData = (chunk: Buffer) => { + const ch = chunk.toString('utf8'); + if (ch === '\n' || ch === '\r' || ch === '\u0004') { cleanup(); process.stdout.write('\n'); resolve(input); } + else if (ch === '\u0003') { cleanup(); process.stdout.write('\n'); reject(new Error('Abgebrochen')); } + else if (ch === '\u007f' || ch === '\b') input = input.slice(0, -1); + else input += ch; + }; + const cleanup = () => { stdin.setRawMode(false); stdin.pause(); stdin.removeListener('data', onData); }; + stdin.on('data', onData); + }); +} +/** Passwort holen: interaktiv (verdeckt) abfragen, oder – für Skripte/Automatisierung – aus KC_CLI_PASSWORD lesen. + * Nie als --password=…-Argument, das in `ps aux` für alle lokalen Nutzer sichtbar wäre. */ +async function readPassword(): Promise { + if (process.env.KC_CLI_PASSWORD) return process.env.KC_CLI_PASSWORD; + const pw = await promptHidden('Passwort (min. 12 Zeichen): '); + const repeat = await promptHidden('Passwort wiederholen: '); + if (pw !== repeat) throw new Error('Passwörter stimmen nicht überein'); + return pw; +} if (cmd === 'create-superadmin') { - const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin', pw = opt('password'); - if (!email || !pw || pw.length < 12) { console.error('Nutzung: create-superadmin --email=… --name=… --password='); process.exit(2); } - if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits'); process.exit(1); } + const email = opt('email')?.toLowerCase(), name = opt('name') ?? 'Superadmin'; + if (!email) { console.error('Nutzung: create-superadmin --email=… [--name=…] (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); } + if (await one('SELECT 1 AS x FROM users WHERE email = ?', [email])) { console.error('E-Mail existiert bereits (für einen Reset stattdessen: reset-password --email=…)'); process.exit(1); } + const cap = await assertStaffCapacity(); + if (!cap.allowed) { console.error(cap.reason); process.exit(1); } + const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); }); + if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); } const id = randomUUID(); - await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, { memoryCost: 19456, timeCost: 2, parallelism: 1 })]); + await run("INSERT INTO users (id, email, name, password_hash, kind, staff_role, status, email_verified_at) VALUES (?,?,?,?,'staff','superadmin','active',UTC_TIMESTAMP(3))", [id, email, name, await hash(pw, ARGON)]); await audit({ actorType: 'system', action: 'user.create', resourceType: 'user', resourceId: id, after: { email, kind: 'staff', staffRole: 'superadmin', via: 'cli' } }); console.log('Superadmin angelegt:', email, '(2FA muss beim ersten Login eingerichtet werden)'); +} else if (cmd === 'reset-password') { + // Fallback für den Fall, dass die Selbstbedienung (E-Mail-Link unter /passwort-vergessen) nicht erreichbar ist + // (z. B. Postfach ebenfalls verloren, oder SMTP noch nicht eingerichtet). Funktioniert für jeden Nutzer, nicht nur Staff. + const email = opt('email')?.toLowerCase(); + if (!email) { console.error('Nutzung: reset-password --email=… (Passwort wird danach abgefragt, nie als Argument)'); process.exit(2); } + const u = await one('SELECT id FROM users WHERE email = ?', [email]); + if (!u) { console.error('Kein Nutzer mit dieser E-Mail'); process.exit(1); } + const pw = await readPassword().catch((e) => { console.error((e as Error).message); process.exit(1); }); + if (pw.length < 12) { console.error('Passwort zu kurz (min. 12 Zeichen)'); process.exit(2); } + await run('UPDATE users SET password_hash = ?, failed_logins = 0, locked_until = NULL WHERE id = ?', [await hash(pw, ARGON), u.id]); + await audit({ actorType: 'system', action: 'auth.password.reset', resourceType: 'user', resourceId: u.id, after: { via: 'cli' } }); + console.log('Passwort zurückgesetzt für', email, '- bestehende Sitzungen bleiben aktiv (nicht automatisch abgemeldet)'); } else if (cmd === 'connector-secrets') { // Zugangsdaten einer Verbindung aus einer geschützten Datei setzen (Werte erscheinen nie in Argumenten/Logs) // Nutzung: connector-secrets --name=Licensing --file=/pfad/datei.txt (Zeilen SCHLÜSSEL=Wert; LICENSING_API_USER→username, LICENSING_API_PASSWORD→password) @@ -49,8 +96,15 @@ if (cmd === 'create-superadmin') { else if (sub === 'restore-test') { const r = await runRestoreTest(cfg, args[1]); console.log(JSON.stringify(r, null, 2)); process.exitCode = r.ok ? 0 : 1; } else if (sub === 'status') console.log(JSON.stringify(await readStatus(cfg), null, 2)); else { console.error('Nutzung: backup run | restore-test [datei] | status'); process.exitCode = 2; } +} else if (cmd === 'audit-purge') { + // Löscht Audit-Einträge jenseits der konfigurierten Aufbewahrungsfrist (auch: audit_settings.retention_days). + // Läuft sonst täglich automatisch im Worker; hier v. a. zum manuellen Testen/Nachvollziehen. + const { purgeAuditRetention } = await import('../core/audit.js'); + const days = opt('days') ? Number(opt('days')) : (await one('SELECT retention_days FROM audit_settings WHERE id = 1'))?.retention_days ?? 180; + const r = await purgeAuditRetention(days); + console.log(JSON.stringify(r, null, 2)); } else { - console.error('Befehle: create-superadmin, connector-secrets, import-domains, backup'); + console.error('Befehle: create-superadmin, reset-password, connector-secrets, import-domains, backup, audit-purge'); process.exit(2); } await pool.end(); diff --git a/apps/api/src/cli/migrate.ts b/apps/api/src/cli/migrate.ts index 59addb4..1d07980 100644 --- a/apps/api/src/cli/migrate.ts +++ b/apps/api/src/cli/migrate.ts @@ -1,3 +1,4 @@ +import { createHash } from 'node:crypto'; import { readdirSync, readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { join, dirname } from 'node:path'; @@ -5,19 +6,43 @@ import '../core/config.js'; import { pool } from '../core/db.js'; const dir = join(dirname(fileURLToPath(import.meta.url)), '../../../../migrations'); +const sha256 = (s: string) => createHash('sha256').update(s).digest('hex'); + export async function migrate(): Promise { - await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))'); - const done = new Set((await pool.query('SELECT name FROM schema_migrations') as any)[0].map((r: { name: string }) => r.name)); - for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { - if (done.has(f)) continue; - const c = await pool.getConnection(); - try { - // DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch. - const stmts = readFileSync(join(dir, f), 'utf8').replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean); - for (const st of stmts) await c.query(st); - await c.query('INSERT INTO schema_migrations (name) VALUES (?)', [f]); - console.log('migriert:', f); - } finally { c.release(); } + await pool.query('CREATE TABLE IF NOT EXISTS schema_migrations (name VARCHAR(200) PRIMARY KEY, checksum CHAR(64) NULL, applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3))'); + await pool.query('ALTER TABLE schema_migrations ADD COLUMN IF NOT EXISTS checksum CHAR(64) NULL'); // bestehende Installationen ohne die Spalte nachziehen + // Sperre gegen gleichzeitige Migrationsläufe (z. B. zwei parallele Deploys) – sonst könnten beide dieselbe neue + // Migration anwenden oder sich beim Anlegen der Zeile in schema_migrations in die Quere kommen. + const [[lockRow]] = await pool.query('SELECT GET_LOCK(?, 10) AS got', ['kc_migrate']) as any; + if (Number(lockRow?.got) !== 1) throw new Error('Migrations-Sperre konnte nicht erlangt werden (läuft bereits eine andere Migration? Zeitüberschreitung nach 10s)'); + try { + const rows = (await pool.query('SELECT name, checksum FROM schema_migrations') as any)[0] as { name: string; checksum: string | null }[]; + const applied = new Map(rows.map((r) => [r.name, r.checksum])); + for (const f of readdirSync(dir).filter((n) => n.endsWith('.sql')).sort()) { + const content = readFileSync(join(dir, f), 'utf8'); + const sum = sha256(content); + if (applied.has(f)) { + const stored = applied.get(f); + // Bereits angewendete Migrationen dürfen nachträglich nicht mehr verändert werden (sonst driften Umgebungen + // unbemerkt auseinander, je nachdem wann sie migriert haben). Alte Zeilen ohne Prüfsumme werden einmalig nachgetragen. + if (stored == null) await pool.query('UPDATE schema_migrations SET checksum = ? WHERE name = ?', [sum, f]); + else if (stored !== sum) throw new Error(`Migration ${f} wurde bereits angewendet, ihr Inhalt hat sich seitdem aber geändert (Prüfsumme weicht ab). Bitte die Datei nicht nachträglich bearbeiten, sondern eine neue Migration anlegen.`); + continue; + } + const c = await pool.getConnection(); + try { + // DDL ist in MariaDB nicht transaktional; jede Migration einzeln, bei Fehler Abbruch. + const stmts = content.replace(/^\s*--.*$/gm, '').split(/;\s*\n/).map((s) => s.trim()).filter(Boolean); + for (const st of stmts) await c.query(st); + await c.query('INSERT INTO schema_migrations (name, checksum) VALUES (?, ?)', [f, sum]); + console.log('migriert:', f); + } finally { c.release(); } + } + } finally { + await pool.query('SELECT RELEASE_LOCK(?)', ['kc_migrate']).catch(() => undefined); } } -if (import.meta.url === `file://${process.argv[1]}`) { await migrate(); await pool.end(); } +if (import.meta.url === `file://${process.argv[1]}`) { + try { await migrate(); } catch (e) { console.error((e as Error).message); await pool.end(); process.exit(1); } + await pool.end(); +} diff --git a/apps/api/src/core/auth.ts b/apps/api/src/core/auth.ts index 5e87b9a..c668ef6 100644 --- a/apps/api/src/core/auth.ts +++ b/apps/api/src/core/auth.ts @@ -4,12 +4,15 @@ import { one, query, run } from './db.js'; import { config } from './config.js'; import { randomToken, sha256, safeEqual } from './crypto.js'; import { forbidden, unauthorized } from './errors.js'; +import { audit } from './audit.js'; import { can, type Principal, type OrgRole, type StaffRole } from './policy.js'; export const COOKIE = 'kc_session'; const SESSION_HOURS = 12; const IDLE_MINUTES = 120; +export const IMPERSONATION_MINUTES = 60; +export interface Impersonating { orgId: string; orgName: string; customerNumber: string; reason: string; startedAt: string; expiresAt: string } export interface AuthContext { sessionId: string; csrf: string; @@ -17,6 +20,10 @@ export interface AuthContext { mfaEnrolled: boolean; user: { id: string; email: string; name: string }; principal: Principal; + /** Gesetzt, während ein Staff-Mitglied "als Kunde" unterwegs ist (siehe customers-Modul, /admin/customers/:id/impersonate). + * principal ist in diesem Fall absichtlich auf 'customer' mit Mitgliedschaft in genau dieser Organisation umgeschaltet - + * echte Rechteeinschränkung, nicht nur eine andere Oberfläche. user bleibt die echte Staff-Identität (fürs Audit). */ + impersonating: Impersonating | null; } declare module 'fastify' { interface FastifyRequest { auth?: AuthContext; correlationId: string } @@ -39,7 +46,8 @@ async function loadAuth(req: FastifyRequest): Promise { const token = req.cookies[COOKIE]; if (!token) return undefined; const s = await one( - `SELECT s.id, s.csrf_token, s.pending_mfa, u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status, + `SELECT s.id, s.csrf_token, s.pending_mfa, s.impersonating_org_id, s.impersonation_reason, s.impersonation_started_at, s.impersonation_expires_at, + u.id AS uid, u.email, u.name, u.kind, u.staff_role, u.status, (SELECT COUNT(*) FROM mfa_totp m WHERE m.user_id = u.id AND m.confirmed_at IS NOT NULL) AS mfa FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > UTC_TIMESTAMP(3) @@ -47,12 +55,30 @@ async function loadAuth(req: FastifyRequest): Promise { [sha256(token), IDLE_MINUTES], ); if (!s || s.status !== 'active') return undefined; - const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]); await run('UPDATE sessions SET last_seen_at = UTC_TIMESTAMP(3) WHERE id = ?', [s.id]); + + let principal: Principal = { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: [] }; + let impersonating: Impersonating | null = null; + if (s.impersonating_org_id) { + if (new Date(s.impersonation_expires_at as Date) <= new Date()) { + // Abgelaufen: Sitzung fällt automatisch auf die echte Staff-Identität zurück, kein harter Logout. + await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [s.id]); + await audit({ actorType: 'user', actorId: s.uid, orgId: s.impersonating_org_id, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: s.impersonating_org_id, after: { reason: 'expired' } }).catch(() => undefined); + } else { + const org = await one('SELECT name, customer_number FROM organizations WHERE id = ?', [s.impersonating_org_id]); + if (org) { + principal = { userId: s.uid, kind: 'customer', staffRole: null, memberships: [{ orgId: s.impersonating_org_id, role: 'owner' }] }; + impersonating = { orgId: s.impersonating_org_id, orgName: org.name, customerNumber: org.customer_number, reason: s.impersonation_reason, startedAt: s.impersonation_started_at, expiresAt: s.impersonation_expires_at }; + } + } + } + if (!impersonating) { + const ms = await query('SELECT org_id, role FROM memberships WHERE user_id = ?', [s.uid]); + principal.memberships = ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })); + } return { sessionId: s.id, csrf: s.csrf_token, pendingMfa: !!s.pending_mfa, mfaEnrolled: Number(s.mfa) > 0, - user: { id: s.uid, email: s.email, name: s.name }, - principal: { userId: s.uid, kind: s.kind, staffRole: (s.staff_role as StaffRole | null) ?? null, memberships: ms.map((m) => ({ orgId: m.org_id as string, role: m.role as OrgRole })) }, + user: { id: s.uid, email: s.email, name: s.name }, principal, impersonating, }; } diff --git a/apps/api/src/core/license.ts b/apps/api/src/core/license.ts new file mode 100644 index 0000000..be2d85f --- /dev/null +++ b/apps/api/src/core/license.ts @@ -0,0 +1 @@ +export * from '@kc/platform/license'; diff --git a/apps/api/src/modules/audit/index.ts b/apps/api/src/modules/audit/index.ts index 571015d..cac1e3c 100644 --- a/apps/api/src/modules/audit/index.ts +++ b/apps/api/src/modules/audit/index.ts @@ -1,10 +1,18 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; -import { query } from '../../core/db.js'; -import { verifyAuditChain } from '../../core/audit.js'; -import { requirePermission } from '../../core/auth.js'; +import { one, query, run } from '../../core/db.js'; +import { audit, verifyAuditChain, purgeAuditRetention } from '../../core/audit.js'; +import { clientIp, requirePermission } from '../../core/auth.js'; +import { badRequest } from '../../core/errors.js'; import type { KcModule } from '../../core/module.js'; +const AUDIT_SQL = 'SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json FROM audit_events'; +const listQuery = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), from: z.string().date().optional(), to: z.string().date().optional() }); +// Formel-Injection-Schutz wie im Rechnungs-CSV-Export (apps/api/src/modules/invoices/index.ts) - hier lokal +// dupliziert statt importiert, damit Module weiterhin ohne Querverweise aufeinander auskommen. +const csvSafe = (s: string) => (/^[=+\-@\t]/.test(s) ? `'${s}` : s); +const csvCell = (s: string) => { const v = csvSafe(s); return /[;"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; }; + export const auditModule: KcModule = { name: 'audit', register(app: FastifyInstance) { @@ -12,8 +20,7 @@ export const auditModule: KcModule = { requirePermission(req, 'audit.read'); const q = z.object({ action: z.string().max(100).optional(), actor: z.string().uuid().optional(), org: z.string().uuid().optional(), limit: z.coerce.number().int().min(1).max(500).default(100) }).parse(req.query); const rows = await query( - `SELECT id, ts, actor_type, actor_id, org_id, action, resource_type, resource_id, result, error_class, correlation_id, ip, before_json, after_json - FROM audit_events WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?) + `${AUDIT_SQL} WHERE (? IS NULL OR action LIKE CONCAT(?, '%')) AND (? IS NULL OR actor_id = ?) AND (? IS NULL OR org_id = ?) ORDER BY id DESC LIMIT ?`, [q.action ?? null, q.action ?? null, q.actor ?? null, q.actor ?? null, q.org ?? null, q.org ?? null, q.limit], ); @@ -23,5 +30,58 @@ export const auditModule: KcModule = { requirePermission(req, 'audit.read'); return verifyAuditChain(); }); + + // ---- Aufbewahrungs-Policy (Default reflektiert den DSGVO-Grundsatz der Speicherbegrenzung, Art. 5 Abs. 1 + // lit. e - die DSGVO selbst schreibt dafür keine feste Zahl vor; änderbar je nach eigener Löschrichtlinie) --- + app.get('/admin/audit/settings', async (req) => { + requirePermission(req, 'audit.read'); + const s = await one('SELECT retention_days, updated_at FROM audit_settings WHERE id = 1'); + const last = await one('SELECT purged_at, purged_count, purged_through_id, retention_days FROM audit_retention_checkpoints ORDER BY id DESC LIMIT 1'); + return { + retentionDays: s?.retention_days ?? 180, updatedAt: s?.updated_at ?? null, + lastPurge: last ? { at: last.purged_at, count: last.purged_count, throughId: last.purged_through_id, retentionDays: last.retention_days } : null, + }; + }); + app.put('/admin/audit/settings', async (req) => { + const a = requirePermission(req, 'settings.write'); + const b = z.object({ retentionDays: z.number().int().min(30).max(3650) }).parse(req.body); + await run('UPDATE audit_settings SET retention_days = ? WHERE id = 1', [b.retentionDays]); + await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.settings.update', resourceType: 'audit_settings', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { retentionDays: b.retentionDays } }); + return { ok: true }; + }); + // Manuelles Anstoßen (der Worker macht das sonst täglich); v. a. zum sofortigen Nachvollziehen nach einer + // Änderung der Frist gedacht, nicht für den Alltag. + app.post('/admin/audit/purge', async (req) => { + const a = requirePermission(req, 'settings.write'); + const s = await one('SELECT retention_days FROM audit_settings WHERE id = 1'); + const res = await purgeAuditRetention(s?.retention_days ?? 180).catch((e: unknown) => { throw badRequest((e as Error).message, 'PURGE_FAILED'); }); + await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.purge.manual', resourceType: 'audit_events', correlationId: req.correlationId, ip: clientIp(req), after: res }); + return res; + }); + + /** CSV-Export (für Auskunftsersuchen/Nachweis vor einer geplanten Aufräumung). before/after sind bereits + * beim Schreiben maskiert (siehe mask() in @kc/platform/audit) - hier keine zusätzliche Maskierung nötig. */ + app.get('/admin/audit/export.csv', async (req, reply) => { + const a = requirePermission(req, 'audit.read'); + const q = listQuery.parse(req.query); + const conds: string[] = ['1=1']; const params: unknown[] = []; + if (q.action) { conds.push('action LIKE CONCAT(?, ?)'); params.push(q.action, '%'); } + if (q.actor) { conds.push('actor_id = ?'); params.push(q.actor); } + if (q.org) { conds.push('org_id = ?'); params.push(q.org); } + if (q.from) { conds.push('ts >= ?'); params.push(q.from); } + if (q.to) { conds.push('ts <= DATE_ADD(?, INTERVAL 1 DAY)'); params.push(q.to); } + const rows = await query(`${AUDIT_SQL} WHERE ${conds.join(' AND ')} ORDER BY id LIMIT 50000`, params); + const header = ['ID', 'Zeit', 'Akteur-Typ', 'Akteur-ID', 'Organisation-ID', 'Aktion', 'Objekt-Typ', 'Objekt-ID', 'Ergebnis', 'Fehlerklasse', 'Korrelations-ID', 'IP', 'Vorher', 'Nachher']; + const lines = [header.join(';')]; + for (const r of rows) { + lines.push([ + String(r.id), (r.ts as Date).toISOString(), r.actor_type, r.actor_id ?? '', r.org_id ?? '', csvCell(r.action), r.resource_type ?? '', r.resource_id ?? '', + r.result, r.error_class ?? '', r.correlation_id ?? '', r.ip ?? '', csvCell(r.before_json ? JSON.stringify(r.before_json) : ''), csvCell(r.after_json ? JSON.stringify(r.after_json) : ''), + ].join(';')); + } + await audit({ actorType: 'user', actorId: a.user.id, action: 'audit.export_csv', resourceType: 'audit_events', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } }); + reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', `attachment; filename="audit-export-${q.from ?? 'alle'}_${q.to ?? 'alle'}.csv"`); + return reply.send('' + lines.join('\r\n') + '\r\n'); + }); }, }; diff --git a/apps/api/src/modules/backup/index.ts b/apps/api/src/modules/backup/index.ts index 3e3bbd1..d251ac2 100644 --- a/apps/api/src/modules/backup/index.ts +++ b/apps/api/src/modules/backup/index.ts @@ -13,6 +13,7 @@ import { passwordMeta, passwordProblem, setBackupPassword, MIN_PASSWORD } from ' import { loadTargets, testTarget } from '../../ops/targets.js'; import { clientIp, requirePermission } from '../../core/auth.js'; import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js'; +import { featureRequiresLicense, hasFeature } from '../../core/license.js'; import type { KcModule } from '../../core/module.js'; const statusFile = () => process.env.BACKUP_STATUS_FILE ?? '/var/lib/kundencenter/backup-status.json'; @@ -111,6 +112,7 @@ export const backupModule: KcModule = { app.post('/admin/backup/targets', async (req) => { const a = requirePermission(req, 'backup.secrets'); + if (!(await hasFeature('backup_remote'))) throw forbidden(featureRequiresLicense('backup_remote'), 'LICENSE_REQUIRED'); const b = targetSchema.parse(req.body); if (await one('SELECT 1 AS x FROM backup_targets WHERE name = ?', [b.name])) throw conflict('Der Name ist bereits vergeben.', 'NAME_EXISTS'); const { cfg, sec, path } = split(b); const id = randomUUID(); diff --git a/apps/api/src/modules/catalog/index.ts b/apps/api/src/modules/catalog/index.ts index 42aa2ce..5c31527 100644 --- a/apps/api/src/modules/catalog/index.ts +++ b/apps/api/src/modules/catalog/index.ts @@ -11,7 +11,7 @@ import { one, query, run, tx } from '../../core/db.js'; import { audit } from '../../core/audit.js'; import { clientIp, requireAuth, requirePermission } from '../../core/auth.js'; import { AppError, badRequest, conflict, notFound } from '../../core/errors.js'; -import { canInOrg } from '../../core/policy.js'; +import { can, canInOrg } from '../../core/policy.js'; import type { KcModule } from '../../core/module.js'; const int = (max: number) => z.number().int().min(0).max(max); @@ -272,7 +272,9 @@ export const catalogModule: KcModule = { const q = z.object({ org: z.string().uuid() }).parse(req.query); if (!canInOrg(a.principal, q.org, 'orders.read', 'products.read')) throw notFound(); const org = await one('SELECT customer_type FROM organizations WHERE id = ?', [q.org]); - const rows = await query(`${productSelect} WHERE p.status = 'active' AND p.orderable_by_customer = 1 ORDER BY v.name`); + // Personal darf auch Produkte bestellen, die Kunden nicht selbst bestellen können (siehe POST /orders) + const staff = can(a.principal, 'orders.write'); + const rows = await query(`${productSelect} WHERE p.status = 'active'${staff ? '' : ' AND p.orderable_by_customer = 1'} ORDER BY v.name`); return Promise.all(rows.map(async (r) => { const price = calculatePrice({ basis: r.price_basis, setupCents: r.setup_cents, recurringCents: r.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }); // Staffelpreise nach Laufzeit (optional): je gewählter Laufzeit ein eigener Gesamtpreis statt des Basispreises. @@ -280,7 +282,7 @@ export const catalogModule: KcModule = { termMonths: t.term_months, price: calculatePrice({ basis: r.price_basis, setupCents: 0, recurringCents: t.recurring_cents, taxBp: r.tax_bp, interval: r.billing_interval, quantity: 1, discountBp: 0 }).recurring, })); - return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers }; + return { id: r.id, sku: r.sku, category: r.category, name: r.vname, description: r.description, requiresApproval: !!r.requires_approval, orderableByCustomer: !!r.orderable_by_customer, customerType: org?.customer_type, price, termMonths: r.term_months, renewal: r.renewal, renewalTermMonths: r.renewal_term_months, noticeDays: r.notice_days, termPrices: tiers }; })); }); }, diff --git a/apps/api/src/modules/customers/index.ts b/apps/api/src/modules/customers/index.ts index e4db92e..89ace95 100644 --- a/apps/api/src/modules/customers/index.ts +++ b/apps/api/src/modules/customers/index.ts @@ -3,8 +3,9 @@ import { z } from 'zod'; import { randomUUID } from 'node:crypto'; import { one, query, run, tx } from '../../core/db.js'; import { audit } from '../../core/audit.js'; -import { clientIp, requireAuth, requirePermission } from '../../core/auth.js'; +import { clientIp, requireAuth, requirePermission, IMPERSONATION_MINUTES } from '../../core/auth.js'; import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js'; +import { assertCustomerCapacity } from '../../core/license.js'; import { canInOrg } from '../../core/policy.js'; import { createInvitedUser, createToken, inviteLink, mailInvite } from '../../core/accounts.js'; import { enqueue } from '../../core/jobs.js'; @@ -59,6 +60,9 @@ async function nextCustomerNumber(c: Parameters[2]): Promise export const customersModule: KcModule = { name: 'customers', + permissions: { + staff: { admin: ['customers.impersonate'], superadmin: ['customers.impersonate'] }, + }, register(app: FastifyInstance) { // ---- Admin: Kunden ----------------------------------------------------- app.get('/admin/customers', async (req) => { @@ -84,6 +88,8 @@ export const customersModule: KcModule = { if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.owner.email])) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet', 'EMAIL_EXISTS'); const orgId = randomUUID(); const res = await tx(async (c) => { + const cap = await assertCustomerCapacity(c); + if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED'); const number = await nextCustomerNumber(c); await run('INSERT INTO organizations (id, customer_number, name, customer_type) VALUES (?,?,?,?)', [orgId, number, b.name, b.type], c); const bp = b.billing; @@ -105,6 +111,20 @@ export const customersModule: KcModule = { return loadOrg(id); }); + /** "Als Kunde ansehen": schaltet die eigene Sitzung zeitlich begrenzt (IMPERSONATION_MINUTES) auf die + * Sicht dieses Kunden um (echte Rechteeinschränkung, nicht nur eine andere Oberfläche). Admin/Superadmin + * only; Grund ist Pflicht und wird protokolliert. Lässt sich nicht verschachteln (erfordert customers.read, + * das während einer laufenden Impersonation nicht mehr greift). */ + app.post('/admin/customers/:id/impersonate', async (req) => { + const a = requirePermission(req, 'customers.impersonate'); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ reason: z.string().trim().min(3).max(300) }).parse(req.body); + const org = await one('SELECT id, name, customer_number FROM organizations WHERE id = ?', [id]); if (!org) throw notFound(); + await run('UPDATE sessions SET impersonating_org_id = ?, impersonation_reason = ?, impersonation_started_at = UTC_TIMESTAMP(3), impersonation_expires_at = DATE_ADD(UTC_TIMESTAMP(3), INTERVAL ? MINUTE) WHERE id = ?', [id, b.reason, IMPERSONATION_MINUTES, a.sessionId]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: id, action: 'customer.impersonate.start', resourceType: 'organization', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), after: { reason: b.reason, minutes: IMPERSONATION_MINUTES } }); + return { ok: true, orgName: org.name, customerNumber: org.customer_number, expiresInMinutes: IMPERSONATION_MINUTES }; + }); + /** Begrüßungsmail: Zugang (neuer Einladungslink oder Login-Link, falls schon aktiv), aktuelle Produkte, kurze Anleitung, Discord-Hinweis. */ app.post('/admin/customers/:id/welcome-mail', async (req) => { const a = requirePermission(req, 'customers.write'); @@ -145,6 +165,10 @@ export const customersModule: KcModule = { if (type === 'private' && (after.company || after.vatId)) throw badRequest('Privatkunden haben keine Firma und keine USt-IdNr. Bitte beides entfernen.', 'PRIVATE_NO_COMPANY'); if (type === 'business') { if (!(after.company || name)) throw badRequest('Geschäftskunden benötigen einen Firmennamen', 'BUSINESS_NEEDS_COMPANY'); applyTypeRules('business', name, { vatId: after.vatId ?? undefined }); } await tx(async (c) => { + if (b.status === 'active' && before.status === 'closed') { + const cap = await assertCustomerCapacity(c); + if (!cap.allowed) throw forbidden(cap.reason!, 'CUSTOMER_LIMIT_REACHED'); + } if (b.name || b.status || b.customerType) await run('UPDATE organizations SET name = COALESCE(?, name), status = COALESCE(?, status), customer_type = COALESCE(?, customer_type) WHERE id = ?', [b.name ?? null, b.status ?? null, b.customerType ?? null, id], c); const keys = Object.keys(patch); if (keys.length) await run(`UPDATE billing_profiles SET ${keys.map((k) => `${cols[k]} = ?`).join(', ')} WHERE org_id = ?`, [...keys.map((k) => patch[k] ?? null), id], c); @@ -221,6 +245,8 @@ export const customersModule: KcModule = { const o = await one('SELECT id, customer_number, customer_type FROM organizations WHERE id = ?', [it.linkToOrgId], cn); if (!o) throw badRequest('Der gewählte Kunde existiert nicht', 'BAD_ORG'); orgId = o.id; customerNumber = o.customer_number; orgType = o.customer_type; } else { + const cap = await assertCustomerCapacity(cn); + if (!cap.allowed) throw badRequest(cap.reason!, 'CUSTOMER_LIMIT_REACHED'); if (!ownerEmail || !email.safeParse(ownerEmail).success) throw badRequest('Für den Inhaber fehlt eine gültige E-Mail-Adresse', 'OWNER_EMAIL'); if (await one('SELECT 1 AS x FROM users WHERE email = ?', [ownerEmail], cn)) throw conflict('Diese E-Mail ist bereits einem Benutzer zugeordnet (bitte mit vorhandenem Kunden verknüpfen)', 'EMAIL_EXISTS'); if (it.type === 'private' && (c.company && !it.name)) { /* Firma vorhanden, aber als Privatkunde gewählt: Person als Name */ } diff --git a/apps/api/src/modules/discord/index.ts b/apps/api/src/modules/discord/index.ts index 9406a8e..f8a543a 100644 --- a/apps/api/src/modules/discord/index.ts +++ b/apps/api/src/modules/discord/index.ts @@ -5,6 +5,7 @@ import { audit } from '../../core/audit.js'; import { encrypt, decrypt, randomToken } from '../../core/crypto.js'; import { clientIp, requireAuth, requirePermission } from '../../core/auth.js'; import { badRequest } from '../../core/errors.js'; +import { featureRequiresLicense, hasFeature } from '../../core/license.js'; import { rl, config } from '../../core/config.js'; import type { KcModule } from '../../core/module.js'; @@ -12,6 +13,7 @@ const ID = /^\d{15,25}$/; // Discord-Snowflake-IDs const settingsView = (s: any) => ({ enabled: !!s.enabled, hasToken: !!s.token_enc, guildId: s.guild_id, adminChannelId: s.admin_channel_id, clientId: s.client_id, hasClientSecret: !!s.client_secret_enc, ticketChannelId: s.ticket_channel_id, + ticketCategoryId: s.ticket_category_id, ticketLogChannelId: s.ticket_log_channel_id, supportRoleIds: s.support_role_ids, configured: !!s.token_enc, lastConnectedAt: s.last_connected_at, lastError: s.last_error, updatedAt: s.updated_at, }); const redirectUri = () => `${config.baseUrl}/api/discord/oauth/callback`; @@ -30,11 +32,18 @@ export const discordModule: KcModule = { token: z.string().max(200).nullable().optional(), // undefined = unverändert lassen, null = löschen guildId: z.string().trim().regex(ID).nullable(), adminChannelId: z.string().trim().regex(ID).nullable(), ticketChannelId: z.string().trim().regex(ID).nullable(), + // optional, damit ältere Formulare die Werte nicht versehentlich löschen + ticketCategoryId: z.string().trim().regex(ID).nullable().optional(), ticketLogChannelId: z.string().trim().regex(ID).nullable().optional(), + supportRoleIds: z.string().trim().regex(/^\d{15,25}(\s*,\s*\d{15,25})*$/).max(500).nullable().optional(), enabled: z.boolean().default(false), clientId: z.string().trim().regex(ID).nullable(), clientSecret: z.string().max(200).nullable().optional(), }).parse(req.body); + if (b.enabled && !(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED'); const sets = ['guild_id = ?', 'admin_channel_id = ?', 'ticket_channel_id = ?', 'enabled = ?', 'client_id = ?', 'updated_by = ?']; const params: unknown[] = [b.guildId, b.adminChannelId, b.ticketChannelId, b.enabled ? 1 : 0, b.clientId, a.user.id]; + if (b.ticketCategoryId !== undefined) { sets.push('ticket_category_id = ?'); params.push(b.ticketCategoryId); } + if (b.ticketLogChannelId !== undefined) { sets.push('ticket_log_channel_id = ?'); params.push(b.ticketLogChannelId); } + if (b.supportRoleIds !== undefined) { sets.push('support_role_ids = ?'); params.push(b.supportRoleIds ? b.supportRoleIds.split(',').map((x) => x.trim()).join(',') : null); } if (b.token !== undefined) { sets.push('token_enc = ?'); params.push(b.token ? encrypt(JSON.stringify({ token: b.token })) : null); } if (b.clientSecret !== undefined) { sets.push('client_secret_enc = ?'); params.push(b.clientSecret ? encrypt(JSON.stringify({ secret: b.clientSecret })) : null); } await run(`UPDATE discord_settings SET ${sets.join(', ')} WHERE id = 1`, params); @@ -96,6 +105,7 @@ export const discordModule: KcModule = { /** Startet den Discord-OAuth-Fluss: legt einen kurzlebigen Zustand an und leitet den Browser zu Discord weiter. */ app.get('/discord/oauth/start', async (req, reply) => { const a = requireAuth(req); + if (!(await hasFeature('discord'))) throw badRequest(featureRequiresLicense('discord'), 'LICENSE_REQUIRED'); const s = await one('SELECT client_id FROM discord_settings WHERE id = 1'); if (!s?.client_id) throw badRequest('Discord-Anmeldung ist noch nicht eingerichtet.', 'DISCORD_OAUTH_NOT_CONFIGURED'); const state = randomToken(32); diff --git a/apps/api/src/modules/domains/index.ts b/apps/api/src/modules/domains/index.ts index 1a16cd5..5783b57 100644 --- a/apps/api/src/modules/domains/index.ts +++ b/apps/api/src/modules/domains/index.ts @@ -20,7 +20,7 @@ async function snapshot(tld: string) { } const recView = (r: any) => ({ id: r.id, domain: r.domain, tld: r.tld, orgId: r.org_id, customer: r.org_name ?? null, customerNumber: r.customer_number ?? null, resourceId: r.resource_id, source: r.source, termMonths: r.term_months, costNetCents: r.cost_net_cents, costGrossCents: r.cost_gross_cents, setupCostCents: r.setup_cost_cents, sellNetCents: r.sell_net_cents, taxBp: r.tax_bp, sellGrossCents: r.sell_gross_cents, profitNetCents: r.sell_net_cents === null || r.cost_net_cents === null ? null : r.sell_net_cents - r.cost_net_cents, - procurement: r.procurement, orderedAt: r.ordered_at, orderedRef: r.ordered_ref, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at }); + procurement: r.procurement, orderedAt: r.ordered_at, note: r.note, pricedAt: r.priced_at, createdAt: r.created_at }); const REC_SELECT = 'SELECT d.*, o.name AS org_name, o.customer_number FROM domain_records d LEFT JOIN organizations o ON o.id = d.org_id'; const SUGGEST = ['de', 'com', 'net', 'org', 'eu', 'info']; const marginIn = z.object({ type: z.enum(['percent', 'fixed']).nullable(), value: z.number().int().min(0).max(100_000_00).nullable() }).refine((m) => (m.type === null) === (m.value === null), 'Art und Wert gehören zusammen'); @@ -87,10 +87,10 @@ export const domainsModule: KcModule = { }); app.patch('/admin/domain-records/:id', async (req) => { const a = requirePermission(req, 'domains.write'); const { id } = z.object({ id: z.string().uuid() }).parse(req.params); - const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedRef: z.string().max(100).nullable().optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body); + const b = z.object({ procurement: z.enum(['open', 'ordered', 'external']).optional(), orderedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), note: z.string().max(300).nullable().optional(), orgId: z.string().uuid().nullable().optional(), reprice: z.boolean().optional() }).parse(req.body); const rec = await one('SELECT * FROM domain_records WHERE id = ?', [id]); if (!rec) throw notFound(); if (b.procurement) await run('UPDATE domain_records SET procurement = ?, ordered_at = ? WHERE id = ?', [b.procurement, b.procurement === 'ordered' ? (rec.ordered_at ?? new Date()) : null, id]); - if (b.orderedRef !== undefined) await run('UPDATE domain_records SET ordered_ref = ? WHERE id = ?', [b.orderedRef, id]); + if (b.orderedAt) await run("UPDATE domain_records SET ordered_at = ?, procurement = IF(procurement = 'open', 'ordered', procurement) WHERE id = ?", [`${b.orderedAt} 00:00:00`, id]); if (b.note !== undefined) await run('UPDATE domain_records SET note = ? WHERE id = ?', [b.note, id]); if (b.orgId !== undefined) await run('UPDATE domain_records SET org_id = ? WHERE id = ?', [b.orgId, id]); if (b.reprice) { const p = await snapshot(rec.tld); await run('UPDATE domain_records SET term_months=?, cost_net_cents=?, cost_gross_cents=?, setup_cost_cents=?, sell_net_cents=?, tax_bp=?, sell_gross_cents=?, priced_at=UTC_TIMESTAMP(3) WHERE id = ?', [p.termMonths, p.costNet, p.costGross, p.setup, p.net, p.tax, p.gross, id]); } diff --git a/apps/api/src/modules/identity/index.ts b/apps/api/src/modules/identity/index.ts index 5a91382..ffbd351 100644 --- a/apps/api/src/modules/identity/index.ts +++ b/apps/api/src/modules/identity/index.ts @@ -8,6 +8,7 @@ import { one, query, run, tx } from '../../core/db.js'; import { audit } from '../../core/audit.js'; import { COOKIE, clientIp, createSession, requireAuth, requirePermission } from '../../core/auth.js'; import { badRequest, conflict, forbidden, notFound, unauthorized } from '../../core/errors.js'; +import { assertStaffCapacity } from '../../core/license.js'; import { decrypt, encrypt, sha256 } from '../../core/crypto.js'; import { can, staffPermissions } from '../../core/policy.js'; import { createInvitedUser, createToken, mailInvite, inviteLink, resetLink } from '../../core/accounts.js'; @@ -93,16 +94,30 @@ export const identityModule: KcModule = { app.get('/auth/me', async (req) => { const a = requireAuth(req, { allowPendingMfa: true, allowUnenrolledStaff: true }); - const orgs = a.principal.memberships.length - ? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id]) - : []; + // Während einer Impersonation stammt die Mitgliedschaft nicht aus der echten memberships-Tabelle + // (der Staff-Nutzer ist dort nicht wirklich Mitglied) - direkt aus dem Impersonation-Kontext aufbauen. + const orgs = a.impersonating + ? [{ id: a.impersonating.orgId, name: a.impersonating.orgName, customer_number: a.impersonating.customerNumber, role: 'owner' }] + : a.principal.memberships.length + ? await query('SELECT o.id, o.name, o.customer_number, m.role FROM memberships m JOIN organizations o ON o.id = m.org_id WHERE m.user_id = ?', [a.user.id]) + : []; return { user: a.user, kind: a.principal.kind, staffRole: a.principal.staffRole, permissions: staffPermissions(a.principal.staffRole), pendingMfa: a.pendingMfa, mfaEnrolled: a.mfaEnrolled, mfaEnrollRequired: a.principal.kind === 'staff' && !a.mfaEnrolled, organizations: orgs.map((o) => ({ id: o.id, name: o.name, customerNumber: o.customer_number, role: o.role })), csrf: a.csrf, + impersonating: a.impersonating, }; }); + /** Beendet die eigene Impersonation-Sitzung (siehe POST /admin/customers/:id/impersonate), egal ob abgelaufen oder nicht. */ + app.post('/auth/impersonate/stop', async (req) => { + const a = requireAuth(req, { allowUnenrolledStaff: true }); + if (!a.impersonating) return { ok: true }; + await run('UPDATE sessions SET impersonating_org_id = NULL, impersonation_reason = NULL, impersonation_started_at = NULL, impersonation_expires_at = NULL WHERE id = ?', [a.sessionId]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: a.impersonating.orgId, action: 'customer.impersonate.end', resourceType: 'organization', resourceId: a.impersonating.orgId, correlationId: req.correlationId, ip: clientIp(req), after: { reason: 'manual' } }); + return { ok: true }; + }); + // ---- MFA-Einrichtung --------------------------------------------------- app.post('/auth/mfa/setup', async (req) => { const a = requireAuth(req, { allowUnenrolledStaff: true }); @@ -225,6 +240,8 @@ export const identityModule: KcModule = { const b = z.object({ email, name: z.string().min(1).max(150), staffRole: z.enum(['support', 'accounting', 'admin', 'superadmin']) }).parse(req.body); if ((b.staffRole === 'admin' || b.staffRole === 'superadmin') && !can(a.principal, 'users.write_privileged')) throw forbidden('Nur Superadministratoren dürfen Administratoren anlegen', 'PRIVILEGED_ONLY'); if (await one('SELECT 1 AS x FROM users WHERE email = ?', [b.email])) throw conflict('E-Mail bereits vergeben', 'EMAIL_EXISTS'); + const cap = await assertStaffCapacity(); + if (!cap.allowed) throw forbidden(cap.reason!, 'STAFF_LIMIT_REACHED'); const inv = await tx((c) => createInvitedUser(c, { email: b.email, name: b.name, kind: 'staff', staffRole: b.staffRole })); const mail = await mailInvite(b.email, b.name, inv.token); await audit({ actorType: 'user', actorId: a.user.id, action: 'user.create', resourceType: 'user', resourceId: inv.userId, correlationId: req.correlationId, ip: clientIp(req), after: { email: b.email, kind: 'staff', staffRole: b.staffRole } }); diff --git a/apps/api/src/modules/index.ts b/apps/api/src/modules/index.ts index 06db281..f0f59d6 100644 --- a/apps/api/src/modules/index.ts +++ b/apps/api/src/modules/index.ts @@ -13,6 +13,8 @@ import { ordersModule } from './orders/index.js'; import { backupModule } from './backup/index.js'; import { mailModule } from './mail/index.js'; import { discordModule } from './discord/index.js'; +import { licenseModule } from './license/index.js'; +import { licensingModule } from './licensing/index.js'; /** Aktive Module. Neue Module (Produkte, Verträge, Connectoren, Tickets, Rechnungen) werden hier eingetragen. */ -export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule]; +export const modules: KcModule[] = [systemModule, identityModule, customersModule, auditModule, connectorsModule, resourcesModule, catalogModule, domainsModule, ordersModule, ticketsModule, invoicesModule, backupModule, mailModule, discordModule, licenseModule, licensingModule]; diff --git a/apps/api/src/modules/invoices/index.ts b/apps/api/src/modules/invoices/index.ts index 3195c26..36613c3 100644 --- a/apps/api/src/modules/invoices/index.ts +++ b/apps/api/src/modules/invoices/index.ts @@ -1,6 +1,10 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { randomUUID } from 'node:crypto'; +import { spawn } from 'node:child_process'; +import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import type { PoolConnection } from 'mysql2/promise'; import { calculatePrice } from '@kc/platform/pricing'; import { one, query, run, tx } from '../../core/db.js'; @@ -8,6 +12,7 @@ import { audit } from '../../core/audit.js'; import { enqueue } from '../../core/jobs.js'; import { clientIp, requireAuth, requirePermission } from '../../core/auth.js'; import { badRequest, conflict, forbidden, notFound } from '../../core/errors.js'; +import { featureRequiresLicense, hasFeature } from '../../core/license.js'; import { can, canInOrg } from '../../core/policy.js'; import type { KcModule } from '../../core/module.js'; import { config } from '../../core/config.js'; @@ -37,6 +42,21 @@ async function settings(): Promise { } const complete = (s: CompanySettings) => !!(s.name && s.street && s.zip && s.city && (s.taxNumber || s.vatId)); +export interface DatevSettings { + beraterNr: number | null; mandantNr: number | null; skr: string | null; sachkontenlaenge: number; fiscalYearStart: string; + erloeskonto19: number | null; erloeskonto7: number | null; erloeskonto0: number | null; diktatkuerzel: string | null; +} +async function datevSettings(): Promise { + const s = await one('SELECT * FROM company_settings WHERE id = 1'); + return { + beraterNr: s?.datev_berater_nr ?? null, mandantNr: s?.datev_mandant_nr ?? null, skr: s?.datev_skr ?? null, + sachkontenlaenge: Number(s?.datev_sachkontenlaenge ?? 4), fiscalYearStart: s?.datev_fiscal_year_start ?? '01-01', + erloeskonto19: s?.datev_erloeskonto_19 ?? null, erloeskonto7: s?.datev_erloeskonto_7 ?? null, erloeskonto0: s?.datev_erloeskonto_0 ?? null, + diktatkuerzel: s?.datev_diktatkuerzel ?? null, + }; +} +const datevComplete = (d: DatevSettings) => !!(d.beraterNr && d.mandantNr); + const itemView = (i: any) => ({ id: i.id, contractId: i.contract_id, description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents }); const invoiceView = (v: any) => ({ id: v.id, number: v.number, orgId: v.org_id, orgName: v.org_name, customerNumber: v.customer_number, status: v.status, @@ -57,6 +77,110 @@ const notify = (event: string, extra: Record, key: string, corr const mailTo = (to: string, template: string, vars: Record, key: string, correlationId: string) => enqueue('mail.template', { to, key: template, vars }, { idempotencyKey: key, correlationId }); const deDate = (iso: string) => new Date(iso).toLocaleDateString('de-DE'); const deMoney = (cents: number) => (cents / 100).toLocaleString('de-DE', { style: 'currency', currency: 'EUR' }); +const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x); + +// ---- Export für den Steuerberater (Rechnungsjournal als CSV, Belege als ZIP) ------------------ +const exportQuery = z.object({ + from: z.string().date().optional(), to: z.string().date().optional(), + status: z.enum(['open', 'paid', 'cancelled']).optional(), org: z.string().uuid().optional(), +}); +/** Nie Entwürfe (keine steuerlich relevanten Belege); weitere Filter optional. */ +function exportFilter(q: z.infer): { sql: string; params: unknown[] } { + const conds: string[] = ["v.status != 'draft'"]; const params: unknown[] = []; + if (q.from) { conds.push('v.issue_date >= ?'); params.push(q.from); } + if (q.to) { conds.push('v.issue_date <= ?'); params.push(q.to); } + if (q.status) { conds.push('v.status = ?'); params.push(q.status); } + if (q.org) { conds.push('v.org_id = ?'); params.push(q.org); } + return { sql: conds.join(' AND '), params }; +} +const csvNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); +// Formel-Injection-Schutz (CSV): Zellen, die mit =, +, -, @ oder Tab beginnen, werden von Excel/Sheets sonst als Formel +// ausgeführt. Firmenname/Notiz stammen aus vom Kunden bzw. Personal eingegebenen Feldern – daher immer entschärfen. +const csvSafe = (s: string): string => (/^[=+\-@\t]/.test(s) ? `'${s}` : s); +const csvCell = (s: string): string => { const v = csvSafe(s); return /[;"\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v; }; +const csvDate = (d: unknown) => (d ? new Date(d as string).toISOString().slice(0, 10) : ''); +const STATUS_LABEL: Record = { open: 'Offen', paid: 'Bezahlt', cancelled: 'Storniert' }; + +// ---- DATEV-Buchungsstapel-Export (EXTF, Format 700/21) ----------------------------------------- +// Feldlisten und Reihenfolge gegen die offizielle DATEV-Formatbeschreibung sowie ein reales Beispiel +// aus der etablierten Open-Source-Bibliothek github.com/ledermann/datev verifiziert (nicht aus dem +// Gedächtnis geraten), da ein falsches Spaltenlayout die Datei beim Steuerberater unbrauchbar macht. +const DATEV_BOOKING_HEADER = [ + 'Umsatz (ohne Soll/Haben-Kz)', 'Soll/Haben-Kennzeichen', 'WKZ Umsatz', 'Kurs', 'Basisumsatz', 'WKZ Basisumsatz', 'Konto', 'Gegenkonto (ohne BU-Schlüssel)', 'BU-Schlüssel', 'Belegdatum', + 'Belegfeld 1', 'Belegfeld 2', 'Skonto', 'Buchungstext', 'Postensperre', 'Diverse Adressnummer', 'Geschäftspartnerbank', 'Sachverhalt', 'Zinssperre', 'Beleglink', + 'Beleginfo – Art 1', 'Beleginfo – Inhalt 1', 'Beleginfo – Art 2', 'Beleginfo – Inhalt 2', 'Beleginfo – Art 3', 'Beleginfo – Inhalt 3', 'Beleginfo – Art 4', 'Beleginfo – Inhalt 4', + 'Beleginfo – Art 5', 'Beleginfo – Inhalt 5', 'Beleginfo – Art 6', 'Beleginfo – Inhalt 6', 'Beleginfo – Art 7', 'Beleginfo – Inhalt 7', 'Beleginfo – Art 8', 'Beleginfo – Inhalt 8', + 'KOST1 – Kostenstelle', 'KOST2 – Kostenstelle', 'Kost Menge', 'EU-Land u. USt-IdNr.', 'EU-Steuersatz', 'Abw. Versteuerungsart', 'Sachverhalt L+L', 'Funktionsergänzung L+L', + 'BU 49 Hauptfunktionstyp', 'BU 49 Hauptfunktionsnummer', 'BU 49 Funktionsergänzung', + 'Zusatzinformation – Art 1', 'Zusatzinformation – Inhalt 1', 'Zusatzinformation – Art 2', 'Zusatzinformation – Inhalt 2', 'Zusatzinformation – Art 3', 'Zusatzinformation – Inhalt 3', + 'Zusatzinformation – Art 4', 'Zusatzinformation – Inhalt 4', 'Zusatzinformation – Art 5', 'Zusatzinformation – Inhalt 5', 'Zusatzinformation – Art 6', 'Zusatzinformation – Inhalt 6', + 'Zusatzinformation – Art 7', 'Zusatzinformation – Inhalt 7', 'Zusatzinformation – Art 8', 'Zusatzinformation – Inhalt 8', 'Zusatzinformation – Art 9', 'Zusatzinformation – Inhalt 9', + 'Zusatzinformation – Art 10', 'Zusatzinformation – Inhalt 10', 'Zusatzinformation – Art 11', 'Zusatzinformation – Inhalt 11', 'Zusatzinformation – Art 12', 'Zusatzinformation – Inhalt 12', + 'Zusatzinformation – Art 13', 'Zusatzinformation – Inhalt 13', 'Zusatzinformation – Art 14', 'Zusatzinformation – Inhalt 14', 'Zusatzinformation – Art 15', 'Zusatzinformation – Inhalt 15', + 'Zusatzinformation – Art 16', 'Zusatzinformation – Inhalt 16', 'Zusatzinformation – Art 17', 'Zusatzinformation – Inhalt 17', 'Zusatzinformation – Art 18', 'Zusatzinformation – Inhalt 18', + 'Zusatzinformation – Art 19', 'Zusatzinformation – Inhalt 19', 'Zusatzinformation – Art 20', 'Zusatzinformation – Inhalt 20', + 'Stück', 'Gewicht', 'Zahlweise', 'Forderungsart', 'Veranlagungsjahr', 'Zugeordnete Fälligkeit', 'Skontotyp', 'Auftragsnummer', 'Buchungstyp', 'USt-Schlüssel (Anzahlungen)', + 'EU-Mitgliedstaat (Anzahlungen)', 'Sachverhalt L+L (Anzahlungen)', 'EU-Steuersatz (Anzahlungen)', 'Erlöskonto (Anzahlungen)', 'Herkunft-Kz', 'Leerfeld', 'KOST-Datum', 'SEPA-Mandatsreferenz', + 'Skontosperre', 'Gesellschaftername', 'Beteiligtennummer', 'Identifikationsnummer', 'Zeichnernummer', 'Postensperre bis', 'Bezeichnung', 'Kennzeichen', 'Festschreibung', 'Leistungsdatum', + 'Datum Zuord.', 'Fälligkeit', 'Generalumkehr', 'Steuersatz', 'Land', 'Abrechnungsreferent', 'BVV-Position', 'EU-Mitgliedstaat u. UStID (Ursprung)', 'EU-Steuersatz (Ursprung)', 'Abw. Skontokonto', +]; // 125 Felder +const pad2 = (n: number) => String(n).padStart(2, '0'); +const datevYmd = (d: Date) => `${d.getUTCFullYear()}${pad2(d.getUTCMonth() + 1)}${pad2(d.getUTCDate())}`; +const datevDm = (d: Date) => `${pad2(d.getUTCDate())}${pad2(d.getUTCMonth() + 1)}`; +const datevQ = (s: string) => `"${csvSafe(s).replace(/"/g, '""')}"`; +const datevNum = (cents: number) => (cents / 100).toFixed(2).replace('.', ','); +/** Eine Buchungszeile mit genau 125 Feldern; nur die übergebenen (1-basierten) Spaltennummern werden gesetzt, der Rest bleibt leer. */ +function datevRow(vals: Record): string { + const arr = new Array(DATEV_BOOKING_HEADER.length).fill(''); + for (const [idx, val] of Object.entries(vals)) arr[Number(idx) - 1] = val; + return arr.join(';'); +} +// Zeichen außerhalb 0x00–0xFF (z. B. der Halbgeviertstrich – in den DATEV-Spaltennamen), die es in ISO-8859-1 +// nicht gibt, aber im von DATEV traditionell erwarteten Windows-1252 auf einem eigenen Byte liegen. +const WIN1252_HIGH: Record = { + 0x20ac: 0x80, 0x201a: 0x82, 0x0192: 0x83, 0x201e: 0x84, 0x2026: 0x85, 0x2020: 0x86, 0x2021: 0x87, 0x02c6: 0x88, 0x2030: 0x89, 0x0160: 0x8a, 0x2039: 0x8b, 0x0152: 0x8c, 0x017d: 0x8e, + 0x2018: 0x91, 0x2019: 0x92, 0x201c: 0x93, 0x201d: 0x94, 0x2022: 0x95, 0x2013: 0x96, 0x2014: 0x97, 0x02dc: 0x98, 0x2122: 0x99, 0x0161: 0x9a, 0x203a: 0x9b, 0x0153: 0x9c, 0x017e: 0x9e, 0x0178: 0x9f, +}; +/** Node kennt kein natives Windows-1252 (nur strikt ISO-8859-1 als "latin1"); Zeichen >0xFF würden sonst stillschweigend verstümmelt. */ +function toWin1252(str: string): Buffer { + const bytes: number[] = []; + for (const ch of str) { const cp = ch.codePointAt(0)!; bytes.push(cp <= 0xff ? cp : (WIN1252_HIGH[cp] ?? 0x3f)); } + return Buffer.from(bytes); +} + +/** Absender-/Empfängerdaten und Positionen für das PDF – mit demselben Rückfall wie /invoices/:id/pdf + * für Rechnungen, die vor der Absender-Einfrierung (Migration 020) ausgestellt wurden. */ +async function pdfInput(v: Record): Promise<{ inv: InvoiceForPdf; seller: CompanySettings }> { + const items = await query('SELECT * FROM invoice_items WHERE invoice_id = ? ORDER BY sort_order', [v.id]); + let seller = jparse(v.seller_snapshot_json) as CompanySettings | null; + let customer = jparse(v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null; + if (!seller || !customer) { + const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [v.org_id]); + seller ??= await settings(); + customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null }; + } + const inv: InvoiceForPdf = { + number: v.number, issueDate: v.issue_date, dueDate: v.due_date, status: v.status, paymentMethod: v.payment_method, note: v.note, + totalNetCents: v.total_net_cents, totalTaxCents: v.total_tax_cents, totalGrossCents: v.total_gross_cents, + customer, items: items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), + }; + return { inv, seller }; +} +function streamToBuffer(s: NodeJS.ReadableStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + s.on('data', (c) => chunks.push(c)); s.on('end', () => resolve(Buffer.concat(chunks))); s.on('error', reject); + }); +} +/** Externes Programm ohne Shell starten (wie in ops/backup.ts, hier lokal, um dort keine Export-Belange zu vermischen). */ +function runBin(cmd: string, args: string[], cwd: string): Promise { + return new Promise((resolve, reject) => { + const p = spawn(cmd, args, { cwd, stdio: ['ignore', 'ignore', 'pipe'] }); + let err = ''; p.stderr!.on('data', (d) => { err += d; }); + p.on('error', (e) => reject(new Error(`${cmd} konnte nicht gestartet werden: ${e.message}`))); + p.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`${cmd} Fehler (Exit ${code}): ${err.trim().slice(-500)}`)))); + }); +} export const invoicesModule: KcModule = { name: 'invoices', @@ -66,7 +190,11 @@ export const invoicesModule: KcModule = { }, register(app: FastifyInstance) { // ---- Firmenstammdaten (für den Rechnungskopf) --------------------------- - app.get('/admin/company-settings', async (req) => { requirePermission(req, 'invoices.read'); const s = await settings(); return { ...s, complete: complete(s) }; }); + app.get('/admin/company-settings', async (req) => { + requirePermission(req, 'invoices.read'); + const s = await settings(); const d = await datevSettings(); + return { ...s, complete: complete(s), datev: { ...d, complete: datevComplete(d) } }; + }); app.put('/admin/company-settings', async (req) => { const a = requirePermission(req, 'settings.write'); const b = z.object({ @@ -74,8 +202,17 @@ export const invoicesModule: KcModule = { taxNumber: z.string().trim().max(50).optional(), vatId: z.string().trim().max(30).optional(), bankName: z.string().trim().max(150).optional(), iban: z.string().trim().max(34).optional(), bic: z.string().trim().max(11).optional(), invoicePrefix: z.string().trim().regex(/^[A-Za-z0-9]{1,10}$/).optional(), defaultDueDays: z.number().int().min(0).max(180).optional(), paymentMethods: z.array(z.string().trim().min(1).max(50)).min(1).max(10).optional(), footerText: z.string().trim().max(500).nullable().optional(), + datevBeraterNr: z.number().int().min(1001).max(9999999).nullable().optional(), datevMandantNr: z.number().int().min(1).max(99999).nullable().optional(), + datevSkr: z.enum(['03', '04']).nullable().optional(), datevSachkontenlaenge: z.number().int().min(4).max(8).optional(), + datevFiscalYearStart: z.string().regex(/^\d{2}-\d{2}$/).optional(), + datevErloeskonto19: z.number().int().min(0).max(999999999).nullable().optional(), datevErloeskonto7: z.number().int().min(0).max(999999999).nullable().optional(), datevErloeskonto0: z.number().int().min(0).max(999999999).nullable().optional(), + datevDiktatkuerzel: z.string().trim().max(2).nullable().optional(), }).parse(req.body); - const cols: Record = { name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text' }; + const cols: Record = { + name: 'name', street: 'street', zip: 'zip', city: 'city', country: 'country', taxNumber: 'tax_number', vatId: 'vat_id', bankName: 'bank_name', iban: 'iban', bic: 'bic', invoicePrefix: 'invoice_prefix', defaultDueDays: 'default_due_days', footerText: 'footer_text', + datevBeraterNr: 'datev_berater_nr', datevMandantNr: 'datev_mandant_nr', datevSkr: 'datev_skr', datevSachkontenlaenge: 'datev_sachkontenlaenge', datevFiscalYearStart: 'datev_fiscal_year_start', + datevErloeskonto19: 'datev_erloeskonto_19', datevErloeskonto7: 'datev_erloeskonto_7', datevErloeskonto0: 'datev_erloeskonto_0', datevDiktatkuerzel: 'datev_diktatkuerzel', + }; const sets: string[] = []; const params: unknown[] = []; for (const [k, col] of Object.entries(cols)) if ((b as Record)[k] !== undefined) { sets.push(`${col} = ?`); params.push((b as Record)[k]); } if (b.paymentMethods) { sets.push('payment_methods = ?'); params.push(JSON.stringify(b.paymentMethods)); } @@ -285,21 +422,119 @@ export const invoicesModule: KcModule = { const res = await loadInvoice(id); if (!res || !canInOrg(a.principal, res.v.org_id, 'invoices.read', 'invoices.read') || (!staff && res.v.status === 'draft')) throw notFound(); if (res.v.status === 'draft') throw badRequest('Für Entwürfe gibt es noch kein PDF. Bitte zuerst ausstellen.', 'INVOICE_DRAFT'); - const jparse = (x: unknown) => (x == null ? null : typeof x === 'string' ? JSON.parse(x) : x); - let seller = jparse(res.v.seller_snapshot_json) as CompanySettings | null; - let customer = jparse(res.v.buyer_snapshot_json) as InvoiceForPdf['customer'] | null; - if (!seller || !customer) { // vor Migration 020 ausgestellt: kein eingefrorener Stand vorhanden, Rückfall auf die damals übliche Live-Anzeige - const org = await one('SELECT o.name, b.company, b.street, b.zip, b.city, b.country, b.vat_id FROM organizations o LEFT JOIN billing_profiles b ON b.org_id = o.id WHERE o.id = ?', [res.v.org_id]); - seller ??= await settings(); - customer ??= { name: org!.company || org!.name, street: org!.street, zip: org!.zip, city: org!.city, country: org!.country ?? 'DE', vatId: org!.vat_id ?? null }; - } - const inv: InvoiceForPdf = { - number: res.v.number, issueDate: res.v.issue_date, dueDate: res.v.due_date, status: res.v.status, paymentMethod: res.v.payment_method, note: res.v.note, - totalNetCents: res.v.total_net_cents, totalTaxCents: res.v.total_tax_cents, totalGrossCents: res.v.total_gross_cents, - customer, items: res.items.map((i) => ({ description: i.description, quantity: Number(i.quantity), unitPriceNetCents: i.unit_price_net_cents, taxBp: i.tax_bp, discountBp: i.discount_bp, netCents: i.net_cents, taxCents: i.tax_cents, grossCents: i.gross_cents })), - }; + const { inv, seller } = await pdfInput(res.v); reply.header('content-type', 'application/pdf').header('content-disposition', `inline; filename="${res.v.number}.pdf"`); return reply.send(renderInvoicePdf(inv, seller)); }); + + /** Rechnungsjournal als CSV (Semikolon, Dezimalkomma, UTF-8-BOM – öffnet sich in Excel ohne Umweg). + * Für den Steuerberater/die Buchhaltung: eine Zeile je ausgestellter Rechnung/Stornorechnung, nie Entwürfe. */ + app.get('/admin/invoices/export.csv', async (req, reply) => { + const a = requirePermission(req, 'invoices.read'); + const q = exportQuery.parse(req.query); + const { sql, params } = exportFilter(q); + const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 10000`, params); + const numberOf = new Map(rows.map((v) => [v.id as string, v.number as string])); + const missing = [...new Set(rows.flatMap((v) => [v.cancels_invoice_id, v.cancelled_by_invoice_id]).filter((id): id is string => !!id && !numberOf.has(id)))]; + if (missing.length) for (const r of await query(`SELECT id, number FROM invoices WHERE id IN (${missing.map(() => '?').join(',')})`, missing)) numberOf.set(r.id, r.number); + const header = ['Rechnungsnummer', 'Rechnungsdatum', 'Faelligkeitsdatum', 'Kundennummer', 'Kunde', 'Land', 'USt-IdNr', 'Netto', 'USt effektiv %', 'USt-Betrag', 'Brutto', 'Waehrung', 'Status', 'Bezahlt am', 'Zahlungsart', 'Storno zu', 'Storniert durch', 'Notiz']; + const lines = [header.join(';')]; + for (const v of rows) { + const buyer = jparse(v.buyer_snapshot_json) as { name?: string; country?: string; vatId?: string } | null; + const rate = v.total_net_cents !== 0 ? Math.round((v.total_tax_cents / v.total_net_cents) * 10000) / 100 : 0; + lines.push([ + v.number ?? '', csvDate(v.issue_date), csvDate(v.due_date), v.customer_number ?? '', csvCell(buyer?.name || v.org_name || ''), + buyer?.country ?? 'DE', buyer?.vatId ?? '', csvNum(v.total_net_cents), rate.toString().replace('.', ','), csvNum(v.total_tax_cents), csvNum(v.total_gross_cents), + v.currency ?? 'EUR', STATUS_LABEL[v.status] ?? v.status, v.paid_at ? csvDate(v.paid_at) : '', v.payment_method ?? '', + v.cancels_invoice_id ? (numberOf.get(v.cancels_invoice_id) ?? '') : '', v.cancelled_by_invoice_id ? (numberOf.get(v.cancelled_by_invoice_id) ?? '') : '', csvCell(v.note ?? ''), + ].join(';')); + } + await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_csv', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } }); + reply.header('content-type', 'text/csv; charset=utf-8').header('content-disposition', `attachment; filename="rechnungsjournal-${q.from ?? 'alle'}_${q.to ?? 'alle'}.csv"`); + return reply.send('' + lines.join('\r\n') + '\r\n'); + }); + + /** Alle passenden Rechnungs-PDFs als ZIP (die eigentlichen Belege, wie sie GoBD neben dem Journal verlangt). */ + app.get('/admin/invoices/export.zip', async (req, reply) => { + const a = requirePermission(req, 'invoices.read'); + const q = exportQuery.parse(req.query); + const { sql, params } = exportFilter(q); + const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 500`, params); + if (rows.length === 0) throw notFound(); + const work = await mkdtemp(join(tmpdir(), 'kc-invoice-export-')); + try { + const names: string[] = []; + const seen = new Set(); + for (const v of rows) { + const { inv, seller } = await pdfInput(v); + const buf = await streamToBuffer(renderInvoicePdf(inv, seller)); + let base = (v.number ?? v.id).replace(/[^A-Za-z0-9._-]/g, '_'); + let name = `${base}.pdf`; let n = 2; + while (seen.has(name)) name = `${base}_${n++}.pdf`; // theoretisch eindeutig durch die Rechnungsnummer, doppelte Absicherung + seen.add(name); names.push(name); + await writeFile(join(work, name), buf); + } + await runBin('zip', ['-q', '-X', '_export.zip', ...names], work); + const zip = await readFile(join(work, '_export.zip')); + await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_zip', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length } }); + reply.header('content-type', 'application/zip').header('content-disposition', `attachment; filename="rechnungen-${q.from ?? 'alle'}_${q.to ?? 'alle'}.zip"`); + return reply.send(zip); + } finally { await rm(work, { recursive: true, force: true }); } + }); + + /** DATEV-Buchungsstapel (EXTF, Format 700/21) zum direkten Import beim Steuerberater. Pro Rechnung eine + * Buchungszeile je USt-Satz-Gruppe (Konto = aus der Kundennummer abgeleitetes Debitorenkonto, Gegenkonto = + * konfiguriertes Erlöskonto des jeweiligen Satzes) – das "Automatikkonten"-Verfahren, bei dem der + * Steuerschlüssel durch das Erlöskonto selbst festgelegt ist (kein geratener BU-Schlüssel nötig). */ + app.get('/admin/invoices/export.datev', async (req, reply) => { + const a = requirePermission(req, 'invoices.read'); + if (!(await hasFeature('datev'))) throw forbidden(featureRequiresLicense('datev'), 'LICENSE_REQUIRED'); + const q = exportQuery.parse(req.query); + const d = await datevSettings(); + if (!datevComplete(d)) throw badRequest('DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer müssen unter Einstellungen → Firma → DATEV-Export hinterlegt sein.', 'DATEV_SETTINGS_INCOMPLETE'); + const { sql, params } = exportFilter(q); + const rows = await query(`${INVOICE_SQL} WHERE ${sql} ORDER BY v.issue_date, v.number LIMIT 10000`, params); + if (rows.length === 0) throw notFound(); + + const erloeskontoFor = (taxBp: number): number | null => (taxBp === 1900 ? d.erloeskonto19 : taxBp === 700 ? d.erloeskonto7 : taxBp === 0 ? d.erloeskonto0 : null); + const errors: string[] = []; + const bookings: string[] = []; + let minDate: Date | null = null; let maxDate: Date | null = null; + for (const v of rows) { + const issueDate = new Date(v.issue_date); + if (!minDate || issueDate < minDate) minDate = issueDate; if (!maxDate || issueDate > maxDate) maxDate = issueDate; + const digits = String(v.customer_number ?? '').replace(/\D/g, ''); + if (!digits) { errors.push(`${v.number}: Kundennummer "${v.customer_number}" enthält keine Ziffern – Debitorenkonto nicht ableitbar`); continue; } + const groups = await query('SELECT tax_bp, SUM(gross_cents) AS gross FROM invoice_items WHERE invoice_id = ? GROUP BY tax_bp', [v.id]); + for (const g of groups) { + const gross = Number(g.gross); if (gross === 0) continue; + const konto = erloeskontoFor(g.tax_bp); + if (konto == null) { errors.push(`${v.number}: kein Erlöskonto für ${(g.tax_bp / 100).toLocaleString('de-DE')} % USt konfiguriert (unter Einstellungen → Firma → DATEV-Export ergänzen)`); continue; } + bookings.push(datevRow({ + 1: datevNum(Math.abs(gross)), 2: datevQ(gross >= 0 ? 'S' : 'H'), 7: digits, 8: String(konto), + 10: datevDm(issueDate), 11: datevQ(v.number), 14: datevQ(`Rechnung ${v.number} ${v.org_name}`.slice(0, 60)), + })); + } + } + if (errors.length) throw badRequest(`DATEV-Export nicht möglich – bitte zuerst beheben:\n${errors.slice(0, 20).join('\n')}${errors.length > 20 ? `\n… und ${errors.length - 20} weitere` : ''}`, 'DATEV_MAPPING_INCOMPLETE'); + if (bookings.length === 0) throw notFound(); + + const from = q.from ? new Date(q.from) : minDate!; const to = q.to ? new Date(q.to) : maxDate!; + const [fyMonth, fyDay] = d.fiscalYearStart.split('-').map(Number); + const wjBeginn = new Date(Date.UTC(from.getUTCFullYear(), fyMonth! - 1, fyDay)); + const now = new Date(); + const erzeugtAm = `${datevYmd(now)}${pad2(now.getUTCHours())}${pad2(now.getUTCMinutes())}${pad2(now.getUTCSeconds())}${String(now.getUTCMilliseconds()).padStart(3, '0')}`; + const s = await settings(); + const header = [ + datevQ('EXTF'), '700', '21', datevQ('Buchungsstapel'), '13', erzeugtAm, '', datevQ('KC'), datevQ((s.name ?? 'Kundencenter').slice(0, 25)), '', + String(d.beraterNr), String(d.mandantNr), datevYmd(wjBeginn), String(d.sachkontenlaenge), datevYmd(from), datevYmd(to), + datevQ(`Rechnungen ${datevYmd(from)}-${datevYmd(to)}`.slice(0, 30)), d.diktatkuerzel ? datevQ(d.diktatkuerzel) : '', '1', '', '', datevQ('EUR'), + '', '', '', '', d.skr ? datevQ(d.skr) : '', '', '', '', '', + ].join(';'); + const lines = [header, DATEV_BOOKING_HEADER.join(';'), ...bookings]; + await audit({ actorType: 'user', actorId: a.user.id, action: 'invoice.export_datev', resourceType: 'invoice_export', correlationId: req.correlationId, ip: clientIp(req), after: { ...q, rows: rows.length, bookings: bookings.length } }); + reply.header('content-type', 'text/csv; charset=windows-1252').header('content-disposition', `attachment; filename="EXTF_Buchungsstapel_${datevYmd(from)}_${datevYmd(to)}.csv"`); + return reply.send(toWin1252(lines.join('\r\n') + '\r\n')); + }); }, }; diff --git a/apps/api/src/modules/license/index.ts b/apps/api/src/modules/license/index.ts new file mode 100644 index 0000000..f245dea --- /dev/null +++ b/apps/api/src/modules/license/index.ts @@ -0,0 +1,51 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { one } from '../../core/db.js'; +import { audit } from '../../core/audit.js'; +import { clientIp, requirePermission } from '../../core/auth.js'; +import { badRequest } from '../../core/errors.js'; +import { checkLicenseNow, getEdition, getEntitlement, setLicenseKey, FEATURE_LABEL, LICENSED_FEATURES, LicenseCheckError } from '../../core/license.js'; +import type { KcModule } from '../../core/module.js'; + +/** Eigene Lizenz der Kundencenter-Installation gegenüber licensing.flessinglabs.com. Nicht zu verwechseln mit + * dem "licensing"-Connector (Einstellungen > Verbindungen), der KUNDEN-Lizenzen verwaltet. */ +export const licenseModule: KcModule = { + name: 'license', + permissions: { + staff: { admin: ['license.read'], superadmin: ['license.read', 'settings.write'] }, + }, + register(app: FastifyInstance) { + app.get('/admin/license/status', async (req) => { + requirePermission(req, 'license.read'); + const ent = await getEntitlement(); + const row = await one('SELECT instance_id, license_key_enc IS NOT NULL AS has_key, last_error, last_attempt_at FROM license_state WHERE id = 1'); + const customers = await one("SELECT COUNT(*) AS n FROM organizations WHERE status IN ('active','suspended')"); + const staff = await one("SELECT COUNT(*) AS n FROM users WHERE kind = 'staff' AND status IN ('active','invited')"); + const ed = await getEdition(); + const edition = { ...ed, staffCount: Number(staff?.n ?? 0), allFeatures: LICENSED_FEATURES.map((f) => ({ key: f, label: FEATURE_LABEL[f], enabled: ed.features.includes(f) })) }; + return { ...ent, edition, instanceId: row?.instance_id ?? null, hasKey: !!row?.has_key, lastError: row?.last_error ?? null, lastAttemptAt: row?.last_attempt_at ?? null, customerCount: Number(customers?.n ?? 0) }; + }); + + app.put('/admin/license/settings', async (req) => { + const a = requirePermission(req, 'settings.write'); + const b = z.object({ licenseKey: z.string().trim().min(8).max(200) }).parse(req.body); + await setLicenseKey(b.licenseKey); + await audit({ actorType: 'user', actorId: a.user.id, action: 'license.settings.update', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { licenseKey: '***' } }); + let checked = false; let error: string | undefined; + try { await checkLicenseNow(); checked = true; } catch (e) { error = (e as Error).message; } + return { ok: true, checked, error }; + }); + + app.post('/admin/license/check', async (req) => { + const a = requirePermission(req, 'settings.write'); + try { + const r = await checkLicenseNow(); + await audit({ actorType: 'user', actorId: a.user.id, action: 'license.check', resourceType: 'license_state', resourceId: '1', correlationId: req.correlationId, ip: clientIp(req), after: { valid: r.valid, plan: r.plan } }); + return r; + } catch (e) { + if (e instanceof LicenseCheckError) throw badRequest(e.message, 'LICENSE_CHECK_FAILED'); + throw e; + } + }); + }, +}; diff --git a/apps/api/src/modules/licensing/index.ts b/apps/api/src/modules/licensing/index.ts new file mode 100644 index 0000000..038531a --- /dev/null +++ b/apps/api/src/modules/licensing/index.ts @@ -0,0 +1,266 @@ +import type { FastifyInstance } from 'fastify'; +import { z } from 'zod'; +import { randomUUID } from 'node:crypto'; +import { ConnectorError } from '@kc/connector-sdk'; +import { createLicensingAdmin, loadInstance, upsertResource, type LicensingAdmin } from '@kc/connectors'; +import { one, query, run } from '../../core/db.js'; +import { rl } from '../../core/config.js'; +import { audit } from '../../core/audit.js'; +import { enqueue } from '../../core/jobs.js'; +import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; +import { AppError, badRequest, forbidden, notFound } from '../../core/errors.js'; +import { can, canInOrg } from '../../core/policy.js'; +import type { KcModule } from '../../core/module.js'; + +/** + * Lizenzverwaltung: Übersicht, Vergabe und Pflege von Kunden-Lizenzen im eigenen Lizenzsystem (licensing.flessinglabs.com). + * Datenbasis der Übersicht ist der Abgleich (resources, type = 'license'); Detail und Änderungen gehen live ans Lizenzsystem. + * Vergabe "mit Vertrag" läuft über den normalen Bestellweg (POST /orders), hier nur die Vergabe ohne Berechnung. + * Nicht zu verwechseln mit dem Modul "license" (eigene Lizenz dieser Installation). + */ +const json = (v: unknown, d: T): T => (v == null ? d : typeof v === 'string' ? JSON.parse(v) : (v as T)); +const LIC_SQL = `SELECT r.*, i.connector_key, i.health, i.enabled AS inst_enabled, o.name AS org_name, o.customer_number, + (SELECT c.id FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_id, + (SELECT c.number FROM contracts c WHERE c.resource_id = r.id ORDER BY c.created_at DESC LIMIT 1) AS contract_number + FROM resources r JOIN connector_instances i ON i.id = r.instance_id LEFT JOIN organizations o ON o.id = r.org_id + WHERE r.type = 'license' AND i.connector_key = 'licensing'`; + +function listView(r: any) { + const d = json<{ details?: Record; limits?: Record }>(r.data_json, {}); + const x = d.details ?? {}; + return { + id: r.id, name: r.name, state: r.state, validFrom: r.valid_from, validUntil: r.valid_until, syncedAt: r.synced_at, missing: !!r.missing_since, + orgId: r.org_id, orgName: r.org_name ?? null, customerNumber: r.customer_number ?? null, contractId: r.contract_id ?? null, contractNumber: r.contract_number ?? null, + program: x.program ?? null, programId: x.programId ?? null, product: x.product ?? null, edition: x.edition ?? null, keyMasked: x.licenseKeyMasked ?? null, + activationsUsed: x.activationsUsed ?? 0, activationLimit: x.activationLimit ?? null, trial: !!x.trial, trialPending: !!x.trialPending, addon: !!x.addon, + parentLicenseId: x.parentLicenseId ?? null, revoked: !!x.revoked, externalRef: r.external_ref, + }; +} +const access = (a: AuthContext, r: any) => can(a.principal, 'licenses.read') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.read', 'licenses.read')); +/** Kunden-Selbstbedienung: Inhaber/Admin der Organisation dürfen eigene Geräte freigeben (Aktivierung zurücksetzen). */ +const canResetActivation = (a: AuthContext, r: any) => can(a.principal, 'licenses.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'licenses.manage', 'licenses.write')); +async function loadLicense(id: string) { return one(`${LIC_SQL} AND r.id = ?`, [id]); } +async function adminFor(instanceId: string, correlationId: string): Promise { + const { inst, ctx } = await loadInstance(instanceId, correlationId); + if (!inst.enabled) throw new AppError(409, 'CONNECTOR_DISABLED', 'Die Verbindung zum Lizenzsystem ist deaktiviert.'); + return createLicensingAdmin(ctx); +} +/** Fehler des Lizenzsystems verständlich weitergeben (abgelehnte Eingaben mit dessen Begründung). */ +function providerError(e: unknown): never { + if (e instanceof ConnectorError) { + if (e.code === 'INVALID_INPUT' || e.code === 'CONFLICT') throw new AppError(e.code === 'CONFLICT' ? 409 : 400, 'LICENSING_REJECTED', `Das Lizenzsystem lehnt das ab: ${e.detail ?? e.userMessage}`); + if (e.code === 'NOT_FOUND') throw new AppError(404, 'LICENSING_NOT_FOUND', 'Die Lizenz wurde im Lizenzsystem nicht gefunden.'); + throw new AppError(502, 'CONNECTOR_ERROR', `Lizenzsystem: ${e.userMessage}`); + } + throw e; +} +/** Nach einer Änderung: lokalen Stand sofort aktualisieren (Übersicht) und vollständigen Abgleich anstoßen. */ +async function refresh(admin: LicensingAdmin, r: any, raw: Parameters[0] | undefined, correlationId: string) { + if (raw) await upsertResource(r.instance_id, await admin.normalize(raw)).catch(() => undefined); + await enqueue('connector.sync', { instanceId: r.instance_id }, { idempotencyKey: `sync:${r.instance_id}:licensing:${Math.floor(Date.now() / 15000)}`, correlationId }); +} +const reasonSchema = z.string().trim().max(255).optional(); + +export const licensingModule: KcModule = { + name: 'licensing', + permissions: { + staff: { support: ['licenses.read'], accounting: ['licenses.read'], admin: ['licenses.read', 'licenses.write'], superadmin: ['licenses.read', 'licenses.write'] }, + org: { owner: ['licenses.read', 'licenses.manage'], admin: ['licenses.read', 'licenses.manage'], member: ['licenses.read'] }, + }, + register(app: FastifyInstance) { + // ---- Übersicht ------------------------------------------------------------------------------------------- + app.get('/licenses', async (req) => { + const a = requireAuth(req); + const q = z.object({ org: z.string().uuid().optional(), state: z.enum(['active', 'suspended', 'expired']).optional(), expiring: z.enum(['1']).optional(), unassigned: z.enum(['1']).optional(), q: z.string().trim().max(100).optional() }).parse(req.query); + const staff = can(a.principal, 'licenses.read'); + const orgs = staff ? (q.org ? [q.org] : null) : a.principal.memberships.map((m) => m.orgId); + if (orgs && orgs.length === 0) return []; + const where: string[] = []; const params: unknown[] = []; + if (orgs) { where.push(`r.org_id IN (${orgs.map(() => '?').join(',')})`); params.push(...orgs); } + if (q.state) { where.push('r.state = ?'); params.push(q.state); } + if (q.expiring) where.push("r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)"); + if (q.unassigned && staff) where.push('r.org_id IS NULL'); + if (q.q) { where.push('(r.name LIKE ? OR o.name LIKE ? OR o.customer_number = ? OR r.external_ref = ?)'); params.push(`%${q.q}%`, `%${q.q}%`, q.q, q.q); } + const rows = await query(`${LIC_SQL}${where.length ? ' AND ' + where.join(' AND ') : ''} ORDER BY (r.valid_until IS NULL), r.valid_until, r.name LIMIT 1000`, params); + return rows.map(listView); + }); + app.get('/admin/licenses/summary', async (req) => { + requirePermission(req, 'licenses.read'); + const s = await one(`SELECT COUNT(*) AS total, SUM(r.state = 'active') AS active, SUM(r.state = 'suspended') AS suspended, SUM(r.state = 'expired') AS expired, + SUM(r.state = 'active' AND r.valid_until IS NOT NULL AND r.valid_until <= DATE_ADD(UTC_TIMESTAMP(3), INTERVAL 30 DAY)) AS expiring, SUM(r.org_id IS NULL) AS unassigned + FROM resources r JOIN connector_instances i ON i.id = r.instance_id WHERE r.type = 'license' AND i.connector_key = 'licensing' AND r.missing_since IS NULL`); + const n = (v: unknown) => Number(v ?? 0); + return { total: n(s?.total), active: n(s?.active), suspended: n(s?.suspended), expired: n(s?.expired), expiring: n(s?.expiring), unassigned: n(s?.unassigned) }; + }); + + // ---- Detail (live aus dem Lizenzsystem, Rückfall auf den letzten Abgleich) -------------------------------- + app.get('/licenses/:id', async (req) => { + const a = requireAuth(req); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const r = await loadLicense(id); + if (!r || !access(a, r)) throw notFound(); + const staff = can(a.principal, 'licenses.read'); const write = can(a.principal, 'licenses.write'); + const base = listView(r); + let live: Awaited> | null = null; let liveError: string | null = null; + try { live = await (await adminFor(r.instance_id, req.correlationId)).get(r.external_ref); } + catch (e) { liveError = e instanceof ConnectorError ? e.userMessage : e instanceof AppError ? e.message : 'Das Lizenzsystem ist nicht erreichbar.'; } + if (live) await upsertResource(r.instance_id, live.resource).catch(() => undefined); + // Add-ons dieser Lizenz und (bei Add-ons) die Basislizenz, soweit im Kundencenter bekannt + const addons = (await query(`${LIC_SQL} AND r.instance_id = ? AND JSON_VALUE(r.data_json, '$.details.parentLicenseId') = ?`, [r.instance_id, r.external_ref])) + .filter((x) => access(a, x)).map(listView); + const parentRef = live?.raw.parent_license_id ?? base.parentLicenseId; + const parent = parentRef ? await one(`${LIC_SQL} AND r.instance_id = ? AND r.external_ref = ?`, [r.instance_id, String(parentRef)]) : null; + const history = staff ? (await query("SELECT action, actor_id, result, ts AS created_at FROM audit_events WHERE resource_type = 'resource' AND resource_id = ? ORDER BY id DESC LIMIT 30", [id])).map((h) => ({ action: h.action, result: h.result, at: h.created_at, actorId: h.actor_id })) : []; + const actorNames = new Map((history.length ? await query(`SELECT id, name FROM users WHERE id IN (${[...new Set(history.map((h) => h.actorId).filter(Boolean))].map(() => '?').join(',') || 'NULL'})`, [...new Set(history.map((h) => h.actorId).filter(Boolean))]) : []).map((u) => [u.id, u.name])); + const caps = json((await one('SELECT capabilities_json FROM connector_instances WHERE id = ?', [r.instance_id]))?.capabilities_json, []); + return { + ...(live ? listView({ ...r, name: live.resource.name, state: live.resource.state, valid_from: live.resource.validFrom, valid_until: live.resource.validUntil, data_json: { details: live.resource.details } }) : base), + live: !!live, liveError, + activations: live?.activations ?? [], entitlement: live?.entitlement ?? null, limits: live?.limits ?? null, + origin: staff ? (live?.origin ?? null) : null, providerStatus: live?.raw.status ?? null, revokeReason: staff ? (live?.raw.revoke_reason ?? null) : null, + durationType: live?.raw.duration_type ?? null, productId: live?.raw.product_id ?? null, lastCheckAt: live?.raw.last_check_at ?? null, + addons, parent: parent && access(a, parent) ? listView(parent) : null, + history: history.map((h) => ({ ...h, actor: h.actorId ? (actorNames.get(h.actorId) ?? null) : 'System' })), + can: { reveal: caps.includes('secret.reveal') && (can(a.principal, 'resources.write') || (!!r.org_id && canInOrg(a.principal, r.org_id, 'resources.manage', 'resources.write'))), // gleiche Regel wie /resources/:id/reveal + resetActivation: !!live && canResetActivation(a, r), manage: write && !!live }, + }; + }); + + // ---- Aktivierung (Gerät) freigeben: Personal oder Kunde (Inhaber/Admin) für die eigene Lizenz ------------ + app.delete('/licenses/:id/activations/:activationId', { config: rl(10, '10 minutes') }, async (req) => { + const a = requireAuth(req); + const { id, activationId } = z.object({ id: z.string().uuid(), activationId: z.coerce.number().int().positive() }).parse(req.params); + const r = await loadLicense(id); + if (!r || !access(a, r)) throw notFound(); + if (!canResetActivation(a, r)) throw forbidden('Geräte dieser Lizenz können nur vom Inhaber oder Support freigegeben werden', 'ACTIVATION_RESET_FORBIDDEN'); + const admin = await adminFor(r.instance_id, req.correlationId); + try { await admin.deleteActivation(r.external_ref, activationId); } catch (e) { providerError(e); } + await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.activation.reset', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { activationId } }); + await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId); + return { ok: true }; + }); + + // ---- Personal: Limits, Produkt (Upgrade/Testumwandlung), Entitlement, Lebenszyklus -------------------------- + app.patch('/admin/licenses/:id', async (req) => { + const a = requirePermission(req, 'licenses.write'); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ + maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).nullable().optional(), + customerLimit: z.number().int().min(0).max(1000000).nullable().optional(), graceDays: z.number().int().min(0).max(365).nullable().optional(), + productId: z.number().int().positive().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), expiresAt: z.iso.datetime().nullable().optional(), + }).parse(req.body); + const r = await loadLicense(id); if (!r) throw notFound(); + const body: Record = {}; + if (b.maxActivations !== undefined) body.max_activations = b.maxActivations; + if (b.userLimit !== undefined) body.user_limit = b.userLimit; + if (b.customerLimit !== undefined) body.customer_limit = b.customerLimit; + if (b.graceDays !== undefined) body.grace_days = b.graceDays; + if (b.productId !== undefined) body.product_id = b.productId; + if (b.durationType !== undefined) body.duration_type = b.durationType; + if (b.expiresAt !== undefined) body.expires_at = b.expiresAt; + if (!Object.keys(body).length) throw badRequest('Keine Änderung angegeben'); + const admin = await adminFor(r.instance_id, req.correlationId); + let raw; try { raw = await admin.update(r.external_ref, body); } catch (e) { providerError(e); } + await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b }); + await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw ?? raw, req.correlationId); + return { ok: true }; + }); + app.post('/admin/licenses/:id/entitlement', async (req) => { + const a = requirePermission(req, 'licenses.write'); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ fromProduct: z.boolean().default(false), planKey: z.string().trim().max(50).optional(), modules: z.array(z.string().trim().min(1).max(100)).max(200).optional(), + customerLimit: z.number().int().min(0).max(1000000).optional(), clearCustomerLimit: z.boolean().default(false), reason: reasonSchema }).parse(req.body); + const r = await loadLicense(id); if (!r) throw notFound(); + const admin = await adminFor(r.instance_id, req.correlationId); + try { await admin.entitlement(r.external_ref, { from_product: b.fromProduct, plan_key: b.planKey, modules: b.modules, customer_limit: b.customerLimit, clear_customer_limit: b.clearCustomerLimit, reason: b.reason ?? `Kundencenter (${a.user.name})` }); } catch (e) { providerError(e); } + await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: 'license.entitlement', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: b }); + await refresh(admin, r, (await admin.get(r.external_ref).catch(() => null))?.raw, req.correlationId); + return { ok: true }; + }); + app.post('/admin/licenses/:id/lifecycle', async (req) => { + const a = requirePermission(req, 'licenses.write'); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ action: z.enum(['suspend', 'unsuspend', 'extend', 'revoke']), until: z.iso.datetime().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).optional(), count: z.number().int().min(1).max(120).optional(), reason: reasonSchema }).parse(req.body); + if (b.action === 'extend' && !b.until && !b.durationType) throw badRequest('Bitte ein Datum oder eine Laufzeit angeben'); + if (b.action === 'revoke' && !b.reason) throw badRequest('Bitte einen Grund für den Widerruf angeben'); + const r = await loadLicense(id); if (!r) throw notFound(); + // Idempotenz pro Bestätigungsdialog (Header), sonst pro Minute: ein Doppelklick verlängert nicht zweimal + const hdr = req.headers['idempotency-key']; + const key = typeof hdr === 'string' && /^[\w-]{8,100}$/.test(hdr) ? hdr : `${id}:${b.action}:${b.until ?? ''}:${b.durationType ?? ''}:${b.count ?? ''}:${Math.floor(Date.now() / 60000)}`; + const body: Record = { reason: b.reason ?? `Kundencenter (${a.user.name})` }; + if (b.action === 'extend') Object.assign(body, b.until ? { until: b.until } : { duration_type: b.durationType, count: b.count ?? 1 }); + const admin = await adminFor(r.instance_id, req.correlationId); + let out; try { out = await admin.lifecycle(r.external_ref, b.action, body, `kc:${key}`); } catch (e) { providerError(e); } + await audit({ actorType: 'user', actorId: a.user.id, orgId: r.org_id, action: `license.${b.action}`, resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), after: { ...b, changed: out?.changed } }); + await refresh(admin, r, out?.license, req.correlationId); + return { ok: true, changed: !!out?.changed }; + }); + app.patch('/admin/licenses/:id/assign', async (req) => { + const a = requirePermission(req, 'licenses.write'); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ orgId: z.string().uuid().nullable() }).parse(req.body); + const r = await loadLicense(id); if (!r) throw notFound(); + if (b.orgId && !(await one('SELECT 1 AS x FROM organizations WHERE id = ?', [b.orgId]))) throw badRequest('Kunde nicht gefunden'); + await run('UPDATE resources SET org_id = ? WHERE id = ?', [b.orgId, id]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId ?? r.org_id, action: 'resource.update', resourceType: 'resource', resourceId: id, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), before: { orgId: r.org_id }, after: { orgId: b.orgId } }); + return { ok: true }; + }); + + // ---- Vergabe ohne Berechnung (Kulanz, Test, intern). Mit Vertrag: normaler Bestellweg (POST /orders). --------- + app.get('/admin/licenses/catalog', async (req) => { + requirePermission(req, 'licenses.write'); + const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1"); + if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet (Einstellungen → Verbindungen).'); + let catalog; try { catalog = await (await adminFor(inst.id, req.correlationId)).catalog(); } catch (e) { providerError(e); } + // Produkte des Kundencenters, die eine Lizenz bereitstellen (für "mit Vertrag") + const shop = (await query(`SELECT p.id, v.name, v.recurring_cents, v.setup_cents, v.price_basis, v.billing_interval, v.term_months, v.provisioning_json + FROM products p JOIN product_versions v ON v.id = p.current_version_id WHERE p.status = 'active' AND p.connector_instance_id = ? ORDER BY v.name`, [inst.id])) + .map((p) => ({ id: p.id, name: p.name, recurringCents: p.recurring_cents, setupCents: p.setup_cents, priceBasis: p.price_basis, interval: p.billing_interval, termMonths: p.term_months, provisioning: json(p.provisioning_json, {}) })); + return { instanceId: inst.id, ...catalog, shopProducts: shop }; + }); + app.post('/admin/licenses', { config: rl(20, '1 minute') }, async (req) => { + const a = requirePermission(req, 'licenses.write'); + const b = z.object({ + orgId: z.string().uuid(), kind: z.enum(['license', 'trial', 'addon']), programId: z.number().int().positive(), productId: z.number().int().positive(), + parentId: z.string().uuid().optional(), durationType: z.enum(['WEEK', 'MONTH', 'YEAR', 'UNLIMITED']).default('YEAR'), expiresAt: z.iso.datetime().optional(), + maxActivations: z.number().int().min(1).max(1000).optional(), userLimit: z.number().int().min(0).max(1000000).optional(), customerLimit: z.number().int().min(0).max(1000000).optional(), + keyPrefix: z.enum(['PREMIUM', 'TRIAL', 'LIFETIME']).optional(), note: z.string().trim().max(50).optional(), + }).parse(req.body); + const org = await one("SELECT o.id, o.name, o.customer_number, o.status FROM organizations o WHERE o.id = ?", [b.orgId]); + if (!org) throw badRequest('Kunde nicht gefunden'); + if (org.status !== 'active') throw badRequest('Für gesperrte oder beendete Kunden kann keine Lizenz vergeben werden', 'ORG_INACTIVE'); + const owner = await one("SELECT u.name, u.email FROM memberships m JOIN users u ON u.id = m.user_id WHERE m.org_id = ? ORDER BY (m.role = 'owner') DESC, m.created_at LIMIT 1", [b.orgId]); + const inst = await one("SELECT id FROM connector_instances WHERE connector_key = 'licensing' AND enabled = 1 ORDER BY created_at LIMIT 1"); + if (!inst) throw new AppError(409, 'NO_LICENSING', 'Es ist keine aktive Verbindung zum Lizenzsystem eingerichtet.'); + let parentRef: number | undefined; + if (b.kind === 'addon') { + if (!b.parentId) throw badRequest('Für ein Add-on bitte die Basislizenz wählen'); + const p = await loadLicense(b.parentId); + if (!p || p.org_id !== b.orgId || p.instance_id !== inst.id) throw badRequest('Die Basislizenz gehört nicht zu diesem Kunden'); + parentRef = Number(p.external_ref); + } + const ref = `kc-${randomUUID()}`; // Herkunft: verhindert Doppelanlage bei Wiederholung (source + external_ref eindeutig) + const customer = { source: 'kundencenter', customer_name: org.name, customer_email: owner?.email ?? null, customer_contact: owner?.name ?? null, customer_reference: org.customer_number, order_ref: b.note ? b.note.slice(0, 50) : 'ohne Berechnung', external_ref: ref }; + const admin = await adminFor(inst.id, req.correlationId); + let raw; + try { + if (b.kind === 'trial') { + if (!owner?.email) throw badRequest('Für einen Test braucht der Kunde eine E-Mail-Adresse (Ansprechpartner).'); + raw = await admin.createTrial({ program_id: b.programId, product_id: b.productId, max_activations: b.maxActivations, key_prefix: b.keyPrefix, ...customer }); + } else { + raw = await admin.create({ + program_id: b.programId, product_id: b.productId, duration_type: b.durationType, is_active: true, ...(b.expiresAt ? { expires_at: b.expiresAt } : {}), + max_activations: b.maxActivations, user_limit: b.userLimit, customer_limit: b.kind === 'addon' ? undefined : b.customerLimit, key_prefix: b.keyPrefix, + parent_license_id: parentRef, ...customer, + }); + } + } catch (e) { if (e instanceof AppError) throw e; providerError(e); } + if (!raw || typeof raw.id !== 'number') throw new AppError(502, 'CONNECTOR_ERROR', 'Unerwartete Antwort des Lizenzsystems'); + const resourceId = await upsertResource(inst.id, await admin.normalize(raw)); + await run("UPDATE resources SET org_id = ?, customer_actions = COALESCE(customer_actions, '[]') WHERE id = ?", [b.orgId, resourceId]); + await audit({ actorType: 'user', actorId: a.user.id, orgId: b.orgId, action: 'license.issue', resourceType: 'resource', resourceId, connector: 'licensing', correlationId: req.correlationId, ip: clientIp(req), + after: { kind: b.kind, programId: b.programId, productId: b.productId, durationType: b.kind === 'trial' ? 'TRIAL' : b.durationType, expiresAt: b.expiresAt, maxActivations: b.maxActivations, licenseId: raw.id, billing: 'none', note: b.note } }); + return { id: resourceId }; + }); + }, +}; diff --git a/apps/api/src/modules/mail/index.ts b/apps/api/src/modules/mail/index.ts index 0dacce1..c0503b6 100644 --- a/apps/api/src/modules/mail/index.ts +++ b/apps/api/src/modules/mail/index.ts @@ -6,6 +6,7 @@ import { audit } from '../../core/audit.js'; import { encrypt, decrypt } from '../../core/crypto.js'; import { clientIp, requirePermission } from '../../core/auth.js'; import { badRequest, notFound } from '../../core/errors.js'; +import { featureRequiresLicense, hasFeature } from '../../core/license.js'; import { rl } from '../../core/config.js'; import { renderTemplateText, renderTemplateHtml, sendMail } from '../../core/mail.js'; import type { KcModule } from '../../core/module.js'; @@ -124,6 +125,7 @@ export const mailModule: KcModule = { secureMode: z.enum(['tls', 'starttls']).default('tls'), username: z.string().trim().max(200).nullable(), password: z.string().max(500).nullable().optional(), folder: z.string().trim().min(1).max(200).default('INBOX'), enabled: z.boolean().default(false), }).parse(req.body); + if (b.enabled && !(await hasFeature('imap'))) throw badRequest(featureRequiresLicense('imap'), 'LICENSE_REQUIRED'); const sets = ['host = ?', 'port = ?', 'secure_mode = ?', 'username = ?', 'folder = ?', 'enabled = ?', 'updated_by = ?']; const params: unknown[] = [b.host, b.port, b.secureMode, b.username, b.folder, b.enabled ? 1 : 0, a.user.id]; if (b.password !== undefined) { sets.push('secrets_enc = ?'); params.push(b.password ? encrypt(JSON.stringify({ password: b.password })) : null); } diff --git a/apps/api/src/modules/orders/index.ts b/apps/api/src/modules/orders/index.ts index 6682f56..978ad80 100644 --- a/apps/api/src/modules/orders/index.ts +++ b/apps/api/src/modules/orders/index.ts @@ -5,7 +5,9 @@ import type { PoolConnection } from 'mysql2/promise'; import { calculatePrice } from '@kc/platform/pricing'; import { ORDER_MACHINE, CONTRACT_MACHINE, transition, type OrderEvent } from '@kc/platform/statemachine'; import { consumerTerms, effectiveCancelDate } from '@kc/platform/contractterms'; +import { peekRenewalToken, applyRenewalDecision, RenewalTokenError } from '@kc/platform/contractRenewal'; import { one, query, run, tx } from '../../core/db.js'; +import { rl } from '../../core/config.js'; import { audit } from '../../core/audit.js'; import { enqueue } from '../../core/jobs.js'; import { clientIp, requireAuth, requirePermission, type AuthContext } from '../../core/auth.js'; @@ -51,7 +53,7 @@ export const ordersModule: KcModule = { permissions: { staff: { support: ['orders.read', 'contracts.read'], accounting: ['orders.read', 'contracts.read'], - admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], + admin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write'], superadmin: ['orders.read', 'orders.write', 'orders.approve', 'contracts.read', 'contracts.write', 'contracts.edit'], }, org: { owner: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], admin: ['orders.read', 'orders.create', 'contracts.read', 'contracts.cancel'], member: ['orders.read', 'contracts.read'] }, }, @@ -186,7 +188,7 @@ export const ordersModule: KcModule = { const { id } = z.object({ id: z.string().uuid() }).parse(req.params); const c = await one(`${CONTRACT_SQL} WHERE c.id = ?`, [id]); if (!c || !canInOrg(a.principal, c.org_id, 'contracts.read', 'contracts.read')) throw notFound(); - return { ...contractView(c), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') }; + return { ...contractView(c), canEdit: can(a.principal, 'contracts.edit') && !['cancelled', 'expired', 'failed'].includes(c.status), canCancel: ['active', 'suspended'].includes(c.status) && !c.cancel_requested_at && canInOrg(a.principal, c.org_id, 'contracts.cancel', 'contracts.write') }; }); /** Kündigung: zum nächstmöglichen Termin unter Beachtung von Laufzeit und Frist; sofort nur durch Personal. */ app.post('/contracts/:id/cancel', async (req) => { @@ -217,5 +219,54 @@ export const ordersModule: KcModule = { await audit({ actorType: 'user', actorId: a.user.id, orgId: c.org_id, action: 'contract.cancel.revoke', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req) }); return { status: 'ok' }; }); + + /** Nachträgliche Anpassung (nur Superadmin), z. B. Preis für übernommene Altverträge. Der Preis wird serverseitig neu berechnet. */ + app.patch('/admin/contracts/:id', async (req) => { + const a = requirePermission(req, 'contracts.edit'); + const { id } = z.object({ id: z.string().uuid() }).parse(req.params); + const b = z.object({ + basis: z.enum(['net', 'gross']).optional(), interval: z.enum(['once', 'monthly', 'yearly']).optional(), + recurringCents: z.number().int().min(0).max(100_000_000).optional(), setupCents: z.number().int().min(0).max(100_000_000).optional(), + discountBp: z.number().int().min(0).max(10000).optional(), + termEnd: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(), renewal: z.enum(['auto', 'none']).optional(), + renewalTermMonths: z.number().int().min(0).max(120).optional(), noticeDays: z.number().int().min(0).max(365).optional(), + reason: z.string().trim().min(3).max(300), + }).parse(req.body); + return tx(async (c) => { + const k = await one('SELECT * FROM contracts WHERE id = ? FOR UPDATE', [id], c); + if (!k) throw notFound(); + if (['cancelled', 'expired', 'failed'].includes(k.status)) throw badRequest('Beendete Verträge können nicht mehr angepasst werden', 'CONTRACT_ENDED'); + const snap = typeof k.price_snapshot_json === 'string' ? JSON.parse(k.price_snapshot_json) : k.price_snapshot_json; + let price; + try { + price = calculatePrice({ basis: b.basis ?? snap.basis, interval: b.interval ?? snap.interval, setupCents: b.setupCents ?? snap.unitSetupCents, recurringCents: b.recurringCents ?? snap.unitRecurringCents, taxBp: snap.taxBp, quantity: snap.quantity, discountBp: b.discountBp ?? snap.discountBp, currency: snap.currency }); + } catch (e) { if (e instanceof RangeError) throw badRequest(e.message); throw e; } + const renewal = b.renewal ?? k.renewal; const renewalTermMonths = b.renewalTermMonths ?? Number(k.renewal_term_months); const noticeDays = b.noticeDays ?? Number(k.notice_days); + if (renewal === 'auto' && renewalTermMonths < 1) throw badRequest('Bei automatischer Verlängerung muss die Verlängerungsdauer mindestens 1 Monat sein'); + const termEnd = b.termEnd === undefined ? k.term_end : b.termEnd === null ? null : new Date(`${b.termEnd}T00:00:00Z`); + const next = { ...snap, ...price, terms: { ...(snap.terms ?? {}), renewal, renewalTermMonths, noticeDays }, adjusted: { at: new Date().toISOString(), by: a.user.id, reason: b.reason } }; + // Neues Laufzeitende = neue Erinnerung fällig + const termChanged = b.termEnd !== undefined && String(termEnd ?? '') !== String(k.term_end ?? ''); + await run(`UPDATE contracts SET price_snapshot_json = ?, term_end = ?, renewal = ?, renewal_term_months = ?, notice_days = ?${termChanged ? ', renewal_reminder_sent_at = NULL' : ''} WHERE id = ?`, + [JSON.stringify(next), termEnd, renewal, renewalTermMonths, noticeDays, id], c); + await audit({ actorType: 'user', actorId: a.user.id, orgId: k.org_id, action: 'contract.adjust', resourceType: 'contract', resourceId: id, correlationId: req.correlationId, ip: clientIp(req), + before: { recurring: snap.recurring, setup: snap.setup, basis: snap.basis, interval: snap.interval, discountBp: snap.discountBp, termEnd: k.term_end ? new Date(k.term_end).toISOString() : null, renewal: k.renewal, renewalTermMonths: k.renewal_term_months, noticeDays: k.notice_days }, + after: { recurring: price.recurring, setup: price.setup, basis: price.basis, interval: price.interval, discountBp: price.discountBp, termEnd: termEnd ? new Date(termEnd).toISOString() : null, renewal, renewalTermMonths, noticeDays, reason: b.reason } }, c); + return { ok: true }; + }); + }); + + // ---- Vertrags-Erinnerung per Mail: "Behalten"/"Kündigen" ohne Login, über einen Einmal-Link ------------- + app.get('/contracts/renewal-decision', async (req) => { + const { token } = z.object({ token: z.string().min(20).max(100) }).parse(req.query); + const info = await peekRenewalToken(token); + if (!info) throw badRequest('Link ungültig oder abgelaufen', 'INVALID_TOKEN'); + return info; + }); + app.post('/contracts/renewal-decision', { config: rl(10, '10 minutes') }, async (req) => { + const b = z.object({ token: z.string().min(20).max(100), action: z.enum(['keep', 'cancel']) }).parse(req.body); + try { return await applyRenewalDecision(b.token, b.action); } + catch (e: unknown) { if (e instanceof RenewalTokenError) throw badRequest(e.message, 'INVALID_TOKEN'); throw e; } + }); }, }; diff --git a/apps/api/src/modules/resources/index.ts b/apps/api/src/modules/resources/index.ts index 893eb3a..52fc74e 100644 --- a/apps/api/src/modules/resources/index.ts +++ b/apps/api/src/modules/resources/index.ts @@ -1,6 +1,7 @@ import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import { randomUUID, createHash } from 'node:crypto'; +import { domainToUnicode } from 'node:url'; import { ACTION_CAPABILITY, ACTIONS, loadInstance, type ActionName } from '@kc/connectors'; import { ConnectorError, type ChildKind } from '@kc/connector-sdk'; import { decrypt, encrypt, randomToken } from '../../core/crypto.js'; @@ -88,7 +89,10 @@ export const resourcesModule: KcModule = { const staff = can(a.principal, 'resources.read'); const jobs = await query("SELECT id, status, last_error, attempts, created_at, updated_at, JSON_VALUE(payload, '$.action') AS action FROM jobs WHERE type = 'connector.execute' AND JSON_VALUE(payload, '$.resourceId') = ? ORDER BY created_at DESC LIMIT 10", [id]); const data = json<{ limits?: object; usage?: object; details?: object }>(r.data_json, {}); - return { ...view(r, staff), limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) }; + // Registrierte Domains aus der Domain-Aufstellung (Laufzeit/Registrierung), nicht die Live-Einträge im Panel; Preise nur für Personal + const domains = (await query('SELECT id, domain, term_months, procurement, sell_gross_cents FROM domain_records WHERE resource_id = ? ORDER BY domain', [id])) + .map((x) => ({ id: x.id, domain: domainToUnicode(x.domain) || x.domain, termMonths: x.term_months, registered: x.procurement !== 'open', ...(staff ? { procurement: x.procurement, sellGrossCents: x.sell_gross_cents } : {}) })); + return { ...view(r, staff), domains, limits: data.limits ?? {}, usage: data.usage ?? {}, details: data.details ?? {}, allowedActions: allowedActions(r, a), canReveal: canReveal(r, a), canResetPassword: canResetPassword(r, a), hasChildren: childCapsTop(r).includes('children.read'), canLogin: canLoginTop(r, a), customerActions: staff ? json(r.customer_actions, []) : undefined, jobs: jobs.map((j) => ({ id: j.id, action: j.action, status: j.status, error: j.last_error, attempts: j.attempts, createdAt: j.created_at, updatedAt: j.updated_at })) }; }); app.post('/resources/:id/actions', async (req, reply) => { diff --git a/apps/api/src/ops/backup.ts b/apps/api/src/ops/backup.ts index ccb6eb1..be408c1 100644 --- a/apps/api/src/ops/backup.ts +++ b/apps/api/src/ops/backup.ts @@ -3,15 +3,17 @@ import { createReadStream, createWriteStream, existsSync } from 'node:fs'; import { chmod, mkdir, mkdtemp, readdir, readFile, rename, rm, stat, writeFile, copyFile } from 'node:fs/promises'; import { createHash } from 'node:crypto'; import { tmpdir } from 'node:os'; -import { join, basename } from 'node:path'; +import { join, basename, dirname } from 'node:path'; import mysql from 'mysql2/promise'; import { config } from '../core/config.js'; import { query, one } from '../core/db.js'; import { audit, verifyAuditChain } from '../core/audit.js'; import { enqueue } from '../core/jobs.js'; +import { STORE_ROOT as ATTACHMENT_DIR } from '../modules/tickets/files.js'; import { fileName, parseName, selectDeletions, type Keep } from './retention.js'; import { getBackupPassword } from './settings.js'; import { buildRemote, loadTargets, friendly, type Remote } from './targets.js'; +import { hasFeature } from '../core/license.js'; /** Konfiguration aus /etc/kundencenter/backup.env (nur Namen/Pfade, keine Zugangsdaten der Ziele; die liegen in der rclone-Konfiguration). */ export interface BackupConfig { dir: string; recipient: string | null; identity: string | null; remotes: string[]; rclone: string; rcloneConfig: string | null; keep: Keep; statusFile: string; envDir: string } @@ -72,6 +74,21 @@ async function tableCounts(q: (sql: string) => Promise): Promise { + if (!existsSync(dir)) return { files: 0, bytes: 0 }; + let files = 0; let bytes = 0; + const walk = async (d: string) => { + for (const e of await readdir(d, { withFileTypes: true })) { + const p = join(d, e.name); + if (e.isDirectory()) await walk(p); + else if (e.isFile()) { files += 1; bytes += (await stat(p)).size; } + } + }; + await walk(dir); + return { files, bytes }; +} + /** Erstellt ein verschlüsseltes Backup (DB-Dump + Konfiguration/Schlüssel), lädt es zu allen Zielen hoch und räumt nach Aufbewahrungsregeln auf. */ export async function runBackup(c: BackupConfig, now = new Date()): Promise> { const t0 = Date.now(); const targets: { name: string; ok: boolean; error?: string }[] = []; const dests: { label: string; remote: string; env: Record }[] = []; const built: Remote[] = []; @@ -87,8 +104,12 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise query(sql)); const migrations = (await query('SELECT name FROM schema_migrations ORDER BY name')).map((r) => r.name as string); - await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2)); - await run('tar', ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']); + const attachments = await dirStats(ATTACHMENT_DIR); + await writeFile(join(work, 'manifest.json'), JSON.stringify({ version: 1, createdAt: now.toISOString(), database: config.db.database, counts, migrations, attachments, dumpSha256: await sha256(join(work, 'db.sql')) }, null, 2)); + const tarArgs = ['-czf', join(work, 'archive.tar.gz'), '-C', work, 'db.sql', 'config', 'manifest.json']; + // Ticket-Anhänge liegen außerhalb von work (STORE_ROOT); eigener -C-Abschnitt bettet sie unter ihrem Basisnamen mit ein. + if (attachments.files > 0) tarArgs.push('-C', dirname(ATTACHMENT_DIR), basename(ATTACHMENT_DIR)); + await run('tar', tarArgs); const out = join(c.dir, name); if (pw) await gpgEncrypt(work, join(work, 'archive.tar.gz'), out, pw.password); else if (c.recipient) await run('age', ['-r', c.recipient, '-o', out, join(work, 'archive.tar.gz')]); @@ -99,9 +120,11 @@ export async function runBackup(c: BackupConfig, now = new Date()): Promise [])).length || c.remotes.length)) targets.push({ name: 'Externe Ziele', ok: true, error: 'übersprungen: nur mit Lizenz' }); + if (remoteAllowed) try { for (const t of await loadTargets(true)) { try { const r = await buildRemote(t, c.rclone); built.push(r); dests.push({ label: t.name, remote: r.remote, env: r.env }); } catch (e) { targets.push({ name: t.name, ok: false, error: friendly(e) }); } } } catch (e) { targets.push({ name: 'Ziele laden', ok: false, error: friendly(e) }); } - for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} }); + if (remoteAllowed) for (const r of c.remotes) dests.push({ label: r, remote: r, env: c.rcloneConfig ? { RCLONE_CONFIG: c.rcloneConfig } : {} }); const okDests: typeof dests = []; for (const d of dests) { try { @@ -172,6 +195,12 @@ export async function runRestoreTest(c: BackupConfig, file?: string): Promise { }); describe('Audit', () => { - it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt Manipulation', async () => { + it('führt eine intakte Hash-Kette, maskiert Geheimnisse und erkennt eine eingeschleuste Fälschung', async () => { expect((await verifyAuditChain()).brokenAt).toBeNull(); const dump = JSON.stringify(await query('SELECT before_json, after_json FROM audit_events')); expect(dump).not.toMatch(/passwort-owner|correct-horse/); - const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1 OFFSET 3'); - await run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id]); - expect((await verifyAuditChain()).brokenAt).toBe(first!.id); + // audit_events ist auf DB-Ebene unveränderlich (Migration 033); eine Manipulation ist daher nur noch als + // eingeschleuste Fälschung denkbar (z. B. Wiedereinspielen einer alten Sicherung neben der echten Kette), + // nicht mehr als nachträgliches UPDATE einer bestehenden Zeile. + await run("INSERT INTO audit_events (actor_type, action, result, prev_hash, hash, hash_version) VALUES ('system','gefaelscht','success', REPEAT('0',64), REPEAT('f',64), 2)"); + const fake = await one("SELECT id FROM audit_events WHERE action = 'gefaelscht'"); + expect((await verifyAuditChain()).brokenAt).toBe(fake!.id); + }); + + it('verweigert UPDATE und DELETE auf audit_events auf DB-Ebene (append-only)', async () => { + const first = await one('SELECT id FROM audit_events ORDER BY id LIMIT 1'); + await expect(run("UPDATE audit_events SET action = 'manipuliert' WHERE id = ?", [first!.id])).rejects.toThrow(/unveraenderlich/); + await expect(run('DELETE FROM audit_events WHERE id = ?', [first!.id])).rejects.toThrow(/unveraenderlich/); }); }); diff --git a/apps/api/test/domains.test.ts b/apps/api/test/domains.test.ts index 0dd4fda..3921bdb 100644 --- a/apps/api/test/domains.test.ts +++ b/apps/api/test/domains.test.ts @@ -88,8 +88,9 @@ describe('Domains: Preisliste, Aufschlag, Prüfung', () => { const rec = await call(app, admin, 'POST', '/admin/domain-records', { domain: 'https://www.Kunde-Test.com/' }); expect(rec.statusCode).toBe(200); expect((await call(app, admin, 'POST', '/admin/domain-records', { domain: 'kunde-test.com' })).statusCode).toBe(400); const rlr = await call(app, admin, 'GET', '/admin/domain-records'); const rl = rlr.json(); expect(rl[0]).toMatchObject({ domain: 'kunde-test.com', costGrossCents: 1250, costNetCents: 1050, sellGrossCents: 1550, sellNetCents: 1303, profitNetCents: 253, procurement: 'open' }); - expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedRef: 'KCS-1' })).statusCode).toBe(200); - expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered', orderedRef: 'KCS-1' }); + expect((await call(app, admin, 'PATCH', `/admin/domain-records/${rec.json().id}`, { procurement: 'ordered', orderedAt: '2020-04-24' })).statusCode).toBe(200); + expect((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0]).toMatchObject({ procurement: 'ordered' }); + expect(new Date((await call(app, admin, 'GET', '/admin/domain-records?status=ordered')).json()[0].orderedAt).getFullYear()).toBe(2020); expect((await call(app, cust, 'GET', '/admin/domain-records')).statusCode).toBe(403); expect((await call(app, sup, 'POST', '/admin/domain-records', { domain: 'x.com' })).statusCode).toBe(403); expect((await call(app, admin, 'GET', '/admin/domain-records?orgId=00000000-0000-4000-8000-000000000000')).json()).toEqual([]); // Kundenfilter await call(app, admin, 'PATCH', '/admin/domain-tlds/com', { active: false }); diff --git a/apps/api/test/orders.test.ts b/apps/api/test/orders.test.ts index 5f34934..8249a31 100644 --- a/apps/api/test/orders.test.ts +++ b/apps/api/test/orders.test.ts @@ -203,6 +203,32 @@ describe('Bruttopreise', () => { }); }); +describe('Vertrag nachträglich anpassen (Superadmin)', () => { + it('berechnet den Preis serverseitig neu, ändert Konditionen und protokolliert; nur Superadmin', async () => { + const admin = await staff('adj-adm@shop.test', 'admin'); const sa = await staff('adj-sa@shop.test', 'superadmin'); + const P = await customer(admin, 'private', 'Anna Anpass', 'adj-p@shop.test'); + const t19 = (await call(app, admin, 'GET', '/admin/tax-rates')).json().find((t: any) => t.rateBp === 1900).id; + const prod = (await call(app, admin, 'POST', '/admin/products', { sku: 'ADJ-1', category: 'service', status: 'active', orderableByCustomer: true, requiresApproval: false, + version: { name: 'Altvertrag', taxRateId: t19, setupCents: 0, recurringCents: 0, billingInterval: 'yearly', termMonths: 12, renewal: 'auto', renewalTermMonths: 12, noticeDays: 30, provisioning: {} } })).json(); + await call(app, P.client, 'POST', '/orders', { orgId: P.org, items: [{ productId: prod.id }] }); + await drain(); + const k = (await call(app, P.client, 'GET', '/contracts')).json()[0]; + const body = { recurringCents: 2499, basis: 'gross', reason: 'Unkostenpreis vereinbart', termEnd: '2027-04-24', noticeDays: 14 }; + expect((await call(app, admin, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403); + expect((await call(app, P.client, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(403); + expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { ...body, reason: '' })).statusCode).toBe(400); + expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, { renewal: 'auto', renewalTermMonths: 0, reason: 'ungültig' })).statusCode).toBe(400); + expect((await call(app, admin, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(false); + expect((await call(app, sa, 'GET', `/contracts/${k.id}`)).json().canEdit).toBe(true); + expect((await call(app, sa, 'PATCH', `/admin/contracts/${k.id}`, body)).statusCode).toBe(200); + const d = (await call(app, P.client, 'GET', `/contracts/${k.id}`)).json(); + expect(d.price.recurring).toEqual({ net: 2100, tax: 399, gross: 2499 }); expect(d.price.basis).toBe('gross'); expect(d.price.name).toBe('Altvertrag'); + expect(d.noticeDays).toBe(14); expect(new Date(d.termEnd).getFullYear()).toBe(2027); expect(d.canEdit).toBe(false); + const log = await one("SELECT after_json FROM audit_events WHERE action = 'contract.adjust' AND resource_id = ?", [k.id]); + expect(JSON.stringify(log?.after_json)).toContain('Unkostenpreis vereinbart'); + }); +}); + describe('Familytool-Editionen (Paket), Edition und Laufzeitpflege', () => { it('importiert das Paket als Entwürfe, sperrt Aktivierung ohne Anbieter-Erweiterung, liefert Editionen und verlängert die Lizenz mit dem Vertrag', async () => { resetMock(); diff --git a/apps/web/src/app/(app)/admin/audit/page.tsx b/apps/web/src/app/(app)/admin/audit/page.tsx index f7ef5aa..f85e9e2 100644 --- a/apps/web/src/app/(app)/admin/audit/page.tsx +++ b/apps/web/src/app/(app)/admin/audit/page.tsx @@ -1,9 +1,48 @@ 'use client'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; import { api, errMsg } from '@/lib/api'; -import { Alert, Empty, fmt } from '@/components/ui'; +import { useSession } from '@/lib/session'; +import { Alert, Empty, Field, fmt } from '@/components/ui'; interface Ev { id: number; ts: string; actorType: string; actorId: string | null; action: string; resourceType: string | null; resourceId: string | null; result: string; correlationId: string | null; ip: string | null } +interface Settings { retentionDays: number; updatedAt: string | null; lastPurge: { at: string; count: number; throughId: number; retentionDays: number } | null } + +function Retention() { + const { can } = useSession(); const w = can('settings.write'); + const [s, setS] = useState(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('GET', '/admin/audit/settings').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function save(e: FormEvent) { + e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); + try { await api('PUT', '/admin/audit/settings', { retentionDays: Number(f.get('retentionDays')) }); setOk('Gespeichert.'); void load(); } + catch (x) { setErr(errMsg(x)); } + } + async function purgeNow() { + setErr(''); setOk(''); setBusy(true); + try { const r = await api<{ purged: number }>('POST', '/admin/audit/purge'); setOk(r.purged ? `${r.purged} Einträge gelöscht.` : 'Nichts zu löschen – keine Einträge jenseits der Frist.'); void load(); } + catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } + } + if (!s) return null; + return (
+

Aufbewahrung & Export

+

+ Die DSGVO selbst schreibt keine feste Zahl vor, nur den Grundsatz der Speicherbegrenzung (Art. 5 Abs. 1 lit. e) – Einträge dürfen nicht länger als nötig aufbewahrt werden. + Die Voreinstellung von 180 Tagen ist eine verbreitete Praxis-Richtgröße für sicherheitsrelevante Protokolldaten, keine gesetzliche Vorgabe. Bitte an die eigene Lösch-/Aufbewahrungsrichtlinie anpassen. +

+ {err && {err}}{ok && {ok}} +
+ + {w && } + {w && } + Export (CSV) +
+ {s.lastPurge + ?

Letzte Aufräumung: {fmt(s.lastPurge.at)}, {s.lastPurge.count} Einträge gelöscht (Frist damals {s.lastPurge.retentionDays} Tage).

+ :

Noch keine Aufräumung durchgeführt.

} +
); +} + export default function Audit() { const [list, setList] = useState(null); const [action, setAction] = useState(''); const [err, setErr] = useState(''); const [chain, setChain] = useState<{ checked: number; brokenAt: number | null } | null>(null); const load = useCallback(() => api('GET', `/admin/audit${action ? `?action=${encodeURIComponent(action)}` : ''}`).then(setList).catch((e) => setErr(errMsg(e))), [action]); @@ -12,6 +51,7 @@ export default function Audit() {

Audit-Protokoll

{err && {err}} {chain && (chain.brokenAt === null ? Hash-Kette intakt ({chain.checked} Einträge geprüft). : Manipulation erkannt: Kette ab Eintrag {chain.brokenAt} fehlerhaft.)} +
setAction(e.target.value)} placeholder="z. B. auth. oder customer." />
{list === null ?

Wird geladen …

: list.length === 0 ? :
diff --git a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx index 1100ffd..c34bb58 100644 --- a/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx +++ b/apps/web/src/app/(app)/admin/kunden/[id]/page.tsx @@ -1,7 +1,7 @@ 'use client'; import { useCallback, useEffect, useState, type FormEvent } from 'react'; import Link from 'next/link'; -import { useParams } from 'next/navigation'; +import { useParams, useRouter } from 'next/navigation'; import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { SecretField } from '@/components/SecretField'; @@ -14,9 +14,11 @@ interface CRow { id: string; number: string; status: string; productName: string interface ORow { id: string; number: string; status: string; createdAt: string; items: { snapshot: { name: string } }[] } interface RRow { id: string; name: string; state: string; validUntil: string | null; stale: boolean; canReveal?: boolean } export default function Kunde() { - const { id } = useParams<{ id: string }>(); const { can } = useSession(); + const { id } = useParams<{ id: string }>(); const { can, reload } = useSession(); const r = useRouter(); const [contracts, setContracts] = useState(null); const [orders, setOrders] = useState(null); const [ress, setRess] = useState(null); const [o, setO] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [confirm, setConfirm] = useState(null); const [switchTo, setSwitchTo] = useState<'private' | 'business' | null>(null); + const [impersonateOpen, setImpersonateOpen] = useState(false); const [impersonateBusy, setImpersonateBusy] = useState(false); + const [welcomeBusy, setWelcomeBusy] = useState(false); const load = useCallback(() => api('GET', `/admin/customers/${id}`).then(setO).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); useEffect(() => { void load(); api('GET', `/contracts?org=${id}`).then(setContracts).catch(() => setContracts([])); api('GET', `/orders?org=${id}`).then(setOrders).catch(() => setOrders([])); api('GET', `/resources?org=${id}`).then(setRess).catch(() => setRess([])); }, [load, id]); if (!o) return msg ? {msg.t} :

Wird geladen …

; @@ -37,7 +39,13 @@ export default function Kunde() { async function setStatus(status: string) { try { setO(await api('PATCH', `/admin/customers/${id}`, { status })); setConfirm(null); setMsg({ k: 'ok', t: 'Status geändert.' }); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } } - const [welcomeBusy, setWelcomeBusy] = useState(false); + async function impersonate(e: FormEvent) { + e.preventDefault(); setImpersonateBusy(true); const f = new FormData(e.currentTarget); + try { + await api('POST', `/admin/customers/${id}/impersonate`, { reason: String(f.get('reason') ?? '').trim() }); + await reload(); r.push('/dashboard'); + } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setImpersonateBusy(false); } + } async function sendWelcome() { setWelcomeBusy(true); try { const r = await api<{ status: string }>('POST', `/admin/customers/${id}/welcome-mail`); setMsg({ k: r.status === 'sent' ? 'ok' : 'warn', t: r.status === 'sent' ? 'Begrüßungsmail gesendet.' : `Nicht gesendet (Status: ${r.status}).` }); } @@ -45,9 +53,15 @@ export default function Kunde() { } return (<>

← Alle Kunden

-

{o.name}

+

{o.name}

{can('customers.impersonate') && }

{o.customerNumber} · {o.customerType === 'business' ? 'Geschäftskunde' : 'Privatkunde'}

{msg && {msg.t}} + {impersonateOpen &&
+

Als Kunde ansehen

+

Schaltet deine Sitzung für 60 Minuten auf die Sicht dieses Kunden um (sichtbarer Banner, jederzeit zurück möglich). Wird protokolliert.

+ +
+ }

Stammdaten & Rechnungsanschrift

{o.customerType === 'business' && } diff --git a/apps/web/src/app/(app)/bestellen/page.tsx b/apps/web/src/app/(app)/bestellen/page.tsx index 37ef405..275ac56 100644 --- a/apps/web/src/app/(app)/bestellen/page.tsx +++ b/apps/web/src/app/(app)/bestellen/page.tsx @@ -5,7 +5,7 @@ import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Empty, eur, Field, type Price, PriceText } from '@/components/ui'; -interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] } +interface P { id: string; sku: string; category: string; name: string; description: string | null; requiresApproval: boolean; orderableByCustomer?: boolean; price: Price; termMonths: number; renewal: string; renewalTermMonths: number; noticeDays: number; termPrices: { termMonths: number; price: { net: number; tax: number; gross: number } }[] } interface Cust { id: string; name: string; customerNumber: string; customerType: 'private' | 'business' } export default function Bestellen() { const { me, can } = useSession(); const r = useRouter(); const staff = can('orders.write'); @@ -23,7 +23,7 @@ export default function Bestellen() {

Neue Bestellung

{err && {err}} {staff &&
} {cat === null ?

Wird geladen …

: cat.length === 0 ?
Aktuell sind keine Produkte für die Selbstbestellung freigegeben.
: (<> -
{cat.map((p) =>

{p.name}

{p.description &&

{p.description}

} +
{cat.map((p) =>

{p.name}

{staff && p.orderableByCustomer === false &&

Nur durch das Personal bestellbar

}{p.description &&

{p.description}

}

{p.termMonths > 0 ? `Mindestlaufzeit ${p.termMonths} Monate` : 'Keine Mindestlaufzeit'}{p.renewal === 'auto' ? `, Verlängerung um ${p.renewalTermMonths} Monat(e)` : ''}, Kündigungsfrist {p.noticeDays} Tage

)}
diff --git a/apps/web/src/app/(app)/dashboard/page.tsx b/apps/web/src/app/(app)/dashboard/page.tsx index adc8776..38cc7a7 100644 --- a/apps/web/src/app/(app)/dashboard/page.tsx +++ b/apps/web/src/app/(app)/dashboard/page.tsx @@ -9,6 +9,7 @@ interface Sys { jobs: Record; oldestPendingJob: string | null; b export default function Dashboard() { const { me, can } = useSession(); const [mine, setMine] = useState<{ contracts: { status: string; cancelEffectiveAt: string | null }[]; resources: { state: string; stale: boolean }[]; orders: { status: string }[] } | null>(null); + const [licenses, setLicenses] = useState<{ active: number; expiring: number; unassigned: number } | null>(null); const [customers, setCustomers] = useState(null); const [sys, setSys] = useState(null); const [err, setErr] = useState(''); useEffect(() => { if (me?.kind === 'customer') { @@ -18,6 +19,7 @@ export default function Dashboard() { if (!me || me.kind !== 'staff') return; if (can('customers.read')) api('GET', '/admin/customers').then((l) => setCustomers(l.length)).catch((e) => setErr(errMsg(e))); if (can('jobs.read')) api('GET', '/admin/system').then(setSys).catch((e) => setErr(errMsg(e))); + if (can('licenses.read')) api<{ active: number; expiring: number; unassigned: number }>('GET', '/admin/licenses/summary').then(setLicenses).catch(() => undefined); }, [me, can]); if (!me) return null; const failed = sys ? (sys.jobs.failed ?? 0) + (sys.jobs.needs_review ?? 0) : 0; @@ -37,6 +39,7 @@ export default function Dashboard() { ) : (<>
{customers !== null &&
{customers}

Kunden

Kunden verwalten
} + {licenses &&
{licenses.active}

Aktive Lizenzen

{licenses.expiring > 0 &&

{licenses.expiring} laufen in 30 Tagen ab

}{licenses.unassigned > 0 &&

{licenses.unassigned} ohne Kunde

}Lizenzen verwalten
} {sys &&
{sys.jobs.scheduled ?? 0}

Wartende Aufträge

} {sys?.backup &&

Backup

Details und Einstellungen

{!sys.backup.configured ? <>

Nicht eingerichtet.

Kein Backup diff --git a/apps/web/src/app/(app)/einstellungen/discord/page.tsx b/apps/web/src/app/(app)/einstellungen/discord/page.tsx index fad2bc9..ec6d536 100644 --- a/apps/web/src/app/(app)/einstellungen/discord/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/discord/page.tsx @@ -4,7 +4,7 @@ import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Field, fmt } from '@/components/ui'; -interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string } +interface Settings { enabled: boolean; hasToken: boolean; guildId: string | null; adminChannelId: string | null; ticketChannelId: string | null; ticketCategoryId: string | null; ticketLogChannelId: string | null; supportRoleIds: string | null; clientId: string | null; hasClientSecret: boolean; configured: boolean; lastConnectedAt: string | null; lastError: string | null; updatedAt: string } /** Schritt-für-Schritt-Anleitung, damit auch ohne Discord-Vorwissen ein Bot eingerichtet werden kann. */ function Guide({ redirectUri }: { redirectUri: string }) { @@ -13,7 +13,7 @@ function Guide({ redirectUri }: { redirectUri: string }) {
  1. Anwendung anlegen: Auf discord.com/developers/applications auf „New Application“ klicken, einen Namen vergeben (z. B. „Kundencenter“).
  2. Bot-Token erzeugen: Im Reiter „Bot“ auf „Reset Token“ klicken und den Token kopieren. Er wird nur dieses eine Mal angezeigt – am besten direkt unten einfügen und speichern. Dort außerdem die Message Content Intent aktivieren (wird benötigt, damit Kundenantworten in Ticket-Threads gelesen werden können).
  3. -
  4. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen. Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.
  5. +
  6. Bot einladen: Im Reiter „OAuth2 → URL Generator“ die Scopes bot und applications.commands ankreuzen, bei den Bot-Berechtigungen „Send Messages“, „Create Private Threads“, „View Channels“ und „Read Message History“ auswählen (für Tickets als eigene Kanäle zusätzlich „Manage Channels“, „Manage Roles“ und „Attach Files“). Die erzeugte URL öffnen und den Bot auf den gewünschten Server einladen.
  7. Kunden-Anmeldung (OAuth) einrichten: Im Reiter „OAuth2 → General“ die Client ID und (unter „Reset Secret“) das Client Secret kopieren, unten eintragen. Unter „Redirects“ genau diese Adresse eintragen: {redirectUri}
  8. IDs ermitteln: In Discord unter Einstellungen → Erweitert den „Entwicklermodus“ aktivieren. Danach mit Rechtsklick auf den Server, die gewünschten Kanäle und die eigene Person jeweils „ID kopieren“ wählen.
  9. Hier eintragen: Token, Server-ID, Kanal-ID für Systemmeldungen (Backup-Warnungen, neue Tickets) und Kanal-ID für Ticket-Threads unten speichern.
  10. @@ -37,7 +37,7 @@ export default function DiscordSettings() { const f = new FormData(form); const v = (k: string) => (String(f.get(k) ?? '').trim() || null); const token = String(f.get('token') ?? ''); const clientSecret = String(f.get('clientSecret') ?? ''); try { - await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) }); + await api('PUT', '/admin/discord/settings', { guildId: v('guildId'), adminChannelId: v('adminChannelId'), ticketChannelId: v('ticketChannelId'), ticketCategoryId: v('ticketCategoryId'), ticketLogChannelId: v('ticketLogChannelId'), supportRoleIds: v('supportRoleIds'), enabled: f.get('enabled') === 'on', clientId: v('clientId'), ...(token ? { token } : {}), ...(clientSecret ? { clientSecret } : {}) }); setMsg({ k: 'ok', t: 'Gespeichert. Die Verbindung wird innerhalb einer Minute automatisch aufgebaut.' }); (form.elements.namedItem('token') as HTMLInputElement).value = ''; (form.elements.namedItem('clientSecret') as HTMLInputElement).value = ''; void load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); } finally { setBusy(false); } } @@ -69,7 +69,14 @@ export default function DiscordSettings() { - + +
+

Tickets als eigene Kanäle

+

Mit einer Ticket-Kategorie bekommt jedes offene Ticket einen eigenen Kanal darin. Sehen können ihn nur die Support-Rollen und die Mitglieder des Kunden, die ihr Discord verknüpft haben. Beim Schließen wird der Kanal entfernt, der Verlauf bleibt im Kundencenter. Meldungen zu neuen Tickets gehen in den Support-Kanal (wird bei leerem Feld automatisch als #ticket-log angelegt). Der Bot braucht dafür auf dem Server die Berechtigungen „Kanäle verwalten“ und „Rollen verwalten“.

+
+ + +

Zuletzt verbunden: {s.lastConnectedAt ? fmt(s.lastConnectedAt) : 'noch nie'}

diff --git a/apps/web/src/app/(app)/einstellungen/firma/page.tsx b/apps/web/src/app/(app)/einstellungen/firma/page.tsx index e8024f9..47e5def 100644 --- a/apps/web/src/app/(app)/einstellungen/firma/page.tsx +++ b/apps/web/src/app/(app)/einstellungen/firma/page.tsx @@ -4,7 +4,8 @@ import { api, errMsg } from '@/lib/api'; import { useSession } from '@/lib/session'; import { Alert, Field } from '@/components/ui'; -interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean } +interface Datev { beraterNr: number | null; mandantNr: number | null; skr: string | null; sachkontenlaenge: number; fiscalYearStart: string; erloeskonto19: number | null; erloeskonto7: number | null; erloeskonto0: number | null; diktatkuerzel: string | null; complete: boolean } +interface Settings { name: string | null; street: string | null; zip: string | null; city: string | null; country: string; taxNumber: string | null; vatId: string | null; bankName: string | null; iban: string | null; bic: string | null; invoicePrefix: string; defaultDueDays: number; paymentMethods: string[]; footerText: string | null; complete: boolean; datev: Datev } export default function Firma() { const { can } = useSession(); const w = can('settings.write'); @@ -15,12 +16,17 @@ export default function Firma() { async function save(e: FormEvent) { e.preventDefault(); setErr(''); setOk(''); const f = new FormData(e.currentTarget); const v = (k: string) => (String(f.get(k) ?? '').trim() || undefined); try { + const n = (k: string) => { const x = v(k); return x === undefined ? null : Number(x); }; await api('PUT', '/admin/company-settings', { name: v('name'), street: v('street'), zip: v('zip'), city: v('city'), country: v('country') ?? 'DE', taxNumber: v('taxNumber'), vatId: v('vatId'), bankName: v('bankName'), iban: v('iban'), bic: v('bic'), invoicePrefix: v('invoicePrefix'), defaultDueDays: Number(f.get('defaultDueDays') ?? 14), paymentMethods: String(f.get('paymentMethods') ?? '').split(',').map((x) => x.trim()).filter(Boolean), footerText: v('footerText') ?? null, + datevBeraterNr: n('datevBeraterNr'), datevMandantNr: n('datevMandantNr'), datevSkr: (v('datevSkr') as '03' | '04' | undefined) ?? null, + datevSachkontenlaenge: Number(f.get('datevSachkontenlaenge') ?? 4), datevFiscalYearStart: v('datevFiscalYearStart') ?? '01-01', + datevErloeskonto19: n('datevErloeskonto19'), datevErloeskonto7: n('datevErloeskonto7'), datevErloeskonto0: n('datevErloeskonto0'), + datevDiktatkuerzel: v('datevDiktatkuerzel') ?? null, }); setOk('Gespeichert.'); void load(); } catch (x) { setErr(errMsg(x)); } @@ -54,6 +60,27 @@ export default function Firma() {
+

DATEV-Export

+

Für den Buchungsstapel-Export unter Rechnungen → Export. Werte vom Steuerberater erfragen; ohne Beraternummer und Mandantennummer ist der DATEV-Export nicht möglich (das generische CSV/ZIP funktioniert unabhängig davon immer).

+ {!s.datev.complete && DATEV-Stammdaten unvollständig: Beraternummer und Mandantennummer fehlen noch.} +
+ + + + + + + + +
+

Erlöskonten je USt-Satz (Automatikkonten-Verfahren: das Erlöskonto selbst legt den Steuersatz fest, z. B. SKR04 4400/4300 oder SKR03 8400/8300 – beim Steuerberater erfragen).

+
+ + + +
{w ? :

Nur Superadministratoren können diese Angaben ändern.

} ); diff --git a/apps/web/src/app/(app)/einstellungen/layout.tsx b/apps/web/src/app/(app)/einstellungen/layout.tsx index d12f32c..e81c34e 100644 --- a/apps/web/src/app/(app)/einstellungen/layout.tsx +++ b/apps/web/src/app/(app)/einstellungen/layout.tsx @@ -14,6 +14,7 @@ export default function SettingsLayout({ children }: { children: ReactNode }) { { href: '/einstellungen/firma', label: 'Firma', show: can('invoices.read') }, { href: '/einstellungen/email', label: 'E-Mail', show: can('email.read') }, { href: '/einstellungen/discord', label: 'Discord', show: can('discord.read') }, + { href: '/einstellungen/lizenz', label: 'Lizenz', show: can('license.read') }, ].filter((t) => t.show); if (tabs.length === 0) return Keine Berechtigung.; return (<> diff --git a/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx new file mode 100644 index 0000000..2cae427 --- /dev/null +++ b/apps/web/src/app/(app)/einstellungen/lizenz/page.tsx @@ -0,0 +1,77 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent } from 'react'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Field, fmt } from '@/components/ui'; + +interface Status { + valid: boolean; plan: string | null; modules: string[]; customerLimit: number | null; userLimit: number | null; + trial: boolean; expiresAt: string | null; configured: boolean; source: 'live' | 'offline-grace' | 'unchecked' | 'unconfigured'; + checkedAt: string | null; message: string | null; instanceId: string | null; hasKey: boolean; lastError: string | null; lastAttemptAt: string | null; + customerCount: number; + edition: { mode: 'licensed' | 'trial'; reason: string | null; customerLimit: number | null; staffLimit: number | null; staffCount: number; allFeatures: { key: string; label: string; enabled: boolean }[] }; +} +const SOURCE_LABEL: Record = { live: 'aktuell geprüft', 'offline-grace': 'Offline-Gnadenzeit', unchecked: 'noch nicht geprüft', unconfigured: 'nicht eingerichtet' }; +const MODULE_LABEL: Record = { billing: 'Rechnungen', domains: 'Domains', provisioning: 'Provisionierung', discord: 'Discord', automation: 'Automatisierung' }; + +export default function LizenzPage() { + const { can } = useSession(); const w = can('settings.write'); + const [s, setS] = useState(null); const [err, setErr] = useState(''); const [ok, setOk] = useState(''); const [busy, setBusy] = useState(false); + const load = useCallback(() => api('GET', '/admin/license/status').then(setS).catch((e) => setErr(errMsg(e))), []); + useEffect(() => { void load(); }, [load]); + + async function saveKey(e: FormEvent) { + e.preventDefault(); setErr(''); setOk(''); setBusy(true); + const f = new FormData(e.currentTarget); + try { + const r = await api<{ ok: boolean; checked: boolean; error?: string }>('PUT', '/admin/license/settings', { licenseKey: String(f.get('licenseKey') ?? '').trim() }); + setOk(r.checked ? 'Lizenzschlüssel gespeichert und erfolgreich geprüft.' : `Lizenzschlüssel gespeichert, Prüfung fehlgeschlagen: ${r.error}`); + e.currentTarget.reset(); void load(); + } catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } + } + async function checkNow() { + setErr(''); setOk(''); setBusy(true); + try { await api('POST', '/admin/license/check'); setOk('Lizenz geprüft.'); void load(); } + catch (x) { setErr(errMsg(x)); } finally { setBusy(false); } + } + if (!s) return err ? {err} :

Wird geladen …

; + return (<> +

Lizenz

+

Die Lizenz dieser Kundencenter-Installation bei licensing.flessinglabs.com (nicht zu verwechseln mit Lizenzen, die über die Verbindung „Lizenzsystem“ an eigene Kunden weiterverkauft werden).

+ {err && {err}}{ok && {ok}} + {s.edition.mode === 'trial' && Testmodus ({s.edition.reason}): höchstens {s.edition.staffLimit} Personal-Konto und {s.edition.customerLimit} Kunden; {s.edition.allFeatures.filter((f) => !f.enabled).map((f) => f.label).join(', ')} nur mit Lizenz. Bestehende Daten bleiben vollständig nutzbar.} + {s.configured && s.source === 'unchecked' && Lizenzschlüssel hinterlegt, erste Prüfung steht noch aus.} + {s.configured && s.source !== 'unchecked' && !s.valid && Lizenz ungültig: {s.message ?? s.lastError ?? 'unbekannter Grund'}. Neue Kunden und kommerzielle Aktionen sind pausiert, bestehende Daten bleiben zugänglich.} + {s.valid && s.customerLimit !== null && s.customerCount >= s.customerLimit && Kundengrenze erreicht ({s.customerCount}/{s.customerLimit}). Neue Kunden können erst nach einem Upgrade angelegt werden.} + {s.valid && s.customerLimit !== null && s.customerCount < s.customerLimit && s.customerCount >= s.customerLimit * 0.9 && Kundengrenze bald erreicht ({s.customerCount}/{s.customerLimit}). Ein Upgrade lohnt sich bald.} + {s.valid && s.trial && s.expiresAt && new Date(s.expiresAt).getTime() - Date.now() < 7 * 86400000 && Testzeitraum endet bald ({fmt(s.expiresAt)}). Danach sind neue Kunden und kommerzielle Aktionen pausiert, bis ein Plan gewählt wird.} + +
+

Edition: {s.edition.mode === 'licensed' ? 'Lizenziert' : 'Testmodus'}

+
+
Personal-Konten
{s.edition.staffCount}{s.edition.staffLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.staffLimit}`}
+
Kunden
{s.customerCount}{s.edition.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.edition.customerLimit}`}
+ {s.edition.allFeatures.map((f) =>
{f.label}
{f.enabled ? enthalten : nur mit Lizenz}
)} +
+
+
+

Status

+
+
Plan
{s.plan ?? '–'}{s.trial ? ' (Test)' : ''}
+
Module
{s.modules.length ? s.modules.map((m) => MODULE_LABEL[m] ?? m).join(', ') : '–'}
+
Kunden
{s.customerCount}{s.customerLimit === null ? ' (unbegrenzt)' : ` / ${s.customerLimit}`}
+
Nutzer-/Slotlimit
{s.userLimit === null ? 'unbegrenzt' : s.userLimit}
+
Gültig bis
{s.expiresAt ? fmt(s.expiresAt) : 'unbegrenzt'}
+
Zuletzt geprüft
{s.checkedAt ? fmt(s.checkedAt) : '–'} ({SOURCE_LABEL[s.source]})
+
+ {w && } +
+ + {w &&
+

Lizenzschlüssel

+ + + } +

Installations-ID: {s.instanceId} (bleibt stabil, zählt sonst als neues Gerät).

+ ); +} diff --git a/apps/web/src/app/(app)/layout.tsx b/apps/web/src/app/(app)/layout.tsx index 5ba1d19..8e3d445 100644 --- a/apps/web/src/app/(app)/layout.tsx +++ b/apps/web/src/app/(app)/layout.tsx @@ -9,6 +9,8 @@ import { ThemeToggle } from '@/components/ThemeToggle'; export default function AppLayout({ children }: { children: ReactNode }) { const { me, loading, can, reload } = useSession(); const r = useRouter(); const path = usePathname(); const [open, setOpen] = useState(false); + const [trial, setTrial] = useState(null); + useEffect(() => { if (me?.kind === 'staff' && can('license.read')) api<{ edition: { mode: string; staffLimit: number | null; customerLimit: number | null } }>('GET', '/admin/license/status').then((s) => setTrial(s.edition.mode === 'trial' ? `Testmodus: höchstens ${s.edition.staffLimit} Personal-Konto und ${s.edition.customerLimit} Kunden, ohne Discord-Bot, E-Mail-Posteingang, DATEV-Export und externe Backups.` : null)).catch(() => undefined); }, [me, can]); const enrollNeeded = !!me?.mfaEnrollRequired; useEffect(() => { if (!loading && (!me || me.pendingMfa)) r.replace('/login'); }, [me, loading, r]); useEffect(() => { if (enrollNeeded && path !== '/konto') r.replace('/konto'); }, [enrollNeeded, path, r]); @@ -20,7 +22,8 @@ export default function AppLayout({ children }: { children: ReactNode }) {
{title}
{items}
); async function logout() { await api('POST', '/auth/logout'); await reload(); r.replace('/login'); } - const produkte = [(me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][]; + async function stopImpersonation() { const orgId = me!.impersonating!.orgId; await api('POST', '/auth/impersonate/stop'); await reload(); r.replace(`/admin/kunden/${orgId}`); } + const produkte = [(me.kind === 'customer' || can('licenses.read')) && ['/lizenzen', me.kind === 'customer' ? 'Meine Lizenzen' : 'Lizenzen'], (me.kind === 'customer' || can('resources.read')) && ['/ressourcen', 'Ressourcen'], (me.kind === 'customer' || can('contracts.read')) && ['/vertraege', 'Verträge'], (me.kind === 'customer' || can('orders.read')) && ['/bestellungen', 'Bestellungen']].filter(Boolean) as [string, string][]; const verwaltung = [can('customers.read') && ['/admin/kunden', 'Kunden'], can('products.read') && ['/admin/produkte', 'Produkte'], can('domains.read') && ['/admin/domains', 'Domain-Aufstellung'], can('users.read') && ['/admin/benutzer', 'Benutzer'], can('jobs.read') && ['/admin/auftraege', 'Aufträge'], can('audit.read') && ['/admin/audit', 'Audit-Protokoll'], (can('connectors.read') || can('backup.read')) && ['/einstellungen', 'Einstellungen']].filter(Boolean) as [string, string][]; const nav = ( ); const bottomLink = (href: string, label: string) => {label}; - return (
+ return (<> + {me.impersonating && ( +
+ 👁 Ansicht als Kunde: {me.impersonating.orgName} ({me.impersonating.customerNumber}) · Grund: {me.impersonating.reason} + +
+ )} +
Kundencenter
- {nav}
{children}
+ {nav}
{trial && path !== '/einstellungen/lizenz' &&
{trial} Lizenz hinterlegen
}{children}
{!enrollNeeded && } -
); +
+ ); } diff --git a/apps/web/src/app/(app)/lizenzen/[id]/page.tsx b/apps/web/src/app/(app)/lizenzen/[id]/page.tsx new file mode 100644 index 0000000..6202aed --- /dev/null +++ b/apps/web/src/app/(app)/lizenzen/[id]/page.tsx @@ -0,0 +1,180 @@ +'use client'; +import { useCallback, useEffect, useState, type FormEvent, type ReactNode } from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { api, errMsg } from '@/lib/api'; +import { useSession } from '@/lib/session'; +import { Alert, Field, ResState, fmt } from '@/components/ui'; +import { SecretField } from '@/components/SecretField'; +import { LicKind, type Lic } from '@/components/Licenses'; + +interface Activation { id: number; instanceId: string | null; hardwareIdMasked: string | null; environment: string | null; lastSeenIp: string | null; productVersion: string | null; activatedAt: string; lastSeenAt: string } +interface Detail extends Lic { + live: boolean; liveError: string | null; activations: Activation[]; + entitlement: { plan: string | null; modules: string[]; customerLimit: number | null; version: number } | null; + limits: { maxActivations: number | null; activationLimit: number | null; userLimit: number | null; graceDays: number | null; effectiveGraceDays: number | null } | null; + origin: { source: string | null; orderRef: string | null; externalRef: string | null; customerName: string | null; customerEmail: string | null; createdAt: string | null } | null; + providerStatus: string | null; revokeReason: string | null; durationType: string | null; productId: number | null; programId: number | null; lastCheckAt: string | null; + addons: Lic[]; parent: Lic | null; history: { action: string; result: string; at: string; actor: string | null }[]; + can: { reveal: boolean; resetActivation: boolean; manage: boolean }; +} +interface CatalogProduct { id: number; name: string; groupName: string; programId: number; type: string; durationType: string; active: boolean } +const DURATION: Record = { WEEK: 'Woche', MONTH: 'Monat', YEAR: 'Jahr', UNLIMITED: 'unbefristet', TRIAL: 'Test' }; +const HISTORY: Record = { + 'license.issue': 'Lizenz vergeben', 'license.update': 'Lizenz geändert', 'license.entitlement': 'Funktionsumfang geändert', 'license.suspend': 'Gesperrt', 'license.unsuspend': 'Entsperrt', + 'license.extend': 'Verlängert', 'license.revoke': 'Widerrufen', 'license.activation.reset': 'Gerät freigegeben', 'resource.reveal': 'Schlüssel angezeigt', 'resource.update': 'Zuordnung geändert', + 'resource.suspend': 'Gesperrt (Vertrag)', 'resource.unsuspend': 'Entsperrt (Vertrag)', 'resource.extend': 'Verlängert (Vertrag)', +}; +const Dd = ({ label, children }: { label: string; children: ReactNode }) =>
{label}
{children}
; +const toLocalInput = (iso: string | null) => (iso ? new Date(new Date(iso).getTime() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0, 10) : ''); + +export default function Lizenz() { + const { id } = useParams<{ id: string }>(); const { can } = useSession(); const staff = can('licenses.read'); const w = can('licenses.write'); + const [custs, setCusts] = useState<{ id: string; name: string; customerNumber: string }[]>([]); const [assignTo, setAssignTo] = useState(''); + const [d, setD] = useState(null); const [msg, setMsg] = useState<{ k: 'ok' | 'err' | 'warn'; t: string } | null>(null); const [busy, setBusy] = useState(false); + const [confirm, setConfirm] = useState Promise }>(null); + const [products, setProducts] = useState(null); + const load = useCallback(() => api('GET', `/licenses/${id}`).then(setD).catch((e) => setMsg({ k: 'err', t: errMsg(e) })), [id]); + useEffect(() => { void load(); }, [load]); + useEffect(() => { if (d?.can.manage && products === null) api<{ products: CatalogProduct[] }>('GET', '/admin/licenses/catalog').then((c) => setProducts(c.products)).catch(() => setProducts([])); }, [d?.can.manage, products]); + useEffect(() => { if (w) api('GET', '/admin/customers').then(setCusts).catch(() => undefined); }, [w]); + if (!d) return msg ? {msg.t} :

Wird geladen …

; + + async function act(fn: () => Promise, ok: string) { + setBusy(true); setMsg(null); + try { await fn(); setMsg({ k: 'ok', t: ok }); setConfirm(null); await load(); } catch (x) { setMsg({ k: 'err', t: errMsg(x) }); setConfirm(null); } finally { setBusy(false); } + } + const ask = (title: string, text: string, run: () => Promise) => setConfirm({ title, text, run }); + const lifecycle = (body: Record) => api('POST', `/admin/licenses/${id}/lifecycle`, body); + const sameKind = (products ?? []).filter((p) => p.programId === d.programId && (p.type === 'ADDON') === d.addon); + const used = d.activations.length; const limit = d.limits?.activationLimit ?? d.activationLimit; + + function saveLimits(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); const n = (k: string) => { const v = String(f.get(k) ?? '').trim(); return v === '' ? null : Number(v); }; + const body: Record = { maxActivations: n('maxActivations') ?? undefined, userLimit: n('userLimit'), graceDays: n('graceDays') }; + if (!d!.addon) body.customerLimit = n('customerLimit'); + void act(() => api('PATCH', `/admin/licenses/${id}`, body), 'Limits gespeichert.'); + } + function saveEntitlement(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); + const modules = String(f.get('modules') ?? '').split(/[\n,]/).map((x) => x.trim()).filter(Boolean); + void act(() => api('POST', `/admin/licenses/${id}/entitlement`, { planKey: String(f.get('plan') ?? '').trim() || undefined, modules, reason: String(f.get('reason') ?? '').trim() || undefined }), 'Funktionsumfang gespeichert (neue Entitlement-Version).'); + } + function changeProduct(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); const productId = Number(f.get('productId')); const durationType = String(f.get('durationType') ?? ''); + const p = sameKind.find((x) => x.id === productId); + ask(d!.trial ? 'Test in Vollversion umwandeln' : 'Produkt wechseln', `Die Lizenz wird auf „${p?.groupName} – ${p?.name}“ umgestellt${durationType ? ` (Laufzeit: ${DURATION[durationType]}, Ablauf wird neu berechnet)` : ''}. Plan, Module und Kundenlimit werden vom neuen Produkt übernommen.`, + () => api('PATCH', `/admin/licenses/${id}`, { productId, ...(durationType ? { durationType } : {}) })); + } + function extend(e: FormEvent) { + e.preventDefault(); const f = new FormData(e.currentTarget); const until = String(f.get('until') ?? ''); const preset = String(f.get('preset') ?? ''); const reason = String(f.get('reason') ?? '').trim() || undefined; + if (until) { const iso = new Date(`${until}T23:59:59`).toISOString(); ask('Verlängern', `Die Lizenz gilt dann bis ${fmt(iso)}.`, () => lifecycle({ action: 'extend', until: iso, reason })); return; } + if (!preset) { setMsg({ k: 'warn', t: 'Bitte ein Datum oder eine Laufzeit wählen.' }); return; } + const [count, durationType] = preset.split(':'); + ask('Verlängern', `Die Lizenz wird um ${count} ${DURATION[durationType!]}${Number(count) > 1 ? 'e' : ''} verlängert (ab dem späteren von heute und dem aktuellen Ablauf).`, () => lifecycle({ action: 'extend', durationType, count: Number(count), reason })); + } + + return (<> +

← Alle Lizenzen

+

{d.program ?? d.name}{d.product ? ` · ${d.product}` : ''}

+ {msg && {msg.t}} + {!d.live && Das Lizenzsystem ist gerade nicht erreichbar ({d.liveError}). Angezeigt wird der Stand vom {fmt(d.syncedAt)}, Änderungen sind währenddessen nicht möglich.} + {d.trial && d.trialPending && Testlizenz: Die Testzeit beginnt erst mit der ersten Aktivierung.} + {d.revoked && Diese Lizenz wurde widerrufen{d.revokeReason ? `: ${d.revokeReason}` : ''}. Ein Widerruf ist endgültig.} + + {d.can.reveal &&

Lizenzschlüssel

} + + {w &&

{d.orgId ? 'Kunde ändern' : 'Kunde zuweisen'}

+
+ + + {d.orgId && } +
+

Ändert nur die Zuordnung im Kundencenter, nicht die Lizenz im Lizenzsystem.

} + +

Details

+
+ {staff &&
{d.orgId ? {d.customerNumber} · {d.orgName} : Nicht zugewiesen}
} +
{d.program ?? '–'}
+
{d.product ?? d.edition ?? '–'}
+
{d.durationType ? DURATION[d.durationType] ?? d.durationType : '–'}
+
{d.validFrom ? fmt(d.validFrom) : '–'}
+
{d.validUntil ? fmt(d.validUntil) : 'unbefristet'}
+
{d.lastCheckAt ? fmt(d.lastCheckAt) : 'noch nie'}
+
{d.contractId ? Vertrag {d.contractNumber} : 'ohne Berechnung'}
+ {d.parent &&
{d.parent.program}{d.parent.product ? ` · ${d.parent.product}` : ''}
} + {d.entitlement &&
{d.entitlement.plan ?? '–'}
} + {d.entitlement && !d.addon &&
{d.entitlement.customerLimit ?? 'unbegrenzt'}
} + {staff && d.origin &&
{d.origin.source ?? '–'}{d.origin.orderRef ? ` · ${d.origin.orderRef}` : ''}
} +
+ {d.entitlement && d.entitlement.modules.length > 0 &&

Freigeschaltete Module: {d.entitlement.modules.join(', ')}

} +
+ +

Geräte ({used} von {limit ?? '∞'})

+ {d.activations.length === 0 ?

{d.live ? 'Die Lizenz ist noch auf keinem Gerät aktiviert.' : 'Keine Angaben verfügbar.'}

:
ZeitAktionAkteurObjektErgebnis
{staff && } + {d.activations.map((a) => + {staff && } + + )}
GerätUmgebungVersionAktiviertZuletzt gesehenIP
{a.instanceId ?? a.hardwareIdMasked ?? `#${a.id}`}{a.environment ?? '–'}{a.productVersion ?? '–'}{fmt(a.activatedAt)}{fmt(a.lastSeenAt)}{a.lastSeenIp ?? '–'}{d.can.resetActivation && }
} + {!staff && d.can.resetActivation &&

Neues Gerät? Altes Gerät hier freigeben, dann das Programm auf dem neuen Gerät mit demselben Schlüssel aktivieren.

} +
+ + {d.can.manage && <> +

Lebenszyklus

+
+ {d.state !== 'suspended' && !d.revoked && } + {d.state === 'suspended' && !d.revoked && } + {!d.revoked && } +
+ {d.durationType !== 'UNLIMITED' && !d.revoked &&
+
+ + + +
+ +
} +
+ + {sameKind.length > 0 &&

{d.trial ? 'Test in Vollversion umwandeln' : 'Produkt wechseln (Upgrade/Downgrade)'}

+ {d.trial && /^(TRIAL|PREMIUM|LIFETIME)-/i.test(d.keyMasked ?? '') && Der Schlüssel trägt ein Editions-Präfix. Programme wie das Familytool erkennen die Edition am Schlüssel – dort bitte stattdessen eine neue Lizenz vergeben.} +
+ + +
+
} + +
+

Limits

+
+ + + {!d.addon && } + + +
+

Funktionsumfang (Version {d.entitlement?.version ?? 0})

+

Plan und Module sind bei der Vergabe an der Lizenz fixiert. Änderungen am Produkt wirken erst nach „Vom Produkt übernehmen“.

+
+
+ +