From fdf1d42a3d9215dabeb93b1ffa71153648ff1888 Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 23 Sep 2026 17:36:30 +0200 Subject: [PATCH 1/4] fix(selfhost): explain unsupported update services --- docs/selfhost-updates.md | 2 ++ scripts/selfhost-update.py | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index 64a0a085..050193e6 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -13,6 +13,8 @@ Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben. +Das betrifft auch bestehende Installationen mit zusätzlichen `matrix-*`-Diensten. Das Standard-Backup enthält weder deren Datenbank noch deren Dateispeicher. Diese Dienste nur in eine Override-Datei zu verschieben erweitert die Unterstützung nicht: Der Updater prüft die zusammengeführte Konfiguration. Vor einem Update muss ihr Erhalt einschließlich Daten, Abhängigkeiten und Wiederherstellung unterstützt werden. Die Fehlermeldung nennt fehlende und zusätzliche Dienste jeweils für die bestehende und die Zielkonfiguration. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf. + Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf. ## Bereitstellung diff --git a/scripts/selfhost-update.py b/scripts/selfhost-update.py index 287f435c..0ecd8d4c 100644 --- a/scripts/selfhost-update.py +++ b/scripts/selfhost-update.py @@ -371,8 +371,19 @@ class Updater: def validate_configs(self): old, new = self.current["services"], self.candidate["services"] required = {"backend", "frontend", "db", "minio", "createbuckets", "traefik", "node-exporter"} - if set(old) != required or set(new) != required: - raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO.") + differences = [] + for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)): + missing, additional = required - set(services), set(services) - required + details = [] + if missing: + details.append("fehlend: " + ", ".join(sorted(missing))) + if additional: + details.append("zusätzlich: " + ", ".join(sorted(additional))) + if details: + differences.append(label + " (" + "; ".join(details) + ")") + if differences: + raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO. " + + ". ".join(differences) + ". Zusätzliche Dienste benötigen Unterstützung für Erhalt, Backup und Wiederherstellung.") for name in required: if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"): raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.") -- 2.49.1 From 847a6162853eadd0623a485f25b56860342e6c00 Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 23 Sep 2026 17:43:12 +0200 Subject: [PATCH 2/4] fix(selfhost): retire deprecated Matrix services during updates --- docs/selfhost-updates.md | 13 +- scripts/selfhost-update.py | 90 ++++++++++- scripts/update/tests/test_updater.py | 216 ++++++++++++++++++++++++++- 3 files changed, 311 insertions(+), 8 deletions(-) diff --git a/docs/selfhost-updates.md b/docs/selfhost-updates.md index 050193e6..7eb29caf 100644 --- a/docs/selfhost-updates.md +++ b/docs/selfhost-updates.md @@ -13,7 +13,11 @@ Der Updater verweigert externe Datenbanken, externen S3-/Seafile-Speicher, zusätzliche Dienste, Compose-Profile, geänderte Datenpfade und PostgreSQL-Imagewechsel. Dafür braucht es zunächst passende Backup-/Migrationsadapter. Eigene Einstellungen innerhalb der unterstützten Dienste können in `docker-compose.override.yml` **oder** `docker-compose.override.yaml` bleiben; der Updater verwendet diese Datei bei jedem Compose-Aufruf. Änderungen an dieser Override-Datei werden nicht überschrieben. -Das betrifft auch bestehende Installationen mit zusätzlichen `matrix-*`-Diensten. Das Standard-Backup enthält weder deren Datenbank noch deren Dateispeicher. Diese Dienste nur in eine Override-Datei zu verschieben erweitert die Unterstützung nicht: Der Updater prüft die zusammengeführte Konfiguration. Vor einem Update muss ihr Erhalt einschließlich Daten, Abhängigkeiten und Wiederherstellung unterstützt werden. Die Fehlermeldung nennt fehlende und zusätzliche Dienste jeweils für die bestehende und die Zielkonfiguration. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf. +Eine gezielte Ausnahme ist die Stilllegung des **entfernten alten Matrix-Stacks**: `matrix-db`, `matrix-synapse`, `matrix-redis`, `matrix-element`, `matrix-livekit`, `matrix-rtc-jwt`, `matrix-turn` und `matrix-well-known` dürfen in der bestehenden Konfiguration vorkommen. Die Zielkonfiguration muss ausschließlich die sieben FEDEO-Dienste enthalten, auch nach Zusammenführung mit einer Override-Datei. Matrix wird nicht in den neuen Stack übernommen. Andere Zusatzdienste bleiben gesperrt; die Fehlermeldung nennt die konkreten Abweichungen. Ein Abbruch mit `mutationStarted: false` und `recoveryRequired: false` benötigt keine Wiederherstellung und keinen `resolve`-Aufruf. + +Für diesen Übergang müssen die Matrix-Daten lokal unter `matrix/` liegen, insbesondere `matrix/postgres` und `matrix/synapse`; symbolische Links, andere konfigurierte Datenpfade, benannte Volumes und spezielle Compose-Konfigurationen benötigen eine manuelle Stilllegung. Die tatsächlichen Bind-Mounts der alten Container müssen zur Compose-Datei passen. Der Plan nennt `retiredServices` und `retainedDataPaths` und berücksichtigt den zusätzlichen Backup-Platz. Alle Matrix-Container werden vor dem Backup gestoppt. Das komplette lokale `matrix/`-Verzeichnis wird bei gestoppter Matrix-Datenbank in `files.tar` gesichert. Erst nach erfolgreichem Backup setzt der Updater die Neustartrichtlinie der alten Matrix-Container auf `no`, damit sie auch nach einem Host-Neustart stillgelegt bleiben. Er löscht weder diese Container noch ihre Daten oder Volumes. Image-seitig angelegte anonyme Volumes und schreibbare Container-Schichten bleiben an den alten Containern erhalten; sie sind nicht Bestandteil von `files.tar`. Die alten Container daher für die Wiederherstellung behalten und nicht mit `--remove-orphans` oder einem Docker-Prune entfernen. + +Die Stilllegung vor der Dateisicherung entspricht den Anforderungen an ein [PostgreSQL-Dateisystem-Backup](https://www.postgresql.org/docs/16/backup-file.html); die Container-Richtlinie wird mit [Docker `update --restart`](https://docs.docker.com/reference/cli/docker/container/update/) geändert. Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Frontend-Healthchecks enthalten. Ältere Releases bleiben über den in `docs/releases.md` beschriebenen manuellen Ablauf installierbar. Ein Versionswechsel des Betriebssystems oder der Docker Engine gehört nicht zu diesem Ablauf. @@ -21,6 +25,8 @@ Die Zielversion muss bereits die festen MinIO-Digests und die neuen Backend-/Fro Neue versionierte Installer laden das Skript und diese Anleitung bereits ins Betriebsverzeichnis und berücksichtigen Python 3 bei den Basispaketen. Bestehende Installationen können `selfhost-update.py` und `SELFHOST-UPDATES.md` aus den Assets eines entsprechenden veröffentlichten Releases übernehmen. Zuerst mit dessen `SHA256SUMS` prüfen, anschließend das Skript unter `/opt/fedeo/scripts/selfhost-update.py` ablegen. Für diese erstmalige Bereitstellung ist noch ein Serverzugriff erforderlich. +Ein bereits heruntergeladener Updater aus Release `3.1.0` enthält die Matrix-Stilllegung noch nicht. Dafür das Hostskript durch den korrigierten Stand aus einem neueren Release oder dem geprüften Repository-Commit ersetzen. Dieser kann weiterhin `3.1.0` als Zielrelease verwenden; dessen veröffentlichte Assets werden nicht verändert. Ein erneuter Download des alten `3.1.0`-Updater-Assets liefert weiterhin den alten Stand. Der Updater ersetzt sich beim Anwenden nicht selbst. + Downloads erfolgen ausschließlich vom konfigurierten FEDEO-Repository über HTTPS. Die Dateiprüfsummen werden gegen `SHA256SUMS`, das Manifest gegen den Git-Tag geprüft. Die Prüfsummen sind **keine unabhängige Signatur**; das Vertrauen liegt beim Gitea-Server und dessen Release-Veröffentlichung. Falls das Repository privat ist, `FEDEO_RELEASE_TOKEN` mit Leserechten nur für den Aufruf setzen. Registry-Anmeldung bei Bedarf vorher über `docker login git.federspiel.tech` im Konto des Updaters durchführen. Der Updater speichert den Release-Token nicht in Statusdateien und gibt ihn bei Weiterleitungen an andere Hosts nicht weiter. ## Prüfen, anwenden und Status lesen @@ -61,6 +67,7 @@ Während des ersten Ausbaus den Befehl in einer bestehenden persistenten Termina 4. Speicherplatz prüfen: mindestens 5 GiB frei am Docker-Datenverzeichnis vor dem Pull sowie geschätzte Backup-Größe plus Reserve im Betriebsverzeichnis. Images laden, Backup-Platz erneut prüfen. 5. Schreibende Dienste und Proxy anhalten. PostgreSQL bleibt für `pg_dump` aktiv. 6. Konfiguration, Container-Metadaten, PostgreSQL-Dump im Custom-Format, globale PostgreSQL-Rollen, gestoppten MinIO-Dateispeicher und Zertifikate sichern. Dump und TAR auf Lesbarkeit prüfen und SHA-256-Prüfsummen schreiben. `backup/complete.json` markiert ein vollständig geschriebenes Backup; ein Restore-Probelauf auf einer getrennten Instanz wird dadurch nicht ersetzt. + Beim Übergang vom alten Matrix-Stack enthält das TAR zusätzlich `matrix/`. Container-Metadaten enthalten die ursprünglichen Image-IDs, Mounts und Neustartrichtlinien; `complete.json` nennt die stillgelegten Dienste und erhaltenen Container. Vor dem Dateiaustausch wird deren automatischer Neustart deaktiviert. Das Zielrelease enthält keine Matrix-Dienste und keine Backend-Abhängigkeit auf Synapse mehr. 7. Compose-Datei und `.env` einzeln atomar ersetzen. In `.env` wird ausschließlich `FEDEO_VERSION` verändert; `.env.example` und Setup-Skript werden erneuert. Den laufenden Updater ersetzt dieser Vorgang nicht. 8. Infrastruktur starten, Bucket-Einrichtung ausführen und Migrationen explizit in einem benannten Einmalcontainer starten. Beim anschließenden regulären Backend-Start läuft dessen bestehende Migrationsprüfung nochmals; bereits angewendete Migrationen werden dabei übersprungen. 9. Stack mit `up --wait` starten. Backend-/Frontend-Image-IDs und Healthchecks sowie öffentlich gemeldete Version und Commit prüfen. Erst dann den Release als installiert speichern. @@ -88,6 +95,8 @@ Für die Rückkehr zum alten Stand in einem geplanten Wartungsfenster: sudo python3 /opt/fedeo/scripts/selfhost-update.py resolve --version 3.0.0 ``` +Bei Rückkehr zum alten Matrix-Stack dessen ursprüngliche Compose-Datei aus demselben Backup verwenden. Falls nötig, das vollständige `matrix/`-Verzeichnis aus `files.tar` bei gestoppten Matrix-Diensten wiederherstellen, insbesondere den gesamten PostgreSQL-Cluster mit dem ursprünglichen Datenbank-Image. Vorhandene Container-Schichten und anonyme Volumes bleiben an den behaltenen Containern erhalten. Für jeden betroffenen Container die ursprüngliche `HostConfig.RestartPolicy` aus `backup/containers.json` wieder setzen, beispielsweise `docker update --restart=unless-stopped CONTAINER_ID`, wenn genau diese Richtlinie gespeichert wurde. Danach die zuvor laufenden Matrix-Dienste mit der ursprünglichen Compose-Konfiguration starten und prüfen. `resolve` für den alten Stand prüft auch deren Image, Laufzustand und Neustartrichtlinie. `resolve` für den Zielrelease verlangt weiterhin gestoppte Matrix-Container mit deaktiviertem automatischem Neustart. Es führt diese Schritte nicht selbst aus. + `resolve` prüft die angegebene Backend-Version, Backend-Erreichbarkeit, Frontend-Erreichbarkeit, laufende Kerndienste, passende Anwendungs-Images und das Fehlen eines zurückgebliebenen Migrationscontainers. Es stellt selbst **keine Daten wieder her**. Bei alten Entwicklungsinstallationen die tatsächlich von `/backend/version` gemeldete Entwicklungsversion angeben. Ein späteres Update kann nach einer manuellen Compose-Wiederherstellung erneut `--accept-compose-replacement` erfordern. ## Entwicklung und Tests @@ -97,4 +106,4 @@ npm test npm run commits:check ``` -Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Ein vollständiger Test mit realem PostgreSQL-/MinIO-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. +Die Update-Tests verwenden den echten Compose-Parser und einen simulierten Docker-Daemon. Sie prüfen u. a. Erfolg, Prüfsummen, Release-Auswahl, unveränderte Konfiguration, Speicherplatz, parallele Aufträge, Backupfehler, Migrationsfehler, falsche laufende Versionen und unterbrochene Aufträge. Die Matrix-Tests prüfen Stilllegung, Daten-/Container-Erhalt, Backup vor Änderung der Neustartrichtlinien, Fehler und Unterbrechungen sowie die weitere Ablehnung unbekannter Dienste. Ein vollständiger Test mit realem PostgreSQL-/MinIO-/Matrix-Restore auf einer separaten Selfhost-Testinstanz ist vor dem Produktionseinsatz zusätzlich erforderlich. diff --git a/scripts/selfhost-update.py b/scripts/selfhost-update.py index 0ecd8d4c..69c92ce8 100644 --- a/scripts/selfhost-update.py +++ b/scripts/selfhost-update.py @@ -29,6 +29,8 @@ REGISTRY = "git.federspiel.tech/flfeders/fedeo" VERSION = re.compile(r"(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\Z") FILES = ("release-manifest.json", "docker-compose.selfhost.yml", ".env.example", "selfhost-setup.sh", "CHANGELOG.md") GIB = 1024 ** 3 +LEGACY_MATRIX_SERVICES = {"matrix-db", "matrix-synapse", "matrix-redis", "matrix-element", + "matrix-livekit", "matrix-rtc-jwt", "matrix-turn", "matrix-well-known"} class UpdateError(Exception): @@ -346,6 +348,18 @@ class Updater: ids = self.compose(False, "ps", "--all", "--quiet").split() self.containers = [self.inspect(container) for container in ids] write_json(self.job / "original-containers.json", self.containers) + self.retired_containers = [c for c in self.containers + if c["Config"]["Labels"].get("com.docker.compose.service") in self.retired_services] + if any(c.get("HostConfig", {}).get("AutoRemove") for c in self.retired_containers): + raise UpdateError("Matrix-Container mit automatischer Löschung können nicht zur Wiederherstellung erhalten werden.") + for container in self.retired_containers: + name = container["Config"]["Labels"]["com.docker.compose.service"] + expected = {v["target"]: v["source"] for v in self.current["services"][name].get("volumes", [])} + actual = {v["Destination"]: v["Source"] for v in container.get("Mounts", []) if v["Type"] == "bind"} + if actual != expected: + raise UpdateError(f"{name}: Tatsächliche Matrix-Datenpfade weichen von der Compose-Konfiguration ab.") + self.save(retiredServices=self.retired_services, + retiredContainerIds=[c["Id"] for c in self.retired_containers]) if any(c["State"]["Running"] and c["Config"]["Labels"].get("com.docker.compose.oneoff", "False").lower() == "true" for c in self.containers): raise UpdateError("Ein Einmalcontainer läuft noch; dessen Arbeit zuerst abschließen.") self.running = [c["Config"]["Labels"]["com.docker.compose.service"] for c in self.containers @@ -360,11 +374,14 @@ class Updater: 'exec psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Atc "SELECT pg_database_size(current_database())"').strip()) minio_size = int(self.runner.run(["du", "-sb", str(self.root / "minio")]).split()[0]) self.backup_size = database_size * 2 + minio_size + GIB + if self.matrix_backup_paths: + self.backup_size += int(self.runner.run(["du", "-sb", str(self.root / "matrix")]).split()[0]) self.check_space(self.backup_size, self.home) plan = {"version": self.target, "revision": self.manifest["commit"], "previousVersion": old_version, "images": self.images, "composeDiff": str(self.job / "compose.diff"), "releaseNotes": str(self.job / "CHANGELOG.md"), "requiresComposeAdoption": not self.managed, - "estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"])} + "estimatedBackupBytes": self.backup_size, "services": list(self.candidate["services"]), + "retiredServices": self.retired_services, "retainedDataPaths": self.matrix_backup_paths} write_json(self.job / "plan.json", plan) return plan @@ -374,6 +391,8 @@ class Updater: differences = [] for label, services in (("Bestehende Konfiguration", old), ("Zielkonfiguration", new)): missing, additional = required - set(services), set(services) - required + if services is old: + additional -= LEGACY_MATRIX_SERVICES details = [] if missing: details.append("fehlend: " + ", ".join(sorted(missing))) @@ -384,6 +403,9 @@ class Updater: if differences: raise UpdateError("Dieser Updater unterstützt den Standard-Selfhost-Stack mit lokalen Daten und MinIO. " + ". ".join(differences) + ". Zusätzliche Dienste benötigen Unterstützung für Erhalt, Backup und Wiederherstellung.") + self.retired_services = sorted(set(old) & LEGACY_MATRIX_SERVICES) + self.matrix_backup_paths = [] + self.validate_legacy_matrix() for name in required: if old[name].get("profiles") or new[name].get("profiles") or new[name].get("build"): raise UpdateError("Build-Kontexte und Compose-Profile werden nicht unterstützt.") @@ -437,7 +459,46 @@ class Updater: if value is None or (value == "" and key in ("DOMAIN", "POSTGRES_PASSWORD", "COOKIE_SECRET", "JWT_SECRET", "ENCRYPTION_KEY")): raise UpdateError(f"Fehlende Umgebungsvariable: {name}.{key}.") + def validate_legacy_matrix(self): + """Only retire the known, local legacy stack; never silently drop arbitrary services.""" + if not self.retired_services: + return + matrix_root = self.root / "matrix" + for name in self.retired_services: + service = self.current["services"][name] + if any(service.get(key) for key in ("profiles", "build", "volumes_from", "configs", "secrets")): + raise UpdateError(f"{name}: Abweichende Matrix-Konfiguration benötigt eine manuelle Stilllegung.") + volumes = service.get("volumes", []) + for volume in volumes: + source = Path(volume.get("source", "")) + if (volume.get("type") != "bind" or not source.is_relative_to(matrix_root) + or source.resolve() != source or not source.exists()): + raise UpdateError(f"{name}: Matrix-Daten müssen ohne symbolische Links unter {matrix_root} liegen.") + expected = {"matrix-db": ("postgres", "/var/lib/postgresql/data"), + "matrix-synapse": ("synapse", "/data")}.get(name) + if expected and (not (matrix_root / expected[0]).is_dir() or not any( + v.get("source") == str(matrix_root / expected[0]) and v.get("target") == expected[1] for v in volumes)): + raise UpdateError(f"{name}: Das lokale Standard-Matrix-Datenverzeichnis fehlt.") + if matrix_root.exists() or matrix_root.is_symlink(): + if matrix_root.is_symlink() or not matrix_root.is_dir(): + raise UpdateError("Das Matrix-Datenverzeichnis muss ein reguläres lokales Verzeichnis sein.") + # A cold PostgreSQL backup must include WAL and tablespaces, not external symlink targets. + if any(path.is_symlink() for path in matrix_root.rglob("*")): + raise UpdateError("Symbolische Links in Matrix-Daten benötigen eine manuelle Sicherung/Stilllegung.") + self.matrix_backup_paths = ["matrix"] + + def verify_matrix_stopped(self, *, retired=False): + for container_id in self.state.get("retiredContainerIds", []): + container = self.inspect(container_id) + state = container.get("State", {}) + if state.get("Running") or state.get("Restarting") or state.get("Paused"): + raise UpdateError("Ein alter Matrix-Container läuft noch; Stilllegung nicht abgeschlossen.") + if retired and container.get("HostConfig", {}).get("RestartPolicy", {}).get("Name") != "no": + raise UpdateError("Ein alter Matrix-Container könnte automatisch neu starten; Stilllegung nicht abgeschlossen.") + def backup(self): + self.verify_matrix_stopped() + self.validate_legacy_matrix() directory = self.job / "backup" directory.mkdir(mode=0o700) for name in (".env", "docker-compose.yml", ".env.example", "scripts/selfhost-setup.sh"): @@ -451,7 +512,7 @@ class Updater: self.compose(False, "exec", "-T", "db", "pg_restore", "--list", input_file=directory / "database.dump") self.compose(False, "exec", "-T", "db", "sh", "-ec", 'exec pg_dumpall -U "$POSTGRES_USER" --globals-only', output=directory / "globals.sql") - paths = ["minio"] + paths = ["minio", *self.matrix_backup_paths] if (self.root / "traefik/letsencrypt").is_dir(): paths.append("traefik/letsencrypt") self.runner.run(["tar", "-cpf", str(directory / "files.tar"), "-C", str(self.root), *paths], timeout=7200) @@ -465,7 +526,10 @@ class Updater: digest.update(chunk) os.fsync(stream.fileno()) checksums[str(path.relative_to(directory))] = digest.hexdigest() - write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar", "sha256": checksums}) + self.verify_matrix_stopped() + write_json(directory / "complete.json", {"createdAt": now(), "database": "database.dump", "files": "files.tar", + "retiredServices": self.retired_services, "retainedContainerIds": self.state["retiredContainerIds"], + "matrixDataPaths": self.matrix_backup_paths, "sha256": checksums}) self.save("backed_up", backup=str(directory)) def verify(self, version, revision=None): @@ -498,6 +562,13 @@ class Updater: raise UpdateError("Konfiguration wurde während des Backups verändert; erneut prüfen.") # From here recovery must be explicit: migrations or infrastructure can alter persisted data. self.save("applying", mutationStarted=True) + if self.retired_containers: + self.save("retiring_matrix") + self.verify_matrix_stopped() + # Keep containers, writable layers and volumes for recovery; prevent restart on host reboot. + for container in self.retired_containers: + self.runner.run(["docker", "update", "--restart=no", container["Id"]]) + self.verify_matrix_stopped(retired=True) atomic(self.root / "docker-compose.yml", (self.job / "target.compose.yml").read_bytes()) atomic(self.root / ".env", (self.job / "target.env").read_bytes()) atomic(self.root / ".env.example", (self.job / ".env.example").read_bytes()) @@ -511,6 +582,7 @@ class Updater: self.save("starting") self.compose(True, "up", "-d", "--wait", "--wait-timeout", "600", timeout=900) self.save("verifying") + self.verify_matrix_stopped(retired=True) for name, image in self.images.items(): container_id = self.compose(True, "ps", "--quiet", name).strip() container = self.inspect(container_id) @@ -596,10 +668,22 @@ class Updater: if not expected or container.get("Image") != expected: raise UpdateError(f"{name} entspricht weder dem gewählten Zielrelease noch dem gesicherten Originalstand.") if version == manifest["version"]: + self.verify_matrix_stopped(retired=True) self.verify(version, manifest["commit"]) write_json(self.home / "installed.json", {"manifest": manifest, "composeSha256": sha((self.job / "target.compose.yml").read_bytes()), "installedAt": now()}) else: + for original in read_json(self.job / "original-containers.json", []): + if original.get("Id") not in state.get("retiredContainerIds", []): + continue + name = original["Config"]["Labels"]["com.docker.compose.service"] + container_id = self.compose(False, "ps", "--all", "--quiet", name).strip() + restored = self.inspect(container_id) if container_id else {} + if (restored.get("Image") != original["Image"] + or restored.get("State", {}).get("Running") != original["State"]["Running"] + or (original["State"]["Running"] and restored.get("State", {}).get("Health", {}).get("Status") not in (None, "healthy")) + or restored.get("HostConfig", {}).get("RestartPolicy") != original["HostConfig"]["RestartPolicy"]): + raise UpdateError("Die ursprünglichen Matrix-Container und Neustartrichtlinien müssen zuerst wiederhergestellt werden.") previous = read_json(self.job / "original-installed.json", {}) if previous: write_json(self.home / "installed.json", previous) diff --git a/scripts/update/tests/test_updater.py b/scripts/update/tests/test_updater.py index 68cceda6..22edaced 100644 --- a/scripts/update/tests/test_updater.py +++ b/scripts/update/tests/test_updater.py @@ -6,6 +6,7 @@ import os import io from pathlib import Path import subprocess +import tarfile import tempfile import unittest from unittest.mock import patch @@ -35,6 +36,7 @@ class Http: self.bad_health = False self.release = {'id': 1, 'tag_name': 'v3.1.0', 'draft': False, 'prerelease': False} self.fail_download = False + self.reported_version = '3.1.0' def refresh(self): self.files['SHA256SUMS'] = ''.join(hashlib.sha256(blob).hexdigest() + ' ' + name + '\n' @@ -59,7 +61,7 @@ class Http: if url.endswith('/tags/v3.1.0'): return {'commit': {'sha': 'a' * 40}} if url.endswith('/backend/version'): - return {'product': 'FEDEO', 'version': '3.1.0'} + return {'product': 'FEDEO', 'version': self.reported_version} if url.endswith('/health'): return {'status': 'ok', 'product': 'FEDEO', 'version': '3.0.0' if self.bad_health else '3.1.0', 'revision': 'a' * 40} raise AssertionError(url) @@ -74,6 +76,9 @@ class Runner: self.fail = None self.on_pull = None self.interrupt = None + self.matrix_containers = {} + self.on_stop = None + self.real_tar = False def run(self, args, output=None, input_file=None, **kwargs): self.calls.append(args) @@ -91,8 +96,13 @@ class Runner: return 'image-' + args[3].rsplit('/', 1)[-1].split('@')[0] if args[:2] == ['docker', 'inspect']: name = args[2] + if name in self.matrix_containers: + return json.dumps([self.matrix_containers[name]]) return json.dumps([{'Image': 'image-' + name, 'Config': {'Labels': {'com.docker.compose.service': name}}, 'State': {'Running': name != 'createbuckets', 'Health': {'Status': 'healthy'}}}]) + if args[:3] == ['docker', 'update', '--restart=no']: + self.matrix_containers[args[3]]['HostConfig']['RestartPolicy'] = {'Name': 'no', 'MaximumRetryCount': 0} + return args[3] if 'config' in args: result = subprocess.run(args, env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']}, capture_output=True, text=True) @@ -102,15 +112,25 @@ class Runner: if 'ps' in args: if args[:2] == ['docker', 'ps']: return '' - if args[-1] in ('backend', 'frontend', 'db', 'minio'): + if args[-1] in ('backend', 'frontend', 'db', 'minio') or args[-1] in self.matrix_containers: return args[-1] - return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' + return 'backend\nfrontend\ndb\nminio\ntraefik\nnode-exporter\ncreatebuckets\n' + '\n'.join(self.matrix_containers) + if 'stop' in args or 'start' in args: + for name, container in self.matrix_containers.items(): + if name in args: + container['State']['Running'] = 'start' in args + if 'stop' in args and self.on_stop: + self.on_stop() if 'pull' in args and self.on_pull: self.on_pull() if any('pg_database_size' in part for part in args): return '1000' if args[0] == 'du': return '1000\tminio' + if args[0] == 'tar' and self.real_tar: + with open(output, 'wb') if output else open(os.devnull, 'wb') as destination: + subprocess.run(args, stdout=destination, check=True) + return '' if output: Path(output).write_bytes(b'PGDMP fake dump') if args[0] == 'tar' and '-cpf' in args: @@ -153,6 +173,196 @@ class UpdateTests(unittest.TestCase): self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose) self.assertFalse(self.called('stop')) + def add_legacy_matrix(self): + services = {} + for name in sorted(u.LEGACY_MATRIX_SERVICES): + services[name] = {'image': 'example.test/' + name + ':legacy', 'restart': 'unless-stopped', + 'container_name': 'fedeo-' + name, 'networks': ['internal']} + self.runner.matrix_containers[name] = { + 'Id': name, 'Image': 'image-' + name, + 'Config': {'Labels': {'com.docker.compose.service': name}}, 'Mounts': [], + 'HostConfig': {'RestartPolicy': {'Name': 'unless-stopped', 'MaximumRetryCount': 0}}, + 'State': {'Running': True, 'Health': {'Status': 'healthy'}}} + for name, directory, target in [('matrix-db', 'postgres', '/var/lib/postgresql/data'), + ('matrix-synapse', 'synapse', '/data')]: + source = self.root / 'matrix' / directory + source.mkdir(parents=True) + (source / 'data').write_text('preserved ' + name) + services[name]['volumes'] = [{'type': 'bind', 'source': str(source), 'target': target}] + self.runner.matrix_containers[name]['Mounts'] = [{'Type': 'bind', 'Source': str(source), 'Destination': target}] + # Use the real Compose parser to model an older base file, including the removed dependency. + config = json.loads(subprocess.check_output(['docker', 'compose', '--project-directory', str(self.root), + '--env-file', str(self.root / '.env'), '-f', str(self.root / 'docker-compose.yml'), + 'config', '--format', 'json'], env={'PATH': os.environ['PATH'], 'HOME': os.environ['HOME']})) + config['services'].update(services) + config['services']['backend']['depends_on']['matrix-synapse'] = {'condition': 'service_healthy'} + (self.root / 'docker-compose.yml').write_bytes(data(config)) + self.original_compose = (self.root / 'docker-compose.yml').read_bytes() + + def test_check_plans_matrix_retirement_without_stopping_or_disabling_it(self): + self.add_legacy_matrix() + result = self.updater.execute('check', '3.1.0') + self.assertEqual(set(result['plan']['retiredServices']), u.LEGACY_MATRIX_SERVICES) + self.assertEqual(result['plan']['retainedDataPaths'], ['matrix']) + self.assertEqual(result['plan']['estimatedBackupBytes'], 4000 + u.GIB) + self.assertFalse(self.called('--restart=no')) + self.assert_untouched() + + def test_matrix_is_backed_up_then_retired_without_removing_containers_or_data(self): + self.add_legacy_matrix() + # An image-created Redis volume must remain attached to the retained container. + self.runner.matrix_containers['matrix-redis']['Mounts'] = [ + {'Type': 'volume', 'Source': '/var/lib/docker/volumes/redis/_data', 'Destination': '/data', 'Name': 'redis'}] + result = self.run_update() + self.assertEqual(result['status'], 'succeeded') + self.assertNotIn(b'matrix-', (self.root / 'docker-compose.yml').read_bytes()) + self.assertTrue((self.root / 'matrix/postgres/data').exists()) + self.assertTrue((self.root / 'matrix/synapse/data').exists()) + backup = Path(result['backup']) + self.assertEqual((backup / 'config/docker-compose.yml').read_bytes(), self.original_compose) + metadata = u.read_json(backup / 'complete.json') + self.assertEqual(metadata['matrixDataPaths'], ['matrix']) + self.assertEqual(set(metadata['retainedContainerIds']), u.LEGACY_MATRIX_SERVICES) + original = u.read_json(backup / 'containers.json') + self.assertEqual(next(c for c in original if c.get('Id') == 'matrix-db')['HostConfig']['RestartPolicy']['Name'], 'unless-stopped') + for container in self.runner.matrix_containers.values(): + self.assertFalse(container['State']['Running']) + self.assertEqual(container['HostConfig']['RestartPolicy']['Name'], 'no') + calls = self.runner.calls + archive = next(i for i, a in enumerate(calls) if a[0] == 'tar' and '-cpf' in a) + retire = next(i for i, a in enumerate(calls) if '--restart=no' in a) + self.assertIn('matrix', calls[archive]) + self.assertLess(archive, retire) + self.assertFalse(any('--remove-orphans' in a or a[:2] in (['docker', 'rm'], ['docker', 'volume']) for a in calls)) + # Future updates see stopped Matrix orphans; they do not bring them back. + (self.root / '.env').write_bytes(self.original_env) + self.assertEqual(self.run_update()['status'], 'succeeded') + self.assertFalse(any(c['State']['Running'] for c in self.runner.matrix_containers.values())) + + def test_matrix_archive_failure_preserves_base_and_restarts_original_stack(self): + self.add_legacy_matrix() + self.runner.fail = lambda a: a[0] == 'tar' and '-cpf' in a + with self.assertRaises(u.UpdateError): + self.run_update() + self.assertFalse(self.called('--restart=no')) + self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose) + self.assertFalse(self.updater.status()['recoveryRequired']) + self.assertTrue(all(c['State']['Running'] for c in self.runner.matrix_containers.values())) + + def test_real_matrix_archive_contains_database_media_and_configuration(self): + self.add_legacy_matrix() + self.runner.real_tar = True + (self.root / 'matrix/livekit.yaml').write_text('preserved config') + (self.root / 'matrix/postgres/pg_wal').mkdir() + (self.root / 'matrix/postgres/pg_wal/segment').write_bytes(b'preserved WAL') + expected = {str(path.relative_to(self.root)): path.read_bytes() + for path in (self.root / 'matrix').rglob('*') if path.is_file()} + result = self.run_update() + backup = Path(result['backup']) + with tarfile.open(backup / 'files.tar') as archive: + for name, contents in expected.items(): + self.assertEqual(archive.extractfile(name).read(), contents) + self.assertEqual(u.sha((backup / 'files.tar').read_bytes()), u.read_json(backup / 'complete.json')['sha256']['files.tar']) + + def test_matrix_must_be_stopped_before_a_cold_backup(self): + self.add_legacy_matrix() + self.runner.on_stop = lambda: self.runner.matrix_containers['matrix-db']['State'].update(Running=True) + with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container läuft noch'): + self.run_update() + self.assertFalse(self.called('-cpf')) + self.assertFalse(self.called('--restart=no')) + + def test_interrupted_retirement_requires_recovery_and_preserves_backup(self): + self.add_legacy_matrix() + self.runner.interrupt = lambda a: '--restart=no' in a and a[-1] == 'matrix-element' + with self.assertRaises(KeyboardInterrupt): + self.run_update() + state = self.updater.status() + self.assertEqual(state['phase'], 'retiring_matrix') + self.assertTrue(state['recoveryRequired']) + self.assertTrue((Path(state['backup']) / 'complete.json').exists()) + self.assertEqual((self.root / 'docker-compose.yml').read_bytes(), self.original_compose) + self.assertFalse(any(c['State']['Running'] for c in self.runner.matrix_containers.values())) + + def test_matrix_symlinks_and_nonstandard_mounts_are_rejected_before_stopping(self): + self.add_legacy_matrix() + link = self.root / 'matrix/postgres/pg_wal' + link.symlink_to(self.root / 'postgres', target_is_directory=True) + with self.assertRaisesRegex(u.UpdateError, 'Symbolische Links'): + self.run_update() + self.assert_untouched() + link.unlink() + self.runner.matrix_containers['matrix-db']['Mounts'][0]['Source'] = '/different/postgres' + with self.assertRaisesRegex(u.UpdateError, 'Tatsächliche Matrix-Datenpfade'): + self.run_update() + self.assert_untouched() + + def test_unknown_additional_services_are_still_rejected(self): + self.add_legacy_matrix() + config = json.loads(self.original_compose) + config['services']['matrix-custom'] = {'image': 'example.test/custom:latest'} + (self.root / 'docker-compose.yml').write_bytes(data(config)) + with self.assertRaisesRegex(u.UpdateError, 'matrix-custom'): + self.run_update() + self.assertFalse(self.called('stop')) + + def test_matrix_cannot_be_reintroduced_by_an_override(self): + self.add_legacy_matrix() + (self.root / 'docker-compose.override.yml').write_text('services:\n matrix-element:\n image: example.test/element:legacy\n') + with self.assertRaisesRegex(u.UpdateError, 'Zielkonfiguration.*matrix-element'): + self.run_update() + self.assert_untouched() + + def test_nonstandard_matrix_bind_and_named_volume_are_rejected(self): + self.add_legacy_matrix() + for volume in [{'type': 'bind', 'source': str(self.root / 'postgres'), 'target': '/data'}, + {'type': 'volume', 'source': 'matrixdata', 'target': '/data'}]: + with self.subTest(volume=volume['type']): + config = json.loads(self.original_compose) + config['services']['matrix-synapse']['volumes'] = [volume] + config['volumes'] = {'matrixdata': {}} + (self.root / 'docker-compose.yml').write_bytes(data(config)) + with self.assertRaisesRegex(u.UpdateError, 'Matrix-Daten müssen'): + self.run_update() + self.assertFalse(self.called('stop')) + + def test_resolve_target_requires_matrix_to_remain_retired(self): + self.add_legacy_matrix() + self.runner.fail = lambda a: 'migrate' in a + with self.assertRaises(u.UpdateError): + self.run_update() + self.runner.fail = None + self.runner.matrix_containers['matrix-db']['HostConfig']['RestartPolicy']['Name'] = 'always' + with self.assertRaisesRegex(u.UpdateError, 'automatisch neu starten'): + self.updater.resolve('3.1.0') + self.assertTrue(self.updater.status()['recoveryRequired']) + self.runner.matrix_containers['matrix-db']['HostConfig']['RestartPolicy']['Name'] = 'no' + self.assertEqual(self.updater.resolve('3.1.0')['status'], 'recovered') + + def test_resolve_rollback_requires_original_matrix_images_state_and_restart_policy(self): + self.add_legacy_matrix() + originals = copy.deepcopy(self.runner.matrix_containers) + self.runner.fail = lambda a: 'migrate' in a + with self.assertRaises(u.UpdateError): + self.run_update() + self.runner.fail = None + self.http.reported_version = '3.0.0' + (self.root / 'docker-compose.yml').write_bytes(self.original_compose) + (self.root / '.env').write_bytes(self.original_env) + with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container und Neustartrichtlinien'): + self.updater.resolve('3.0.0') + self.assertTrue(self.updater.status()['recoveryRequired']) + self.runner.matrix_containers = copy.deepcopy(originals) + for key, value in [('Image', 'wrong-image'), ('HostConfig', {'RestartPolicy': {'Name': 'no'}}), + ('State', {'Running': True, 'Health': {'Status': 'unhealthy'}})]: + with self.subTest(key=key): + self.runner.matrix_containers['matrix-db'][key] = value + with self.assertRaisesRegex(u.UpdateError, 'Matrix-Container und Neustartrichtlinien'): + self.updater.resolve('3.0.0') + self.runner.matrix_containers = copy.deepcopy(originals) + self.assertEqual(self.updater.resolve('3.0.0')['status'], 'recovered') + self.assertFalse(self.updater.status()['recoveryRequired']) + def test_success_pins_images_preserves_configuration_and_checks_both_public_services(self): result = self.run_update() self.assertEqual(result['status'], 'succeeded') -- 2.49.1 From cbec5b058d5ec78237b74b3eb536093e9b5da6d9 Mon Sep 17 00:00:00 2001 From: flfeders Date: Wed, 23 Sep 2026 18:08:09 +0200 Subject: [PATCH 3/4] feat(banking): add cost centres and VAT to account allocations --- .../0071_statement_allocation_costcentre.sql | 2 + backend/db/migrations/meta/_journal.json | 7 ++ backend/db/schema/statementallocations.ts | 2 + backend/src/mcp/statementAllocations.ts | 6 ++ backend/src/mcp/tools/accounting.ts | 5 +- .../src/modules/bank-allocation-details.ts | 30 +++++++ backend/src/routes/banking.ts | 4 + backend/src/utils/export/datev.ts | 43 +++++++--- backend/tests/bankAllocationDetails.test.ts | 69 ++++++++++++++++ backend/tests/datevExport.test.ts | 78 ++++++++++++++++++- docs-site/content/bedienung/bankportal.md | 12 +++ docs/bedienung/bankportal.md | 12 +++ frontend/components/costcentreDisplay.vue | 46 ++++++++++- frontend/components/displayBWASummary.vue | 9 ++- frontend/composables/useDepreciation.ts | 12 +-- frontend/pages/accounting/bwa.vue | 16 ++-- frontend/pages/accounting/depreciation.vue | 5 +- frontend/pages/accounting/tax.vue | 34 ++++++-- .../banking/statements/[mode]/[[id]].vue | 61 ++++++++++++++- frontend/tests/bank-allocation-tax.test.cjs | 63 +++++++++++++++ frontend/utils/bankAllocationTax.ts | 43 ++++++++++ 21 files changed, 517 insertions(+), 42 deletions(-) create mode 100644 backend/db/migrations/0071_statement_allocation_costcentre.sql create mode 100644 backend/src/modules/bank-allocation-details.ts create mode 100644 backend/tests/bankAllocationDetails.test.ts create mode 100644 frontend/tests/bank-allocation-tax.test.cjs create mode 100644 frontend/utils/bankAllocationTax.ts diff --git a/backend/db/migrations/0071_statement_allocation_costcentre.sql b/backend/db/migrations/0071_statement_allocation_costcentre.sql new file mode 100644 index 00000000..8ac5f23e --- /dev/null +++ b/backend/db/migrations/0071_statement_allocation_costcentre.sql @@ -0,0 +1,2 @@ +ALTER TABLE "statementallocations" + ADD COLUMN "costcentre" uuid REFERENCES "costcentres"("id"); diff --git a/backend/db/migrations/meta/_journal.json b/backend/db/migrations/meta/_journal.json index 9ba06fde..ab6d3bcd 100644 --- a/backend/db/migrations/meta/_journal.json +++ b/backend/db/migrations/meta/_journal.json @@ -477,6 +477,13 @@ "when": 1788850800000, "tag": "0070_native_communication_chat", "breakpoints": true + }, + { + "idx": 68, + "version": "7", + "when": 1790121600000, + "tag": "0071_statement_allocation_costcentre", + "breakpoints": true } ] } diff --git a/backend/db/schema/statementallocations.ts b/backend/db/schema/statementallocations.ts index 8f674b44..3d6032d6 100644 --- a/backend/db/schema/statementallocations.ts +++ b/backend/db/schema/statementallocations.ts @@ -18,6 +18,7 @@ import { incominginvoices } from "./incominginvoices" import { createddocuments } from "./createddocuments" import { bankstatements } from "./bankstatements" import { accounts } from "./accounts" // Falls noch nicht erstellt → bitte melden! +import { costcentres } from "./costcentres" export const statementallocations = pgTable("statementallocations", { id: uuid("id").primaryKey().defaultRandom(), @@ -58,6 +59,7 @@ export const statementallocations = pgTable("statementallocations", { manualBookingDate: text("manual_booking_date"), datevTaxKey: text("datev_tax_key"), + costcentre: uuid("costcentre").references(() => costcentres.id), bookingMode: text("booking_mode").notNull().default("expense"), depreciationMonths: integer("depreciation_months"), diff --git a/backend/src/mcp/statementAllocations.ts b/backend/src/mcp/statementAllocations.ts index c1c33e65..f5e26974 100644 --- a/backend/src/mcp/statementAllocations.ts +++ b/backend/src/mcp/statementAllocations.ts @@ -1,3 +1,5 @@ +import { normalizeBankAllocationDetails } from "../modules/bank-allocation-details" + const hasValue = (value: unknown) => value !== null && value !== undefined && value !== "" const stringArg = (args: Record, key: string) => { @@ -110,6 +112,10 @@ export const prepareStatementAllocationInput = (args: Record) = if (hasInvoice) payload.manualInvoiceSide = manualInvoiceSide } + const details = normalizeBankAllocationDetails({ ...payload, costcentre: args.costcentre }) + if (args.costcentre !== undefined) payload.costcentre = details.costcentre + if (args.datevTaxKey !== undefined) payload.datevTaxKey = details.datevTaxKey + if (bookingMode === "expense") return payload const depreciationMonths = requiredNumber(args, "depreciationMonths") diff --git a/backend/src/mcp/tools/accounting.ts b/backend/src/mcp/tools/accounting.ts index 310aa7ae..06521ead 100644 --- a/backend/src/mcp/tools/accounting.ts +++ b/backend/src/mcp/tools/accounting.ts @@ -2,6 +2,7 @@ import { outgoingAddressSchema, outgoingRowsSchema, outgoingReportSchema } from import { limitFromArgs, offsetFromArgs, paginatedResult, paginationProperties } from "../pagination" import { and, desc, eq, ilike, or } from "drizzle-orm" import { randomUUID } from "node:crypto" +import { validateBankAllocationCostCentre } from "../../modules/bank-allocation-details" import { accounts, bankstatements, @@ -406,6 +407,7 @@ const assertTenantEntityExists = async ( const assertStatementAllocationReferences = async (context: any, payload: Record) => { await Promise.all([ + validateBankAllocationCostCentre(context.server.db, context.tenantId, payload.costcentre), assertTenantEntityExists(context, bankstatements, bankstatements.id, bankstatements.tenant, payload.bankstatement, "Bankumsatz"), assertTenantEntityExists(context, createddocuments, createddocuments.id, createddocuments.tenant, payload.createddocument, "Ausgangsbeleg"), assertTenantEntityExists(context, incominginvoices, incominginvoices.id, incominginvoices.tenant, payload.incominginvoice, "Eingangsbeleg"), @@ -1419,7 +1421,8 @@ export const accountingTools: McpTool[] = [ manualBookingDate: { type: "string", format: "date", description: "Buchungsdatum einer manuellen Buchung." }, manualInvoiceSide: { type: "string", enum: ["debit", "credit"], description: "Soll/Haben-Seite eines Eingangsbelegs in einer manuellen Buchung." }, description: { type: "string" }, - datevTaxKey: { type: "string", description: "Optionaler DATEV-Steuerschlüssel, z. B. 9, 8, 19 oder 18." }, + datevTaxKey: { type: "string", description: "DATEV-Steuerschlüssel auf den Bruttobetrag: 9/8 für 19/7 % Vorsteuer, 3/2 für 19/7 % Umsatzsteuer, 40 ohne Steuer (Automatik aufheben)." }, + costcentre: { type: ["string", "null"], description: "Kostenstellen-UUID für direkte Bankbuchungen auf Sachkonten oder zusätzliche Konten." }, bookingMode: { type: "string", enum: ["expense", "depreciation_single", "depreciation_bundle"], default: "expense" }, depreciationMonths: { type: "number", minimum: 1 }, depreciationStartDate: { type: "string", format: "date" }, diff --git a/backend/src/modules/bank-allocation-details.ts b/backend/src/modules/bank-allocation-details.ts new file mode 100644 index 00000000..cec07586 --- /dev/null +++ b/backend/src/modules/bank-allocation-details.ts @@ -0,0 +1,30 @@ +import { and, eq } from "drizzle-orm" +import { costcentres } from "../../db/schema" + +const invalidInput = (message: string) => Object.assign(new Error(message), { statusCode: 400 }) + +export function normalizeBankAllocationDetails(payload: Record) { + const costcentre = payload.costcentre ? String(payload.costcentre).trim() : null + const datevTaxKey = payload.datevTaxKey ? String(payload.datevTaxKey).trim() : null + if (costcentre && !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(costcentre)) { + throw invalidInput("Die Kostenstelle muss eine gültige UUID sein.") + } + if (datevTaxKey && !/^\d{1,4}$/.test(datevTaxKey)) { + throw invalidInput("Der DATEV-Steuerschlüssel muss aus 1 bis 4 Ziffern bestehen.") + } + if (costcentre && (!payload.bankstatement || !(payload.account || payload.ownaccount) + || payload.createddocument || payload.incominginvoice || payload.customer || payload.vendor)) { + throw invalidInput("Kostenstellen sind hier nur für direkte Bankbuchungen auf Buchungskonten zulässig.") + } + return { costcentre, datevTaxKey } +} + +export async function validateBankAllocationCostCentre(db: any, tenantId: number, costcentre: string | null) { + if (!costcentre) return + const [record] = await db.select({ id: costcentres.id }).from(costcentres).where(and( + eq(costcentres.id, costcentre), + eq(costcentres.tenant, tenantId), + eq(costcentres.archived, false), + )).limit(1) + if (!record) throw invalidInput("Die Kostenstelle wurde nicht gefunden oder ist archiviert.") +} diff --git a/backend/src/routes/banking.ts b/backend/src/routes/banking.ts index d80da564..92349ebd 100644 --- a/backend/src/routes/banking.ts +++ b/backend/src/routes/banking.ts @@ -9,6 +9,7 @@ import { DE_BANK_CODE_TO_NAME } from "../utils/deBankCodes" import { DE_BANK_CODE_TO_BIC } from "../utils/deBankBics" import { centralServicesClient } from "../modules/push-server.client" import { findBankInstitutionByBic } from "../modules/banking-institution" +import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../modules/bank-allocation-details" import { bankrequisitions, @@ -1166,6 +1167,8 @@ export default async function bankingRoutes(server: FastifyInstance) { const { data: payload } = req.body as { data: any } const prepared = prepareStatementAllocationPayload(payload) if (prepared.error) return reply.code(400).send({ error: prepared.error }) + Object.assign(prepared.data, normalizeBankAllocationDetails(prepared.data)) + await validateBankAllocationCostCentre(server.db, req.user.tenant_id, prepared.data.costcentre) prepared.data = await useCurrentIncomingInvoiceAmount(req.user.tenant_id, prepared.data) const inserted = await server.db.insert(statementallocations).values({ @@ -1217,6 +1220,7 @@ export default async function bankingRoutes(server: FastifyInstance) { return reply.send(createdRecord) } catch (err) { + if (err.statusCode === 400) return reply.code(400).send({ error: err.message }) console.error(err) return reply.code(500).send({ error: "Failed to create statement" }) } diff --git a/backend/src/utils/export/datev.ts b/backend/src/utils/export/datev.ts index 1182d242..29f09cc9 100644 --- a/backend/src/utils/export/datev.ts +++ b/backend/src/utils/export/datev.ts @@ -23,7 +23,8 @@ import { vendors, bankaccounts, bankstatements, - ownaccounts + ownaccounts, + costcentres } from "../../../db/schema"; dayjs.extend(isBetween); @@ -80,6 +81,29 @@ const formatDatevDate = (date: dayjs.ConfigType, format: string) => { return parsed.isValid() ? parsed.tz(DATEV_TIMEZONE).format(format) : ""; }; +export const getBankAccountBookingLine = (alloc: any) => { + const bs = alloc.bankstatement; + const account = alloc.account || alloc.ownaccount; + const columns = Array(124).fill(""); + const quote = (value: any) => `"${escapeString(String(value ?? ""))}"`; + columns[0] = displayCurrency(Number(alloc.amount), true); + columns[1] = quote(Number(alloc.amount) < 0 ? "H" : "S"); + columns[6] = bs.account?.datevNumber || ""; + columns[7] = account.number; + columns[8] = quote(alloc.datevTaxKey || ""); + columns[9] = formatDatevDate(bs.date, "DDMM"); + columns[13] = quote(`${Number(alloc.amount) > 0 ? "ZE" : "ZA"} ${account.number} - ${account.label || account.name || ""} ${alloc.description || ""} ${bs.text || ""}`.substring(0, 59)); + columns[20] = quote("Geschäftspartner"); + columns[21] = quote(bs.debName || bs.credName || ""); + columns[28] = quote("Belegdatum"); + columns[29] = quote(formatDatevDate(bs.date, "DD.MM.YYYY")); + columns[36] = quote(alloc.costcentre?.number || ""); + columns[47] = "Bank-Id"; + columns[48] = bs.id; + columns[112] = "0"; + return columns.join(";"); +}; + const getIncomingInvoiceTaxOverride = (invoice: any) => { if (invoice.taxAmountOverride === null || invoice.taxAmountOverride === undefined || invoice.taxAmountOverride === "") return null; @@ -136,7 +160,7 @@ export async function buildExportZip( const endDateFmt = formatDatevDate(endDate, "YYYYMMDD"); let header = `"EXTF";700;21;"Buchungsstapel";13;${dateNowStr};;"FE";"Florian Federspiel";;${beraternr};${mandantennr};20250101;4;${startDateFmt};${endDateFmt};"Buchungsstapel";"FF";1;0;1;"EUR";;"";;;"03";;;"";""`; - let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;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. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;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;Stueck;Gewicht;Zahlweise;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`; + let colHeaders = `Umsatz;Soll-/Haben-Kennzeichen;WKZ Umsatz;Kurs;Basisumsatz;WKZ Basisumsatz;Konto;Gegenkonto;BU-Schluessel;Belegdatum;Belegfeld 1;Belegfeld 2;Skonto;Buchungstext;Postensperre;Diverse Adressnummer;Geschaeftspartnerbank;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. (Bestimmung);EU-Steuersatz (Bestimmung);Abw. Versteuerungsart;Sachverhalt L+L;Funktionsergaenzung L+L;BU 49 Hauptfunktionstyp;BU 49 Hauptfunktionsnummer;BU 49 Funktionsergaenzung;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;Stueck;Gewicht;Zahlweise;Veranlagungsjahr;Zugeordnete Faelligkeit;Skontotyp;Auftragsnummer;Buchungstyp;USt-Schluessel (Anzahlungen);EU-Mitgliedstaat (Anzahlungen);Sachverhalt L+L (Anzahlungen);EU-Steuersatz (Anzahlungen);Erloeskonto (Anzahlungen);Herkunft-Kz;Leerfeld;KOST-Datum;SEPA-Mandatsreferenz;Skontosperre;Gesellschaftername;Beteiligtennummer;Identifikationsnummer;Zeichnernummer;Postensperre bis;Bezeichnung SoBil-Sachverhalt;Kennzeichen SoBil-Buchung;Festschreibung;Leistungsdatum;Datum Zuord. Steuerperiode;Faelligkeit;Generalumkehr;Steuersatz;Land;Abrechnungsreferenz;BVV-Position;EU-Mitgliedstaat u. UStID(Ursprung);EU-Steuersatz(Ursprung);Abw. Skontokonto`; // --------------------------------------------------------- // 1. DATEN LADEN (CORE API SELECT & JOIN) @@ -209,6 +233,7 @@ export async function buildExportZip( direct_vend: vendors, // Direkte Zuordnung an Kreditor direct_cust: customers, // Direkte Zuordnung an Debitor own: ownaccounts, + costcentre: costcentres, contra_acc: ContraAccount, contra_vend: ContraVendor, contra_cust: ContraCustomer, @@ -233,6 +258,7 @@ export async function buildExportZip( .leftJoin(vendors, eq(statementallocations.vendor, vendors.id)) .leftJoin(customers, eq(statementallocations.customer, customers.id)) .leftJoin(ownaccounts, eq(statementallocations.ownaccount, ownaccounts.id)) + .leftJoin(costcentres, and(eq(statementallocations.costcentre, costcentres.id), eq(costcentres.tenant, tenantId))) .leftJoin(ContraAccount, eq(statementallocations.contraAccount, ContraAccount.id)) .leftJoin(ContraVendor, eq(statementallocations.contraVendor, ContraVendor.id)) .leftJoin(ContraCustomer, eq(statementallocations.contraCustomer, ContraCustomer.id)) @@ -257,10 +283,10 @@ export async function buildExportZip( // Mapping: Wir bauen das komplexe Objekt nach, das die CSV Logik erwartet const statementallocationsList = allocRaw.map(r => ({ ...r.allocation, - bankstatement: { + bankstatement: r.bs ? { ...r.bs, account: r.ba // Nesting für bs.account.datevNumber - }, + } : null, createddocument: r.cd ? { ...r.cd, customer: r.cd_cust @@ -273,6 +299,7 @@ export async function buildExportZip( vendor: r.direct_vend, customer: r.direct_cust, ownaccount: r.own, + costcentre: r.costcentre, contraAccount: r.contra_acc, contraVendor: r.contra_vend, contraCustomer: r.contra_cust, @@ -453,9 +480,7 @@ export async function buildExportZip( const vend = ii.vendor; bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${vend?.vendorNumber};"";${formatDatevDate(ii.date, "DDMM")};"${ii.reference}";;;"${`ZA${alloc.description} ${bsText} `.substring(0,59)}";;;;;;;"Geschäftspartner";"${vend?.name}";"Kundennummer";"${vend?.vendorNumber}";"Belegnummer";"${ii.reference}";"Leistungsdatum";"${formatDatevDate(ii.date, "DD.MM.YYYY")}";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); } else if(alloc.account) { - const acc = alloc.account; - let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; - bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${acc.number};"";${dateVal};"";;;"${`${vorzeichen} ${acc.number} - ${escapeString(acc.label)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${bs.credName || ''}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); + bookingLines.push(getBankAccountBookingLine(alloc)); } else if(alloc.vendor) { const vend = alloc.vendor; let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; @@ -465,9 +490,7 @@ export async function buildExportZip( let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${cust.customerNumber};"";${dateVal};"";;;"${`${vorzeichen} ${cust.customerNumber} - ${escapeString(cust.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${cust.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); } else if(alloc.ownaccount) { - const own = alloc.ownaccount; - let vorzeichen = Math.sign(alloc.amount) > 0 ? "ZE" : "ZA"; - bookingLines.push(`${displayCurrency(alloc.amount,true)};"${shSelector}";;;;;${datevKonto};${own.number};"";${dateVal};"";;;"${`${vorzeichen} ${own.number} - ${escapeString(own.name)}${escapeString(alloc.description)}${bsText}`.substring(0,59)}";;;;;;;"Geschäftspartner";"${own.name}";"Kundennummer";"";"Belegnummer";"";"Leistungsdatum";"";"Belegdatum";"${dateFull}";;;;;;;;;;"";;;;;;;;Bank-Id;${alloc.bankstatement.id};;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;0;;;;"";;;;;;`); + bookingLines.push(getBankAccountBookingLine(alloc)); } }); diff --git a/backend/tests/bankAllocationDetails.test.ts b/backend/tests/bankAllocationDetails.test.ts new file mode 100644 index 00000000..07609d39 --- /dev/null +++ b/backend/tests/bankAllocationDetails.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { PgDialect } from "drizzle-orm/pg-core" +import { normalizeBankAllocationDetails, validateBankAllocationCostCentre } from "../src/modules/bank-allocation-details" +import { prepareStatementAllocationInput } from "../src/mcp/statementAllocations" +import bankingRoutes from "../src/routes/banking" +import { statementallocations } from "../db/schema" + +const costcentre = "57d31d62-11a2-47c5-b074-69f9c5ab8bba" + +test("keeps cost centre and VAT key on the gross bank allocation through input preparation", () => { + const prepared = prepareStatementAllocationInput({ bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: " 9 " }) + assert.equal(prepared.amount, -119) + assert.equal(prepared.costcentre, costcentre) + assert.equal(prepared.datevTaxKey, "9") + assert.deepEqual(normalizeBankAllocationDetails({ amount: -119, account: 13, bankstatement: 42 }), { costcentre: null, datevTaxKey: null }) +}) + +test("rejects invalid cost centres, ambiguous document assignments and malformed tax keys", () => { + assert.throws(() => normalizeBankAllocationDetails({ costcentre: "invalid" }), /UUID/) + assert.throws(() => normalizeBankAllocationDetails({ costcentre, bankstatement: 1, account: 13, incominginvoice: 2 }), /direkte Bankbuchungen/) + assert.throws(() => normalizeBankAllocationDetails({ datevTaxKey: '9";evil' }), /Steuerschlüssel/) +}) + +test("cost centre lookup requires the active tenant and excludes archived centres", async () => { + let condition: any + const query = { from: () => query, where: (value: any) => { condition = value; return query }, limit: async () => [] } + await assert.rejects(validateBankAllocationCostCentre({ select: () => query }, 7, costcentre), /nicht gefunden oder ist archiviert/) + const sql = new PgDialect().sqlToQuery(condition) + assert.match(sql.sql, /"costcentres"\."tenant"/) + assert.match(sql.sql, /"costcentres"\."archived"/) + assert.deepEqual(sql.params, [costcentre, 7, false]) + await validateBankAllocationCostCentre({ select: () => ({ ...query, from: () => ({ where: () => ({ limit: async () => [{ id: costcentre }] }) }) }) }, 7, costcentre) + await validateBankAllocationCostCentre({}, 7, null) +}) + +test("banking API persists the gross allocation with cost centre and VAT, and rejects unknown centres before writing", async () => { + const handlers = new Map() + const saved: any[] = [] + let centreExists = true + const lookup: any = { from: () => lookup, where: () => lookup, limit: async () => centreExists ? [{ id: costcentre }] : [] } + const server: any = { + get() {}, patch() {}, delete() {}, + post: (url: string, handler: any) => handlers.set(url, handler), + db: { + select: () => lookup, + insert: (table: any) => ({ values: (value: any) => { + if (table === statementallocations) saved.push(value) + return { returning: async () => [{ id: "allocation", ...value }] } + } }), + }, + } + await bankingRoutes(server) + const handler = handlers.get("/banking/statements") + const request = { user: { tenant_id: 7, user_id: "user" }, body: { data: { bankstatement: 42, account: 13, amount: -119, costcentre, datevTaxKey: "9" } } } + let status = 200 + let response: any + const reply = { code: (code: number) => { status = code; return reply }, send: (body: any) => { response = body; return reply } } + await handler(request, reply) + assert.equal(status, 200) + assert.equal(response.amount, -119) + assert.equal(response.costcentre, costcentre) + assert.equal(response.datevTaxKey, "9") + assert.equal(saved[0].tenant, 7) + centreExists = false + await handler(request, reply) + assert.equal(status, 400) + assert.equal(saved.length, 1) +}) diff --git a/backend/tests/datevExport.test.ts b/backend/tests/datevExport.test.ts index 6ee76448..e2dfea79 100644 --- a/backend/tests/datevExport.test.ts +++ b/backend/tests/datevExport.test.ts @@ -1,6 +1,8 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { getCreatedDocumentRevenueLines } from "../src/utils/export/datev"; +import { buildExportZip, getBankAccountBookingLine, getCreatedDocumentRevenueLines } from "../src/utils/export/datev"; +import { Uint8ArrayReader, TextWriter, ZipReader } from "@zip.js/zip.js"; +import { statementallocations } from "../db/schema"; test("exports gross amounts for DATEV automatic revenue accounts", () => { const revenueLines = getCreatedDocumentRevenueLines({ @@ -27,3 +29,77 @@ test("preserves negative gross amounts for cancellations", () => { assert.deepEqual(revenueLines, [{ account: "8400", amount: -119 }]); }); + +for (const [amount, taxKey, side] of [[-119, "9", "H"], [-107, "8", "H"], [119, "3", "S"], [107, "2", "S"], [119, "9", "S"], [-119, "3", "H"], [-100, "40", "H"]] as const) { + test(`exports gross ${amount} with tax key ${taxKey} and cost centre`, () => { + const columns = getBankAccountBookingLine({ + amount, datevTaxKey: taxKey, account: { number: 4900, label: "Konto" }, + costcentre: { id: "internal-uuid", number: "KST-1001" }, + bankstatement: { id: 42, date: "2026-09-23", account: { datevNumber: "1200" } }, + }).split(";"); + assert.equal(columns.length, 124); + assert.equal(columns[0], `${Math.abs(amount)},00`); + assert.equal(columns[1], `"${side}"`); + assert.equal(columns[6], "1200"); + assert.equal(columns[7], "4900"); + assert.equal(columns[8], `"${taxKey}"`); + assert.equal(columns[36], '"KST-1001"'); + assert.equal(columns[112], "0"); + }); +} + +test("exports legacy allocations and additional accounts without inventing a tax key", () => { + const columns = getBankAccountBookingLine({ + amount: -12.34, ownaccount: { number: "4901", name: "Zusatzkonto" }, + bankstatement: { id: 1, date: "2026-09-23", account: { datevNumber: "1200" } }, + }).split(";"); + assert.equal(columns[0], "12,34"); + assert.equal(columns[7], "4901"); + assert.equal(columns[8], '""'); + assert.equal(columns[36], '""'); +}); + +test("ZIP export carries allocation metadata into the matching CSV columns and leaves invoice payments untaxed", async () => { + const bank = { id: 42, date: "2026-09-23", text: "Zahlung" }; + const rows = [ + { allocation: { id: "a", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, acc: { number: 4900 }, costcentre: { number: "1001" } }, + { allocation: { id: "b", amount: -59.5, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, own: { number: 4901 }, costcentre: { number: "1002" } }, + { allocation: { id: "c", amount: -119, datevTaxKey: "9" }, bs: bank, ba: { datevNumber: "1200" }, ii: { id: 3, date: bank.date, reference: "ER-3" }, ii_vend: { vendorNumber: 70001 } }, + { allocation: { id: "d", amount: 119, datevTaxKey: "9", manualBookingDate: bank.date }, bs: null, acc: { number: 4900 }, contra_acc: { number: 1200 } }, + ]; + const db = { + select: () => { + let result: any[] = []; + const query: any = { + from: (table: any) => { result = table === statementallocations ? rows : []; return query; }, + leftJoin: () => query, where: () => query, orderBy: () => query, + then: (resolve: any) => Promise.resolve(result).then(resolve), + }; + return query; + }, + }; + const archive = await buildExportZip({ db } as any, 1, "2026-09-01", "2026-09-30", "123", "456"); + const reader = new ZipReader(new Uint8ArrayReader(archive)); + try { + const entries = await reader.getEntries(); + const csvEntry = entries.find(entry => entry.filename.startsWith("EXTF_Buchungsstapel")); + assert.ok(csvEntry && "getData" in csvEntry); + const csv = await csvEntry.getData!(new TextWriter()); + const [, header, ...bookings] = csv.trim().split("\n").map(line => line.split(";")); + assert.equal(bookings.length, 4); + assert.equal(header.length, 124); + for (const [i, costcentre] of ["1001", "1002"].entries()) { + assert.equal(bookings[i].length, header.length); + assert.equal(bookings[i][header.indexOf("Umsatz")], "59,50"); + assert.equal(bookings[i][header.indexOf("BU-Schluessel")], '"9"'); + assert.equal(bookings[i][header.indexOf("KOST1 - Kostenstelle")], `"${costcentre}"`); + assert.equal(bookings[i][header.indexOf("Festschreibung")], "0"); + } + assert.equal(bookings[2][8], '""'); + assert.equal(bookings[3][8], '"9"'); + assert.equal(bookings[3][6], "4900"); + assert.equal(bookings[3][7], "1200"); + } finally { + await reader.close(); + } +}); diff --git a/docs-site/content/bedienung/bankportal.md b/docs-site/content/bedienung/bankportal.md index 7c75f256..13f3a101 100644 --- a/docs-site/content/bedienung/bankportal.md +++ b/docs-site/content/bedienung/bankportal.md @@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. +## Direkt auf ein Buchungskonto buchen + +Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen. + +- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um. +- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll. +- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle. + +Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal. + +Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False). + ## Gute Praxis - Regelmäßig abrufen, damit sich keine großen Rückstände bilden. diff --git a/docs/bedienung/bankportal.md b/docs/bedienung/bankportal.md index 7c75f256..13f3a101 100644 --- a/docs/bedienung/bankportal.md +++ b/docs/bedienung/bankportal.md @@ -24,6 +24,18 @@ Im Bankportal verbindest du Konten, prüfst Umsätze und unterstützt die Zuordn - `Vorschläge`: Automatische Zuordnungen zu Belegen oder Kategorien. - `Manuelle Zuordnung`: Falls kein passender Vorschlag vorhanden ist. +## Direkt auf ein Buchungskonto buchen + +Öffne einen Bankumsatz und wähle das Buchungskonto. Der zugewiesene Betrag ist immer der **Bruttobetrag**, auch bei Teilbuchungen. + +- Wähle **19 % / 7 % Vorsteuer** für Ausgaben mit abziehbarer Vorsteuer oder **19 % / 7 % Umsatzsteuer** für steuerpflichtige Einnahmen. Bei Erstattungen bleibt die ursprüngliche Steuerart erhalten; das Vorzeichen kehrt ihre Wirkung um. +- Wähle **Ohne Umsatzsteuer**, wenn aus dieser Buchung keine Steuer berechnet werden soll. +- Wähle bei Bedarf eine **Kostenstelle**. Die Zuordnung gilt für den jeweiligen Teilbetrag und erscheint auch in der Auswertung der übergeordneten Kostenstelle. + +Vor dem Buchen werden Netto-, Steuer- und Bruttobetrag angezeigt. Beispielsweise ergeben **−119,00 EUR mit 19 % Vorsteuer** einen Nettoaufwand von 100,00 EUR und 19,00 EUR Vorsteuer. Umsatzsteuerauswertung, BWA und Abschreibungsgrundlage berücksichtigen die Steueraufteilung. Die Zuordnung zu einem vorhandenen Rechnungsbeleg erfasst dessen Steuer nicht noch einmal. + +Der DATEV-Export übergibt den Bruttobetrag mit dem gewählten BU-Schlüssel (9/8 für Vorsteuer, 3/2 für Umsatzsteuer) und die Kostenstellennummer in **KOST1**. „Ohne Umsatzsteuer“ verwendet Schlüssel 40, um auch eine Kontenautomatik aufzuheben. Bestehende Buchungen ohne Steuerauswahl werden nicht nachträglich umgerechnet. Die Zuordnung folgt den [DATEV-Steuerschlüsseln](https://www.datev.de/dnlexom/v2/content/files/st2286167563_de.pdf) und der [DATEV-Dokumentation zur Aufhebung der Automatik](https://apps.datev.de/help-center/api/knowledge-help/v1/content/entities/st2112778507_de.pdf?docId=0907888&save=False). + ## Gute Praxis - Regelmäßig abrufen, damit sich keine großen Rückstände bilden. diff --git a/frontend/components/costcentreDisplay.vue b/frontend/components/costcentreDisplay.vue index 09a4e06d..138dc130 100644 --- a/frontend/components/costcentreDisplay.vue +++ b/frontend/components/costcentreDisplay.vue @@ -1,5 +1,6 @@