SIMPro

SIMPro — Betreiber-Anleitung

Sascha Invoice Manager Pro

Server aufsetzen, betreiben, sichern und aktualisieren

Stand: 1. September 2026 · Fassung 0.3.3


Jeder Befehl in dieser Anleitung wurde am laufenden System ausgeführt, soweit das gefahrlos möglich war. Was hier steht, ist nicht abgeschrieben, sondern nachgesehen — das Protokoll liegt als docs/ANLEITUNGSABGLEICH.md daneben.


1. Überblick

SIMPro besteht aus zwei Teilen: einem Server, der in Docker läuft und alle Daten hält, und den Apps für Windows und Android. Dazwischen sitzt kein Drittanbieter — die Apps sprechen mit genau einem Server, und zwar mit Ihrem.

Zwei Betriebsarten, dieselbe Software.

Wo der Server stehtFür wen
Cloud (Regelfall)app.simpro-software.at, betrieben vom Hersteller in einem Rechenzentrum in DeutschlandAlle, die sich nicht um einen Server kümmern wollen
Eigenserver (auf Anfrage)Im Haus des Betriebs oder bei seinem AnbieterWer die Daten auf eigener Hardware haben will

Diese Anleitung beschreibt beides. Wo es einen Unterschied gibt, steht er dabei; wo keiner steht, gilt beides.

BausteinAufgabeWo
PostgreSQL (db)Datenbank mit allen Kunden, Artikeln, Belegen, ZahlungenContainer, Daten im DATA_DIR
API (api)Anwendungslogik, PDF-Erzeugung, E-Mail-Versand, nächtliche LäufeContainer
Caddy (caddy bzw. caddy-internal)HTTPS-Zugang, Zertifikate von Let's EncryptContainer, Port 443
Sicherung (backup)Nächtliche verschlüsselte Sicherung mit resticContainer, sichert lokal und außer Haus
Windows-AppBedienung am PCInstaller vom GitHub-Release
Android-AppBedienung am TelefonPlay Store (interner Test) oder APK

Der Dienst heißt caddy-internal, wenn der Stapel hinter einem vorhandenen Webserver läuft (DEPLOY_MODE=BEHIND_PROXY), und caddy im eigenständigen Betrieb. Wer docker compose logs -f caddy auf einer BEHIND_PROXY-Installation eingibt, bekommt eine leere Ausgabe und keinen Fehler — was leicht wie „alles ruhig" aussieht.

Der gesamte Zustand des Systems liegt in zwei Dingen: dem Ordner DATA_DIR (Datenbank, PDFs, Logos, Zertifikate) und der Datei .env (Konfiguration und Geheimnisse). Wer beides kopiert, betreibt das System auf einem anderen Rechner unverändert weiter.


2. Voraussetzungen

Hardware

Für einen einzelnen Betrieb genügt ein kleiner, sparsamer Rechner, der dauerhaft läuft:

  • Empfehlung: Mini-PC mit Intel N100 oder vergleichbar, 8 GB RAM, 256 GB SSD (ab etwa 150 €). Verbrauch rund 10 Watt.
  • Alternativ: ein vorhandener PC, der ohnehin läuft, oder ein NAS mit Docker-Unterstützung (Synology, QNAP).
  • Kabelgebunden zum Router. WLAN ist für einen Server ungeeignet.
  • Eine USV ist nicht zwingend, aber sinnvoll: ein Stromausfall mitten in einem Schreibvorgang ist die häufigste Ursache für Datenbankschäden.

Zur Belegerkennung. Seit Erweiterungsstufe B bringt das API-Abbild tesseract-ocr samt deutschem Sprachmodell und poppler-utils mit — rund 60 MB mehr im Abbild. Sie laufen auf diesem Rechner; es geht kein Beleg hinaus (siehe docs/DECISIONS.md, N19).

Beim Rechenbedarf ändert das wenig: ein PDF mit Textebene wird ausgelesen und kostet Millisekunden. Nur ein fotografierter Beleg geht durch die Erkennung, und das dauert auf einem N100 wenige Sekunden je Seite. Wer viele Fotobelege erfasst, merkt es; wer PDFs bekommt, nicht.

Nachsehen lässt es sich so:

docker compose exec api tesseract --list-langs

Dort müssen deu und eng stehen. Fehlen sie, arbeitet alles weiter — nur ohne Vorschläge, und die Maske sagt das ausdrücklich statt eine Fehlermeldung zu zeigen.

Betriebssystem

  • Ubuntu Server 24.04 LTS — empfohlen. Stabil, wartungsarm, keine Lizenzkosten.
  • Windows 11 mit Docker Desktop — funktioniert, braucht aber einen angemeldeten Benutzer und ist nach Windows-Updates gelegentlich neu zu starten. Für Dauerbetrieb ist Ubuntu die bessere Wahl.

Netzwerk und Domain

  • Eine eigene Domain oder Subdomain, z. B. rechnung.firma.at. Registriert auf den Betreiber, nicht auf Sie.
  • DynDNS, damit die Domain immer auf die aktuelle Internet-Adresse des Routers zeigt. Die meisten Router (FRITZ!Box, Speedport) können das direkt.
  • Portfreigabe: ausschließlich Port 443 (TCP) auf den Server. Kein anderer. Caddy holt die Zertifikate über TLS-ALPN-01, und das läuft über 443 — Port 80 wird nicht gebraucht.
  • Eine feste lokale IP für den Server (im Router „immer dieselbe IP zuweisen"), sonst zeigt die Portfreigabe ins Leere, sobald der Router eine andere Adresse vergibt.

Konten, die vorher da sein müssen

KontoWofürAuf wen
Sicherungsspeicher (Hetzner Storage Box oder Backblaze B2)Verschlüsselte Sicherung außer Haus, wenige Euro im MonatBetreiber
E-Mail-Postausgang (SMTP)Versand von Rechnungen und MahnungenBetreiber, meist der bestehende Mailanbieter
Domain mit DynDNSZugriff von außenBetreiber
GitHub-KontoZugang zu Releases und QuellcodeSie als Entwickler; Betreiber als Mitarbeiter eingeladen
Google Play ConsoleVerteilung der Android-AppSie; Betreiber als Admin eingeladen

3. Server vorbereiten

Ubuntu Server 24.04

Ubuntu installieren (Standardinstallation, OpenSSH-Server anhaken, kein Docker-Snap). Dann als normaler Benutzer:

sudo apt update && sudo apt upgrade -y
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER
sudo apt install -y git
# abmelden und neu anmelden, damit die Docker-Gruppe wirkt
docker --version

Automatische Sicherheitsupdates:

sudo apt install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades

Windows 11 mit Docker Desktop

  1. PowerShell als Verwalter: wsl --install, danach Neustart.
  2. winget install Docker.DockerDesktop, Docker Desktop starten, WSL2-Backend bestätigen, kein Konto nötig.
  3. winget install Git.Git.
  4. In Docker Desktop unter Settings → General „Start Docker Desktop when you sign in" aktivieren und in Windows die automatische Anmeldung des Benutzers einrichten, sonst läuft nach einem Neustart nichts.
  5. Energieoptionen: Ruhezustand und Standby auf Nie.

Docker Desktop legt seine Abbilder in einer VHDX-Datei auf C: ab, auch wenn DATA_DIR woanders liegt. Diese Datei wächst und schrumpft nicht von selbst. Siehe Abschnitt 8, „Aufräumen".


4. Installation

Quellcode holen

git clone https://github.com/Suschinator420/SuschiRechnungen.git rechnung
cd rechnung

Bei einem privaten Repository fragt Git nach der Anmeldung; unter Ubuntu vorher ein Personal Access Token in GitHub anlegen (Settings → Developer settings → Tokens) und als Passwort verwenden.

Einrichtungsskript ausführen

Das Skript fragt alle Werte ab, erzeugt die Geheimnisse, schreibt die .env, legt das DATA_DIR an und startet den Stapel. Es lässt sich jederzeit erneut ausführen; bestehende Werte werden als Vorgabe angeboten, Geheimnisse nie neu erzeugt.

./setup.sh          # Ubuntu
.\setup.ps1         # Windows (PowerShell)

Die Fragen und die passenden Antworten für einen eigenständigen Server:

FrageAntwort
Betriebsmodus (DEPLOY_MODE)STANDALONE — Caddy übernimmt HTTPS selbst. BEHIND_PROXY nur, wenn bereits ein anderer Webserver auf 443 läuft
Domainrechnung.firma.at (die DynDNS-Domain)
E-Mail für Let's Encrypt (ACME_EMAIL)Adresse des Betreibers; dorthin gehen Warnungen bei Zertifikatsproblemen
HTTP-/HTTPS-Port80 und 443 belassen
DATA_DIRz. B. /srv/rechnung-data (Ubuntu) oder D:\rechnung-data (Windows). Auf der SSD, nicht auf einem USB-Stick
SMTP-Server, Port, Benutzer, Passwort, AbsenderVom Mailanbieter des Betreibers. Port 587 mit STARTTLS ist der Normalfall
Sicherungsziel (BACKUP_*)Zugangsdaten des Sicherungsspeichers. Das restic-Passwort wird erzeugt und muss gesichert werden (Abschnitt 6)
Erster VerwalterE-Mail-Adresse des Betreibers. Das Startpasswort wird angezeigt und muss beim ersten Login geändert werden

Nach dem Durchlauf prüfen:

docker compose ps            # alle Container "healthy" bzw. "running"
curl -k https://localhost/health

Im Modus BEHIND_PROXY antwortet der Stapel nicht auf 443, sondern auf dem internen Port:

curl -s http://127.0.0.1:8080/health

Beides liefert {"status":"ok"}.

Ersteinrichtung über die App

Ein frisch aufgesetzter Server hat keinen Benutzer. Die App merkt das und zeigt statt der Anmeldung „Dieser Server ist noch nicht eingerichtet". Der Weg dahinter braucht vier Angaben — Einrichtungsschlüssel, Betriebsname, E-Mail, Passwort.

Der Schlüssel steht im Serverprotokoll und in setup-token.txt im Datenverzeichnis:

docker compose logs api | grep -i einrichtungsschluessel
cat "$DATA_DIR/setup-token.txt"

Er gilt genau einmal; danach löscht der Server ihn. Solange der Server leer ist, ist dieser Weg offen — sobald der erste Benutzer existiert, nicht mehr.

Danach steht der Betrieb, und die App zeigt die Übersicht. Was ein Betreiber dort im Blick behält, ist die unterste Kachel:

Die Übersicht, mit dem Zustand der Sicherung
Die Übersicht, mit dem Zustand der Sicherung

Erster Zugriff

Sobald DynDNS und Portfreigabe stehen (Abschnitt 5), holt Caddy innerhalb einer Minute das Zertifikat. Dann in der App als Serveradresse https://rechnung.firma.at eintragen und mit dem ersten Verwalter anmelden. Dieselbe Adresse gilt im Firmennetz und von unterwegs.

Solange keine Domain vorhanden ist, lässt sich das System im Heimnetz testen: Caddy stellt dann ein eigenes Zertifikat aus, und die App zeigt beim ersten Verbinden dessen Fingerabdruck, den man einmalig bestätigt. Details in docs/AUSPROBIEREN.md.


5. Zugriff von außen

  1. Feste IP für den Server. Im Router unter Heimnetz/Netzwerk das Gerät suchen und „immer dieselbe IPv4-Adresse zuweisen" aktivieren.
  2. DynDNS. Im Router unter Internet → Freigaben → DynDNS Anbieter, Domain und Zugangsdaten eintragen. Bei einer FRITZ!Box kann MyFRITZ! als DynDNS dienen; dann lautet die Domain xyz.myfritz.net und wird als CNAME-Ziel der eigenen Domain hinterlegt.
  3. Portfreigabe. Internet → Freigaben → Portfreigabe → Gerät wählen → neue Freigabe: Protokoll TCP, Port 443 auf 443. Sonst nichts.
  4. Prüfen. Vom Telefon aus im Mobilfunknetz (WLAN aus) https://rechnung.firma.at/health aufrufen. Es muss {"status":"ok"} erscheinen, ohne Zertifikatswarnung.

Manche Router — und Anbieter mit „DS-Lite" oder CGNAT — erlauben keine Portfreigabe über IPv4. Dann hilft ein Anruf beim Anbieter (Umstellung auf echte IPv4, oft kostenlos) oder ein VPN wie Tailscale, beschrieben in docs/NETWORK.md.

Zweite Domain / Domainwechsel

Ein Server kann unter mehreren Namen erreichbar sein — und das ist der einzige schmerzfreie Weg, den Namen zu wechseln: erst den neuen daneben stellen, dann in Ruhe umstellen, dann den alten abschalten. Wer stattdessen umbenennt, sperrt in dem Moment jedes Gerät aus, das noch den alten Namen kennt.

So läuft es hier — die Reihenfolge ist wichtig:

  1. DNS. Bei Cloudflare für die neue Domain einen A-Record auf die öffentliche IP-Adresse des Anschlusses anlegen. Wichtig: DNS only, nicht „Proxied" (die orange Wolke aus). Durch den Cloudflare-Proxy käme fremdes TLS dazwischen, und die ACME-Prüfung über Port 80 fände den Server nicht.
  2. DDNS. Die Adresse ändert sich beim Anschluss regelmäßig. Der Router (hier ein Teltonika) hält den A-Record aktuell: unter Services → Dynamic DNS ein Profil mit Anbieter Cloudflare, der Zone, dem Hostnamen und einem API-Token, das nur Zone.DNS: Edit für genau diese Zone darf. Ein Token mit mehr Rechten hat auf einem Router nichts verloren.
  3. Warten und nachsehen. nslookup rechnung.neue-domain.at muss die richtige Adresse liefern, bevor es weitergeht. Ein Zertifikat für einen Namen, der noch woandershin zeigt, gibt es nicht.
  4. Server-Block. Vor dem Rechnung-Stack steht auf diesem Rechner ein nginx. Der neue Name kommt in B:\nginx\conf\sites\rechnung.conf: ein Port-80-Block (ACME-Ablage plus Umleitung auf HTTPS, für beide Namen derselbe) und ein eigener Port-443-Block je Name. Was beide gemeinsam haben — Kopfzeilen, Zeitlimits, die Weiterleitung an 127.0.0.1:8080 — steht einmal in rechnung-weiterleitung.inc und wird zweimal eingebunden. Zwei Kopien desselben Blocks wären der sichere Weg, dass eines Tages nur eine davon gepflegt wird.
  5. Zertifikat. Erst den Port-443-Block weglassen, sonst startet nginx nicht: er verlangt eine Zertifikatsdatei, die es noch nicht gibt. Also: Port-80-Block einspielen, nginx -t, nginx -s reload, dann

`` B:\win-acme\wacs.exe --target manual --host rechnung.neue-domain.at ^ --friendlyname "rechnung.neue-domain.at" ^ --validation filesystem --webroot B:\nginx\acme-webroot ^ --store pemfiles --pemfilespath B:\nginx\certs\rechnung-neu ^ --installation script --script B:\nginx\acme-reload.cmd --accepttos ``

Den Zielordner vorher anlegen — win-acme legt ihn nicht selbst an und bricht sonst mit invalid --pemfilespath ab. Danach den Port-443-Block ergänzen, wieder nginx -t und nginx -s reload.

  1. Prüfen. Beide Namen müssen 200 liefern und ein gültiges, auf den jeweiligen Namen ausgestelltes Zertifikat zeigen:

`` curl -o NUL -w "%{http_code}\n" https://rechnung.alte-domain.at/health curl -o NUL -w "%{http_code}\n" https://rechnung.neue-domain.at/health ``

  1. Verlängerung. win-acme merkt sich jede Bestellung und verlängert sie, wenn es regelmäßig aufgerufen wird. Eine tägliche geplante Aufgabe B:\win-acme\wacs.exe --renew erledigt das für alle Zertifikate auf einmal; nach jeder Verlängerung ruft acme-reload.cmd ein nginx -s reload auf, damit das neue Zertifikat auch ausgeliefert wird. Der Aufruf läuft ohne Nachfrage durch und ist ohne fällige Bestellung in Sekunden fertig.

win-acme bringt seine Aufgabe selbst mit. Sie heißt win-acme renew (acme-v02.api.letsencrypt.org) und trägt die Einstellungen aus settings.json: Start 09:00 plus eine Zufallsverzögerung von bis zu vier Stunden — damit nicht alle Welt zur selben Minute bei Let's Encrypt anklopft —, Zeitlimit zwei Stunden, Erneuerung ab 55 Resttagen. wacs.exe pflegt sie bei jeder Aktualisierung mit. Das ist die Aufgabe, die bleiben soll.

Sie muss unter dem Systemkonto laufen. Eine Aufgabe im Modus nur interaktiv läuft nur, solange jemand angemeldet ist — nach einem Neustart, bei dem sich niemand anmeldet, läuft sie nie. Zertifikate laufen nach 90 Tagen ab, und ein Server, der durchläuft, aber niemanden angemeldet hat, fällt dann still aus.

Nachsehen lässt sich das nur erhöht: eine Aufgabe des Systemkontos ist für einen normalen Benutzer nicht einmal lesbar.

``powershell # PowerShell als Verwalter Get-ScheduledTask | Where-Object { $_.TaskName -match 'acme|wacs' } | Select-Object TaskName, State, @{n='Konto';e={$_.Principal.UserId}} ``

Fehlt sie oder läuft sie nicht unbeaufsichtigt, legt B:\Rechnung-privat\werkzeuge\zertifikatsaufgabe-einrichten.ps1 eine unter SYSTEM an (ebenfalls erhöht). Stehen am Ende zwei Aufgaben da — die eigene und eine von Hand angelegte —, räumt B:\Rechnung-privat\werkzeuge\zertifikatsaufgabe-aufraeumen.ps1 auf: es vergleicht beide, prüft, ob die bleibende wirklich unbeaufsichtigt läuft, und löscht erst dann die andere. Eine Aufräumaktion, die am Ende gar keine Verlängerung hinterlässt, wäre schlimmer als zwei.

Wann die Apps umgestellt werden — zuletzt. Solange beide Namen bedient werden, ist nichts eilig. Der Server nimmt jeden der beiden an; welchen ein Gerät verwendet, steht in dessen Einstellungen unter Serveradresse. Sinnvolle Reihenfolge: erst den neuen Namen einige Tage mitlaufen lassen, dann Gerät für Gerät umstellen (und dabei prüfen, dass die Anmeldung geht), dann eine Weile warten, und erst danach den alten Namen aus nginx nehmen. Das Zertifikat des alten Namens läuft von selbst aus; die Bestellung dazu gehört dann mit wacs.exe --cancel entfernt, sonst versucht die Verlängerung monatelang etwas, das niemand mehr braucht.

Überwachung von außen

/health antwortet ohne Anmeldung und verrät nichts über den Betrieb — genau so viel, wie eine Überwachung braucht:

{"status":"ok"}

Ein Dienst wie UptimeRobot prüft alle fünf Minuten auf diese Adresse und meldet sich, wenn der Server nicht antwortet.


6. Sicherung

Was gesichert wird und wie

Der Sicherungs-Container erstellt jede Nacht einen Datenbank-Auszug und sichert ihn zusammen mit dem Dateispeicher (PDFs, Logos, Belegscans) verschlüsselt per restic — in drei Ziele: lokal im DATA_DIR, auf die externe Platte, und auf den Speicher außer Haus. Alle drei mit demselben Passwort, jedes als eigenes Repository. Aufbewahrt werden 7 tägliche, 4 wöchentliche, 12 monatliche und 7 jährliche Stände.

Bleibt ein Lauf aus oder schlägt fehl, geht eine E-Mail an den Verwalter, und die Übersicht in der App zeigt es. Ausführlicher steht es unter Verwaltung → Server, Updates und Sicherung:

Serverstatus: Sicherung, Datenbank, Plattenplatz, E-Mail, Benutzer, Protokoll
Serverstatus: Sicherung, Datenbank, Plattenplatz, E-Mail, Benutzer, Protokoll

Die 3-2-1-Regel

Die Regel, an der sich jede Sicherung messen lässt:

Drei Kopien der Daten, auf zwei verschiedenen Medien, eine davon außer Haus.

So sieht das hier aus:

KopieWoWogegen sie hilft
Die Daten selbstim DATA_DIR auf der Serverplatte—
Sicherung 1daneben, im DATA_DIREin Fehlgriff: gelöschter Kunde, verkorkster Import
Sicherung 2externe Platte unter R:\rechnung-backupDer Ausfall der Serverplatte
Sicherung 3Speicher außer Haus (restic-Ziel)Feuer, Diebstahl, Wasser

Die erste Sicherung liegt auf derselben Platte wie die Daten. Fällt die aus, sind beide weg — und man hängt an einer Leitung, die vielleicht gerade nicht da ist. Deshalb die zweite auf einem eigenen Medium.

Die externe Platte ersetzt den Speicher außer Haus nicht. Sie steht neben dem Server, im selben Raum. Was den Server trifft — Feuer, Wasser, ein Einbrecher, der den Rechner mitnimmt —, trifft sie mit. Sie ist die zweite Kopie auf einem zweiten Medium, nicht die dritte an einem anderen Ort.

Die Platte dient nur der Sicherung. Keine Urlaubsbilder, keine Installationsdateien, kein „da ist ja noch Platz". Eine Platte, auf der auch anderes liegt, wird irgendwann für anderes gebraucht — und ist dann in dem Moment nicht da, in dem es darauf ankommt. Sie bleibt außerdem angesteckt; eine Sicherung, an die man denken muss, findet nicht statt.

Die externe Platte einrichten

Einmalig, in PowerShell im Projektverzeichnis:

.\scripts\backup-extern-einrichten.ps1

Das Skript prüft Laufwerk, Platz und Dateisystem, legt R:\rechnung-backup an, setzt die Marke (siehe unten) und trägt die beiden Werte in die .env ein. Danach:

docker compose up -d backup
docker compose exec backup /usr/local/bin/backup.sh   # den ersten Lauf gleich

Der Laufwerksbuchstabe muss fest sein. Wandert er beim nächsten Anstecken, findet die Sicherung ihr Ziel nicht mehr. Fest zuweisen in der Datenträgerverwaltung → Rechtsklick auf die Partition → Laufwerkbuchstaben und -pfade ändern.

Die Marke. Im Zielverzeichnis liegt eine Datei .rechnung-sicherungsziel. An ihr erkennt die Sicherung, dass die Platte wirklich da ist. Das ist kein Umstand, sondern notwendig: Docker legt einen fehlenden Pfad als leeres Verzeichnis an, und ohne Marke sähe eine abgesteckte Platte genau aus wie eine leere. Die Sicherung liefe dann klaglos auf die interne Platte, in ein Verzeichnis, das niemand je ansieht — es sähe aus, als funktionierte alles. Löschen Sie die Datei nicht.

Die Falle: Docker sieht die Platte nicht

Diese kostet einen Nachmittag, wenn man sie nicht kennt.

Docker Desktop läuft auf Windows in einer eigenen kleinen Linux-Maschine (WSL). Die hängt die Windows-Laufwerke beim Hochfahren ein. Eine Platte, die danach angesteckt wird, gibt es dort nicht — auch wenn Windows sie längst anzeigt.

Und dann passiert das Unangenehme: Docker legt den fehlenden Pfad als leeres Verzeichnis innerhalb der Linux-Maschine an. Auf der Windows-Seite stimmt alles — Ordner da, Marke da, .env eingetragen. Im Container ist das Verzeichnis leer. Die Sicherung findet die Marke nicht, hält die Platte für abgesteckt und überspringt sie. Jede Nacht, ohne dass etwas kaputtgeht.

Deshalb prüft das Einrichtungsskript von der anderen Seite. Es startet einen Wegwerf-Container mit genau demselben Pfad und sieht nach, ob die Marke dort ankommt. Sagt es Ja, ist die Sache erledigt. Sagt es Nein, nennt es die Abhilfe und beendet sich mit Rückgabewert 2.

Die Abhilfe ist eine von zweien:

  • Docker Desktop neu starten (Rechtsklick im Infobereich → Restart). Beim Hochfahren findet die Maschine die Platte und hängt sie ein. Das ist der saubere Weg.
  • Nachreichen ohne Neustart, wenn gerade etwas läuft, das nicht unterbrochen werden soll:

``powershell wsl -d docker-desktop -e mount -t drvfs R: /mnt/host/r docker compose up -d --force-recreate backup ``

Das --force-recreate ist nötig: der Container erbt seine Einhängepunkte beim Anlegen. Ein laufender Container sieht die nachgereichte Platte nicht. Diese Lösung hält bis zum nächsten Start der Linux-Maschine.

Damit die Frage gar nicht aufkommt: die Platte angesteckt lassen. Dann ist sie beim nächsten Hochfahren da und wird von selbst eingehängt. Das ist ohnehin gemeint — eine Sicherung, an die man denken muss, findet nicht statt.

Nur ein Lauf auf einmal

Der Container stößt beim Start eine Sicherung an, daneben steht der Zeitplan, und dazwischen tippt jemand den Lauf von Hand an. Treffen sich zwei, räumt der eine dem anderen das Arbeitsverzeichnis weg und hält das Repository gesperrt — das Ergebnis sieht aus wie ein kaputtes Repository, ist aber nur ein Zusammenstoß.

Deshalb hält sich jeder Lauf eine Sperre. Wer als Zweiter kommt, schreibt eine Zeile ins Protokoll und geht:

[…] ein anderer Lauf ist noch unterwegs - dieser hier entfaellt

Das ist kein Fehler und wird auch nicht als solcher vermerkt. Es lief schon.

Wenn die Platte nicht angesteckt ist

Der Lauf geht trotzdem durch: die interne Sicherung und der Speicher außer Haus werden geschrieben. Für die Platte kommt ein eigener Eintrag mit dem Zustand Übersprungen — nicht Fehlgeschlagen, denn fehlgeschlagen ist nichts.

  • Bis drei Tage: eine Notiz im Protokoll. Wer die Platte übers Wochenende mitnimmt, soll nicht jeden Morgen eine Warnung bekommen — sonst sieht er nach einer Woche keine mehr an, auch nicht die, auf die es ankommt.
  • Ab dem vierten Tag: eine Warnung.

Was regelmäßig geprüft wird

WannWasWo
Jeden LaufDie Sicherung selbst, danach das Ausdünnen alter Ständebeide lokalen Ziele und das Ziel außer Haus
Sonntagsrestic check --read-data-subset=5% — eine rollende Leseprobe gegen Bitfäulealle Ziele
Am Monatserstenrestic check vollständig — ob der Baum überhaupt zusammenhängtalle Ziele

Die Leseprobe sieht 5 Prozent der Daten an, die monatliche Prüfung die ganze Struktur. Ein Repository, dem der Index fehlt, fällt sonst erst bei der Wiederherstellung auf — also an dem Tag, an dem man es braucht.

Aus der externen Platte wiederherstellen

Wie sonst auch, nur mit dem anderen Repository:

# Was auf der Platte liegt
docker compose run --rm -e RESTIC_REPOSITORY=/backup-extern/restic \
  restore restic snapshots

# Und zurückspielen
docker compose run --rm -e RESTIC_REPOSITORY=/backup-extern/restic \
  restore restore.sh --snapshot <id>

Üben Sie das einmal, bevor Sie es brauchen. Eine Sicherung, aus der noch nie jemand etwas zurückgeholt hat, ist eine Vermutung. Am besten in eine Wegwerf-Datenbank, damit der laufende Betrieb unberührt bleibt — wie es in docs/BACKUP.md steht.

Das restic-Passwort

Das restic-Passwort aus der .env ist der einzige Schlüssel zu allen Sicherungen. Ohne dieses Passwort ist jedes Backup wertlos. Es gehört in den Passwortmanager des Betreibers und zusätzlich auf Papier an einen sicheren Ort. Dasselbe gilt für die Zugangsdaten des Sicherungsspeichers.

Die Befehle

# Eine Sicherung von Hand anstoßen
docker compose run --rm backup backup.sh

# Was gesichert ist, auflisten
docker compose run --rm backup restore.sh --list

# Einen Stand zurückspielen
docker compose run --rm restore restore.sh --snapshot <snapshot-id>

Die Ausgabe eines Laufs sieht so aus:

[2026-08-30T12:26:37+02:00] backup started (target LOCAL)
[2026-08-30T12:26:37+02:00] dumping database
[2026-08-30T12:26:38+02:00] backing up to /backup/local
[2026-08-30T12:26:39+02:00] pruning /backup/local
[2026-08-30T12:26:40+02:00] weekly integrity check on /backup/local
[2026-08-30T12:26:41+02:00] backup finished (snapshot b95ac899)

Nicht restic snapshots und nicht backup-now. Beides steht in älteren Notizen und beides scheitert: restic allein kennt das Repository nicht („Please specify repository location"), und backup-now gibt es nicht („exec: backup-now: not found"). Die drei Befehle oben sind die richtigen.

Wiederherstellung üben

Eine Sicherung, die nie zurückgespielt wurde, ist keine Sicherung. Der Ablauf ist in docs/BACKUP.md mit Übungsprotokoll beschrieben. Beim Test auf dem Entwicklungssystem dauerte ein vollständiges Zurückspielen unter 20 Sekunden.

Empfehlung: einmal nach der Installation und danach jährlich — am besten auf einem zweiten Rechner, damit der Ernstfall „Server ist tot, neuer Rechner steht da" wirklich durchgespielt ist.

Zuletzt geprobt am 20.09.2026, beide Ziele: Schnappschuss d27dc8b0 aus der lokalen Kopie und a84f70ca von der Storage Box, beide vom selben Tag, 03:00. Zurückgespielt in eine Wegwerf-Datenbank, Dauer 7 Sekunden. Verglichen wurden 5 Belege gegen 5 im Betrieb, der benannte Beleg ER-2026-0001 vom 03.09.2026 war auffindbar, und der Dateispeicher stimmte mit 23 Dateien / 1.808.695 Bytes auf das Byte. Das Protokoll mit den drei Fehlern, die dieser Lauf gefunden hat, steht in docs/BACKUP.md.


7. Updates einspielen

Seit Fassung 0.1.16 hält sich der Server selbst auf dem Laufenden — nicht heimlich, sondern nachvollziehbar: er sieht nach, er sagt Bescheid, und einspielen tut es ein Skript auf dem Rechner. Was von Hand ging, geht weiter von Hand; es muss nur nicht mehr.

Was von selbst passiert

Jede Nacht um 02:00 fragt der Server bei GitHub nach, ob es eine neuere Fassung gibt. Findet er eine, steht sie in der App unter Einstellungen → Programm → Server: die Nummer, die Release-Notizen und, was als Nächstes geschieht. Zugleich geht eine Benachrichtigung an alle Verwalter — Glocke und E-Mail.

Ob eine Fassung von selbst eingespielt wird, entscheidet nicht der Server, sondern die Release-Notiz. Steht dort eine Zeile

update: auto

läuft sie im nächsten Wartungsfenster (Vorgabe 03:00, einstellbar über UPDATE_WINDOW in der .env). Steht dort update: manual — oder steht dort gar nichts —, wartet sie auf den Knopf Jetzt einspielen. Das Fehlen der Markierung gilt als manual, und das ist Absicht: eine Fassung, bei der niemand darüber nachgedacht hat, ob sie unbeaufsichtigt laufen darf, läuft nicht unbeaufsichtigt. Vorabfassungen (pre-release) laufen nie von selbst.

Damit überhaupt etwas laufen kann, muss das Updateskript regelmäßig aufgerufen werden — es fragt den Server, ob etwas ansteht, und tut sonst nichts:

# Linux: stündlich in der Crontab des Benutzers, dem der Stack gehört
0 * * * * cd /opt/rechnung && ./scripts/update.sh >> data/logs/update.log 2>&1
# Windows: eine geplante Aufgabe, stündlich
schtasks /create /tn "Rechnung Update" /sc hourly `
  /tr "powershell -File B:\Rechnung\scripts\update.ps1"

Damit das Skript den Server fragen und ihm melden kann, braucht es einen Zugang. In der .env genügt eines von beidem:

UPDATE_TOKEN=…            ein fertiges Zugriffstoken (bei Zwei-Faktor der
                          bequemere Weg — erneuern, wenn es abläuft)
UPDATE_USER=…             oder Benutzer und Passwort eines Verwalters
UPDATE_PASSWORD=…

Ohne Zugang läuft ein Update trotzdem durch, wenn es von Hand angestoßen wird — es steht dann nur nichts davon in der Oberfläche.

Was der Knopf tut

Jetzt einspielen setzt eine Markierung. Mehr nicht — und das hat einen Grund: der Server steckt in einem Container. Er kann sich nicht selbst anhalten, kein Abbild ziehen und sich nicht wieder starten, und er soll es auch nicht können: kein Container dieses Stapels bekommt den Docker-Socket, denn wer den hat, ist auf dem Rechner root. Der Server entscheidet also, das Skript auf dem Rechner tut. Beim nächsten stündlichen Lauf holt es sich den Auftrag ab.

Zwischen Knopf und Ausführung liegt deshalb bis zu eine Stunde. In der App steht in dieser Zeit, dass es angefordert ist.

Der Ablauf, und warum er so ist

scripts/update.sh (bzw. update.ps1) macht immer dasselbe, in dieser Reihenfolge:

  1. Sicherung — vor allem anderen. Eine Migration lässt sich nicht zurückdrehen; der restic-Schnappschuss ist der einzige Weg zurück. Scheitert die Sicherung, wird nicht aktualisiert.
  2. Abbild ziehen — vor dem Anhalten. Wer erst anhält und dann zieht, steht bei einem Netzfehler mit einem toten Server da.
  3. Anhalten, starten. Die Migrationen laufen beim Start des Containers.
  4. /health — mit Zeitlimit (UPDATE_HEALTH_TIMEOUT, Vorgabe 180 s).
  5. Melden — Erfolg wie Fehlschlag, mit dem vollständigen Protokoll.

Wenn es schiefgeht

Wird der Server nicht gesund, rollt das Skript von selbst zurück: API anhalten, Datenbank aus dem Schnappschuss von Schritt 1 zurückspielen, altes Abbild starten, warten, bis er wieder antwortet. Erst dann meldet es.

Das Zurückspielen der Datenbank ist der wichtige Teil. Eine Migration, die zur Hälfte lief, hinterlässt einen Eintrag „fehlgeschlagen" — und danach startet auch die alte Fassung nicht mehr, denn Prisma verweigert den Start, solange eine gescheiterte Migration in der Datenbank steht. Ohne den Schnappschuss stünde man dann vor einem Server, der weder vorwärts noch rückwärts geht.

Die Belegdateien werden dabei bewusst nicht angefasst: ein Update ändert die Datenbank, nicht den Dateispeicher.

Erfolg und Fehlschlag gehen als Benachrichtigung an alle Verwalter — Glocke und E-Mail. Das Protokoll steht in der App unter Einstellungen → Programm → Server, aufklappbar, Schritt für Schritt mit Uhrzeit.

Eine Einschränkung, die man kennen sollte. Rollt ein Update auf eine Fassung zurück, die älter als 0.1.16 ist, kann das Skript den Fehlschlag nirgends melden — die alte Fassung kennt den Endpunkt dafür noch nicht. Das Protokoll steht dann nur auf dem Rechner. Ab 0.1.16 kommt jede Meldung an.

Von Hand

Geht weiterhin, und für einen großen Sprung ist es der ruhigere Weg:

cd rechnung
./scripts/update.sh --pruefen              # was anstünde
./scripts/update.sh --fassung 0.1.17       # genau diese einspielen

Ohne --fassung fragt es den Server und tut nichts, solange nichts angefordert ist.

Der Weg der Apps: über den Server, nicht über GitHub

Seit 0.2.2 fragt keine App mehr selbst bei GitHub nach. Sie fragt ihren eigenen Server (/api/v1/updates/latest), und der fragt GitHub. Was zurückkommt: die Fassung, die Release-Notizen, je Datei ihre Prüfsumme aus SHA256SUMS.txt — und eine Adresse, die wieder auf den eigenen Server zeigt (/api/v1/updates/datei/…). Von dort holt die App den Installer.

Drei Gründe, und der dritte hat es ans Licht gebracht:

  1. Eine App in einem Netz, das nur den eigenen Server erreichen darf, kommt trotzdem an ihre neue Fassung.
  2. Ein privates Repository braucht ein Token auf dem Server statt eines auf jedem Arbeitsplatz.
  3. Ohne Token antwortet GitHub mit 404, nicht mit 403. Die Prüfung fällt also nicht auf — sie findet nur nie etwas. Genau das war monatelang der Fall, ohne dass es jemandem auffiel.

Der Weiterleitungsdienst ist keiner: der Server gibt nur Dateien heraus, die als Anhang genau dieses Releases dastehen, und nur an angemeldete Benutzer.

Das Token für ein privates Repository

Ist das Repository nicht öffentlich, braucht der Server ein Zugriffstoken:

# .env auf dem Server
UPDATE_GITHUB_TOKEN=github_pat_…

Ein fein abgestuftes Token mit genau einer Berechtigung: Lesezugriff auf die Contents (dazu gehören die Releases) des einen Repositories. Kein repo-Vollzugriff, kein Token eines Menschen, kein Token der GitHub-CLI — ein Server, der nachts nach Aktualisierungen sieht, braucht nichts, womit sich Code ändern lässt.

Fehlt es, sagt die Prüfung es im Klartext; die Meldung steht in der App unter Einstellungen → Programm → Server.

Wenn das Token abläuft

Ein fein abgestuftes Token hat ein Ablaufdatum. Und ein abgelaufenes Token bringt bei GitHub dieselbe Antwort wie gar keines: 404. Die Prüfung fällt also nicht aus — sie hört auf, etwas zu finden, und das sieht aus wie „alles aktuell".

Deshalb überwacht der Server das Datum:

  • Es kommt aus der Antwort von GitHub selbst (Kopfzeile github-authentication-token-expiration) — das ist die verlässliche Quelle, denn sie stammt vom Aussteller.
  • Fehlt sie, gilt UPDATES_TOKEN_EXPIRES=2027-09-01 aus der .env.
  • Steht beides nicht da, wird nicht gewarnt. Eine Warnung über ein Datum, das niemand kennt, wäre geraten.

Ab 30 Tagen vorher steht die Warnung unter Einstellungen → Programm → Server, und alle Verwalter bekommen eine Benachrichtigung — Glocke und E-Mail, höchstens einmal am Tag. Eine Warnung, die jede Nacht neu kommt, wird nach der dritten nicht mehr gelesen und fehlt dann auch am letzten Tag.

Erneuern heißt: neues Token auf GitHub erzeugen, in die .env eintragen, docker compose up -d api. Das Datum daneben nicht vergessen, falls GitHub es nicht selbst mitschickt.

Für Shopify gibt es das nicht. Der Zugang eines eigenen Apps („custom app") läuft nicht ab, und Shopify nennt in seinen Antworten kein Datum. Was dort passieren kann, ist etwas anderes: der Zugang wird entfernt. Das merkt der Server sofort — Shopify schickt app/uninstalled, der Laden wird abgeschaltet und trägt den Grund.

Wo die Abbilder herkommen### Wo die Abbilder herkommen

Jedes Release veröffentlicht das Server-Abbild nach ghcr.io/suschinator420/suschirechnungen, mit der Fassungsnummer und mit latest. Das Skript zieht von dort; eine andere Quelle lässt sich über UPDATE_IMAGE in der .env eintragen.

Apps

  • Windows: Die App sieht beim Start bei GitHub nach. Gibt es eine neuere Fassung, fragt sie nach: „Neue Fassung x.y.z verfügbar — jetzt installieren?" Wer zustimmt, sieht den Download, danach prüft die App die Datei gegen SHA256SUMS.txt aus demselben Release, startet den Installer und beendet sich. Wer ablehnt, wird beim nächsten Start wieder gefragt.
  • Android über Play Store: Updates kommen von selbst, sobald ein Release im internen Testkanal liegt.
  • Android über APK: Neue APK vom Release herunterladen und installieren. Die Anmeldung bleibt erhalten, solange dieselbe Signatur verwendet wird — das stellt der Workflow sicher.

Reihenfolge: erst Server, dann Apps. Der Server bedient Apps der vorherigen Minor-Fassung weiter — welche das ist, sagt er selbst unter /api/v1/version. Eine App, die zu alt ist, sagt es beim Anmelden und lässt sich nicht wegklicken; eine ältere, aber noch unterstützte weist einmal freundlich darauf hin und arbeitet normal weiter.


Der Windows-Installer von Hand

Der übliche Weg ist die App selbst: Einstellungen → Programm, dort steht die neue Fassung, und ein Druck darauf lädt sie, prüft die Prüfsumme, startet den Installer und beendet die App — genau in dieser Reihenfolge, denn ein Installer kommt an eine offene Datei nicht heran.

Wer ihn von Hand startet, sollte zwei Dinge wissen.

Er verlangt keine Verwalterrechte. Die Installation liegt unter %LOCALAPPDATA%\Programs\Rechnung und gehört dem angemeldeten Benutzer (PrivilegesRequired=lowest). Es gibt deshalb auch keine Rückfrage der Benutzerkontensteuerung — wer auf eine wartet, wartet vergebens.

Er scheitert, solange die App läuft. Der Installer versucht sie über den Neustart-Manager von Windows zu schließen; gelingt das nicht, kann er rechnung.exe nicht ersetzen:

DeleteFile: The existing file appears to be in use (5). Retrying.

Nach fünf Versuchen kommt eine Rückfrage Abbrechen / Wiederholen / Ignorieren. Mit /SUPPRESSMSGBOXES wird sie automatisch mit Abbrechen beantwortet — der Lauf rollt zurück und endet mit Rückgabewert 5. Von außen sieht das aus, als sei nichts geschehen.

Also: die App vorher schließen. Dann läuft auch rechnung-setup-x.y.z.exe /VERYSILENT /SUPPRESSMSGBOXES /NORESTART durch.

Das Protokoll steht unter %TEMP%. Seit 0.4.1 schreibt der Installer bei jedem Lauf eines — auch beim stillen —, als Setup Log <Datum> #<Nummer>.txt. Davor gab es nur mit /LOG=<Datei> eines, und ein gescheiterter stiller Lauf hinterließ nichts: das ist der Grund, warum ein hängengebliebenes Update einmal aussah, als schriebe der Installer „nicht einmal sein Protokoll".

RückgabewertWas er heißt
0durchgelaufen
1Setup konnte nicht starten
2im Assistenten abgebrochen, bevor installiert wurde
5während der Installation abgebrochen — fast immer die laufende App
8ein Neustart ist nötig

8. Laufender Betrieb

Was regelmäßig zu prüfen ist

Wie oftWasWo
WöchentlichLetzte Sicherung erfolgreich, Datenbankgröße, Protokollkette intaktApp → Übersicht, oder Verwaltung → Server, Updates und Sicherung
MonatlichBetriebssystem-Updates eingespielt, Container laufen, PlattenplatzSSH bzw. Docker Desktop
Bei neuem ReleaseServer aktualisieren, dann AppsAbschnitt 7
JährlichWiederherstellung üben, Konto beim Sicherungsspeicher und Domain verlängertAbschnitt 6

Protokoll-Kette prüfen rechnet die Prüfsummen des Änderungsprotokolls nach. Sie hängen aneinander; ein nachträglich geänderter Eintrag bricht die Kette. Der Knopf sitzt unten im Serverstatus.

Die App warnt von selbst: ein Hinweis ab unter 25 Prozent frei, eine dringende Meldung ab unter 15 Prozent. Auf derselben Platte liegen Datenbank, Dateien und Sicherung; eine Sicherung, die mitten im Schreiben auf eine volle Platte läuft, hinterlässt kein Backup, sondern ein halbes.

Was die Platte wirklich belegt

Gemessen am 4. September 2026 auf simpro-cloud-1, nach dem Aufräumen:

PostenGrößeAnteil
Docker-Abbilder6,63 GB78 %
/usr (Betriebssystem)1,5 GB18 %
apt-Zwischenspeicher261 MB3 %
Docker-Bauzwischenspeicher211 MB2 %
Datenbank (data/postgres)71 MB0,8 %
Website16 MB0,2 %
systemd-Journal19,6 MB0,2 %
Protokolle (data/logs)9,3 MB0,1 %
Lokale Sicherung (data/backup)1,6 MB0,02 %
Belegdateien (data/files)28 KB~0
Belegt8,5 GB von 75 GB (12 %)

Alles außer den Abbildern ist Rundungsfehler. Ein Abbild ist 3,18 GB groß, weil Chromium für die PDF-Ausgabe darin steckt. Wer auf einer vollen Platte zuerst die Sicherungen wegräumt, räumt 1,6 MB weg und verliert die schnellste Wiederherstellungsquelle.

Die lokale Vorhaltung steht auf 7 täglich / 4 wöchentlich / 12 monatlich / 7 jährlich — nominell sieben Jahre, tatsächlich 1,6 MB in dreizehn Dateien, weil restic nur Änderungen ablegt und ein Datenbankabzug dieses Betriebs komprimiert unter einem Megabyte liegt. Eine Verkürzung würde nichts sparen.

Ohne das Horten von Abbildern wächst die Platte um wenige Megabyte am Tag.

Was der Server nachts von selbst tut

ZeitWas
3:30Aufräumen: abgelaufene Token, Anmeldeversuche älter als 90 Tage, Abgleichvorgänge älter als 30 Tage, Meldungen älter als 90 Tage
3:30Aufbewahrungsbericht: was nach sieben Jahren löschbar wäre. Gelöscht wird nichts — das ist eine Entscheidung des Betriebs, nicht eines Cron-Auftrags
3:30Meldungen erzeugen: überfällige Rechnungen, fällige Abos, Artikel unter Mindestbestand, ausgebliebene Sicherung, Mahnvorschläge
3:30Bankumsätze holen, falls ein Konto verbunden ist — und an Zustimmungen erinnern, die in weniger als 14 Tagen ablaufen
3:30Shopify-Bestellungen zu Aufträgen machen, falls ein Laden angebunden ist
stündlichDie täglichen Zusammenfassungen verschicken, für alle, deren eingestellte Stunde gerade geschlagen hat
3:15 (Vorgabe)Die Sicherung, siehe Abschnitt 6

Alles davon steht im Anwendungsprotokoll — auch wenn nichts anliegt. Eine Prüfung, die im stillen Fall gar nichts schreibt, lässt sich von einer Prüfung, die nicht läuft, nicht unterscheiden.

Bankumsätze selbst abholen (freiwillig)

Ohne diesen Abschnitt bleibt es beim Einlesen von Kontoauszugsdateien. Das funktioniert unverändert und wird durch nichts hiervon angetastet.

Der Unterschied ist nicht die Genauigkeit, sondern wer daran denken muss. Eine Datei liest jemand ein, wenn er daran denkt; wer zweimal im Monat daran denkt, mahnt Kunden, die vor drei Wochen bezahlt haben.

Was Sie brauchen

Ein Konto bei einem PSD2-Aggregator. Enable Banking ist der Weg, den man heute gehen kann; GoCardless Bank Account Data nimmt seit Juli 2025 keine neuen Kunden mehr an (der Adapter dafür ist da und nützt nur, wer schon einen Zugang hat).

Dort eine Anwendung anlegen. Heraus kommen eine Anwendungskennung und eine private Schlüsseldatei (.pem).

**Was Sie nicht brauchen: ein eigenes eIDAS-Zertifikat (QWAC).** Das kostet 3.000 bis 8.000 Euro im Jahr und ist der Grund, warum es in Europa kaum kostenlose Bankschnittstellen gibt. Nötig ist es nur für den, der selbst Zahlungsdienstleister wird. Über einen Aggregator ist der Aggregator der lizenzierte Teilnehmer, nicht Sie.

Was in die .env kommt

ENABLEBANKING_APPLICATION_ID=<aus dem Kundenbereich>
ENABLEBANKING_PRIVATE_KEY=/etc/rechnung/enablebanking.pem
BANK_REDIRECT_URL=https://<Ihre Domain>/einstellungen/bankkonten/zurueck

Die Schlüsseldatei muss für den Container lesbar sein — legen Sie sie neben die .env und binden Sie sie ein, oder tragen Sie einen Pfad ein, der im Container existiert. Sie gehört nicht ins Repository und nicht in eine Sicherung, die jemand anders lesen kann: mit ihr wird jede Anfrage unterschrieben.

Die Rückkehradresse muss beim Anbieter als erlaubt hinterlegt sein. Fehlt sie dort, bricht die Anmeldung ganz am Ende ab — mit einer Meldung der Bank, die niemand versteht.

Danach docker compose up -d --force-recreate api.

Was der Betrieb dann tut

Einstellungen → Bankkonten → Konto verbinden. Die Anmeldung findet im Browser bei der Bank statt; dieses Programm sieht die Bankzugangsdaten nie und kann nur lesen, nicht überweisen.

Die erste Probe ist die Bankliste. Steht die eigene Bank nicht drin, nützt der Abgleich für dieses Konto nichts — dann bleibt der Weg über die Datei.

Was Sie im Auge behalten müssen

Die Zustimmung läuft nach höchstens 90 Tagen ab. Das ist PSD2 und keine Einschränkung dieses Programms. Danach kommen keine Umsätze mehr — und zwar still: es gibt keine Fehlermeldung, es sieht aus wie eine ruhige Woche.

Dagegen zwei Dinge: der nächtliche Lauf meldet ab 14 Tagen vorher, und der Bildschirm Bankkonten zeigt die Restlaufzeit auch dann, wenn noch zwei Monate Zeit sind.

Die Automatik

Je Konto einstellbar und voreingestellt aus. Sie bucht eine Zahlung ohne Rückfrage nur, wenn alle drei zutreffen:

  • Die Belegnummer steht im Verwendungszweck, als eigenes Wort
  • Der Betrag stimmt auf den Cent mit dem offenen Posten
  • Es passt genau ein Beleg

Alles andere landet wie bisher in der Vorschlagsliste. Warum so eng: wer täglich vierzig Überweisungen bestätigt, von denen achtunddreißig eindeutig sind, klickt nach einer Woche ohne zu lesen — und übersieht die zwei, bei denen es darauf angekommen wäre.

Jede automatische Buchung wird gemeldet (Zahlung automatisch zugeordnet, voreingestellt an) und lässt sich zurücknehmen. An jeder Zeile steht, warum sie gebucht wurde — oder warum nicht.

Einen Shopify-Laden anbinden (freiwillig)

Wer online verkauft, tippt die Bestellungen sonst ab. Bei zehn am Tag sind das zehn Gelegenheiten, sich zu vertippen — bei Beträgen, die später auf einer Rechnung stehen.

Was in Shopify anzulegen ist

Eine eigene App (custom app), keine aus dem App Store:

  1. Im Shop-Admin: Einstellungen → Apps und Vertriebskanäle → App entwickeln
  2. App anlegen, Admin-API-Berechtigungen wählen:

| Berechtigung | Wofür | Nötig? | |---|---|---| | read_orders | Bestellungen nachholen, wenn ein Webhook verloren geht | ja | | read_products | Die SKU einer Bestellung dem Artikel zuordnen | ja | | read_customers | Kundendaten der Bestellung | ja | | read_locations | Die Lagerorte des Shops anzeigen | nur mit Rückschreiben | | read_inventory | Den Bestand im Shop lesen | nur mit Rückschreiben | | write_inventory | Den Bestand im Shop setzen | nur mit Rückschreiben |

Wer älter als 60 Tage zurückschauen will, braucht zusätzlich read_all_orders — das vergibt Shopify nur auf Antrag.

Pflicht dazu: write_webhooks (und read_webhooks). Ohne sie kann der Server die Webhooks nicht selbst anlegen; es käme dann keine Bestellung von allein an, nur der nächtliche Abgleich holte sie nach. Der Verbindungstest benennt den fehlenden Scope.

Empfohlen dazu: read_draft_orders. Nicht für den Betrieb — es wird nie ein Entwurf übernommen —, sondern für die Diagnose. Ein Entwurf sieht im Shopify-Admin wie eine Bestellung aus, ist aber keine und löst keinen Webhook aus. Mit dieser Berechtigung sagt der Verbindungstest, dass welche liegen; ohne sie steht dort nur, dass er es nicht sehen kann.

  1. Installieren. Heraus kommt ein Admin-API-Zugriffstoken (shpat_…)

Dann in diesem Programm: Einstellungen → Shopify → Laden eintragen, mit der Ladenadresse (mein-laden.myshopify.com), dem Token und dem API-Geheimschlüssel der App (shpss_…, in Shopify bei der App unter den API-Zugangsdaten).

Die Webhooks legt der Server selbst an — beim Eintragen, beim Einschalten und bei jedem Verbindungstest. Von Hand ist dort nichts mehr einzutragen. Er legt an, was fehlt, richtet eine falsche Adresse, ersetzt einen, der in einer alten Fassung zugestellt wird, und entfernt eigene Einträge zu Themen, die dieses Programm nicht mehr verarbeitet:

EreignisWofür
orders/createDie neue Bestellung
orders/updatedDie nachträglich bezahlte
orders/cancelledDamit aus einer stornierten Bestellung kein Auftrag mehr wird
app/uninstalledWird der Zugang entfernt, schaltet sich der Laden ab, statt still zu verstummen

Angefasst wird nur, was dieser App gehört. Was jemand früher von Hand im Shop-Admin eingetragen hat, sieht dieses Programm gar nicht und rührt es deshalb auch nicht an — das gehört dort gelöscht, sonst kommt jede Bestellung zweimal.

Warum der API-Geheimschlüssel: Shopify unterschreibt einen Webhook, den eine App über die Schnittstelle anlegt, mit dem Geheimschlüssel der App — nicht mit dem Geheimnis, das beim Eintragen zurückkommt. Fehlt er, kommt jede Bestellung an und wird als falsche Unterschrift abgewiesen: der Shop meldet erfolgreiche Zustellung, hier entsteht nichts. Der Verbindungstest sagt es ausdrücklich, wenn er fehlt.

Das alte Webhook-Geheimnis gibt es weiterhin, und Läden, deren Webhooks noch von Hand im Admin stehen, laufen unverändert: die Annahmestelle prüft gegen beide.

Der Weg muss von außen erreichbar sein. Der Webhook kommt von Shopify und nicht aus dem Hausnetz: POST /api/v1/shopify/webhook auf der Domain, unter der dieser Server ohnehin steht. Ein eigener Server-Block ist dafür nicht nötig, ein eigener Port auch nicht — der Weg läuft durch dieselbe Weiterleitung wie die übrige Schnittstelle. Was er zusätzlich braucht: der rohe Rumpf muss unverändert ankommen. Ein Zwischenglied, das JSON neu formatiert, erzeugt andere Bytes und damit eine andere Unterschrift; die Bestellung wird dann mit gültigem Geheimnis abgewiesen, und niemand sieht, warum.

**Drücken Sie danach in der App Verbindung prüfen.** Der Test ändert nichts und meldet, was sonst erst an der ersten Bestellung auffällt: die Währung des Ladens, die tatsächlich vergebenen Berechtigungen und die Lagerorte.

Das Token liegt verschlüsselt in der Datenbank (AES-256-GCM, Schlüssel aus APP_ENCRYPTION_KEY) — wie das TOTP-Geheimnis. Wer den Schlüssel wechselt, muss den Laden neu eintragen; bis dahin wird kein Webhook angenommen, und das ist Absicht.

Der Webhook-Endpunkt ist die einzige öffentliche Stelle dieses Servers, die Belege erzeugt. Shopify kann sich nicht anmelden — es kennt weder Benutzer noch Mandanten. Die HMAC-Unterschrift ist die ganze Tür. Ein Webhook ohne gültige Unterschrift wird verworfen, und zwar mit derselben Antwort wie einer von einem unbekannten Laden: wer probiert, welche Läden eingetragen sind, soll es nicht am Statuscode merken.

Was dann geschieht

Der Webhook legt die Bestellung nur ab und antwortet sofort. Shopify erwartet eine Antwort binnen fünf Sekunden und stellt sonst erneut zu — wer erst übersetzt und dann antwortet, bekommt bei jeder langsamen Sekunde dieselbe Bestellung ein zweites Mal.

Übersetzt wird nachts, oder auf Knopfdruck. Aus einer Bestellung wird ein Auftrag, kein Rechnung: Kunde über die E-Mail-Adresse zusammengeführt, Artikel über SKU oder Barcode, Versand als eigene Position.

Die Rückfallebene: wenn ein Webhook verloren geht

Ein Webhook ist eine Zustellung, kein Vertrag. Er geht verloren, wenn der Server gerade neu startet, wenn die Leitung klemmt, wenn Shopify nach mehreren Fehlversuchen aufgibt. In genau diesem Fall gibt es keine Fehlermeldung — nur eine Bestellung, die niemand je gesehen hat.

Deshalb fragt der Server stündlich nach, was sich im Shop geändert hat (updated_at, nicht created_at — sonst fehlt jede Bestellung, die nachträglich bezahlt wurde), und legt an, was fehlt. Der Schlüssel dagegen ist die Shopify-Bestellnummer: dieselbe Bestellung wird nie zweimal angelegt.

An jeder Bestellung steht, woher sie kam. Steht dort überwiegend abgleich, kommen die Webhooks nicht an — eine Diagnose, die sonst niemand stellt, weil am Ende ja alles da ist.

Nur Euro

Bestellungen in einer anderen Währung werden nicht übernommen. Sie landen in der Warteliste mit dem Grund. Dieses Programm rechnet in Euro, kennt keine Kurse und führt keinen Kurstag; ein Betrag in fremder Währung als Euro verbucht fällt niemandem auf.

Das ist der Grund, warum der Verbindungstest die Währung des Ladens meldet: ein Shop, der in Dollar rechnet, nimmt jede Bestellung an — und keine davon kommt an.

Was ausdrücklich nicht von selbst geschieht

Es wird kein Artikel angelegt. Eine unbekannte SKU wird gemeldet und wartet. Ein Artikel, den ein Webhook erzeugt hat, stünde ohne Preis, ohne Steuersatz und ohne Konto im Stamm — und niemand wüsste, woher er kam.

Es entsteht kein halber Auftrag. Fehlt ein Artikel, wartet die ganze Bestellung. Ein Auftrag, dem Positionen fehlen, sieht aus wie ein Auftrag und ist keiner.

Unser Steuersatz gilt, nicht der des Shops. Läuft beides auseinander, wird es gemeldet: die Rechnung stellen wir aus, und für ihre Richtigkeit haften wir. Wer im Shop 10 % einstellt und im Artikelstamm 20 %, bekommt sonst Rechnungen, die dem Kunden anders aussehen als seine Bestellung.

Eine Rechnung nur auf Wunsch. Die Einstellung ist voreingestellt aus: eine festgeschriebene Rechnung lässt sich nur noch per Storno korrigieren.

Der Lagerstand zurück in den Shop

Ebenfalls freiwillig und ebenfalls aus. Wer es einschaltet, macht dieses Programm zur Quelle der Wahrheit für den Bestand — und überschreibt damit, was jemand im Shop von Hand eingetragen hat.

Dafür braucht die App im Shop zusätzlich read_locations, read_inventory und write_inventory.

Und einen zugeordneten Lagerort. Ein Shop führt oft mehr als einen; schon ein frisch angelegter Entwicklungsladen bringt drei mit („Shop location", „My Custom Location", „Snow City Warehouse"). Solange keiner zugeordnet ist, wird nichts geschrieben. Die Zuordnung steht in der App unter Lager im Shop.

Früher nahm das Rückschreiben schlicht den ersten Lagerort. Das ist keine Zuordnung, das ist ein Würfel — und der Bestand landet im falschen Lager, ohne Fehlermeldung.

Geschrieben wird nur, was beide Seiten kennen: ein Artikel mit einer SKU, zu der es im Shop eine Variante gibt. Was der Shop nicht führt, wird übersprungen — ihn dort anzulegen wäre etwas anderes als einen Bestand zu melden.

Ein negativer Bestand geht als Null hinaus. Bei uns ist er eine Auskunft („mehr verkauft als da"), im Shop wäre er eine Einladung, noch mehr zu verkaufen.

Shopifys Schnittstelle begrenzt die Anzahl der Anfragen mit einem Eimer: bei den kleinen Tarifen fasst er 40 und läuft mit zwei je Sekunde nach, bei größeren mehr (ein Entwicklungsladen meldet 400). Das Programm rechnet deshalb mit dem Verhältnis und nicht mit einer festen Zahl. Beim Rückschreiben geht es um eine Anfrage je Artikel — bei dreihundert Artikeln also um einige Minuten. Das Programm bremst von selbst, bevor es klemmt; im Protokoll steht am Ende, wie viele geschrieben und wie viele übersprungen wurden.

Stellschrauben in der .env

Alle haben brauchbare Vorgaben; wer nichts einträgt, bekommt sie.

NameVorgabeWofür
MAX_FAILED_LOGINS10Ab dem wievielten Fehlversuch ein Konto gesperrt wird
LOCKOUT_MINUTES15Die erste Sperre
LOCKOUT_MAX_MINUTES1440Die Sperre verdoppelt sich mit jedem weiteren Fehlversuch — bis hierher. Ohne Deckel könnte jemand, der eine E-Mail-Adresse kennt, ein Konto dauerhaft lahmlegen; ein Verwalter hebt die Sperre ohnehin sofort auf
MAX_DEVICES10Wie viele Geräte je Konto gleichzeitig angemeldet sein dürfen. Beim elften fällt das älteste heraus. 0 schaltet die Grenze ab
ACCESS_TOKEN_TTL900Lebensdauer des Zugriffstokens in Sekunden
REFRESH_TOKEN_TTL2592000Lebensdauer des Erneuerungstokens (30 Tage). Ein gleitendes Fenster: jede Erneuerung setzt es neu
SESSION_MAX_LIFETIME2592000Die absolute Höchstdauer einer Sitzung, gerechnet ab der Anmeldung — nicht ab der letzten Erneuerung. Ohne diesen Deckel lebt eine Sitzung, die knapp innerhalb des Fensters benutzt wird, unbegrenzt weiter. Neu in 0.1.15; bestehende Sitzungen haben die Grenze rückwirkend ab ihrem Beginn bekommen
ENABLE_API_DOCSausDie Schnittstellenbeschreibung unter /api/docs. Im Betrieb aus lassen — sie zeigt jedem die vollständige Endpunktliste
PDF_ALLOW_NO_SANDBOXausNotausgang. Chromium setzt die PDFs seit 0.1.15 mit Sandkasten; auf einem Wirt mit altem Kern kann der fehlen, und dann steht der PDF-Dienst still. Das ist die richtige Vorgabe — still ist besser als heimlich ungeschützt. Wer ihn dort braucht, schaltet ihn hiermit bewusst ab und findet die Begründung im Protokoll wieder

Nach einer Änderung: docker compose up -d --force-recreate api.

APP_SECRET zu tauschen beendet jede laufende Sitzung. Das ist die schnellste Notbremse, wenn etwas passiert ist, und kostet nichts als eine erneute Anmeldung aller. Ausführlich in docs/SICHERHEIT.md, Abschnitt 13.

Protokolle ansehen

docker compose logs -f api              # Anwendungsprotokoll
docker compose logs -f caddy-internal   # Zugriffe, Zertifikate (BEHIND_PROXY)
docker compose logs -f caddy            # dasselbe im Modus STANDALONE
docker compose logs -f backup           # Sicherungen

Aufräumen

Docker sammelt alte Abbilder an. Auf dem Entwicklungsrechner läuft dafür eine wöchentliche geplante Aufgabe unter dem Konto SYSTEM, die scripts/aufraeumen.ps1 startet. Sie entfernt Abbilder, die älter als eine Woche und unbenutzt sind, und greift zusätzlich, sobald mehr als 10 GB belegt sind — die reine Altersregel feuert bei täglichen Bauten nämlich nie.

Von Hand, monatlich, genügt auf einem reinen Server:

docker system prune -f

Unter Windows liegen die Abbilder in einer VHDX-Datei auf C:, nicht im DATA_DIR. Diese Datei schrumpft nicht, wenn Abbilder gelöscht werden — sie gibt den Platz nur intern frei. Wer die Datei wirklich verkleinern will, braucht Optimize-VHD oder ein Zurücksetzen von Docker Desktop.

Umzug auf einen anderen Rechner

  1. Auf dem alten Server: docker compose down, dann DATA_DIR und .env auf den neuen Rechner kopieren.
  2. Auf dem neuen Rechner: Docker installieren, Quellcode holen, .env hineinkopieren, den DATA_DIR-Pfad darin anpassen, docker compose up -d.
  3. DynDNS bzw. Portfreigabe auf den neuen Rechner umstellen.
  4. Die Apps merken davon nichts, die Serveradresse bleibt gleich.

Dafür gibt es zwei Skripte, die den Vorgang samt Integritätsprüfung übernehmen:

./scripts/migrate-export.sh [zielverzeichnis]     # auf dem alten Rechner
./scripts/migrate-import.sh <archiv>              # auf dem neuen

Unter Windows heißen sie migrate-export.ps1 und migrate-import.ps1.


8.1 Das Zustellungsprotokoll der Webhooks

Jede eingehende Webhook-Anfrage bekommt eine Zeile — die angenommene, die abgewiesene und die von einem unbekannten Laden.

Warum das nötig war. Dreimal stand hier die Frage im Raum, ob ein Webhook angekommen ist, und dreimal war sie nicht beantwortbar. Eine angenommene Zustellung hinterlässt eine Spur (lastEventAt, die Webhook-Kennung am Beleg); eine abgewiesene hinterließ nur eine Zeile im Behälterprotokoll — und die ist beim nächsten Neustart weg. „Nie angekommen" und „angekommen und abgewiesen" sahen von außen gleich aus, obwohl das eine ein Netzproblem ist und das andere ein falscher Schlüssel.

Zu sehen sind die letzten zehn im Verbindungstest (Einstellungen → Onlineshop → Verbindung testen). Eine leere Liste heißt: es kam nie etwas an.

Was in einer Zeile steht: Zeitpunkt, Thema, Laden, ob die Unterschrift stimmte, was daraus wurde (ANGENOMMEN, ABGEWIESEN, UNBEKANNT, FEHLER) und der Grund. Kein Rumpf — die Bestellung selbst steht ohnehin in der Bestellliste, und ein Protokoll mit Kundendaten wäre eine zweite Ablage personenbezogener Daten ohne Zweck.

Aufbewahrung 90 Tage; die nächtliche Wartung räumt Ältere weg. Länger wäre eine Sammlung fremder Anfragedaten ohne Zweck, kürzer hilft nicht bei einer Frage, die erst nach zwei Wochen gestellt wird.

Nach außen ändert das Protokoll nichts: Shopify bekommt weiterhin 200, und ein unbekannter Laden erfährt weiterhin nicht, dass er unbekannt ist.


8.2 Die Ausgabenseite: was der Betreiber davon wissen muss

Der Anwender findet die Ausgaben in seiner Anleitung, Kapitel 13 und 14. Hier steht, was beim Betrieb daran hängt.

Der Nummernkreis der Ausgabenbelege

Seit 0.2.0 gibt es einen zwölften Nummernkreis: EXPENSE_RECEIPT, Vorgabe ER-{YYYY}-{SEQ}. Er ist der einzige für etwas, das hereinkommt statt hinauszugehen — die Belegsammlung nach § 132 BAO braucht eine eigene Ordnung, die der Lieferant nicht liefert.

Er verhält sich wie die anderen: Zeilensperre beim Ziehen, Jahreszähler, änderbares Muster unter Einstellungen → Nummernkreise. Bei einer bestehenden Installation legt ihn die Migration mit an; wer ihn vorher schon von Hand angelegt hatte, behält seinen.

Die Belege selbst

Ein angehängter Beleg liegt wie jede andere Datei im Dateispeicher unter data/files und wird von restic mitgesichert. Die Aufbewahrungsfrist steht an der Datei (retainUntil): sieben Jahre ab Ende des Kalenderjahres, so wie es § 132 BAO verlangt. Die Aufräumroutine rührt sie bis dahin nicht an — auch dann nicht, wenn der Datensatz daneben verworfen wurde.

Die Zuordnung auf die Kennzahlen der Beilage E 1a

Der Jahresreport sortiert nach den Kennzahlen des Formulars. Die Zuordnung steht als eine Quelle im Code (server/src/steuer/e1a.ts); hier ist sie zum Nachschlagen. Bezeichnungen wörtlich aus dem Formular E 1a-K, Version vom 07.02.2025 (Beilage zur Einkommensteuererklärung E 1 für Kleinbetriebe für 2024, formulare.bmf.gv.at).

KZBezeichnung laut FormularAusgabengruppen des Programms
9040Betriebseinnahmen (Waren-/Leistungserlöse)die bezahlten Ausgangsrechnungen
9100Waren, Rohstoffe, HilfsstoffeWareneinkauf
9110Beigestelltes Personal (Fremdpersonal) und Fremdleistungen(noch keine eigene Gruppe — Fremdleistungen liegen als Kategorie unter Wareneinkauf)
9120Ausgaben für eigenes PersonalPersonal
9130Abschreibungen auf das AnlagevermögenAbschreibung, Geringwertige Wirtschaftsgüter
9160Reise- und Fahrtspesen inkl. Km-Geld und DiätenReise
9180Ausgaben für Miete, Pacht, LeasingMiete
9220Zinsen und ähnliche AufwendungenZinsen
9225Eigene Pflichtversicherungsbeiträge, Beiträge zu Versorgungs- und Unterstützungseinrichtungen und Beiträge zur SelbständigenvorsorgeSozialversicherung
9230Übrige, in den obigen Kennzahlen nicht erfasste, BetriebsausgabenEnergie, Instandhaltung, Kfz-Kosten, Werbung, Büromaterial, Telefon/Internet, Versicherungen, Beiträge, Rechts- und Beratungskosten, Sonstiges

Warum so viele Gruppen auf 9230 zeigen: das Formular hat weniger Zeilen als ein Betrieb Ausgabenarten. Im Programm bleiben sie getrennt, weil der Betrieb sie getrennt sehen will; im Formular landen sie in einer Summe. Der Jahresreport zeigt unter der Sammelzeile, woraus sie besteht.

Was das Programm bewusst nicht ausrechnet: Gewinnfreibetrag (9221/9227), Investitionsfreibetrag (9276/9277), die Arbeitsplatzpauschalen (9215/9217), Öko-Zuschlag (9338), Spenden (9243 ff.). Nichts davon ist aus Buchungen ablesbar. Wer es automatisch ausrechnen ließe, gäbe eine Zahl aus, für die er nicht geradestehen kann.

Wenn sich das Formular ändert, ist e1a.ts die einzige Stelle. Der Compiler meldet sich, wenn eine neue Ausgabengruppe ohne Kennzahl dazukommt.

Die Übernahme aus einer Tabelle

POST /incoming-invoices/uebernahme nimmt den Inhalt einer CSV-Datei als Zeichenkette entgegen — zweistufig: ohne schreiben wird nur geprüft. Die Grenze liegt bei zwei Megabyte Text, was für einige zehntausend Zeilen reicht; sie ist keine eigene Upload-Strecke, sondern der normale JSON-Rumpf.

Angelegt werden Entwürfe. Sie zählen nirgends mit, bis jemand sie freigibt — das ist Absicht: eine übernommene Zeile hat keine Steuerangaben und keinen Beleg, und beides soll ein Mensch ansehen, bevor es in die Einnahmen-Ausgaben-Rechnung geht.


9. Die Kasse

Ab 0.3.0 kann vor Ort kassiert werden — Kurse, Startgelder, Kleinverkauf. Der Bereich heißt Kasse und steht auf dem Telefon an zweiter Stelle in der unteren Leiste.

Kassiert wird am Telefon, ausgewertet am Rechner. Auf Windows zeigt der Bereich den Tagesabschluss und den Weg zu den Einstellungen, aber keinen Verkauf. Das hat zwei Gründe: kassiert wird dort, wo der Kunde steht, und zwei Geräte, die gleichzeitig kassieren, teilen sich eine Belegnummernfolge, die lückenlos bleiben muss.

Ohne Verbindung wird nicht kassiert. Alles andere in der App arbeitet offline weiter; die Kasse nicht. Die Belegnummer zieht der Server, und eine Lücke darin lässt sich nachträglich nicht heilen. Der Kassenbildschirm sagt das deutlich, statt eine Sicherheit vorzutäuschen, die er nicht hat.

Der Punkt, an dem sich Betriebe verrechnen

Eine Bankomat- oder Kreditkartenzahlung vor Ort ist ein Barumsatz (§ 131b Abs 1 Z 3 BAO), auch wenn kein Schein den Besitzer wechselt. „Wir nehmen ja kaum Bargeld" stimmt in vielen Betrieben — und die Kartenumsätze daneben reißen die Grenze trotzdem.

Eine Überweisung ist dagegen kein Barumsatz. Deshalb steht „Auf Rechnung" auf dem Kassenbildschirm immer zur Verfügung, auch wenn bar und Karte gesperrt sind.

Die zwei Zustände, und wer sie wählt

Bevor bar oder mit Karte kassiert werden kann, muss der Betrieb eine Kassenpflicht-Erklärung abgeben. Sie steht unter Einstellungen → Firmenstammdaten → Kasse und darf nur ein Verwalter setzen.

a) Unter den Grenzen

Jahresumsatz unter 15.000 €, davon unter 7.500 € bar und mit Karte vor Ort.

Dann darf ohne Signatureinheit kassiert werden. Das ist keine Notlösung und kein Testbetrieb: der Beleg ist ein vollwertiger Beleg und trägt keinen Vermerk.

Was trotzdem gilt: die Belegerteilungspflicht nach § 132a BAO. Sie hängt an keiner Umsatzgrenze. Wer einen Barumsatz macht, hat einen Beleg zu erteilen — auch der kleinste Betrieb, auch ohne Registrierkasse. SIMPro erzeugt ihn deshalb bei jedem Weg.

b) Registrierkassenpflichtig – Signatur eingerichtet

Setzt eine vollständig eingerichtete Registrierkasse voraus: fiskaltrust-Konto, FinanzOnline-Webservice-Benutzer, CashBox, geprüfte Verbindung, Startbeleg und dessen Prüfung mit dem Belegcheck. Dieser Zustand lässt sich nicht einfach anklicken — er behauptet zwei Dinge auf einmal, und das zweite kann niemand erklären. Er wird frei, wenn der Einrichtungsassistent alle Schritte grün meldet (kommt mit Stufe 2).

Der Wechsel a → b ist jederzeit möglich. Der Weg b → a verlangt eine Begründung, die dauerhaft im Prüfprotokoll steht: die Registrierkassenpflicht endet nach § 131b Abs 4 BAO erst, wenn die Grenzen in einem Folgejahr unterschritten werden und ein neuerliches Überschreiten nicht zu erwarten ist. Die zweite Hälfte ist eine Prognose.

Was SIMPro mitrechnet — und was es nicht kann

Auf dem Kassenbildschirm und in den Einstellungen stehen beide Werte mit ihrer Ausschöpfung. Ab 80 % bei beiden kommt eine Warnung, in der App und als Meldung an die Verwalter. Bei nur einem der beiden kommt keine: sonst warnte das Programm jeden Betrieb mit ordentlichem Umsatz und ohne einen einzigen Bargeschäftsfall, und nach dem dritten Mal sieht niemand mehr hin.

Gerechnet wird aus den eigenen Belegen:

  • Jahresumsatz netto, aus den festgeschriebenen, nicht stornierten Ausgangsbelegen.
  • Barumsatz aus den Bar- und Kartenzahlungen, nach Zahlungsdatum, umgerechnet auf den Nettoanteil des jeweiligen Belegs.

Die Einschätzung bleibt beim Betrieb. Diese Rechnung ist nur vollständig, wenn aller Umsatz durch dieses Programm läuft. Ein zweiter Betrieb desselben Unternehmers zählt getrennt; Umsätze, die nie hier erfasst wurden, fehlen; ein abweichendes Wirtschaftsjahr verschiebt den Zeitraum. Im Zweifel entscheidet die Steuerberatung, nicht der Balken auf dem Bildschirm.

Der Stichtag

Werden beide Grenzen in einem Quartal überschritten, beginnt die Pflicht nicht sofort. Sie beginnt mit dem viertfolgenden Monat nach Ablauf des Quartals — vier Monate Zeit, eine Kasse einzurichten:

Grenzen gerissen imPflicht ab
1. Quartal (Jän–Mär)1. Juli desselben Jahres
2. Quartal (Apr–Jun)1. Oktober desselben Jahres
3. Quartal (Jul–Sep)1. Jänner des Folgejahres
4. Quartal (Okt–Dez)1. April des Folgejahres

Der häufigste Rechenfehler ist, die vier Monate ab dem Tag des Überschreitens zu zählen statt ab dem Quartalsende. Bei einem Überschreiten im Dezember landet man dann im April — statt im April des nächsten Jahres.

SIMPro zeigt den Stichtag, sobald er feststeht, und ab diesem Tag sind Bar- und Kartenzahlung gesperrt, bis Zustand b eingerichtet ist. Das ist streng, und mit Absicht: ein unsignierter Barumsatz nach dem Stichtag ist eine Finanzordnungswidrigkeit (§ 51 FinStrG), und der Betrieb merkt es erst bei der Prüfung. „Auf Rechnung" bleibt offen.

Das Prüfprotokoll

Jede Entscheidung hängt sich als Zeile an und wird nie überschrieben — mit Datum, Person, Begründung und den Umsatzzahlen, die zu diesem Zeitpunkt galten. Die Datenbank lässt auf dieser Tabelle nur Lesen und Anhängen zu, kein Ändern und kein Löschen.

Wer in drei Jahren gefragt wird, seit wann signiert kassiert wird und was vorher galt, findet die Antwort dort.

Kassenartikel einrichten

Unter Einstellungen → Kassenartikel. Ein Artikelstamm mit vierhundert Positionen ist eine Liste zum Suchen; die Kasse braucht die Auswahl daraus — zehn bis zwanzig sind eine gute Zahl, in der Reihenfolge, in der sie gebraucht werden.

Je Kachel lassen sich einstellen:

  • Reihenfolge durch Ziehen am Griff rechts.
  • Farbe aus neun kräftig verschiedenen Tönen. Farbe ist an einer Kasse kein Schmuck: wer zwanzig Mal in der Stunde dieselbe Kachel trifft, trifft sie über die Farbe, nicht über den Text.
  • Aufschrift, wenn der Artikelname nicht auf eine Kachel passt. „Tageskarte" passt, „Tageskarte Erwachsene inkl. Leihausrüstung" nicht. Ohne eigene Aufschrift steht der Artikelname da und folgt ihm auch, wenn er im Stamm umbenannt wird.

Die Kachel Freier Betrag steht immer vorn und braucht keine Einrichtung. Sie fragt nach Text, Betrag und Steuersatz. Der Text landet auf dem Beleg — auf ihm muss stehen, wofür gezahlt wurde (§ 11 Abs 1 Z 3 UStG); „Diverses" ist bei einer Prüfung keine Bezeichnung.

Kassieren

Kacheln antippen, dann unten die Zahlungsart. Dreimal dieselbe Kachel ergibt drei Stück und keine drei Zeilen.

  • Bar öffnet den Rückgeldrechner. Oben steht der Betrag, darunter die Tasten für das, was ein Mensch hinlegt — bei 17,30 € sind das passend, 18,00, 20,00, 50,00 und 100,00. Das Rückgeld steht darunter, nach dem Abschluss zusätzlich mit der Stückelung („1× 2,00 € · 1× 1,00 € · …"). Eine Unterzahlung nimmt die Kasse nicht an. Teilzahlungen kennt sie nicht; für einen Rest gibt es „Auf Rechnung".
  • Karte zeigt den Betrag zum Abgleich mit dem Terminal und nimmt die Referenz des Terminals auf — freiwillig, aber sie hilft beim Zuordnen der Abrechnung. Ab Stufe 3 übernimmt das die Zahlungs-App auf demselben Gerät.
  • Auf Rechnung braucht einen Kunden: ein Laufkunde hat keine Anschrift, und ohne die ist eine Rechnung nach § 11 UStG unvollständig. Es entsteht eine Rechnung im Rechnungskreis, kein Kassenbeleg — sie ist offen und nicht bezahlt, denn hier ist kein Geld geflossen.

Ein Verkauf mit Bar oder Karte ergibt einen Kassenbeleg im eigenen Nummernkreis KB-JJJJ-NNNN. Er ist sofort bezahlt und hat keine Fälligkeit.

Der Warenkorb bleibt stehen, wenn etwas schiefgeht. Eine Kasse, die bei einem Fehler den Korb wegwirft, lässt jemanden zwanzig Positionen neu tippen.

Eine offene Rechnung vor Ort kassieren

Über den Beleg selbst: eine Rechnung, die jemand am Tresen bar bezahlt, ist ein Barumsatz — es gilt dieselbe Freigabe wie beim Verkauf. Es entsteht kein neuer Beleg: die Rechnung ist schon geschrieben, hier fließt nur das Geld.

Der Tagesabschluss

Über das Symbol rechts oben im Kassenbereich, auch als PDF.

Er ist gebaut, um neben der Kassenlade zu liegen: der Barbetrag steht groß und allein oben. Er ist die einzige Zahl, die mit dem Inhalt der Lade zu vergleichen ist. Kartenumsätze kommen vom Anbieter aufs Konto und stehen daneben, nicht darin — wer beides zusammenzählt und dann nachzählt, findet jeden Abend eine Differenz, die es nicht gibt. Auf dem Ausdruck gibt es Felder für das Zählergebnis, die Differenz und ein Handzeichen.

Gezählt wird nach dem Zahlungsdatum, nicht nach dem Belegdatum: eine Rechnung vom Vormonat, die heute bar bezahlt wird, liegt heute in der Lade.

Der Testmodus

Nur für den Entwicklungsstapel. Er erlaubt Kassieren, wo sonst gar nicht kassiert werden könnte, und stempelt jeden Beleg mit „TESTBELEG – KEINE RKSV-SIGNATUR". Ein solcher Beleg zählt in keiner Summe mit: nicht im Tagesabschluss, nicht in den Umsatzgrenzen, nicht in der E/A-Rechnung. Im Tagesabschluss steht er trotzdem, in einer eigenen Zeile darunter — ihn zu verschweigen wäre die andere Hälfte desselben Fehlers.

Einschalten verlangt zwei Dinge:

  1. KASSE_TESTMODUS=1 in der .env, und
  2. ein selbst gebautes Abbild. Das veröffentlichte trägt eine Markierung aus dem Bauschritt und weist den Modus ab.
# Nur im Entwicklungsstapel:
echo 'KASSE_TESTMODUS=1' >> .env
docker compose build api
API_IMAGE=rechnung-api:local docker compose up -d api

Der Riegel ist nicht absolut, und er soll nicht so klingen. Wer root auf seinem eigenen Server hat, kommt an jedem Riegel vorbei. Er verhindert das Versehen, nicht die Absicht. Die eigentliche Sicherung ist der Stempel am Beleg: er steht in der Datenbank und lässt sich nachträglich nicht wegschalten. Ein Betrieb, der sich als registrierkassenpflichtig erklärt hat, bekommt den Testmodus ohnehin nicht — auch nicht mit gesetzter Variable.

Was noch nicht da ist

Signatur nach RKSVSeit 0.3.1 da — siehe Kapitel 9.1. Erst wenn der Einrichtungsassistent alle sechs Schritte grün meldet, lässt sich Zustand b wählen.
Kartenzahlung über das GerätStufe 3 (Terminal-App) und 4 (Tap to Pay). Bis dahin: am Terminal zahlen, in der Kasse bestätigen.
Bondruck über BluetoothSpäter. Die Belegerteilungspflicht hängt nicht am Papier — ein Beleg per E-Mail, QR oder PDF erfüllt sie ebenso.
Trinkgeld, Kassenlade, Schichten, TeilzahlungNicht gebaut. Sie vorher zu bauen hieße, sie zu raten.

9.1 Die Registrierkasse einrichten (RKSV)

Ab 0.3.1 kann SIMPro Barumsätze signieren — nach der Registrierkassensicherheitsverordnung, über die fiskaltrust.Middleware.

Wer das braucht: ein Betrieb, der die Umsatzgrenzen des § 131b BAO überschreitet (Kapitel 9). Wer darunter liegt, kassiert ohne Signatureinheit weiter — das ist zulässig, und dieses ganze Kapitel geht ihn nichts an.

Der Weg dorthin sind sechs Schritte. SIMPro führt sie unter Einstellungen → Registrierkasse als Liste und lässt immer nur den nächsten offen. Drei davon passieren woanders, und die hakt ein Mensch ab: das Programm kann nicht nachsehen, ob bei fiskaltrust ein Konto besteht.

Vorweg: was das kostet und was es voraussetzt

fiskaltrust-KontoFür den Sandkasten kostenlos. Für den Produktivbetrieb ein Vertrag — dahinter steht eine echte Signaturerstellungseinheit, und die kostet Geld.
FinanzOnlineEin Webservice-Benutzer. Kostenlos, aber er muss angelegt werden.
Ein SmartphoneFür den Belegcheck. Die App „BMF Belegcheck" gibt es für Android und iOS.
Der ContainerDie Middleware läuft im Stapel mit, im Profil kasse. Sie braucht rund 300 MB Arbeitsspeicher.

Schritt 1 — das fiskaltrust-Konto

  1. Auf portal-sandbox.fiskaltrust.at registrieren (Sandkasten) oder auf portal.fiskaltrust.at (Produktion).
  2. Als Land Österreich wählen.
  3. Die Rolle Kassenbetreiber (PosOperator) wählen — und nur diese.

Warum ausgerechnet diese Rolle. Nach § 131b BAO ist der Unternehmer für seine Registrierkasse verantwortlich; die Signaturerstellungseinheit wird auf seine Steuernummer in FinanzOnline registriert. Der Vertrag mit fiskaltrust gehört deshalb Ihnen, die CashBox gehört Ihnen, und die Signatur zahlen Sie. Das ließe sich auch nicht abgeben: Wer die Kasse betreibt, haftet für sie.

Die anderen Rollen brauchen Sie nicht. Kassenhändler (PosDealer) ist für Wiederverkäufer, Kassenhersteller (PosCreator) für Softwarehersteller — diese Rolle hat SIMPro selbst. Bis 0.5.47 stand an dieser Stelle irrtümlich „PosDealer beantragen"; wer dem gefolgt ist, sollte die Rolle im Portal wieder deaktivieren.

Danach in SIMPro den Schritt abhaken.

Sandkasten und Produktion sind getrennte Systeme. Eine dort angelegte CashBox ist hier nicht verwendbar und umgekehrt. Der Wechsel ist deshalb kein Umschalten einer Einstellung, sondern eine neue CashBox mit einem neuen Startbeleg — und in SIMPro eine neue Kasse. Die alte bleibt sichtbar, damit die Testbelege nachvollziehbar bleiben, erzeugt aber nichts mehr.

Schritt 2 — der FinanzOnline-Webservice-Benutzer

Die Registrierkasse meldet sich selbst bei FinanzOnline an. Dafür braucht sie einen eigenen Zugang — nicht den, mit dem ein Mensch sich anmeldet.

In FinanzOnline:

  1. Admin → Benutzer → Benutzer anlegen
  2. Als Typ Webservice-Benutzer wählen
  3. Benutzer-Identifikation und PIN vergeben und notieren — die PIN ist danach nicht mehr abrufbar
  4. Dem Benutzer das Recht Registrierkassen (RK) geben

Diese drei Angaben (Teilnehmer-Identifikation, Benutzer-Identifikation, PIN) dann im fiskaltrust-Portal hinterlegen: Konfiguration → FinanzOnline.

Danach in SIMPro den Schritt abhaken.

Sie gehören in B:\Rechnung-privat\ oder in den Passwortmanager des Betreibers, nicht ins Repository und nicht in eine E-Mail.

Schritt 3 — die CashBox

Im fiskaltrust-Portal:

  1. Konfiguration → CashBox → Neue CashBox
  2. Eine Queue für Österreich hinzufügen, Speicherart SQLite
  3. Eine Signaturerstellungseinheit hinzufügen — im Sandkasten das Demo-Zertifikat, in der Produktion die vertraglich vereinbarte
  4. Eine Kassenidentifikationsnummer vergeben. Sie steht später auf jedem Beleg; etwas Sprechendes ist besser als eine Zufallsnummer.
  5. Speichern und neu erstellen drücken

Der Schritt, der am ehesten vergessen wird, ist der fünfte. Queue und Signatureinheit können angelegt sein und trotzdem nicht in der Konfiguration der CashBox stehen — das Portal erzeugt sie erst mit „Speichern und neu erstellen". Vorher lädt die Middleware eine leere Konfiguration, startet, meldet „Started all packages" — und horcht auf keinem Port. In SIMPro sieht das aus wie „nicht erreichbar", und man sucht beim Netzwerk statt im Portal.

Nachsehen lässt es sich: DATA_DIR/fiskaltrust/service/Configuration-*.json. Stehen dort "ftQueues":[] und "ftSignaturCreationDevices":[], ist genau das passiert. Im Portal neu erstellen, dann docker compose --profile kasse up -d --force-recreate fiskaltrust.

Auf derselben Seite stehen danach CashBoxId und AccessToken. Beide werden gleich gebraucht.

Das AccessToken ist ein Passwort. Nicht weitergeben.

Danach in SIMPro den Schritt abhaken.

Schritt 4 — Zugangsdaten eintragen und Verbindung prüfen

Zuerst die Middleware starten. In der .env des Stapels:

FT_CASHBOX_ID=<die CashBoxId aus Schritt 3>
FT_ACCESS_TOKEN=<das AccessToken aus Schritt 3>
FT_SANDBOX=1          # 0 für die Produktion

Dann:

docker compose --profile kasse up -d fiskaltrust
docker compose logs -f fiskaltrust

Beim Start sagt der Container, welche CashBox er bedient und ob er im Sandkasten läuft. Fehlen die Zugangsdaten, startet er gar nicht erst und sagt warum — das ist besser, als zu laufen und bei jedem Beleg zu scheitern.

Danach in SIMPro unter Einstellungen → Betrieb → Registrierkasse → Kasse anlegen:

FeldWas hinein gehört
NameWofür die Kasse steht, z. B. „Kasse Ladengeschäft"
KassenidentifikationsnummerDie aus Schritt 3
Einheitfiskaltrust.Middleware
UmgebungSandkasten oder Produktion
Adressehttp://fiskaltrust:1200 — so heißt der Dienst im Stapel
CashBoxIdAus Schritt 3
AccessTokenAus Schritt 3. Es wird verschlüsselt abgelegt und kommt nie wieder heraus

Dann auf Prüfen. SIMPro spricht die Middleware an und fragt sie nach ihrem Zustand. Erst wenn sie antwortet, geht es weiter.

Warum das Feld „Einheit" auch einen Nachbau anbietet: für Vorführungen und für die Testreihe. Ein Nachbau erzeugt keine gültigen Signaturen; SIMPro sagt das im Verbindungstest und legt einen roten Streifen über den Bildschirm, solange er eingestellt ist. In einem Produktivbetrieb hat er nichts verloren.

Schritt 5 — der Startbeleg

Ein Knopfdruck. Der Startbeleg ist der erste Beleg der Kasse, über null Euro, und er eröffnet die Belegkette (§ 6 RKSV).

Nur einmal. Ein zweiter wäre ein zweiter Anfang derselben Kette; SIMPro lässt ihn nicht zu.

Schritt 6 — der Belegcheck

Den QR-Code des Startbelegs mit der App BMF Belegcheck scannen. Die App sagt, ob die Signatur gültig ist und ob die Kasse bei FinanzOnline registriert ist.

Das Ergebnis dann in SIMPro bestätigen. Damit ist die Kasse aktiv, und jeder Barumsatz wird ab sofort signiert.

Dieser Schritt ist der einzige, den weder SIMPro noch fiskaltrust für den Betrieb tun können. Er ist auch der, mit dem die Finanzverwaltung die Kasse als in Betrieb genommen sieht.

Was danach zu tun ist

WannWasWo
MonatsendeMonatsbeleg — ein Nullbeleg zum Abschluss des MonatsEinstellungen → Betrieb → Registrierkasse
JahresendeJahresbeleg — der Monatsbeleg des Dezember. Er ist gesondert aufzubewahren und bis zum 15. Februar des Folgejahres zu prüfendieselbe Stelle
Bei BedarfNullbeleg — prüft die Signatureinheit, ohne Umsatzdieselbe Stelle
Einmal im QuartalDas Datenerfassungsprotokoll sichernEinstellungen → Betrieb → Registrierkasse → DEP

Wenn die Sicherheitseinrichtung ausfällt

Die Kasse läuft weiter. Das ist kein Notbehelf, sondern in § 17 RKSV ausdrücklich vorgesehen:

  • Der Beleg entsteht, ist gültig und trägt statt des QR-Codes den Vermerk „Sicherheitseinrichtung ausgefallen".
  • SIMPro merkt sich jeden dieser Belege und signiert sie nach, sobald die Einheit wieder antwortet — in der Reihenfolge, in der sie entstanden sind.
  • Auf dem Bildschirm steht ein Streifen mit der Dauer des Ausfalls.

Ab 48 Stunden ist der Ausfall über FinanzOnline zu melden. Das kann kein Programm für den Betrieb tun; SIMPro erinnert daran, mehr nicht.

Nachsignieren geschieht von selbst beim nächsten gelungenen Beleg — oder über den Knopf Jetzt nachsignieren.

Wo das Datenerfassungsprotokoll liegt

Im Datenträger der Middleware, unter DATA_DIR/fiskaltrust. Es ist sieben Jahre aufzubewahren (§ 132 BAO), und weil der Pfad unter DATA_DIR liegt, nimmt die nächtliche Sicherung es ohnehin mit.

Geht dieses Verzeichnis verloren, ist das Protokoll weg — und mit ihm der Nachweis über jeden Barumsatz seit dem Startbeleg. Vor jedem Umzug des Servers also zuerst prüfen, dass es in der Sicherung ist.

Was diese Fassung noch nicht kann

Der Launcher ist ein Release Candidatefiskaltrust veröffentlicht den Launcher v2 noch als rc. Der Container heftet eine datierte Fassung fest; vor dem Produktivbetrieb ist mit fiskaltrust zu klären, welche sie stützen.
Kein offizielles Docker-AbbildEs gibt keines. SIMPro baut den Container selbst aus dem veröffentlichten Zip-Paket.
AußerbetriebnahmeDer Belegtyp ist vorbereitet, der Knopf dafür fehlt noch. Wer eine Kasse stilllegt, macht das vorerst im fiskaltrust-Portal.

9.2 Kartenzahlung einrichten

Ab 0.3.2 kann SIMPro die Kartenzahlung selbst auslösen, statt sie nur zu verbuchen. Der Weg führt über Einstellungen → Kartenzahlung; die Wahl gilt für den ganzen Betrieb.

Vier Wege, und für die meisten reicht der erste

EinstellungWofür
Terminal ohne KopplungJedes beliebige Bankomat-Terminal. Der Betrag wird dort getippt und in SIMPro bestätigt. Keine Einrichtung, kein Vertrag, keine App
Viva.com TerminalDie Viva-App auf demselben Android-Gerät. SIMPro übergibt den Betrag und bekommt das Ergebnis zurück
Stripe Tap to PayKarte direkt ans Telefon. Gebaut, aber gesperrt — siehe unten
NachbauBucht nichts ab. Vorführungen und Schulungen

Der erste Weg hat einen Preis, den man kennen sollte: der Betrag wird zweimal getippt, einmal am Terminal und einmal in SIMPro. Ein Vertipper fällt niemandem auf, und abends stimmt die Abrechnung nicht. Wer viel mit Karte kassiert, fährt mit einer Kopplung besser.

Viva.com Terminal

Voraussetzungen:

  1. Ein Viva-Händlerkonto und die App Viva.com Terminal (com.vivawallet.spoc.payapp) auf demselben Android-Gerät, installiert und angemeldet.
  2. Die Anwendungskennung (appId), die Viva für die aufrufende Anwendung vergibt. Ohne sie bleibt der Weg gesperrt, und SIMPro sagt das.
  3. Wahlweise ein Quellcode (sourceCode). Er trennt in der Viva-Abrechnung die Umsätze dieser Kasse von anderen.

Beide Werte in die Felder eintragen und speichern. Beim nächsten „Karte" öffnet sich die Viva-App mit dem Betrag darin.

Der Beleg des Terminals bleibt aus. SIMPro schaltet ihn ab (show_receipt=false), damit der Kunde nicht zwei Zettel für einen Umsatz bekommt. Der Beleg nach § 132a BAO ist der von SIMPro; der des Terminals ist ein Zahlungsnachweis. Wer den Terminalbeleg trotzdem will, schaltet ihn in den Einstellungen wieder ein.

Stripe Tap to Pay: sichtbar, aber gesperrt

Der Eintrag steht in den Einstellungen und lässt sich nicht wählen. Darunter steht, warum. Das ist Absicht: ein verstecktes Verfahren sucht niemand, und eine App, die beim Versuch abstürzt, ist die schlechteste aller Antworten.

Freigeschaltet wird über die Server-Einstellung je Mandant — ohne neuen App-Bau. Die Prüfliste dazu steht in Kapitel 9.3.

Wenn eine Zahlung nicht zurückkommt

Der Fall, für den der ganze Aufbau da ist: die Anbieter-App stürzt ab oder wird weggewischt, während die Zahlung läuft. SIMPro tritt in dem Moment in den Hintergrund und kann vom Betriebssystem beendet werden — auf einem Telefon mit wenig Speicher ist das kein Sonderfall.

SIMPro rät dann nicht. Es entsteht kein Beleg. Stattdessen bleibt die Zahlung als offen stehen, wird nach fünf Minuten als unklar geführt und erscheint als roter Streifen ganz oben am Kassenbildschirm. Jemand sieht in der Anbieter-App nach, wählt Bezahlt oder Nicht bezahlt und schreibt dazu, woran er es festgemacht hat. Das geht mit Namen und Zeit ins Prüfprotokoll.

Ein Programm, das an dieser Stelle selbst entscheidet, bucht im Zweifel Geld, das nie geflossen ist — oder verliert welches, das geflossen ist. Beides ist teurer als eine Frage.

Was SIMPro von der Karte sieht

Nur, was der Anbieter zurückschickt: die maskierte Kartennummer (515876******4490), die Kartenart, die Referenz und die Autorisierungsnummer. Keine vollständige Kartennummer, keine PIN, keine Prüfziffer. Die Karte wird vom Terminal oder von der Anbieter-App gelesen, nie von SIMPro — das ist auch der Grund, warum für den Weg über die Anbieter-App kein PCI-Fragebogen anfällt.

Karte vor Ort bleibt ein Barumsatz

Die Kartenzahlung ändert nichts an Kapitel 9: nach § 131b BAO zählt eine Zahlung mit Karte am Ort der Leistung wie Bargeld. Sie geht in die Grenze von 7.500 € ein und braucht ab den Umsatzgrenzen dieselbe Signatur. Ein Betrieb, der auf Karte umstellt, um „unter der Bargeldgrenze zu bleiben", hat sich verrechnet.

9.3 Tap to Pay freischalten

Ab 0.3.3 ist der Weg gebaut: die Karte wird direkt an das Telefon gehalten, ohne zweites Gerät. Er ist gesperrt ausgeliefert und wird je Mandant freigeschaltet — ohne neuen App-Bau, aber auch nicht mit einem einzigen Häkchen. Vier Dinge müssen zusammenkommen, und drei davon liegen außerhalb von SIMPro.

Die Prüfliste

#WasWerWoran man sieht, dass es fehlt
1Die App kommt aus dem Play StoreGoogle„Diese App wurde nicht aus dem Play Store installiert."
2Stripe-Händlerkonto, für Terminal freigegebenStripe„Das hinterlegte Stripe-Händlerkonto ist für Tap to Pay nicht freigegeben."
3Das Gerät kann esdas Gerät„Dieses Gerät unterstützt Tap to Pay nicht."
4Schlüssel und Standort hinterlegtSieDie drei Häkchen in den Einstellungen sind nicht alle grün

1. Aus dem Play Store, nicht seitlich aufgespielt

Google prüft das über Play Integrity. Eine APK, die von der Release-Seite heruntergeladen und von Hand installiert wurde, besteht die Prüfung nicht — und zwar dauerhaft, nicht bis zur nächsten Aktualisierung.

Das ist der Punkt, an dem die meisten hängenbleiben. SIMPro wird heute als APK ausgeliefert, nicht über den Play Store. Wer Tap to Pay will, braucht die App aus dem Store; solange es sie dort nicht gibt, bleibt der Weg über die Anbieter-App (Kapitel 9.2) der praktikable.

SIMPro stürzt deswegen nicht ab und lässt sich auch nicht überreden: der Bildschirm sagt den Satz und bietet den Weg über „bestätigen" an.

2. Das Stripe-Konto

  • Ein Stripe-Konto mit freigeschaltetem Terminal. Tap to Pay ist nicht in jedem Land verfügbar; Österreich ist dabei, aber die Freigabe je Konto erteilt Stripe, nicht SIMPro.
  • Ein Terminal-Standort (Stripe-Portal → Terminal → Standorte). Ohne ihn verbindet sich der Leser nicht. Der Verbindungstest in den Einstellungen listet die vorhandenen auf und übernimmt einen per Antippen.
  • Der geheime Schlüssel (sk_…), nicht der veröffentlichbare (pk_…). SIMPro legt ihn verschlüsselt ab und gibt ihn nie wieder heraus.

Testmodus erkennen. Ein Schlüssel mit sk_test_… bucht nichts ab. Die Einstellungen sagen es in Rot dazu, und der Verbindungstest wiederholt es. Wer im Testmodus kassiert und es für echt hält, hat Belege über Geld, das nie kam.

3. Das Gerät

  • NFC, eingeschaltet.
  • Android 8.0 oder neuer — seit 0.3.3 die Untergrenze der ganzen App.
  • Kein verändertes Betriebssystem. Ein gerootetes Gerät wird abgelehnt.
  • Standortzugriff. SIMPro fragt danach, sobald Tap to Pay das erste Mal läuft. Der Grund ist nicht Neugier: Stripe braucht das Land, um die Zahlung dem richtigen Regelwerk zuzuordnen. Ohne die Erlaubnis geht es nicht.

4. In SIMPro

Einstellungen → Kartenzahlung → Stripe Tap to Pay. Der Eintrag ist ausgegraut, bis der Betreiber ihn freischaltet. Danach erscheinen zwei Felder und eine Liste mit drei Häkchen — freigeschaltet, Schlüssel hinterlegt, Standort hinterlegt. Erst wenn alle drei grün sind, kassiert die App damit.

Der Selbstfragebogen zur Kartensicherheit (PCI DSS)

Wer Kartenzahlungen annimmt, hat gegenüber seiner Bank einen Selbstfragebogen auszufüllen (SAQ, Self-Assessment Questionnaire). Welcher, hängt davon ab, wo die Kartendaten durchlaufen.

WegFragebogenWarum
Terminal ohne KopplungSAQ B oder B-IPDie Karte sieht nur das Terminal
Viva-App auf dem TelefonSAQ B-IPDie Karte sieht nur die App des Anbieters
Tap to PaySAQ B-IP (nach Stripes Angabe)Die Karte liest das Stripe-SDK, nicht SIMPro

SIMPro sieht die Kartennummer nie. Zurück kommen die maskierte Nummer (515876******4490), die Kartenart und die Referenz des Anbieters — mehr gibt es an keiner Stelle, und mehr wird an keiner Stelle gespeichert. Das ist der Grund, warum kein Kartendatenumfeld entsteht, das jährlich zu bescheinigen wäre.

Das ersetzt keine Auskunft Ihrer Bank. Welcher Fragebogen für Ihren Vertrag gilt, sagt Ihr Zahlungsdienstleister. Die Tabelle nennt, was üblicherweise zutrifft, damit Sie die richtige Frage stellen können.

Was Tap to Pay nicht ändert

Nichts an Kapitel 9: eine Kartenzahlung am Ort der Leistung ist ein Barumsatz nach § 131b BAO, ganz gleich ob am Terminal, über eine Anbieter-App oder am Telefon. Sie zählt in die Grenze von 7.500 € und braucht ab den Umsatzgrenzen dieselbe Signatur.

10. Cloud-Betrieb

Die eigene Installation bei Hetzner: ein Server, eine Adresse, eine Sicherung, die woanders liegt. Was hier steht, gilt für simpro-cloud-1 unter app.simpro-software.at — und für jede weitere, die mit denselben Skripten entsteht.

Seit dem 25.09.2026 sind es zwei Maschinen. simpro-cloud-1 trägt die Anwendung, simpro-cloud-2 das Schaufenster und die Überwachung. Dieses Kapitel bleibt das der Anwendung; der zweite Server steht in 10.2.

Alle Skripte liegen unter deploy/cloud/ und sind idempotent. Jede Stufe fragt zuerst, was schon da ist, und legt nur an, was fehlt. Zweimal aufrufen kostet nichts. Bricht ein Nachtlauf ab, wird er nicht neu begonnen, sondern fortgesetzt.

Der Aufbau

StufeWas sie tut
01-server-anlegen.shSSH-Schlüssel, Firewall, Server bei Hetzner, Snapshots
02-haerten.shUpdates, kein Root, fail2ban, Zeitzone, Benutzer, Tailscale
03-stack.shDocker, der Stapel dieses Repositories, .env, Caddy
04-dns.shA-Eintrag bei Cloudflare, DNS only
05-selbstaktualisierung.shsystemd-Timer für scripts/update.sh
06-backup.shrestic auf die Storage Box, Wiederherstellungstest
07-ueberwachung.shUptimeRobot von außen, Mailweg von innen
08-ersteinrichtung.shMandant, Verwalter mit zweitem Faktor, Dienst-Token
09-abnahme.shGeht jeden Punkt durch und sagt, woran es hängt

Es ist derselbe Stapel wie überall. compose.yml, docker/ und scripts/ gehen unverändert aus dem Repository auf den Server; Caddy im Profil standalone ist der einzige Eingang und holt sein Zertifikat selbst. Kein zweiter Cloud-Stapel — der wäre ein zweiter Ort, an dem etwas anderes gilt, und der Unterschied fiele erst auf, wenn er weh tut.

Auf dem Server liegt kein Git-Token. Der Stapel wird von der Arbeitsmaschine hinübergelegt, nicht dort geklont. Das einzige Geheimnis, das auf der Maschine liegen muss, ist das Pull-Token für ghcr — das braucht die Selbstaktualisierung ohnehin.

Was wo liegt

Der Stapel/opt/simpro auf dem Server
Daten, Sicherungen, Zertifikate/opt/simpro/data
Zugangsdaten (Passwort, TOTP)B:\Rechnung-privat\cloud\simpro-cloud-zugang.txt
Der SSH-SchlüsselB:\Rechnung-privat\schluessel\ssh\simpro-cloud-1.ed25519
Der restic-SchlüsselB:\Rechnung-privat\cloud\simpro-cloud-restic.txt

Ohne den restic-Schlüssel ist das Backup wertlos. Es gibt keinen zweiten Weg hinein: restic kennt keine Hintertür, und Hetzner kann ihn nicht zurücksetzen. Brennt der Server ab und ist der Schlüssel weg, sind die Sicherungen unbrauchbarer Zufallssalat. Er gehört an einen zweiten Ort, der nicht dieselbe Festplatte ist.

Zugang über Tailscale

Nach der Härtung ist SSH nur noch über Tailscale erreichbar; Port 22 ist in der Firewall geschlossen. Der Weg hinein:

# Einmalig auf dem eigenen Rechner
tailscale up

# Danach
ssh simpro@simpro-cloud-1

Solange tailscale-authkey.txt fehlt, ist Tailscale zwar installiert, tritt aber keinem Netz bei — und Port 22 bleibt dann offen, weil er sonst der einzige Weg auf die Maschine wäre. Ein Server, den niemand mehr erreicht, ist kein sicherer Server, sondern ein verlorener. Sobald der Schlüssel da ist: ./deploy/cloud/02-haerten.sh noch einmal laufen lassen, der Rest geschieht von selbst.

Der Zugang für Automatisierung (Port 2222)

Warum es ihn gibt. Tailscale SSH verlangt alle zwölf Stunden eine Bestätigung im Browser. Für einen Menschen ist das richtig. Für alles, was ohne Menschen laufen muss, ist es unbrauchbar — am 29.09.2026 hat es mitten in einer Störungsdiagnose blockiert, während die Sicherung auf das NAS stillstand.

Deshalb gibt es zwei Wege nebeneinander, und das ist Absicht:

WegPortWofür
Tailscale SSH22Menschen, und der Notausgang, wenn der Schlüssel weg ist
OpenSSH mit Schlüssel2222Automatisierung

Beide hören nur auf der Tailscale-Adresse oder über das Tailnet; von außen ist keiner der beiden erreichbar (am 29.09.2026 gegen die öffentlichen Adressen nachgeprüft). In der Hetzner-Firewall ist 2222 nicht geöffnet und soll es auch nicht werden.

Warum ein zweiter Port und nicht 22 für beides: Wäre OpenSSH auf 22, müsste Tailscale SSH dort weichen — und damit gäbe es keinen Weg mehr hinein, wenn der Schlüssel verloren geht oder sshd nach einem Update nicht hochkommt. Ein Zugang, der die einzige Tür ist, sperrt beim ersten Tippfehler aus.

Der Startreihenfolge wegen FreeBind: Beim Hochfahren bindet systemd die Sockets, bevor Tailscale seine 100.x-Adresse hat. Ohne FreeBind=true scheitert der Bind, und der Port fehlt bis zum nächsten Eingriff. Auf cloud-2 mit einem echten Neustart geprüft: nach rund 30 Sekunden war 2222 wieder da.

Die Einstellung steht je Server in /etc/systemd/system/ssh.socket.d/tailnet-2222.conf.

Rückbau

Eine Zeile je Server, und der zusätzliche Zugang ist weg:

sudo rm /etc/systemd/system/ssh.socket.d/tailnet-2222.conf && \
  sudo systemctl daemon-reload && sudo systemctl restart ssh.socket

Danach bleibt Port 22 über Tailscale SSH wie zuvor.

Woran der Schlüssel gebunden ist

Er gilt nicht überall. In authorized_keys steht er mit Einschränkungen davor:

from="100.75.6.86",no-agent-forwarding,no-X11-forwarding,no-port-forwarding ssh-ed25519 AAAA…
  • from= — nur von SaschaServer (100.75.6.86). Ein gestohlener Schlüssel nützt von einem anderen Rechner aus nichts, auch nicht von einem anderen Gerät im selben Tailnet.
  • no-agent-forwarding, no-X11-forwarding — beides wird nicht gebraucht, und ein weitergereichter Agent macht aus einem Zugang zu einem Server einen Zugang zu allem, was dieser Agent kennt.
  • no-port-forwarding — ebenfalls nicht gebraucht: Gearbeitet wird über docker exec, nicht über Tunnel. Sollte je ein Tunnel nötig sein, ist es ein Wort in dieser Zeile.

Nachgeprüft am 29.09.2026, und zwar in beide Richtungen: Mit einer absichtlich falschen from=-Adresse wird die Anmeldung abgewiesen (Permission denied (publickey)); von SaschaServer aus geht sie. Dass der Netzweg offen ist und wirklich die Beschränkung sperrt, ist vom NAS aus gezeigt — Port 2222 ist von dort erreichbar, die Anmeldung wäre es nicht.

Der private Schlüssel wurde für diese Probe nirgendwohin kopiert. Ein Zugang, den man zum Prüfen verteilen muss, ist beim Prüfen schon verloren.

Widerruf des Schlüssels

Wenn der Schlüssel kompromittiert ist oder der Zugang enden soll — das ist der wichtigere Handgriff, denn er wirkt sofort und unabhängig vom Port:

# Auf jedem Server:
sed -i '/claude-automation/d' ~simpro/.ssh/authorized_keys

Der private Schlüssel liegt auf dem Rechner des Betreibers unter C:\Users\Sasch\.ssh\claude-automation, lesend nur für dieses Konto. Ein Schlüssel je Zweck: Wer einen zweiten Automatisierungsweg braucht, bekommt einen eigenen — dann lässt sich einer widerrufen, ohne den anderen zu treffen. Der Zweck steht im Kommentar des Schlüssels und damit in jeder authorized_keys, in der er auftaucht.

Der Rechner wechselt? Dann ändert sich die from=-Adresse. Sie steht in derselben Zeile; ein sed -i 's|from="[^"]*"|from="<neue IP>"|' auf beiden Servern genügt. Vor dem Ändern die alte Zeile sichern — solange Tailscale SSH auf Port 22 steht, sperrt ein Fehler hier niemanden aus.

Offen

ufw ist auf beiden Servern inaktiv. Die Beschränkung auf das Tailnet leistet derzeit allein die Bindung an die 100.x-Adresse plus die Hetzner-Firewall. Ein Hostfirewall als dritte Schicht wäre richtig, ist aber ein eigener Eingriff — ein falsch gesetztes ufw enable sperrt aus.

Updates

Ein systemd-Timer fragt stündlich nach:

systemctl list-timers simpro-update.timer
journalctl -u simpro-update.service -n 50

Das Skript tut nichts, solange der Server nichts angefordert hat. Der Server entscheidet, wann aktualisiert wird (UPDATE_WINDOW in der .env), nicht der Timer; stündlich zu fragen heißt nur, dass das Fenster zuverlässig getroffen wird, auch wenn die Maschine zwischendurch aus war.

Der Ablauf ist derselbe wie unter Windows: Sicherung, Abbild ziehen, anhalten, Migrationen, starten, /health — und bei einem Fehlschlag alles zurück. Von Hand:

cd /opt/simpro
./scripts/update.sh --pruefen          # nur nachsehen
./scripts/update.sh --fassung 0.3.5    # genau diese einspielen

Wiederherstellung, Schritt für Schritt

Der Ernstfall: die Datenbank ist hin, der Server steht noch.

cd /opt/simpro

# 1. Was liegt da?
docker compose run --rm backup restore.sh --list

# 2. Zurückspielen. Ohne --snapshot nimmt es den jüngsten.
docker compose run --rm backup restore.sh --snapshot latest

# 3. Nachsehen
docker compose exec -T db psql -U rechnung -d rechnung -c 'SELECT count(*) FROM "Document";'

Der schlimmere Ernstfall: der Server ist weg. Dann ist die Reihenfolge:

1./deploy/cloud/01-server-anlegen.sh — neue Maschine, gleicher Name
2./deploy/cloud/02-haerten.sh und 03-stack.sh
3Den restic-Schlüssel aus simpro-cloud-restic.txt in die neue .env
4./deploy/cloud/06-backup.sh — der Schlüssel für die Storage Box
5docker compose run --rm backup restore.sh --snapshot latest
6./deploy/cloud/04-dns.sh — der Name zeigt auf die neue Adresse

Schritt 3 ist der, an dem es scheitert, wenn der Schlüssel nicht da ist. Alles andere lässt sich neu bauen.

Die Sicherung, und wie man ihr glaubt

Täglich um 02:30, verschlüsselt, auf die Hetzner Storage Box über SFTP. Vorgehalten werden 30 Tage täglich und 12 Monate monatlich.

Am ersten des Monats um 04:00 wird sie zurückgespielt — automatisch, in eine Wegwerf-Datenbank, mit einem Vergleich der Belegzahl. Eine Sicherung, die nie zurückgespielt wurde, ist eine Behauptung: restic check sagt, dass die Blöcke zusammenpassen, nicht dass im Auszug etwas Brauchbares steht.

systemctl list-timers simpro-wiederherstellungstest.timer
./scripts/wiederherstellungstest.sh --ziel remote   # von Hand

Schlägt er fehl, entsteht eine BackupRun-Zeile mit dem Zustand FAILED, und die stündliche Überwachung macht daraus eine Mail an den Betreiber.

Zuletzt geprobt am 20.09.2026 — und zwar von Hand, weil die Frage im Raum stand, was die monatliche Probe bisher eigentlich belegt hat. Die Antwort war nichts: Der einzige grüne Lauf davor, am 02.09., hatte null Belege gegen null Belege verglichen — die Datenbank war zu dem Zeitpunkt noch leer, der erste Beleg entstand vier Stunden später. Ein Vergleich, bei dem beide Seiten null sind, geht auch bei einer leeren Sicherung auf. Nebenbei fiel auf, dass der Weg über --ziel lokal seit jeher auf ein Verzeichnis zeigte, das es nicht gibt; der Zeitplan benutzt --ziel remote und war davon nicht betroffen — im Ernstfall hätte es aber genau die lokale Kopie getroffen.

Seither prüft die Probe drei Dinge statt einem, und sie scheitert an jedem davon:

Waslokal (/backup/local)extern (Storage Box)
Schnappschussd27dc8b0, 20.09. 03:00:33a84f70ca, 20.09. 03:00:36
Belegzahl gegen den Betrieb5 gegen 55 gegen 5
Ein benannter Beleg (--beleg, Vorgabe ER-2026-0001)gefunden, vom 03.09.2026gefunden, vom 03.09.2026
Dateispeicher: Dateien und Bytes23 / 1.808.69523 / 1.808.695
Dauer7 Sekunden8 Sekunden

Geprobt wurden beide Ziele, weil ein Beweis für die lokale Kopie keiner für die externe ist. Der Zeitplan nimmt das externe.

Die Produktionsdatenbank wird dabei nur gelesen; zurückgespielt wird in eine Wegwerf-Datenbank, die am Ende wieder verschwindet. Das ausführliche Protokoll steht unter Übungsprotokoll in docs/BACKUP.md.

Die Dateiprüfung bleibt — sie hat sich am ersten Tag bewiesen

Die dritte Zeile der Tabelle ist die jüngste und die, die am meisten wert ist: Sie zählt die Belegdateien und deren Bytes im Schnappschuss gegen den Betrieb. Ein Auszug, dessen Datenbank stimmt und dessen Belegdateien fehlen, sieht in jeder Statistik gesund aus — und fällt erst auf, wenn jemand einen Beleg öffnen will, meist Jahre später bei einer Prüfung.

Gegengeprüft wurde am 20.09.2026 über die ganze Laufzeit und alle drei Ziele. Die Belegdateien waren von Anfang an im Sicherungsumfang (backup.sh: BACKUP_PATHS=("${STAGE}" "/data/files"), beim NAS dieselben zwei Pfade) und wachsen mit dem Betrieb mit:

Schnappschuss vomlokalStorage BoxNAS
02.09. (Einrichtung)0 Dateien0 Dateien—
02.09. abends1 / 10.207 B1 / 10.207 B—
07.09.1 / 10.207 B1 / 10.207 B1 / 10.207 B (09.09.)
14.-15.09.10 / 828.406 B10 / 828.406 B15 / 1.155.121 B (15.09.)
19.09.23 / 1.808.695 B23 / 1.808.695 B23 / 1.808.695 B
20.09.23 / 1.808.695 B23 / 1.808.695 B23 / 1.808.695 B

Es hat also nie eine Lücke gegeben. Die Zahlen unterscheiden sich nur dort, wo der Betrieb an dem Tag andere Bestände hatte.

Eine Falle für den, der das nachstellt: restic ls <schnappschuss> <pfad> steigt nicht in die Tiefe — es zeigt die direkten Kinder und sonst nichts. Wer mit Pfadfilter zählt, bekommt drei Verzeichnisse, keine Datei und den Schreck seines Lebens. Ohne Filter listen und hinterher aussieben.

Der Monitor „SIMPro nächtliche Läufe" meldet Nachtlauf-Zustand, nicht Erreichbarkeit

Das ist der wichtigste Satz dieses Abschnitts, und er hat am 22.09.2026 drei Stunden Fehlersuche gekostet: Ein 503 von diesem Monitor heißt nicht, dass der Server weg ist. Er heißt, dass ein nächtlicher Lauf fehlt.

Der Monitor fragt

GET https://app.simpro-software.at/api/v1/health/sicherungen

und dieser Endpunkt antwortet absichtlich mit 503, sobald einer der drei Läufe zu alt ist — damit ein gewöhnlicher HTTP-Monitor das melden kann, wo der kostenlose Tarif keine Heartbeat-Monitore kennt. Der Antwortkörper sagt, welcher:

degraded
sicherung-lokal: ok
sicherung-nas: veraltet
nas-aufraeumen: ok
KennungWas sie abdecktFrist
sicherung-lokalder 02:30-Lauf, lokal und Storage Box26 Stunden
sicherung-nasder 03:00-Lauf aufs NAS26 Stunden
nas-aufraeumendas wöchentliche Aufräumen auf dem NAS8 Tage

Die Erreichbarkeit prüft ein anderer Monitor, der auf /health zeigt. Wer die beiden verwechselt, sucht einen Serverausfall, den es nicht gibt — genau das ist am 22.09. passiert: Der NAS-Lauf scheiterte um 03:00, die Frist von 26 Stunden lief um 05:00 ab, und ab da stand der Monitor auf Rot. Der Server hat in dieser Zeit durchgehend mit 200 geantwortet.

So liest man einen Alarm dieses Monitors richtig, in dieser Reihenfolge:

  1. Antwortkörper ansehen — er nennt die Kennung. Das ist die ganze Diagnose.
  2. docker exec rechnung-db-1 psql ... auf BackupRun, die letzten Zeilen.
  3. Erst wenn beides nichts hergibt: an einen Serverausfall denken. Und dann /health fragen, nicht diesen Endpunkt.

Erledigt am 23.09.2026. Der Monitor heißt jetzt „SIMPro Nachtlauf-Zustand (NICHT Erreichbarkeit)" (Kennung 803915364), der Erreichbarkeits-Monitor auf /health heißt „SIMPro Erreichbarkeit app (/health)" (803892090) — es gab ihn bereits, mit 5-Minuten-Takt und beiden Alarmkontakten. Beides über node scripts/uptimerobot.js gesetzt und aus der Schnittstelle zurückgelesen.

Was davon noch offen ist:

  • Den Monitor umbenennen in etwas, das im Betreff keine zwei Lesarten hat — etwa „SIMPro Nachtlauf-Zustand (NICHT Erreichbarkeit)". Der Name steht im Betreff der Alarmmail, und der Betreff ist das, was man um sieben Uhr früh auf dem Telefon liest.
  • Falls der Tarif es erlaubt: den Antwortkörper in die Benachrichtigung aufnehmen. Dann steht die Kennung gleich in der Mail.
  • Den Erreichbarkeits-Monitor gegenprüfen: zeigt er wirklich auf /health und heißt er auch so?

Die Demo meldet hier nichts — seit 22.09.2026 auch technisch

Der Demo-Stapel sichert nicht; sein Inhalt ist mit einem Befehl neu erzeugt. Trotzdem lieferte /api/v1/health/sicherungen dort einen dauerhaften 503, weil die Abschaltung nie gesetzt war (Fund F28 vom 13.09.2026 — der Code kam, die Einstellung nicht). Ein Monitor darauf wäre vom ersten Tag an wertlos gewesen.

Seit dem 22.09. steht in compose.demo.yml bei demo-api:

SICHERUNGSWACHE: 'aus'

Der Endpunkt antwortet dort jetzt mit 404 — wie jede Adresse, die es nicht gibt. „Abgeschaltet" wäre selbst eine Auskunft gewesen. Nur ein ausdrückliches Nein schaltet ab: Fehlt die Variable, ist die Wache an.

Beim Anfassen des Demo-Stapels: immer mit -f und -p arbeiten (docker compose -f /opt/simpro/demo/compose.demo.yml -p rechnung-demo) und vorher config --services lesen. Ein docker compose ohne beides hat in diesem Verzeichnis schon einmal die Produktion getroffen.

Angekündigte Wartung: Storage Box, 01.10.2026, 08:00–12:00

Hetzner wartet an dem Vormittag die Hosts FSN1-BX2001–2400; unsere Storage Box simpro-backup (BX11, #643199) ist in dem Fenster zeitweise nicht erreichbar. Steht hier, damit ein roter Lauf an dem Vormittag richtig eingeordnet wird — und nicht als Vorfall.

Nachtrag vom 23.09.2026: Das stimmt so nicht mehr. Seit 0.5.63 läuft stündlich ein Datenbankabzug auf die Storage Box. Zwischen 08:00 und 12:00 fallen damit vier Läufe in das Wartungsfenster, und sie werden scheitern. Die Wache meldet das beim zweiten Fehlschlag hintereinander — also einmal, nicht viermal. Diese eine Mail ist erwartbar und kein Vorfall. Stummgeschaltet wird nichts; die tägliche Sicherung um 02:30 läuft davor und ist nicht betroffen.

Geprüft, was in das Fenster fällt — Stand 22.09., vor dem Stundenabzug: Alle geplanten Läufe liegen davor, und die einzige Uhr, die während der Wartung tickt, greift gar nicht auf die Storage Box zu:

WannWasBerührt die Storage Box?Liegt im Fenster?
02:30backup.sh (lokal + Storage Box)janein, 5,5 h davor
02:30monatliche Strukturprüfung (restic check, weil der 1. des Monats)janein
03:00nas-sicherung.shnein, eigenes Zielnein
04:00Wiederherstellungsprobe (--ziel remote)janein, 4 h davor
stündlichSicherungswache im Servernein — sie liest nur BackupRun in der Datenbankja, aber folgenlos

Die wöchentliche Integritätsprüfung läuft sonntags; der 01.10.2026 ist ein Donnerstag, sie fällt also ohnehin nicht an.

Deshalb wird nichts stummgeschaltet. Es gibt keinen Alarm zu dämpfen: Die Wache meldet Fehlschläge, die in der Datenbank stehen, und in dem Fenster entsteht dort keiner. Eine Unterdrückung wäre hier kein Schutz vor Rauschen, sondern ein blinder Fleck an einem Vormittag, an dem man gerade genauer hinsehen möchte.

Was trotzdem schiefgehen kann, und woran man es erkennt:

  • Wer von Hand eine Sicherung oder die Probe gegen die Storage Box startet, bekommt einen Fehlschlag — erwartbar, kein Vorfall. Die Mail trägt dann [TEST] im Betreff (siehe unten), weil sie von Hand kam.
  • Zieht sich die Wartung über 12:00 hinaus und ist die Storage Box um 02:30 am 02.10. noch weg, schlägt der reguläre Lauf fehl — und das ist dann ein echter Alarm, der genau so ankommen soll.

[TEST] im Betreff: warum eine Alarmmail sagen muss, woher sie kommt

Am 20.09.2026 ging um 16:00 eine Mail mit dem Betreff „Backup fehlgeschlagen" an den Betreiber. Die Sicherung war in Ordnung. Ausgelöst hatte sie ein Probelauf, der von Hand und mit Absicht scheiterte — während die Probe gerade gebaut wurde.

Die Ursache war nicht der Probelauf, sondern der Betreff: Jede FAILED-Zeile in BackupRun hieß bis dahin dasselbe, egal ob sie von einer Sicherung kam oder von der Probe, und egal ob vom Zeitplan oder von Hand. Ein Alarm, der im eigenen Rauschen untergeht, ist keiner mehr. Seither gilt:

WoherBetreff
Sicherung, ZeitplanBackup fehlgeschlagen
Probe, ZeitplanWiederherstellungsprobe fehlgeschlagen
irgendetwas davon, von Hand gestartet[TEST] davor

Ob ein Lauf von Hand kam, rät das Skript nicht: systemd setzt für jeden Dienstlauf INVOCATION_ID, von Hand ist sie leer. Die Marke schreibt das Skript vorne in die Meldung, den Betreff baut daraus sicherungsalarm() im Server. Eine gescheiterte Probe aus dem Zeitplan wird NICHT markiert — die ist ein echter Alarm, und wer sie als Test durchgehen lässt, verliert genau den Monat, in dem es gezählt hätte.

Was gemeldet wird, und von wem

Von außen schaut UptimeRobot alle fünf Minuten auf https://app.simpro-software.at/health. Das ist der einzige Weg, der auch dann noch funktioniert, wenn der Server tot ist — ein Server, der sein eigenes Sterben melden soll, kommt nicht mehr dazu.

Von innen meldet der Server, was von außen niemand sieht:

WasWann
Die Sicherung ist fehlgeschlagen oder ausgebliebenstündlich geprüft
Der Plattenplatz fällt unter 25 %stündlich geprüft, höchstens alle drei Tage gemeldet
Der Plattenplatz fällt unter 15 %stündlich geprüft, täglich gemeldet — mit eigenem Betreff
Das Zertifikat läuft in weniger als 14 Tagen abstündlich geprüft, täglich gemeldet
Es ist gar kein Zertifikat zu sehendasselbe

Alle fünf gehen an ADMIN_EMAIL. Ohne eingerichteten Postausgang entstehen sie, aber niemand bekommt sie — dann stehen sie nur im Protokoll. Ein SMTP-Zugang gehört deshalb zur Einrichtung dazu.

Die Alarmwege — zwei Stufen

Stufe 1: E-Mail an ADMIN_EMAIL. Für alles. Sie entsteht am Server selbst und trägt den ganzen Text — was nicht stimmt, seit wann, und was zu tun ist. Ihr Nachteil: sie kommt vom Server, und ein Server, der ausfällt, schickt nichts mehr.

Stufe 2: Push von UptimeRobot. Für die Dinge, die von außen sichtbar sind: Ausfälle und ausbleibende nächtliche Läufe. Sie kommt von einem fremden Rechner und ist deshalb der einzige Weg, der auch dann noch geht, wenn der Server tot ist.

Drei Schalter, und sie hängen nicht zusammen. Am 4. September 2026 mit vier Probeklingeln durchgemessen — jeder einzelne war nötig, und jeder einzelne sitzt woanders.

SchalterWoWas er entscheidet
Als Priorität festlegenApp-EbeneOb die Meldung bei „Nicht stören" überhaupt erscheint
Wichtigkeit: DringendKanal-EbeneOb sie normalerweise Ton und Einblendung hat
Nicht stören ignorierenKanal-EbeneOb sie während „Nicht stören" Ton machen darf

Samsung: Einstellungen → Apps → UptimeRobot → Benachrichtigungen — die ersten beiden dort, den dritten erst, nachdem man den Kanal selbst angetippt hat (Alerts oder Monitoring). Die App legt ihre Kanäle teils erst beim ersten Alarm an; vorher ist dort nichts einzustellen.

Was die Proben ergaben:

ProbeLageErgebnis
1wie vorgefundenkam an, stumm
3alles auf lautkam an, mit Ton
4Telefon stummgeschaltetkam an, Vibration ohne Ton — richtig so

Ein vibrierendes Telefon auf dem Nachttisch weckt zuverlässiger als ein stummes. Ob Vibration reicht oder der Ton gebraucht wird, ist eine Entscheidung des Betreibers — technisch geht beides, und ohne den dritten Schalter geht nur das erste.

Genau deshalb wird ein Alarmweg ausgelöst und nicht nur eingerichtet. Die Papierform hätte dreimal gestimmt.

Was von außen überwacht wird

MonitorWas er bemerktTakt
app.simpro-software.at/health antwortet nicht — die Anwendung ist weg5 Min
www.simpro-software.atDie Website ist weg5 Min
SIMPro nächtliche Läufe/api/v1/health/sicherungen antwortet 503 — eine nächtliche Sicherung fehlt oder hängt5 Min

Der dritte ist der wichtigste und der am wenigsten offensichtliche. Er ersetzt einen Heartbeat-Monitor, den der kostenlose Tarif nicht hergibt: statt dass jeder Lauf sich meldet, beantwortet der Server die Frage von innen. Ein Lauf, der hängt, hinterlässt keinen frischen Eintrag — also fällt die Antwort auf 503, also schlägt der Monitor an. Genau dieser Fall ist am 4. September 2026 eingetreten und wäre sonst unbemerkt geblieben (DECISIONS N71, N75).

Von Hand nachsehen:

curl https://app.simpro-software.at/api/v1/health/sicherungen

Antwortet ok, ist alles aktuell. Sonst steht je Lauf eine Zeile — fehlt (lief noch nie) oder veraltet (lief, und jetzt nicht mehr).

Die Adresse braucht /api/v1/. /health ist ohne Präfix erreichbar, weil Caddy genau diesen einen Pfad durchreicht; alles andere geht über die API-Adresse.

Was das NAS dazu beiträgt

Das wöchentliche Aufräumen läuft auf dem NAS, mit einem Zugang, den die Cloud absichtlich nicht hat (N61) — der Server sähe davon nie etwas. Deshalb meldet das NAS nach jedem geglückten Lauf einen einzigen Satz:

# am NAS, ausgeloest von aufraeumen.sh
/volume1/docker/simpro-nas/melde-aufgeraeumt.sh

Dafür liegt dort ein Dienst-Token unter /volume1/docker/simpro-nas/dienst-token.txt (Rechte 600). Es erlaubt genau einen Aufruf und sonst nichts. Ein neues stellt man so aus:

docker exec rechnung-api-1 node dist/cli.js dienst-token \
  --zweck NAS_AUFRAEUMEN --name 'NAS Aufraeumen'

Scheitert die Meldung, ist trotzdem aufgeräumt — ein Netzhänger darf keinen guten Lauf zu einem Fehlschlag machen. Bleibt sie dauerhaft aus, meldet der Endpunkt nas-aufraeumen: veraltet, und das ist die richtige Auskunft: aus der Ferne ist „aufgeräumt, aber stumm" von „nicht aufgeräumt" nicht zu unterscheiden.

Stufe 3, offen: SMS oder Anruf

UptimeRobot bietet beides im Bezahltarif an, rund 7 € im Monat. Entscheidung offen. Wofür es spräche: Push setzt ein Telefon voraus, das online ist und dessen App nicht vom Energiesparen abgeräumt wurde. Wogegen: drei Wege für denselben Ausfall sind zwei zu viel, solange der zweite zuverlässig ankommt.

Zu prüfen wäre das erst, wenn eine Push-Meldung einmal nachweislich nicht angekommen ist — vorher wäre es eine Ausgabe gegen ein Gefühl.

Zwei Absender, und warum

Es lesen zwei verschiedene Leute, was dieser Server verschickt.

EinstellungWer liest dasWofür
SMTP_FROMSie selbstDie vier Warnungen oben. Der technische Absender
MAIL_ABSENDERIhre KundenRegistrierung, Adressbestätigung, Passwort vergessen, Lizenz, Löschungsankündigung, Benachrichtigungen, Belege
MAIL_ANTWORT_AN—Wohin die Antwort eines Kunden geht. Ohne Angabe die Adresse aus MAIL_ABSENDER

MAIL_ABSENDER nimmt einen Anzeigenamen mit: MAIL_ABSENDER="SIMPro <office@ihre-domain.at>". Der Name steht im Postfach des Empfängers, bevor er den Betreff liest — und eine Registrierungsbestätigung von einer nackten admin@-Adresse sieht aus wie etwas, das man wegklickt.

Kein „noreply". Wer auf eine dieser Nachrichten antwortet, soll ankommen. Deshalb trägt jede eine Antwortadresse; nur der Belegversand setzt seine eigene, denn dort gehört die Antwort dem Betrieb, der die Rechnung geschrieben hat, und nicht Ihnen.

Ohne beide Angaben bleibt alles, wie es war: dann gilt SMTP_FROM für beides.

Die Adresse muss nicht die des angemeldeten Postfachs sein. Die meisten Anbieter nehmen jeden Absender aus dem eigenen Domainpaket an, auch wenn die SMTP-Anmeldung an einem anderen Postfach hängt. Prüfen Sie es einmal, statt es anzunehmen — eine abgewiesene Registrierungsbestätigung merkt niemand, ausser dem, der sich vergeblich anzumelden versucht.

Und prüfen Sie den SPF-Eintrag Ihrer Domain: er muss den Versandweg abdecken, sonst landet die erste Nachricht an einen neuen Kunden im Spam.

Wer versendet was worüber

Drei Wege, und sie werden gern verwechselt. Nur der mittlere wechselt zu Brevo.

WasWer stösst es anWorüber
Ihre eigene Post — Angebote, Antworten an KundenSie, von Hand aus dem PostfachWorld4You, unverändert
Der Systemversand — Registrierung, Bestätigung, Passwort vergessen, Lizenz, Support, BetriebswarnungenDer ServerBrevo (ab der Umstellung)
Belegversand einer RechnungDer Betrieb aus der AppDer Systemversand, mit der Antwortadresse des Betriebs

Die Postfächer office@, admin@ und sascha.suchan@ bleiben bei World4You — Empfang wie Versand von Hand. Es wechselt allein, worüber der Server seine Nachrichten hinausgibt.

DKIM über Brevo — auf Eis (Stand 8. September 2026)

Brevo wird nicht aktiviert. Der Anbieter verlangt Öffnungs- und Klick-Tracking und hat das Abschalten abgelehnt (Konto muss ein Jahr alt sein, dann Einzelfallprüfung ohne Zusage). Das widerspricht unserer öffentlichen Zusage und der Datenschutzerklärung — World4You bleibt dauerhaft der Versandweg. Wiedervorlage frühestens 09/2027. Die Gründe und die Folgen für DMARC stehen in docs/DECISIONS.md, N99.

Der Abschnitt darunter beschreibt den vorbereiteten Weg und bleibt als Rückweg stehen — die DNS-Vorarbeit (authentifizierte Domain, die beiden brevo._domainkey-CNAMEs, news-Subdomain) verfällt nicht. Nicht als Anleitung zum Umlegen lesen, solange N99 gilt.

Warum der Wechsel überhaupt geplant war. World4You bietet DKIM nur an, solange die Domain auf seinen eigenen Nameservern liegt. Unsere liegt bei Cloudflare, weil dort der A-Eintrag der Anwendung gepflegt wird; die beiden schliessen einander aus, und der Nameserver ist die wichtigere Hälfte. Die Aussage des Panels ist als Bildschirmfoto festgehalten: B:\Rechnung-privat\dkim-world4you-befund.png.

Ein transaktionaler Versender löst genau das: DKIM gehört dort zur Einrichtung, dazu kommen Zustellprotokolle und Rückläufer. Gewählt: Brevo, Gratistarif mit 300 Nachrichten am Tag — für die Testphase reichlich.

Was schon steht:

SchrittStand
SPF um include:spf.brevo.com ergänztsteht, aus 1.1.1.1 und 8.8.8.8 bestätigt
brevo-code-TXT am Apexsteht, neben dem SPF-Eintrag
DKIM brevo1._domainkey / brevo2._domainkeysteht als CNAME ohne Proxy; beide Ziele liefern einen Schlüssel
DMARC mit ruasteht, p=none unverändert
IP-Sperre im Brevo-Kontoerledigt — siehe den Kasten unten, es sind zwei Schalter
Domain im Brevo-Panel bestätigenoffen — und ohne sie ist alles andere umsonst
SMTP_* in der Cloud-.env auf Brevovorbereitet, auskommentiert — World4You aktiv

Der Schritt, den man für eine Formalie hält. Die DNS-Einträge zu setzen genügt nicht: solange die Domain im Brevo-Panel nicht als authentifiziert geführt wird, ersetzt Brevo den Absender durch eine eigene Adresse (admin@<nummer>.brevosend.com) und unterschreibt mit der eigenen Domain. Die Nachricht geht durch, dkim=pass steht in den Kopfzeilen — nur eben für Brevo und nicht für uns.

Am 5. September ist genau das passiert. Aufgefallen ist es erst an den Kopfzeilen einer angekommenen Nachricht; am Server sah alles richtig aus. Siehe DECISIONS N86.

Es sind zwei Schalter, und nur einer heißt, wonach man sucht. Unter Sicherheit → Autorisierte IPs gibt es die Sperre getrennt für API und für SMTP. Am 5. September war die API-Sperre aus und die SMTP-Sperre noch an — und in der Liste stand obendrein eine falsche Adresse. Das Ergebnis sieht aus wie ein Netzproblem und ist keines.

Der Test, der es in zehn Sekunden klärt: einmal mit dem richtigen und einmal mit einem erfundenen Schlüssel anmelden. Kommt beim falschen 535 und beim richtigen 525, sind die Zugangsdaten in Ordnung und die Sperre greift nach der Anmeldung — dann ist es die IP-Liste und nichts anderes.

Die IP-Freigabe ist der Stolperstein — und sie steht nicht dort, wo man sie sucht. Brevo kann das Senden auf eine Liste erlaubter Adressen beschränken. Ist die Sperre aktiv und die Adresse nicht darauf, wird die Anmeldung angenommen und trotzdem abgewiesen: 525 5.7.1 Unauthorized IP address.

Die Liste liegt nicht unter SMTP & API, sondern unter Konto-Menü → Einstellungen → Sicherheit → Autorisierte IPs. Dort steht auch der Schalter Sperre deaktivieren, wenn das Pflegen einzelner Adressen zu mühsam wird.

Einzutragen ist die ausgehende Adresse des Cloud-Servers: 178.104.194.64. Sie lässt sich am Server mit curl -s https://api.ipify.org nachsehen — in Containern und hinter NAT ist sie nicht immer die, die man erwartet. Nachweisen lässt sich der Zustand mit einer reinen Anmeldeprobe (ohne Versand): 525 heisst „Zugangsdaten gut, Adresse gesperrt", 535 heisst „Zugangsdaten falsch". Die Unterscheidung spart das Raten.

Reihenfolge beim nächsten Anlauf: erst einen einzelnen Probeversand über Brevo, dann umstellen. Nicht umstellen und danach prüfen — dazwischen liegt ein Fenster, in dem kein Kunde eine Bestätigung bekommt und niemand es merkt.

Kein halber Zustand. Der Versand wechselt erst auf Brevo, wenn DKIM steht. Vorher bleibt World4You aktiv — ein Wechsel ohne DKIM brächte nichts ausser einem neuen Versandweg ohne die zweite Unterschrift.

Der SPF-Eintrag lautet jetzt:

v=spf1 mx include:spf.w4ymail.at include:spf.brevo.com -all

World4Yous Include bleibt drin — admin@ und office@ versenden weiterhin aus dem Postfach über deren Server. -all bleibt.

Der Rückweg muss ein Fünf-Minuten-Variablentausch bleiben. In der Cloud-.env stehen die alten World4You-Werte auskommentiert unter der Überschrift # --- Rückweg World4You ---. Zurück heisst: die vier SMTP_*-Zeilen tauschen, docker compose up -d api, eine Probemail. Es gibt keinen weiteren Schalter, kein Umstellen in der Datenbank, nichts an der Domain. Der SPF-Eintrag deckt beide Wege ab und muss dafür nicht angefasst werden.

DMARC bleibt vorerst auf p=none

Schärfer zu stellen, bevor DKIM über mehrere Tage nachweislich durchgeht, liefe gegen die Wand: bei p=quarantine landet jede Nachricht im Spam, deren Unterschrift noch nicht sitzt — und man merkt es nicht, weil eine aussortierte Nachricht keinen Fehler erzeugt.

Merkposten mit Termin: etwa eine Woche nach der Umstellung auf p=quarantine heben, danach eine Woche kontrollieren. Steht in docs/STATUS.md unter Offen.

10.1 Die Zweitsicherung auf das eigene NAS

Seit 0.5.13 sichert der Cloud-Server nicht nur zur Storage Box, sondern auch auf ein NAS im Haus des Betreibers. Der Unterschied ist nicht die Technik, sondern der Zugriff im Ernstfall: an die Storage Box kommt man ueber das Netz und ein Konto beim Anbieter, an das NAS kommt man mit der Hand.

Wie es zusammenhaengt

WerWasWann
Cloud-Serversichert zur Storage Box02:30
Cloud-Serversichert aufs NAS03:00
NASraeumt das Repository auf (Fristen 14/8/12)montags 04:30
NASschreibgeschuetzter Btrfs-Schnappschuss, 14 Tagetaeglich 05:00
NASerinnert an die Schubladenplattetaeglich 09:00

Der Cloud-Server erreicht das NAS ueber Tailscale, nicht ueber das Heimnetz — die Adresse im Haus darf sich aendern, der Name im Tailnet nicht. Der Empfaenger (restic/rest-server) lauscht ausschliesslich auf der Tailnet-Adresse; im Heimnetz antwortet er nicht.

Warum der Cloud-Server dort nichts loeschen kann

Der Empfaenger laeuft im Append-only-Modus. Der Zugang der Cloud darf anlegen und lesen, nicht loeschen. Das ist der Sinn: ein uebernommener Cloud-Server ist genau der Fall, gegen den eine Sicherung schuetzt, und wer ihn uebernimmt, soll die Kopien nicht mitloeschen koennen.

Deshalb raeumt das NAS selbst auf, nicht die Cloud. Der Auftrag dort hat einen eigenen Zugang, den die Cloud nicht kennt.

Wiederherstellung aus dem NAS, wenn die Hetzner-Seite ganz weg ist

Der schlimmste Fall: der Cloud-Server ist fort, die Storage Box nicht erreichbar. Dann steht alles Noetige auf dem NAS — auch die Konfiguration: die NAS-Sicherung enthaelt neben Datenbank und Dateispeicher eine Kopie von .env, compose.yml und den Caddyfiles.

Auf dem NAS, als Verwalter:

# 1. Was liegt da?
docker run --rm -v /volume1/simpro-sicherung/restic:/repo \
  -v /volume1/docker/simpro-nas/repo-passwort:/pw:ro \
  -e RESTIC_REPOSITORY=/repo -e RESTIC_PASSWORD_FILE=/pw \
  restic/restic:latest snapshots

# 2. Alles zurueckholen
docker run --rm -v /volume1/simpro-sicherung/restic:/repo \
  -v /volume1/docker/simpro-nas/repo-passwort:/pw:ro \
  -v /volume1/wiederherstellung:/ziel \
  -e RESTIC_REPOSITORY=/repo -e RESTIC_PASSWORD_FILE=/pw \
  restic/restic:latest restore latest --target /ziel

Unter /volume1/wiederherstellung liegen danach:

  • backup/.stage-nas/database.dump — der Datenbankabzug (pg_restore)
  • backup/.stage-nas/schema.sql — dasselbe Schema als lesbares SQL
  • backup/.stage-nas/stapel/ — .env, compose.yml, die Caddyfiles
  • data/files/ — der Dateispeicher (Belege, Logos, Anhaenge)

Damit laesst sich der Stapel auf einem neuen Server aufsetzen: Dateien hinlegen, docker compose up -d db, pg_restore in die frische Datenbank, dann den Rest starten. Der ausfuehrliche Weg steht unter Wiederherstellung, Schritt fuer Schritt; nur die Quelle ist eine andere.

Ohne den Schluessel ist die Kopie wertlos. Er liegt an zwei Stellen: B:\Rechnung-privat\restic-nas-schluessel.txt beim Betreiber und /volume1/docker/simpro-nas/repo-passwort auf dem NAS. Es ist nicht derselbe wie der der Storage Box.

Der Schnappschuss daneben

Taeglich legt das NAS einen schreibgeschuetzten Btrfs-Schnappschuss des Sicherungsordners an, 14 Stueck werden behalten:

ls /volume1/.simpro-snapshots/          # was da ist

Wozu, wenn das Repository ohnehin append-only ist: append-only schuetzt vor dem, der den Cloud-Zugang hat. Es schuetzt nicht vor dem, der hier am NAS Vollzugriff bekommt — und der Aufraeum-Auftrag hat genau den. Ist das Repository beschaedigt, liegt der Vortagesstand daneben:

# aus einem Schnappschuss lesen (nur lesend, nichts wird veraendert)
docker run --rm -v /volume1/.simpro-snapshots/2026-09-03/restic:/repo:ro \
  -v /volume1/docker/simpro-nas/repo-passwort:/pw:ro \
  -e RESTIC_REPOSITORY=/repo -e RESTIC_PASSWORD_FILE=/pw \
  restic/restic:latest snapshots

Die Schubladenplatte

Eine USB-Platte mit dem Etikett SIMPRO-OFFLINE. Wird sie angesteckt, laeuft die Kopie von selbst; danach kommt eine E-Mail, und die Platte ist ausgehaengt.

Anstecken → warten, bis die Bestaetigungsmail kommt → abziehen → Schublade. Mehr ist nicht zu tun.

Erkannt wird sie am Etikett, nicht am Geraetenamen: /dev/sdc ist keine Platte, sondern eine Reihenfolge — steckt einmal ein Stick daneben, heisst die Sicherungsplatte ploetzlich anders. Ohne das Etikett passiert nichts; eine fremde USB-Platte wird nie beschrieben.

Laeuft zehn Tage lang keine Kopie, erinnert das NAS per E-Mail (hoechstens alle drei Tage, damit es eine Erinnerung bleibt und kein Laerm wird).

Die Platte, die drinsteckt: WD Red 6 TB (WDC WD60EFRX-68L0BN1, Serie WD-WX41D97DS5K2) im SABRENT-Gehaeuse mit eigenem Netzteil. Am 4. September 2026 eingerichtet: GPT, eine Partition, ext4, Etikett SIMPRO-OFFLINE, ohne die fuenf Prozent Reserve fuer root (-m 0) -- bei 6 TB waeren das dreihundert Gigabyte, die niemand je braucht.

ext4 und nicht Btrfs, obwohl /volume1 Btrfs ist: eine Notfallplatte soll in jedem Rettungssystem aufgehen, und ext4 ist ueberall eingebaut.

Vor dem Formatieren nachgewiesen leer -- keine Partitionstabelle, kein Dateisystem, und vier Leseproben bei 0, 1 TB, 3 TB und 5,7 TB ausschliesslich Nullen. blkid allein haette das nicht bewiesen: wer eine Platte roh beschreibt, hinterlaesst vorne nichts und hinten alles.

Was nach einer Firmware-Aktualisierung des NAS zu pruefen ist

Die Zeitplaene sind systemd-Timer unter /etc/systemd/system/. Eine grosse UGOS-Aktualisierung kann dort aufraeumen. Ein Blick danach genuegt:

systemctl list-timers 'simpro-*'        # drei Stueck sollen dastehen
docker ps --filter name=tailscale --filter name=rest-server

Die Skripte selbst liegen unter /volume1/docker/simpro-nas/ und damit auf dem Datenvolume — die ueberleben.

Die Merkliste vor und nach jedem UGOS-Update

Updates stehen auf manuell, das hier ist also eine bewusste Liste und kein Schutz vor Ueberraschungen. Drei Punkte:

Womit
SSH noch an?ssh Sascha@nas-dxp2800 true
Beide Behaelter oben?docker ps --filter name=tailscale --filter name=rest-server
Rechte auf simpro-sicherung intakt?stat -c '%a %U' /volume1/simpro-sicherung -- erwartet 700 restic-sicherung

Ein Update kann SSH abschalten UND Ordnerrechte zuruecksetzen. Beides ist am 4. September 2026 gleichzeitig passiert, und beides war von aussen nicht zu sehen.

Was am 4. September 2026 wirklich los war

Zwei Ursachen uebereinander, und die zweite haette ein Neustart nicht behoben:

  1. Der REST-Server haengt fest an 100.73.173.11:8000, also an der Tailscale-Adresse. Beim Hochfahren startet Docker ihn, bevor Tailscale die Adresse vergeben hat: cannot assign requested address, Exit 128. Tailscale kommt hoch, der Empfaenger nicht.
  2. Die Aktualisierung hatte /volume1/simpro-sicherung auf Rechte 000 gesetzt. Eigentuemer und Gruppe stimmten, aber niemand durfte hinein. Auch nach einem Start kam der Behaelter nur bis touch: /data/.htpasswd: Permission denied.

Das NAS war dabei die ganze Zeit im Tailnet erreichbar und verschluckte die Pakete an Port 8000, statt sie abzulehnen -- kein Fehler, nur Stille.

Drei Dinge sorgen seither dafuer, dass das auffaellt:

WoWas
nas-sicherung.sh (Cloud)Jeder restic-Aufruf steht unter einer Uhr: eine halbe Stunde fuer die Sicherung, fuenf Minuten fuer die Handgriffe davor. Ein haengender Lauf wird damit zu einem gescheiterten
nas-wache.ts (Cloud)Ein Lauf, der laenger als vier Stunden auf RUNNING steht, zaehlt als gescheitert. Ohne das haette die Wache nie gemeldet: sie verlangt zwei Fehlschlaege, und ein haengender Lauf scheitert nie
aufstehen.sh (NAS)Die Aufsteh-Meldung, unten

Siehe DECISIONS.md, N71.

Die Aufsteh-Meldung

Nach jedem Start des NAS laeuft simpro-aufstehen.service einmal durch. Sie prueft -- und repariert, was sich reparieren laesst, statt es nur zu melden:

  • wartet bis zu zwei Minuten auf die Tailscale-Adresse
  • Volume da?
  • Rechte auf simpro-sicherung intakt? Wenn nicht: wiederherstellen
  • beide Behaelter oben? Wenn nicht: starten
  • antwortet Port 8000 mit 401, also mit Zugangsschutz?
  • steht PermitRootLogin auf prohibit-password? Wenn nicht: setzen — seit 22.09.2026 repariert die Aufsteh-Meldung auch das, nicht nur die Rechte. Mit Sicherungskopie, sshd -t als Entscheider und reload statt restart; ist die Konfiguration ungültig, wird sie nicht angefasst, sondern nur gemeldet. Warum die Grenze verschoben wurde, steht in docs/BACKUP.md.
  • wie alt ist der juengste Schnappschuss, und die juengste Btrfs-Kopie?

Danach kommt eine E-Mail -- auch wenn alles in Ordnung ist. Eine Meldung, die nur im Fehlerfall kommt, beweist nie, dass sie noch funktioniert, und genau darauf verlaesst man sich dann.

Betreff: [SIMPro NAS] NAS neu gestartet - alles in Ordnung
         [SIMPro NAS] NAS neu gestartet - 2 Sache(n) repariert, jetzt in Ordnung
         [SIMPro NAS] NAS neu gestartet - 1 Sache(n) stimmen nicht

Sie ersetzt die Wache auf dem Cloud-Server nicht: kommt das NAS gar nicht hoch, schickt es auch keine Meldung. Genau dafuer gibt es die andere.

Von aussen pruefen, ohne sich am NAS anzumelden:

tailscale ping nas-dxp2800                                   # ist die Maschine da
docker exec rechnung-backup-1 nc -z -w 5 nas-dxp2800 8000    # antwortet der Empfaenger

Antwortet das erste und das zweite nicht, ist genau dieser Fall eingetreten.

Das Einschaltverhalten nach einem Stromausfall

Beim DXP2800 keine Sache der Befehlszeile, sondern eine Einstellung im BIOS beziehungsweise in der UGOS-Systemsteuerung: nach Netzwiederkehr einschalten (englisch oft AC Power Recovery: Power On). Steht sie auf aus, bleibt das NAS nach einem Stromausfall liegen -- und die naechtliche Sicherung geht ins Leere, bis jemand den Knopf drueckt.

Einmal setzen, danach uebernimmt die Aufsteh-Meldung: kommt sie nach einem Ausfall, ist das Geraet von selbst hochgekommen.

Nach einem gemeldeten Stromausfall: einmal restic check

Seit dem 9. Oktober 2026.

Meldet der Wächter (scripts/stromausfall-waechter.ps1) einen unsauberen Neustart, dann einmal von Hand prüfen, statt auf den wöchentlichen Lauf zu warten:

ssh simpro@simpro-cloud-1 \
  'docker exec rechnung-backup-1 sh -lc "RESTIC_REPOSITORY=/backup/local restic check"'

Und dasselbe für die beiden anderen Ziele — Storagebox und NAS:

ssh simpro@simpro-cloud-1 'docker exec rechnung-backup-1 sh -lc \
  "RESTIC_REPOSITORY=\$BACKUP_REMOTE_REPOSITORY restic check"'
ssh simpro@simpro-cloud-1 'docker exec rechnung-backup-1 sh -lc \
  "RESTIC_PASSWORD=\$NAS_RESTIC_PASSWORD RESTIC_REPOSITORY=\$NAS_REPOSITORY \
   RESTIC_REST_USERNAME=\$NAS_REST_USERNAME RESTIC_REST_PASSWORD=\$NAS_REST_PASSWORD \
   restic check"'

Die zwei RESTIC_REST_-Zeilen gehören seit dem 13.10.2026 dazu: Benutzer und Kennwort des NAS stehen seitdem neben der Adresse und nicht mehr darin. Fehlen sie, antwortet das NAS mit 401 — und das liest sich wie ein Datenschaden, obwohl nur die Anmeldung fehlt.

Warum das nötig ist, obwohl es den Wächter und den Wöchentlichen gibt

Weil die beiden verschiedene Fragen beantworten und die Lücke dazwischen genau hier liegt:

prüftprüft nicht
nas-lesbarkeit.shob die Dateien lesbar sind (Stichproben auf config, snapshots, index, data)ob eine davon abgeschnitten ist, und ob eine Sperre hängt
backup.sh, wöchentlichcheck --read-data-subset=5%, bricht hart abnichts — aber erst beim nächsten Lauf, also bis zu sieben Tage später

Ein hartes Aus kann die Geschichte nicht beschaedigen — die Ablage ist inhaltsadressiert und wird nur angefügt, es wird nichts umgeschrieben. Was es kann: ein abgeschnittenes Paket hinterlassen, wenn der Strom mitten im Hochladen wegfällt. Das ist kein Datenverlust an alten Schnappschuessen, aber der neueste ist dann unvollständig — und man erfährt es ohne diesen Griff unter Umständen erst eine Woche später, beim nächsten Wöchentlichen.

Dazu der zweite Fall, der schon einmal eingetreten ist: eine verwaiste Sperre blockiert prune. Die Sicherung läuft dabei weiter, und der Wächter bleibt grün — es fällt also von sich aus nicht auf. restic check nennt sie; weg ist sie mit restic unlock.

ssh simpro@simpro-cloud-1 \
  'docker exec rechnung-backup-1 sh -lc "RESTIC_REPOSITORY=/backup/local restic unlock"'

Nicht blind unlock rufen. Eine Sperre kann auch bedeuten, dass gerade wirklich ein Lauf arbeitet. Erst restic list locks ansehen; ist die Sperre jünger als eine Stunde und läuft die nächtliche Sicherung gerade, dann abwarten.


10.2 Der zweite Server: Schaufenster und Wache

Seit dem 25. September 2026.

Bis dahin lag alles auf einer Maschine: die Buchhaltung der Kunden, die Demo-Instanz für die Store-Prüfung und die öffentliche Website. Drei Dinge mit drei sehr verschiedenen Folgen im Ausfall — und eine gemeinsame Ursache, sobald jemand docker compose up -d --force-recreate caddy tippt.

ServerOrtTrägtEin Fehler dort kostet
simpro-cloud-1NürnbergAPI, Produktion, Demodie Buchhaltung der Kunden
simpro-cloud-2FalkensteinWebsite, Statusseite, Uptime KumaAnsehen

Die zwei stehen in verschiedenen Rechenzentren. Das war nicht geplant — cloud-2 ist in Falkenstein gelandet —, aber es ist die bessere Aufstellung: Eine Wache im selben Haus wie der Bewachte schweigt beim Stromausfall mit.

Was wo hinzeigt

NameServer
app.simpro-software.atcloud-1
demo.simpro-software.atcloud-1
www.simpro-software.atcloud-2
simpro-software.at (ohne www)cloud-2, leitet auf www
status.simpro-software.atcloud-2

Die Website ruft weiter /api/ auf ihrer eigenen Adresse

Daran hat sich für den Besucher nichts geändert, und das ist der Sinn der Sache: kein CORS, keine zweite Adresse im Browser, keine Ergänzung in der Datenschutzerklärung. Caddy auf cloud-2 reicht /api/* und /health weiter — zuerst über Tailscale zu cloud-1, und wenn das nicht geht, über die öffentliche Adresse.

Ohne diesen zweiten Weg hätte der Umzug eine neue Einzelfehlerstelle geschaffen, die es vorher nicht gab: Tailscale.

Die Wache

Uptime Kuma auf cloud-2 sieht stündlich — genauer: jede Minute — nach:

  • dem Programm (app…/health) und der Demo,
  • dem Eingang bis in den Ausnahmefilter der Anwendung hinein,
  • dem Sicherungswächter (/api/v1/health/sicherungen),
  • der Website und der Statusseite,
  • der Sicherungsablage am NAS, über Tailscale,
  • der CI auf master — je eine Wache für server und app, alle fünf Minuten (seit 29.09.2026, siehe unten).

Alarmiert wird über zwei Wege, und beide gehen direkt hinaus: E-Mail über World4You an office@simpro-software.at, und ntfy aufs Telefon. Keiner von beiden läuft über cloud-1 — der Alarm „cloud-1 ist tot" darf nicht über cloud-1 gehen.

Am Telefon: die App ntfy (App Store, Google Play oder F-Droid), Server ntfy.sh stehen lassen, dort das Thema aus Rechnung-privat/cloud/simpro-cloud-2-zugang.txt abonnieren. Das Thema ist das Geheimnis: Wer es kennt, liest jede Alarmmeldung mit und kann selbst welche schicken. Es gehört in keine Mail und in keinen Chat.

Die CI-Wache — und warum es sie gibt

Seit 29.09.2026.

Der Anlass: Die CI stand zwei Tage rot, und es ist niemandem aufgefallen. npm audit meldete eine hohe Lücke in fast-uri und brach den Lauf in Schritt 7 ab — alles danach wurde übersprungen, auch der Lint in Schritt 13. Dessen eigener Bruch blieb damit ebenfalls unsichtbar. Zwei Fehler hintereinander, keiner gemeldet.

Zwei Wachen fragen deshalb bei GitHub nach dem neuesten abgeschlossenen Lauf auf master, je Workflow eine:

/actions/workflows/server.yml/runs?branch=master&status=completed&per_page=1 /actions/workflows/app.yml/runs?branch=master&status=completed&per_page=1

Rot ist, was "conclusion":"failure" trägt. Kuma alarmiert nur beim Zustandswechsel — einmal, wenn es kippt, und einmal, wenn es sich fängt.

WasGilt als
failurerot, Alarm
skipped (paths-Filter)grün
cancelled (der Workflow bricht seinen Vorgänger ab)grün
laufendgrün — status=completed lässt sie gar nicht erst durch

Was die Wachen nicht sehen: einen Lauf mit timed_out, und Läufe, die in der Schlange hängen, weil der eigene Läufer (dieser Rechner) aus ist. Beides bräuchte eine eigene Wache; beides ist bewusst offen.

Je Workflow eine und nicht eine für alle: Eine Sammelabfrage über /actions/runs liefert den neuesten abgeschlossenen Lauf irgendeines Workflows — am 29.09.2026 war das app=success, während server rot war. Eine Wache, die beim Anlass schweigt, ist keine. Der schönere Weg über /commits/master/check-runs scheidet aus: Er verlangt Checks: read, und der Token hat bewusst nur Actions: read.

Der Token

Fein abgestuft, nur dieses Repository, nur Actions: read, ein Jahr. Er liegt in Rechnung-privat/github-kuma-token.txt — eigene Datei, nicht als Feld in simpro-cloud-2-zugang.txt: Er hat mit dem Server nichts zu tun und wird eigen getauscht.

⏰ Wiedervorlage: Ende August 2027

Der Token wurde am 29.09.2026 für ein Jahr ausgestellt und läuft am 29.09.2027 ab. Einen Monat vorher erneuern.

Woran man es sonst merkt: gar nicht — und das ist der Punkt. Ein abgelaufener Token gibt 401, die Wache wird rot und meldet „CI kaputt", obwohl die CI grün ist. Ein Fehlalarm, den man zweimal wegwischt und beim dritten Mal nicht mehr liest; danach ist die Wache tot, ohne dass sie schweigt.

Erneuern: neuen Token ziehen, in dieselbe Datei legen, 03-kuma.sh laufen lassen — es zieht die Kopfzeilen nach und meldet headers als Unterschied.

Fehlt die Datei, werden die beiden CI-Wachen übersprungen und alles andere eingerichtet. Das Skript sagt es:

· SIMPro — CI server (master) uebersprungen — CI_TOKEN fehlt

Die Verwaltung von Kuma steht nur im Tailnet

http://simpro-cloud-2:3001

Und nirgends sonst. Der Port hängt an der Tailscale-Adresse der Maschine, die Vorgabe ist 127.0.0.1 — niemals 0.0.0.0.

Das ist kein Schönheitsgrund. Docker schreibt seine Portregeln an der Hetzner-Firewall vorbei direkt in die Weiterleitungskette. Wer sich darauf verlässt, dass Port 3001 in der Firewall nicht steht, hat die Oberfläche öffentlich stehen — und sie kennt jeden überwachten Endpunkt, jedes Alarmziel und die SMTP-Zugangsdaten.

Öffentlich ist nur die Statusseite, und Caddy lässt dorthin eine feste Liste von Pfaden durch. Alles andere bekommt 404, auch /dashboard und /socket.io/.

Die Statusseite zeigt drei Dienste, nicht sieben

https://status.simpro-software.at/ — Programm, Demo, Website. Der Sicherungswächter steht bewusst nicht darauf.

Ein 503 dort heißt: die nächtliche Sicherung ist nicht durchgelaufen. Das ist ein ernster Befund für Sie und kein Ausfall für den Kunden — seine Bücher sind erreichbar, seine Belege gehen hinaus. Ihn rot zu färben hieße, einen Alarm zu schlagen, den der Leser weder deuten noch beantworten kann. Wer alles zeigt, sagt nichts.

Die Gegenwache: cloud-1 sieht zurück

Ab 0.5.66. Die innere Wache auf cloud-1 prüft stündlich, ob die Website drüben antwortet und ob sich der Herzschlag von Kuma bewegt. Gemeldet wird beim zweiten Fehlschlag hintereinander.

Der vierte Fall ist der eigentliche Gewinn: Kuma antwortet mit 200 und misst trotzdem nichts mehr. Dann steht eine Statusseite da, auf der alles grün ist — weil sie den letzten grünen Stand zeigt. Eine eingefrorene Wache ist schlimmer als eine abgestürzte; die abgestürzte fällt auf.

Eingeschaltet wird sie über zwei Zeilen in der .env von cloud-1:

GEGENWACHE_SCHAUFENSTER=https://www.simpro-software.at/ GEGENWACHE_WACHE=https://status.simpro-software.at/api/status-page/heartbeat/simpro

Beide leer heißt aus. Das ist der Normalfall bei einer Installation, die nur einen Server hat.

Bedienung

./deploy/cloud2/01-grundaufbau.sh      # Docker, Verzeichnisse, .env
./deploy/cloud2/02-website.sh          # Website neu bauen und hinüberlegen
./deploy/cloud2/03-kuma.sh             # Monitore und Alarmwege abgleichen
PROBE=1 ./deploy/cloud2/03-kuma.sh     # ... und eine Probemeldung schicken
./deploy/cloud2/04-dns.sh stand        # welcher Name zeigt wohin
./deploy/cloud2/09-abnahme.sh          # Seite für Seite gegen cloud-1 vergleichen

Die Monitore stehen im Repository, in deploy/cloud2/kuma.js. Wer einen ändern will, ändert ihn dort und lässt 03-kuma.sh laufen; das Skript gleicht ab statt nur anzulegen. Was in der Oberfläche geklickt wird, ist beim nächsten Lauf wieder weg — und das ist Absicht: Was die Wache überwacht, gehört ins Repository und nicht in jemandes Erinnerung.

Sicherung: keine, und warum das richtig ist

cloud-2 trägt keine Daten, die eine Sicherung bräuchten. Alles kommt aus dem Repository oder stellt sich selbst wieder her; die Zertifikate holt Caddy in Sekunden neu. Das einzige, was verloren ginge, sind die Messreihen von Kuma — Geschichte, kein Betriebsmittel.

Was er braucht, liegt ohnehin im OFFLINE-TRESOR: das Kuma-Kennwort, das ntfy-Thema, die SMTP-Zugangsdaten, der Tailscale-Schlüssel. Der Wiederaufbau in fünf Zeilen steht in docs/WIEDERAUFBAU-PROBE.md.

11. Ein Konto anlegen: das Häkchen und seine Verantwortung

Seit 0.5.13 wartet ein neu angelegtes Konto auf die Bestätigung seiner E-Mail-Adresse, bevor es sich anmelden kann. Im Anlege-Dialog steht daneben ein Häkchen:

Adresse ist verifiziert — Konto sofort freischalten

Voreingestellt aus. Wer es setzt, sagt damit: ich habe diese Adresse ausserhalb des Programms geprüft und stehe dafür ein. Der richtige Fall dafür ist der Mitarbeiter, der danebensteht und dessen Adresse Sie gemeinsam gelesen haben. Der falsche ist eine Adresse, die Ihnen jemand am Telefon durchgegeben hat.

Die Wahl steht im Prüfprotokoll (user.create mit bestaetigungUebersprungen). Nicht als Vorwurf — sondern weil die Frage später gestellt wird: wer hat gesagt, dass diese Adresse stimmt?

Was Sie in der Benutzerliste sehen

ZustandBedeutung
Wartet auf BestätigungDer Link ist unterwegs, geklickt wurde er nicht. Anmelden geht nicht
Bestätigungslink erneut schickenMit dem Zeitpunkt des letzten Versands. Der ältere Link verfällt dabei
Deaktiviert nach 14 TagenAutomatisch stillgelegt, nicht gelöscht. Aktivieren und Link erneut schicken weckt es wieder

Bestandskonten

Konten, die es vor 0.5.13 schon gab, sind nicht betroffen — auch dann nicht, wenn ihre Adresse nie bestätigt wurde. Das neue Verhalten gilt für das, was ab jetzt entsteht. Ein Sicherheitsgewinn, der einen laufenden Betrieb aussperrt, wird bis Mittag rückgängig gemacht und ist dann gar keiner.


Der Ausfallplan

Was ist losWas zu tun ist
UptimeRobot meldet Ausfall, SSH gehtdocker compose ps, dann docker compose logs api --tail 100
UptimeRobot meldet Ausfall, SSH geht nichtHetzner-Konsole: Server neu starten. Hilft das nicht, aus dem letzten Snapshot wiederherstellen
Zertifikat abgelaufen`docker compose logs caddy \tail -50`. Fast immer ist Port 80 zu — die ACME-Prüfung kommt darüber
Datenbank kaputtWiederherstellung, oben
Server ganz wegNeu aufsetzen, oben. Der restic-Schlüssel ist die Bedingung
Update hat den Server zerlegtEs rollt selbst zurück. Tut es das nicht: ./scripts/update.sh --fassung <vorige>

Die Monatsroutine

Einmal im Monat, zehn Minuten:

1./deploy/cloud/09-abnahme.sh — geht alle Punkte durch
2Nachsehen, dass der Wiederherstellungstest gelaufen ist und gestimmt hat
3Plattenplatz ansehen: df -h /opt/simpro
4Hetzner-Rechnung ansehen — ein vergessener Server kostet weiter
5Prüfen, dass die Zugangsdateien in Rechnung-privat noch da und lesbar sind

Punkt 5 ist der, den alle auslassen, und der einzige, dessen Fehlen man erst im Ernstfall bemerkt.

Was der Betrieb kostet

Server cx33 (4 Kerne, 8 GB, 80 GB) in Nürnberg8,49 € / Monat brutto
Snapshots (20 % vom Server)1,70 € / Monat
Storage Box BX11 (1 TB)ab 3,81 € / Monat brutto
Domain, Cloudflare, UptimeRobot (kostenloser Tarif)0 €
Zusammenrund 14 € / Monat

12. Die Lizenz

Je Betrieb eine Lizenz mit Schlüssel, Laufzeit, Betriebsmodell und Grenzen. Was sie bedeutet, entscheidet eine Sache: ob Neues angelegt werden darf. Alles andere geht immer.

Es gibt keinen Zustand „gesperrt". Eine abgelaufene Lizenz führt in den Lesemodus: ansehen, exportieren, drucken, Datenträger überlassen — alles das geht weiter, dauerhaft. Auch Sicherheitsupdates laufen weiter.

Das ist keine Großzügigkeit, sondern § 132 BAO: die Aufzeichnungen sind sieben Jahre lesbar zu halten. Wer sie hinter eine Bezahlschranke stellt, macht sich zum Mitverursacher eines Verstoßes, den ein anderer begeht.

Die Zustände

Was dastehtWas gilt
Die Lizenz gilt bis …Alles normal
Der Lizenzserver antwortet nichtEs wird weitergearbeitet. Ab dem zweiten Tag steht daneben, wie viele Tage die Kulanz noch läuft
Die Lizenz ist abgelaufenLesemodus
Die Lizenz wurde zurückgezogenLesemodus, mit dem Grund
Seit 30 Tagen nicht bestätigtLesemodus. Sobald die Verbindung wieder steht, geht es ohne Zutun weiter
Keine Lizenz hinterlegtAlles normal — eine frische Installation soll nicht an einem Lizenzserver hängen, den sie noch nicht kennt

Dreißig Tage Kulanz, gerechnet ab der letzten geglückten Antwort. Der Fall ist nicht selten und fast nie böse: ein Netzausfall, eine Firewall, ein abgelaufenes Zertifikat auf meiner Seite. Wer dann am dritten Tag stillsteht, hat ein Problem, das ich verursacht habe.

Was in der .env steht

# Der eigene Schluessel. Er steht auf der Rechnung.
LIZENZ_SCHLUESSEL=SIMPRO-ABCD-EFGH-JKLM-NPQR

# Wen dieser Server fragt. Ohne Vorbelegung: eine Installation, die von sich
# aus bei einem fremden Server anklopft, ist genau das, was niemand will.
LIZENZ_SERVER=https://app.simpro-software.at

Gefragt wird einmal am Tag, um 02:05, zusammen mit der Updateprüfung. Wer gerade verlängert hat, drückt in Einstellungen → Lizenz auf Jetzt nachfragen, statt bis zum nächsten Morgen zu warten.

Für mich: der Lizenzserver

Nur auf meinem Server, und nur dort gibt es die Verwaltung:

LIZENZSERVER=true
# Der Freigabeschalter fuer Eigenserver. Aus, mit Absicht.
LIZENZ_EIGENSERVER_ERLAUBT=false

Und in compose.yml durchreichen. Bis 0.4.5 stand LIZENZSERVER in der .env und kam nie im Container an; der Server hielt sich für eine gewöhnliche Kundeninstallation und antwortete „Diese Installation stellt keine Lizenzen aus". Eine Einstellung, die nicht im environment:-Block des Dienstes api steht, gibt es für das Programm nicht. Prüfen lässt sich das in einem Satz:

docker compose exec api printenv | grep LIZENZ

Ausstellen kann nur, wer beides ist: Systemverwalter und auf dieser Installation angemeldet. Die zwei Bedingungen sind mit Absicht getrennt — die eine sagt, welcher Server die Rolle spielt, die andere, welcher Mensch sie ausüben darf. An ein Konto allein lässt sie sich nicht binden: ein Konto reist mit, ein Lizenzserver nicht. Auf einer Installation ohne LIZENZSERVER erscheint der Knopf Ausstellen deshalb gar nicht, sondern ein Satz, der sagt, warum.

Mein eigener Betrieb braucht keine Lizenz. Erkannt wird er daran, dass ein Systemverwalter in ihm Mitglied ist — dieselbe Formulierung wie beim Schutz vor dem Löschen, und aus demselben Grund: sie hält sich selbst aktuell. Ohne diese Ausnahme entstünde ein Kreis ohne Ausgang, denn im Lesemodus ließe sich auch keine Lizenz mehr anlegen. In der App steht dort „Betreiber (lizenzfrei)".

Schlüssel auf Vorrat

Im Ausstellen-Dialog steht als erste Wahl „Auf Vorrat — noch kein Betrieb". Ein solcher Schlüssel entsteht ohne Betrieb und bindet sich an den ersten, der ihn auf seiner Lizenzseite einlöst — genau einmal, danach an keinen zweiten. Das ist der Weg für einen Interessenten, den es hier als Betrieb noch gar nicht gibt: er registriert sich, legt seinen Betrieb an, trägt den Schlüssel ein.

In der Liste steht so eine Zeile als „Auf Vorrat — noch nicht eingelöst", bis sie jemand einlöst.

Wie lange eine Lizenz laufen darf

ArtLaufzeit
Testhöchstens 365 Tage
Abofrei, auch unbefristet

Der Deckel für Testlizenzen sitzt im Server, nicht im Formular — eine Grenze, die nur ein Eingabefeld kennt, ist keine. Die Absicht ist kaufmännisch: eine Abolizenz entsteht künftig mit dem Abo und wird dort verlängert; was ich von Hand ausstelle, ist ausdrücklich ein vorübergehendes Recht und soll nicht versehentlich zur unbefristeten Freigabe werden.

Seit 0.4.2 gibt es dafür einen Bildschirm: Verwaltung → Lizenzen. Liste mit dem Zustand je Zeile (aktiv, Kulanz, abgelaufen, widerrufen), Ausstellen mit Betrieb, Laufzeit, Betriebsmodell und Grenzen, Verlängern und Widerrufen je Zeile mit Rückfrage, Verlauf je Lizenz. Der Schlüssel steht einmal nach dem Ausstellen da — er gehört in die .env der Installation, für die er gilt.

Die Auswahl Betriebsmodell erscheint nur, wenn LIZENZ_EIGENSERVER_ERLAUBT gesetzt ist. Steht der Schalter aus, weist der Server jede Eigenserver-Lizenz ohnehin ab, und ein Feld, dessen Benutzung immer mit einer Absage endet, wäre eine Falle.

Darunter liegen weiterhin dieselben Endpunkte:

WasWie
Alle LizenzenGET /api/v1/lizenz/verwaltung → { lizenzen, eigenserverErlaubt, istLizenzserver, testlizenzMaxTage }
Betriebe zum AusstellenGET /api/v1/lizenz/verwaltung/mandanten — mit hatLizenz je Betrieb
AusstellenPOST /api/v1/lizenz/verwaltung mit betriebsmodus, laufzeitTage, typ — tenantId weglassen ergibt einen Schlüssel auf Vorrat
Einlösen (der Kunde)POST /api/v1/lizenz/einloesen mit schluessel — nur ADMIN des Betriebs
VerlängernPOST …/:id/verlaengern mit tage
WiderrufenPOST …/:id/widerrufen mit grund — der Grund ist Pflicht
ZurücknehmenPOST …/:id/wiederherstellen
HistorieGET …/:id/historie

Alle davon verlangen die Systemverwalter-Fahne — siehe den nächsten Abschnitt. ADMIN allein reicht nicht mehr, und das ist der Punkt: auf dem Lizenzserver hätte der Verwalter eines Kunden sonst die Lizenz eines anderen in der Hand gehabt.

Die Verwaltung arbeitet auf der Eigentümerverbindung: sie schreibt Zeilen fremder Mandanten und liest über alle hinweg. Die Anwendungsrolle darf Lizenzen nur lesen — eine Installation, die ihre eigene Lizenz verlängern kann, braucht keine.

Verlängern rechnet ab dem alten Ende, oder ab heute, wenn die Lizenz schon abgelaufen war. Sonst verschenkte eine Verlängerung nach drei Monaten Pause die drei Monate, und der Kunde zahlte für eine Zeit, die vorbei ist.

Systemverwalter: wer die Installation verwaltet

Bis 0.4.1 gab es genau eine Sorte Verwalter: ADMIN, und zwar je Mandant. Wer seinen eigenen Betrieb verwaltete, durfte damit auch alles, was die Installation angeht — Mandanten anlegen, Dienst-Token lesen, Updates auslösen, Lizenzen vergeben und zurückziehen. Auf einer Installation mit einer Firma fällt das nicht auf, weil beide dieselbe Person sind. Auf dem Lizenzserver war es ein Loch.

Seit 0.4.2 trennt das die Fahne istSystemverwalter. Sie wird nicht über die Oberfläche vergeben, sondern an der Maschine:

# Wer sie hat
docker compose exec api node dist/cli.js systemverwalter

# Vergeben und wieder nehmen
docker compose exec api node dist/cli.js systemverwalter --email ich@example.at --an
docker compose exec api node dist/cli.js systemverwalter --email ich@example.at --aus

Der Befehl weigert sich, den letzten Träger zu entfernen — sonst wäre die Installation nicht mehr zu verwalten, und der Weg zurück führte über die Datenbank.

Zwei Dinge, die leicht zu verwechseln sind:

  • Die Fahne gilt installationsweit, nicht je Mandant. Sie macht niemanden zum Verwalter eines Betriebs, in dem er nichts verloren hat — @Roles gilt weiter daneben.
  • Der Wächter liest sie bei jedem Aufruf aus der Datenbank und nicht aus dem Zugangstoken. Wer sie entzieht, entzieht sie sofort; sonst behielte ein zurückgezogener Systemverwalter seine Rechte bis zum Ablauf des Tokens, also eine Viertelstunde lang — genau die Viertelstunde, in der man es nicht will.

Die Migration setzt sie beim Umstieg auf 0.4.2 für jeden bestehenden Verwalter: sie führt eine Unterscheidung ein und nimmt niemandem etwas weg. Wer mehrere Kunden auf einer Installation hat, nimmt sie danach von Hand wieder weg — der Befehl oben mit --aus.

Einen neuen Kunden anlegen — in einem Zug

Verwaltung → Mandanten → Firma anlegen. Ein neuer Kunde braucht drei Dinge, und wer sie einzeln zusammensucht, vergisst das dritte:

  1. Die Firma — Name, Kurzzeichen, Farbe, wahlweise Grunddaten aus einer bestehenden übernommen.
  2. Den ersten Verwalter — E-Mail und Name. Er bekommt ein Erstpasswort, das genau einmal angezeigt wird und beim ersten Anmelden gewechselt werden muss.
  3. Eine Testlizenz — gratis, mit frei gewählter Laufzeit.

Beides Letztere ist abwählbar, steht aber vorgeschlagen da, statt still zu fehlen. Schlägt der zweite oder dritte Schritt fehl, bleibt die Firma bestehen und der Fehler wird gemeldet — ein halb angelegter Kunde ist ärgerlich, ein verschwundener wäre schlimmer.

Die Benutzergrenze

Jede Lizenz trägt eine Zahl: wie viele aktive Konten der Betrieb haben darf. Standard sechs; bestehende Lizenzen haben sie mit 0.5.31 per Migration bekommen.

Bis 0.5.30 stand die Zahl nur da. Sie wurde gespeichert, angezeigt und bei keiner Anlage gelesen — ein Betrieb mit „6 Benutzer" auf der Lizenz konnte sechzig anlegen. Seit 0.5.31 greift sie.

Ändern: Verwaltung → Lizenzen, an der Lizenz auf Benutzer. Leer lassen heisst „keine Grenze"; das ist eine Entscheidung, die Sie treffen dürfen, und sie steht dann ebenso im Verlauf. Vorher und Nachher landen in der Lizenzhistorie — in einem halben Jahr ist beantwortbar, wann von wie viel auf wie viel.

Bis zur Zahlung per Karte ist das der Weg für „mehr Benutzer auf Anfrage": eine E-Mail vom Kunden, eine Minute Arbeit.

Drei Dinge, die im Betrieb Fragen auslösen:

  • Gezählt werden aktive Konten; der Inhaber zählt mit. Ein deaktiviertes belegt keinen Platz — das ist der Weg beim Personalwechsel.
  • Geprüft wird auf beiden Anlagewegen: im Anlagefluss des Betriebs und bei der Aufnahme eines bestehenden Kontos in einen zweiten Betrieb.
  • Nie rückwirkend. Liegt ein Betrieb über der Grenze, arbeiten alle weiter; gesperrt ist allein die Neuanlage. Das kommt vor und ist kein Fehler.

Ein wegen der Grenze abgewiesener Versuch steht im Prüfprotokoll des Betriebs als user.anlage_abgelehnt_limit — mit der E-Mail-Adresse und sonst nichts. Wenn jemand anruft, „das Anlegen geht nicht", steht dort, wann und für wen.

Nie eingelöste Vorratsschlüssel entfernen

Widerrufene Lizenzen bleiben stehen. Eine Ausnahme: ein Schlüssel, der nie einem Betrieb zugewiesen war. An ihm hängt kein Geschäftsvorfall — kein Betrieb hat damit gearbeitet, keine Rechnung ist unter ihm entstanden. Er ist ein gedruckter Gutschein, der nie über die Theke ging.

An solchen Zeilen steht Endgültig entfernen; die Rückfrage nennt den Schlüssel. Vorher schreibt der Server lizenz.entfernt ins Prüfprotokoll, mit Schlüssel und Widerrufsgrund — der Weg bleibt lesbar: ausgestellt → widerrufen → entfernt.

Bei jemals eingelösten Lizenzen gibt es diesen Weg nicht, auch nicht bei abgelaufenen oder längst abgelösten. Dort bleibt der Widerruf der Endzustand. Der Server prüft das selbst: er sieht nicht nur nach, ob die Lizenz gerade einem Betrieb gehört, sondern auch, ob sie jemals eingelöst wurde — beim Ablösen durch einen neuen Schlüssel wird die Zuordnung geleert, und eine gebrauchte Lizenz sähe danach aus wie ein Vorratsschlüssel.

Lizenzarten: Test und Abo

Seit 0.4.5 trägt jede Lizenz eine Art, und der Unterschied ist nicht technisch — beide laufen beim Ablauf in denselben Lesemodus. Er ist kaufmännisch, und er gehört trotzdem in die Datenbank: sonst steht in einem halben Jahr eine Liste von vierzig Lizenzen da, und niemand weiß mehr, welche davon je bezahlt wurde.

ArtWofür
TESTgratis, mit Laufzeit. Läuft aus und wird nicht von selbst wieder gültig
ABOzahlender Betrieb. Verlängert sich später über die Zahlung; bis Stripe kommt, von Hand

Alles, was vor 0.4.5 ausgestellt wurde, steht als TEST — es war zum Ausprobieren da, und die Annahme ist die vorsichtigere: eine Testlizenz fälschlich als Abo zu führen wäre eine Rechnung, die niemand erwartet.

Ein Betrieb mit Lizenz wird nicht mehr ausgegraut. Er steht in der Auswahl mit dem Vermerk hat bereits eine, und der Knopf heißt dann Ersetzen: die bisherige endet sofort, der alte Schlüssel gilt nicht mehr, der Verlauf hält fest, was vorher galt. Wer nur die Laufzeit verlängern will, benutzt in der Liste Verlängern — das lässt den Schlüssel stehen.

Warum ersetzen und nicht danebenstellen: ein Betrieb hat genau eine Lizenz; die Spalte ist eindeutig. Zwei gleichzeitig gültige für denselben Betrieb wären eine Frage ohne Antwort.

Wenn ein Kunde sich selbst löscht

Ein Betrieb kann seit 0.4.5 den Weg hinaus selbst gehen — Einstellungen → Betrieb → Betrieb löschen, nur für seine Verwalter. Vier Schritte (Export, Warnhinweis, erneute Anmeldung, Firmenname), dann sieben Tage Frist.

Für Sie als Betreiber heißt das:

  • In Verwaltung → Mandanten steht bei dem Betrieb ein roter Streifen mit dem Vollzugsdatum und dem Namen dessen, der es angefordert hat.
  • Sie können abbrechen — der Knopf daneben. Der Betrieb selbst kann es ebenso; Ihr Weg ist der zweite, nicht der einzige. Für den Anruf „das war ein Versehen".
  • Nach Ablauf räumt der nächtliche Lauf um 03:45 ab — nach der Sicherung um 02:30, damit der letzte Stand noch gesichert ist.
  • Danach steht im Prüfprotokoll der Installation ein Eintrag mit Name, Anforderer, Zeitpunkt der Anforderung und des Vollzugs und der Bestätigung, dass Export und Warnhinweis erledigt waren. Das ist Ihr Nachweis, wenn in einem Jahr jemand fragt.

Was Sie nicht können: einen fremden Betrieb mit festgeschriebenen Belegen hart löschen. Das bleibt der Weg des Mandanten selbst; Ihnen steht das Stilllegen offen.

Konten von der Maschine aus

Zwei Handgriffe, die es nur in der App gab — und die man genau dann braucht, wenn man nicht in die App kommt.

# Ein Konto wieder aktivieren (oder mit --aus stilllegen)
docker compose exec api node dist/cli.js benutzer-aktiv --email office@firma.at

# Ein Erstpasswort setzen, immer mit Änderungszwang.
# Ohne --passwort würfelt der Server eines und zeigt es an.
docker compose exec api node dist/cli.js reset-password   --email office@firma.at --passwort 'mindestens-zwoelf-zeichen'

Warum das Aktivieren auf die Kommandozeile gehört: Deaktivieren ging immer über die Verwaltung. Der Weg zurück führte bisher auch nur dorthin — und genau dort steht man, wenn er gebraucht wird. Wird der letzte Verwalter eines Betriebs versehentlich stillgelegt, kommt niemand mehr an die Verwaltung, um ihn zu reaktivieren. Bliebe SQL, und Handarbeit an der Datenbank ist das, was hier niemand tun soll.

Beide Befehle schreiben ins Prüfprotokoll — dass es geschah und für wen. Das Passwort steht dort nicht; ein Protokoll, das Passwörter mitschreibt, ist ein Schlüsselbund, und es ist unlöschbar.

Jemanden aus einem Betrieb nehmen

# Ohne --auch-konto passiert nichts, wenn es die letzte Zugehörigkeit wäre.
docker compose exec api node dist/cli.js mitgliedschaft-entziehen   --email admin@simpro-software.at --betrieb simpro-software

--betrieb nimmt die Kennung oder den Anzeigenamen. Trifft der Name auf mehrere Betriebe zu, zählt der Befehl sie auf und tut nichts — der falsche wäre hier teuer.

Warum es das an der Maschine braucht. In der App steht Aus dieser Firma nehmen bei jedem Mitglied ausser einem: dem eigenen. Das ist richtig — wer sich selbst hinauswirft, sperrt sich womöglich aus —, ergibt aber einen Kreis, sobald ein Konto sich selbst in einen Betrieb aufgenommen hat und dort keine verwaltende Rolle trägt. Herausnehmen dürfte nur ein Administrator dieses Betriebs; das Konto selbst darf es nicht, weil es sich selbst meint. Genau so blieb das Betreiberkonto am 4. September 2026 in einem fremden Betrieb liegen, in den es sich für einen Versuch mit der Bankanbindung geholt hatte.

Was der Befehl tut — und was nicht. Er macht Zug um Zug dasselbe wie die App: die Zugehörigkeit wird zurückgezogen, nicht gelöscht, damit das Protokoll später noch sagen kann, dass dieses Konto einmal Zugriff hatte. Die offenen Sitzungen enden (ein Zugangstoken, das noch auf den Betrieb zeigt, wäre sonst länger gültig als der Grund dafür). Zwei Prüfeinträge entstehen: einer beim Betreiber, der eine Löschung des Betriebs überlebt, und einer im Betrieb selbst.

Zwei Wächter stehen davor. Den letzten Inhaber nimmt er nicht heraus, und ohne Inhaber auch nicht den letzten Verwalter — ein Betrieb, den niemand mehr führen kann, ist von der Konsole aus kein besserer Zustand als aus der App. --auch-konto hebt das nicht auf: es beantwortet nur die andere Frage, nämlich was aus einem Konto wird, das danach nirgends mehr hingehört. Ohne diesen Zusatz lehnt der Befehl ab, statt jemanden versehentlich auszusperren; mit ihm wird das Konto weich stillgelegt, wie bei einer Kontolöschung in der App.

Wiederherstellungscodes: verloren, nie gesehen, neu

Die zehn Codes ersetzen den Zeitcode, wenn das Telefon mit der Authenticator-App weg ist. Sie werden genau einmal gezeigt; der Server hält nur ihre Hashwerte und kann denselben Satz nicht wiederholen.

Wer sie verloren hat, hilft sich selbst: Einstellungen → Konto → Wiederherstellungscodes neu erzeugen. Verlangt das Passwort und den laufenden Code aus der Authenticator-App — frische Codes ersetzen den zweiten Faktor, wer sie ohne einen bekäme, hätte ihn abgeschafft. Die alten verfallen dabei; ein ausgedruckter Zettel gilt danach nicht mehr.

Und wer sie nie gesehen hat, bekommt sie von selbst. Seit 0.5.7 merkt der Server, ob jemand bestätigt hat, sie gesichert zu haben. Solange nicht, legt jede Anmeldung einen frischen Satz bei, und die App zeigt ihn — bis das Häkchen gesetzt und der Knopf gedrückt ist. Lästig mit Absicht: der Zustand davor ist einer, in dem zehn gültige Codes existieren, die niemand kennt.

Der frische Satz entwertet den vorigen. Das ist der Punkt: ein Satz, den nachweislich niemand bestätigt hat, ist im Zweifel einer, den niemand hat. Zwanzig gültige Codes zu führen, von denen zehn niemandem gehören, wäre das Gegenteil von sicher.

Im Prüfprotokoll stehen alle drei Vorgänge: auth.recovery_codes_reissued (die Anmeldung hat nachgelegt), auth.recovery_codes_regenerated (jemand hat sie selbst neu erzeugt), auth.recovery_codes_confirmed (die Übergabe ist erledigt).

Beim Umstieg auf 0.5.7 gilt jedes Konto mit zweitem Faktor und mindestens einem Code als bestätigt — sie sind über den regulären Weg entstanden, und ihnen einen neuen Satz vorzusetzen wäre ein Schaden. Konten mit zweitem Faktor, aber ohne einen einzigen Code bekommen bei der nächsten Anmeldung zehn neue: dort ist nichts da, was jemand haben könnte.

Den zweiten Faktor neu vergeben lassen

Für ein einzelnes Konto geht das auch über Verwaltung → Benutzer. Von der Maschine aus — und für alle Konten auf einmal — so:

# Ein Konto
docker compose exec api node dist/cli.js reset-totp --email office@firma.at

# Alle: erst ansehen, wen es träfe. Ohne --wirklich passiert nichts.
docker compose exec api node dist/cli.js reset-totp --alle
docker compose exec api node dist/cli.js reset-totp --alle --wirklich

Danach führt die Anmeldung nach dem Passwort nicht mehr zur Codeabfrage, sondern zur Neueinrichtung: QR-Code, Bestätigung mit einem Code, zehn frische Notfallschlüssel. Niemand wird ausgesperrt — das Passwort genügt weiterhin, um bis dorthin zu kommen.

Vier Dinge gehören dazu, nicht eines. Das Geheimnis zu löschen reicht nicht:

warum
Geheimnis und Bestätigung löschensonst fragt die Anmeldung weiter nach dem alten Code
tokenVersion hochzählenzieht die ausgegebenen Zugriffstoken ein
Erneuerungstoken zurückziehendas ist der wichtige — siehe unten
Notfallschlüssel löschensonst kommt ein alter Zettel an der Neueinrichtung vorbei

Die dritte Zeile fehlte dem Kommandozeilenbefehl bis 0.5.3, und sie ist die, an der die ganze Maßnahme hing: rotate() schlägt das vorgelegte Erneuerungstoken in der Tabelle nach und unterschreibt neu — es sieht die tokenVersion nicht an. Ein Gerät, das schon angemeldet war, hätte sich also weiter erneuert und den zweiten Faktor nie wieder zu Gesicht bekommen. Zurückgesetzt hätte man dann nur die, die ohnehin gerade draußen waren.

Jedes zurückgesetzte Konto bekommt einen eigenen Eintrag im Prüfprotokoll (user.totp_reset). Auf der Kommandozeile geschieht das ohne Anmeldung — der Eintrag ist die einzige Spur.

Pflicht oder empfohlen — wer den zweiten Faktor verlangt

Seit 0.5.95 entscheidet das jeder Betrieb für sich, und zwar sein Inhaber in Einstellungen → Betrieb → Zwei-Faktor-Pflicht. Zwei Stellungen:

was sie heißt
PflichtOhne eingerichteten zweiten Faktor kommt niemand in diesen Betrieb hinein. Die Anmeldung führt nach dem Passwort in die Einrichtung, und eine laufende Sitzung ohne Faktor wird bei der nächsten Anfrage abgewiesen
EmpfohlenWer keinen Faktor hat, arbeitet trotzdem. Die App bietet die Einrichtung an, erzwingt sie nicht

Was in beiden Stellungen gleich bleibt, und das ist die wichtigste Zeile hier: Wer einen Faktor eingerichtet hat, wird weiter danach gefragt. Die Einstellung erlässt die Einrichtungspflicht — sie nimmt niemandem seinen Schutz. Genau so verhält sich auch der Instanzschalter AUTH_ZWEITER_FAKTOR_ENTBEHRLICH seit 0.5.70.

Was beim Einspielen von 0.5.95 passiert — und was nicht

Nichts wird gelockert. Jeder Betrieb, der heute besteht, steht danach auf Pflicht; die Wanderung hebt alle ausdrücklich dorthin. Erst wenn ein Inhaber umstellt, ändert sich etwas, und dann hat er es entschieden.

Neue Betriebe entstehen auf „empfohlen". Das ist die Vorgabe und eine bewusste Entscheidung (DECISIONS.md, N174) — kein Versehen und nichts, was der Betreiber nachstellen müsste.

Wo das zuerst auffällt: bei der Selbstregistrierung. Wer sich über die Website anmeldet, ist nach der ersten Anmeldung drin — mit Kennwort allein. Bis 0.5.94 führte dieselbe Anmeldung in die Einrichtung des zweiten Faktors.

Wer das für einen neuen Kunden nicht möchte, hat zwei Wege: Der Inhaber stellt selbst auf „Pflicht", oder der Betreiber setzt es einmal von Hand — UPDATE "Tenant" SET "zweiterFaktor" = 'PFLICHT' WHERE slug = '…';. Der zweite Weg steht hier nur der Vollständigkeit halber: Er erzeugt keinen Eintrag im Prüfprotokoll, und eine Sicherheitseinstellung ohne Spur ist eine, über die später niemand Auskunft geben kann.

Für Systemverwalter gilt sie nicht

Ein Konto mit istSystemverwalter braucht den zweiten Faktor immer, unabhängig von jeder Betriebseinstellung. Kein Inhaber kann das wegstellen, und es steht auch nicht in der Datenbank: Es ist eine Regel im Programm. Ein Konto, das fremde Betriebe sehen kann, ist nicht Sache eines einzelnen Betriebs.

Wer umstellt, steht im Prüfprotokoll

Jede Umstellung erzeugt einen Eintrag tenant.zweiter_faktor mit der Adresse des Inhabers, dem alten und dem neuen Stand und dem Zeitpunkt. Dieselbe Einstellung noch einmal zu speichern erzeugt keinen Eintrag — sonst fände man die eine echte Umstellung zwischen zehn Speichervorgängen nicht mehr.

Was die Umstellung nach „empfohlen" nicht verlangt: ein Geheimnis. Kein Kennwort, kein Code. Das ist Absicht: Ein Inhaber, der seinen zweiten Faktor verloren hat, muss diesen Weg gehen können — sonst bleibt ihm nur der Anruf bei uns. Die Kehrseite ist, dass eine gestohlene Inhaber-Sitzung lockern kann. Dagegen steht der Protokolleintrag, nicht eine zweite Abfrage.

Einen Faktor freiwillig einrichten

In einem Betrieb auf „empfohlen" richtet jeder seinen Faktor selbst ein: Einstellungen → Konto → Zwei-Faktor-Anmeldung. Die App fragt dabei das Kennwort — nicht aus Umständlichkeit, sondern weil eine Einrichtung aus einer fremden Sitzung den rechtmässigen Besitzer aussperren würde: Danach verlangt die Anmeldung einen Code, den der Fremde hat und der Besitzer nicht.

Ein bestehender Faktor lässt sich auf diesem Weg nicht ersetzen. Das ist ein Wechsel und kein Einrichten; dafür bleibt es bei reset-totp über den Betreiber (siehe oben).

Was ein Schritt verlangt, der etwas Schweres tut

Unverändert in beiden Stellungen, und nie nichts:

KontoKennwort ändern, Codes erneuern, Adresse wechseln, Modul schalten
mit Faktorder Code aus der Authenticator-App
ohne Faktordas Kennwort

Bis 0.5.94 wies das Modulschalten ohne Faktor ab — und auf einer Instanz mit AUTH_ZWEITER_FAKTOR_ENTBEHRLICH liess es ohne jede Abfrage durch. Beides ist weg: Ein Modul zu schalten baut eine Oberfläche um, die alle im Betrieb sehen, und ein Fehlklick soll dafür nicht genügen.

Einen Mandanten löschen oder stilllegen

Verwaltung → Mandanten, den Betrieb aufklappen. Was dort angeboten wird, rechnet der Server — und er rechnet es beim Eingriff noch einmal nach.

Löschen gibt es nur für einen Betrieb, der nie ernsthaft gearbeitet hat: kein festgeschriebener Beleg, keine Ausgabe. Das ist der Testmandant, der Vorführmandant, der versehentlich zweimal angelegte. Der Dialog verlangt den Firmennamen eingetippt — ein Häkchen setzt man aus Versehen, einen Firmennamen tippt niemand versehentlich ab.

Stilllegen ist der Weg für alle anderen. Sobald ein Beleg festgeschrieben ist, trägt er eine Nummer aus einem lückenlosen Kreis und fällt unter § 132 BAO — sieben Jahre lesbar. Ihn zu löschen, weil ein Vertrag endet, wäre die Mitwirkung an einem Verstoß, den ein anderer begeht. Stillgelegt heißt: keine Anmeldung mehr, alle Sitzungen enden, die Lizenz endet — die Aufzeichnungen bleiben unverändert. Rückgängig über Wieder zulassen; die Lizenz kommt dabei nicht von selbst zurück, das ist eine kaufmännische Entscheidung.

Was in der Mandantenliste steht

Seit 0.4.8 stehen dort alle Betriebe der Installation, nicht nur die, in denen ich selbst Verwalter bin. Der Anlass war ein Loch, das erst im Betrieb auffiel: nachdem mein Betreiberkonto in einen eigenen Mandanten umgezogen war, verschwanden die Kunden aus der Verwaltung — und mit ihnen der einzige Weg zur Betreuungssicht, denn die braucht die Kennung des Betriebs.

Ein fremder Betrieb ist als solcher gekennzeichnet („· Betreuung" in der Zeile). Aufgeklappt bietet er genau zwei Dinge:

  • Betreuung — die fünf Handgriffe an Konten: anlegen, Passwort zurücksetzen, entsperren, zweiten Faktor zurücksetzen, stilllegen oder wieder zulassen. Jeder davon steht im Prüfprotokoll des Kunden.
  • Lebenszyklus — stilllegen, wieder zulassen, löschen (unter den Bedingungen unten), eine angekündigte Selbstlöschung abbrechen.

Was dort nicht steht, ist der Punkt: keine Belege, keine Kunden, keine Umsätze. Nicht ausgeblendet — es gibt keinen Weg dorthin, und die Zeilensicherheit gäbe ihn auch nicht her. Ein gewöhnlicher Mandanten-Verwalter sieht die fremden Betriebe gar nicht erst; für ihn ist die Liste unverändert.

Zwei Betriebe sind unantastbar, und der Weg wird dort gar nicht erst angeboten: der, in dem Sie gerade angemeldet sind, und jeder, in dem ein Systemverwalter arbeitet. Die zweite Regel ist absichtlich so formuliert statt über eine gesetzte Kennzeichnung — sie hält sich selbst aktuell.

Was ein Löschen hinterlässt: die Einträge des Prüfprotokolls und eine geleerte Hülle namens „Gelöschter Mandant". Beides, weil die Protokollkette darauf zeigt und ein Loch darin den Integritätsnachweis der ganzen Installation kostet. Die Begründung steht in docs/DECISIONS.md, N38. Der Vollzug selbst bekommt einen Eintrag auf Installationsebene: Name, wer, wann, wie viele Zeilen — der Nachweis, den Sie brauchen, wenn in einem Jahr jemand fragt, wo die Daten geblieben sind.

13. Die Windows-App prüfen — nicht mit einem Bildschirmfoto

Die Windows-App zeichnet über Impeller, also auf der Grafikkarte. Eine Aufnahme über GDI — alles, was ein Skript mit CopyFromScreen oder PrintWindow macht — sieht davon nichts: Titelleiste und Rahmen kommen mit, die Fläche des Programms bleibt weiß. Das Fenster ist trotzdem gefüllt.

Wer prüfen will, ob eine Fassung richtig ausgeliefert wurde, nimmt deshalb:

# Fuellt das Fenster nach einem Kaltstart die Arbeitsflaeche?
pwsh -File scripts/fensterprobe.ps1

# Traegt das Programm Namen und Symbol?
(Get-Item .
echnung.exe).VersionInfo | Select ProductName, CompanyName

fensterprobe.ps1 misst GetWindowRect gegen die Arbeitsfläche und nicht IsZoomed: der Zustand „maximiert" ist eine Behauptung des Fensters über sich selbst, die Fläche ist das, was ein Mensch sieht. Rückgabewert 0 heißt: passt.

Für den Inhalt der Oberfläche ist ein Bildschirmtest der bessere Nachweis als ein Foto — er sieht die Widgets statt der Pixel und läuft in jedem Lauf mit. Wo trotzdem ein Bild gebraucht wird, macht es ein Mensch mit Windows-Taste + Druck. Begründung: docs/DECISIONS.md, N52.


14. Wenn in der App etwas schiefgeht: der Fehlerbildschirm

Bis 0.5.4 endete eine Ausnahme beim Aufbau der Oberfläche in einer einfarbig grauen Fläche — kein Text, kein Weg zurück, keine Spur. Das ist Flutters eingebauter Platzhalter; in der Auslieferungsfassung ist er grau und stumm. Ein Anwender kann darüber nur „alles grau" melden.

Seit 0.5.5 steht dort statt dessen ein Bildschirm mit

  • der Überschrift „Etwas ist schiefgegangen",
  • der Meldung selbst, zum Herauskopieren,
  • dem Pfad des Protokolls,
  • einer Schaltfläche „Zurück zur Übersicht"
  • und seit 0.5.15 daneben „An den Support melden" — der Knopf führt in das Support-Formular, mit ausgefülltem Betreff und der Meldung im Text.

Das Protokoll heisst fehler.log und liegt im Anwendungsordner der App, unter Windows also

%APPDATA%\net.suschigames\rechnung\fehler.log

Es hält die letzten 256 KB und beginnt danach von vorne. Was nach einem Geheimnis aussieht, steht nicht drin: Token, Passwörter, Wiederherstellungscodes und lange Schlüssel werden vor dem Schreiben gestrichen. Die Datei darf gefahrlos verschickt werden — sie ist genau dafür da.

Kommt so ein Bildschirm: „An den Support melden" drücken, zwei Sätze dazuschreiben, absenden. Die Daten sind davon nicht betroffen; es ist ein Fehler in der Anzeige. Das Formular nimmt die letzten fünfzig Zeilen der fehler.log von selbst mit — darin steht der Aufrufstapel, und der sagt, welche Zeile es war.

15. Support-Meldungen aus der App

Seit 0.5.15 gibt es in der App Einstellungen → Konto → Hilfe und Support, und zwar für jeden angemeldeten Benutzer. Was dort abgeschickt wird, geht zwei Wege:

  1. Als E-Mail an Sie. Betreff [Support] [Art] Betreff – Betrieb, Antwortadresse ist der Melder — Sie antworten mit „Antworten" direkt an ihn. Anhänge hängen an der Mail.
  2. In eine Tabelle. Sichtbar unter Verwaltung → Support-Meldungen (nur für Systemverwalter), mit Umschalter offen/erledigt.

Der zweite Weg ist die Rückversicherung gegen den ersten: eine Mail, die im Spamordner hängt oder an einem abgelaufenen Postfach scheitert, ist weg — und der Anwender hat trotzdem „abgeschickt" gelesen. In der Liste steht deshalb an jeder Zeile, ob die Mail hinausging; was nicht hinausging, trägt ein eigenes Zeichen. Sehen Sie dort gelegentlich nach, auch wenn das Postfach leer aussieht.

Eine Antwortfunktion gibt es in der App bewusst nicht. Geantwortet wird per E-Mail; zwei Orte, an denen eine Antwort stehen könnte, sind einer zu viel.

Wohin die Mail geht: an BETREIBER_EMAIL aus der .env, wenn gesetzt — dieselbe Adresse, die im Impressum der öffentlichen Seite steht. Sonst an office@simpro-software.at. Auf einem Eigenserver, den jemand anderes betreut, gehört dort dessen Adresse hinein; der Hersteller sieht die Meldung dann gar nicht.

Grenzen: fünf Meldungen je Konto und Tag, drei Anhänge je Meldung, je höchstens 5 MB, nur Bilder und PDF. Die Grenze zählt je Konto, nicht je Anschluss — ein Büro hinter einer Adresse hat mehr als einen Menschen.

Was mitgeschickt wird, entscheidet der Anwender: das Häkchen „Technische Angaben mitsenden" (bei Fehlerberichten vorausgewählt) nimmt App-Fassung, Plattform, Betriebssystem, Betrieb, Adresse, Zeitpunkt und die letzten fünfzig Protokollzeilen mit. Er sieht die Liste vorher aufgeklappt. Kennwörter und Token sind darin gestrichen — zweimal, in der App und noch einmal am Server.

16. Einen Menschen in mehreren Betrieben freischalten

Ein Konto kann zu mehreren Betrieben gehören — eine Anmeldung, ein zweiter Faktor, mehrere Buchhaltungen. Freischalten können das nur Sie.

Der Grund ist keine Einschränkung, sondern eine Folge: wer einen zweiten Betrieb bekommt, ist dort noch kein Verwalter — also kann ihn dort auch niemand aufnehmen. Es muss jemand von außen tun, und der Einzige, der über Betriebsgrenzen hinweg etwas darf, ist der Systemverwalter.

Der Weg

Verwaltung → Mandanten → den Betrieb auswählen → Bestehendes Konto aufnehmen. Suchen Sie über die E-Mail-Adresse (mindestens drei Zeichen), wählen Sie die Rolle — meistens Verwalter — und nehmen Sie auf.

Kein zweites Konto anlegen. Derselbe Mensch hätte dann zwei Passwörter und zwei zweite Faktoren, und eines davon vergisst er. Der Punkt Konto anlegen daneben ist für jemanden, den es noch nirgends gibt.

Beim Anlegen eines neuen Betriebs geht es auch gleich: das Feld Erster Verwalter nimmt ein bestehendes Konto. Sie selbst sind in dem Betrieb dann nicht Mitglied — und sehen die Bücher nicht.

Was dabei protokolliert wird

Jede Zuordnung und jede Entfernung steht in zwei Prüfprotokollen:

  • in Verwaltung → Installations-Prüfprotokoll — der Vorgang geschieht zwischen Betrieben,
  • und im Prüfprotokoll des betroffenen Betriebs — dort ist jemand hinzugekommen, der die Bücher sehen darf.

Das zweite ist das wichtigere: ein Betrieb, der das nur erfährt, wenn er Sie fragt, hat kein Prüfprotokoll, sondern eine Anzeige.

Was Sie nicht können

Den letzten Verwalter eines Betriebs entfernen. Der Versuch endet mit 409 und einem Hinweis. Ein Betrieb ohne Verwalter ist einer, den niemand mehr verwalten kann — auch Sie nicht, außer über die Konsole.

In die Bücher sehen. Die Betreuungssicht kennt Konten und sonst nichts; daran ändert die Mehrbetriebs-Freischaltung nichts.

Anträge auf weitere Betriebe

Verwaltung → Anträge auf weitere Betriebe. Wer schon einen Betrieb führt und einen zweiten möchte, stellt einen Antrag; die Zahl der offenen steht als Marke am Menüpunkt.

Der Antrag hat noch nichts angelegt. Er ist eine Zeile und eine E-Mail an BETREIBER_EMAIL, mit dem Antragsteller als Antwortadresse — auf Antworten zu drücken erreicht den Menschen, um den es geht.

Bestätigen legt den Betrieb an: Mandant, Standort, Nummernkreise, Steuersätze, Textbausteine und eine 30-Tage-Testlizenz — über denselben Weg wie eine Selbstregistrierung, damit sich ein so entstandener Betrieb in nichts von einem anderen unterscheidet. Der Antragsteller wird sein Inhaber. Die Anschrift aus dem Antrag steht anschliessend in den Firmenstammdaten; den Namen kann er dort ändern.

Ablehnen verlangt eine Begründung, und die geht wortgleich an den Antragsteller. Ein „nein" ohne Grund führt zum zweiten Antrag.

Beides steht danach im Installations-Prüfprotokoll (betrieb.antrag_ gestellt, betrieb.antrag_bestaetigt, betrieb.antrag_abgelehnt) — nicht im Protokoll eines einzelnen Betriebs: entschieden wird über etwas, das die Installation betrifft.

Wenn an einem Antrag steht, die Mail sei nicht hinausgegangen, ist das der Fall, für den es diese Liste gibt: der Antragsteller wartet, und ohne die Liste wüsste niemand davon.

Lizenzen

Jeder Betrieb braucht seine Lizenz. Die Mitgliedschaft eines Menschen ändert daran nichts: läuft die Lizenz von Betrieb B ab, steht dort der Lesemodus, während Betrieb A normal weiterarbeitet. Der Umschalter in der App zeigt es je Betrieb an, damit der Anwender den Fehler nicht in der Anwendung sucht.

17. Bankkonten anbinden (Enable Banking)

Die verbundenen Konten am Telefon
Die verbundenen Konten am Telefon

Der Kontoabgleich über eine Bankverbindung. Ohne diese Einrichtung bleibt es beim Dateiimport — der funktioniert weiter, und die Einstellung in der App sagt dann, was fehlt.

Was zu beschaffen ist

Ein Konto auf enablebanking.com, dort eine Anwendung anlegen. Heraus kommen zwei Dinge:

  • eine Anwendungskennung (eine UUID),
  • eine private Schlüsseldatei (.pem), die nur beim Anlegen zum Herunterladen angeboten wird.

Ein eigenes eIDAS-Zertifikat (QWAC) brauchen Sie nicht. Das kostet 3.000 bis 8.000 Euro im Jahr und ist der Grund, warum es in Europa kaum kostenlose Bankschnittstellen gibt. Nötig ist es nur für den, der selbst Zahlungsdienstleister wird; über einen Aggregator ist der Aggregator der lizenzierte Teilnehmer.

**Beantragen Sie die Anwendung nur mit Account Information, nicht mit Payment Initiation.** Dieses Programm liest Umsätze und löst keine Zahlungen aus. Was es nicht kann, kann auch nicht missbraucht werden.

Als Rückkehradresse (redirect URL) tragen Sie bei Enable Banking genau diese ein:

https://app.simpro-software.at/api/v1/bank/callback

Wo es am Server liegt

Die Schlüsseldatei gehört auf die Platte des Servers und nirgendwo sonst hin — nicht ins Repository, nicht in ein Abbild:

/opt/simpro/data/geheim/enablebanking.pem      (0600, simpro:simpro)

Das Verzeichnis wird nur lesbar in den Behälter gehängt (/app/geheim:ro); die compose.yml bringt das mit. In die .env:

ENABLEBANKING_APPLICATION_ID=<die UUID aus dem Kundenbereich>
ENABLEBANKING_PRIVATE_KEY=/app/geheim/enablebanking.pem
ENABLEBANKING_BASE_URL=
BANK_REDIRECT_URL=https://app.simpro-software.at/api/v1/bank/callback

Sandbox oder Produktion

Das hängt an den Zugangsdaten, nicht an einer Adresse. Enable Banking hat eine API; ob eine Anwendung in der Sandbox oder in Produktion läuft, entscheidet die Anwendung selbst.

Woran Sie es erkennen: mit Sandbox-Zugangsdaten steht in der Bankauswahl Mock ASPSP mit, und die Anmeldeadresse zeigt auf tilisy-sandbox.enablebanking.com. Der Wechsel auf Produktion ist eine neue Kennung, eine neue Schlüsseldatei und ein Neustart — kein Codeunterschied.

Wie der Weg läuft

  1. Der Anwender wählt in der App seine Bank und drückt Verbinden.
  2. Der Browser öffnet sich auf einer Seite der Bank. Dort meldet er sich an und gibt den Zugriff frei. Dieses Programm sieht die Bankzugangsdaten nie.
  3. Die Bank schickt den Browser an …/api/v1/bank/callback. Der Server schließt die Verbindung ab, während der Mensch noch die Danke-Seite liest.
  4. In der App tippt er auf Fertig; sie sieht nach, ob es geklappt hat.

Bis 0.5.16 endete Schritt 3 in der App, und die rief den Abschluss mit einem leeren Rückläufer auf — der Weg wäre an seinem letzten Schritt gescheitert, sobald ihn jemand gegangen wäre. Aufgefallen ist es erst beim Bau der Rückkehradresse. Siehe DECISIONS.md, N67.

Der Abruf von Hand

Normalerweise holt die nächtliche Wache die Umsätze. Wer nicht bis zur Nacht warten will — beim Einrichten etwa, oder wenn eine Zahlung fehlt:

docker exec rechnung-api-1 node dist/cli.js bank-abrufen
docker exec rechnung-api-1 node dist/cli.js bank-abrufen --mandant tischlerei

Er meldet je Verbindung, wie viele Zeilen inzwischen daran hängen, und den letzten Fehler, falls einer steht.

Beim ersten Lauf werden 90 Tage geholt, danach nur noch der Zeitraum seit dem letzten Mal (mit drei Tagen Überlappung). Wer eine leere Liste sieht, obwohl das Konto Umsätze hat, sollte zuerst darauf schauen: liegen die Buchungen weiter zurück, kommen sie nicht mit. Genau das passierte am Testkonto der Sandbox, dessen Musterbuchungen von 2021 stammen.

Was mit fremden Währungen passiert

Nichts — und das ist Absicht. Eine Zeile, die nicht auf die Währung des Betriebs lautet, wird nicht zugeordnet, nicht vorgeschlagen und nicht gebucht, auch nicht von Hand. In der Liste steht sie mit der Währung dahinter (359,00 DKK statt 359,00) und lässt sich beiseitelegen.

Der Grund steht in DECISIONS.md, N70: SIMPro rechnet in einer Währung. Ohne Kurs und Umrechnungsdatum wäre „der Betrag stimmt" bei fremdem Geld eine Behauptung über zwei Zahlen, die nichts miteinander zu tun haben.

Die Zustimmung läuft ab

Höchstens 90 Tage — das ist PSD2 und keine Einschränkung dieses Programms. Ein abgelaufener Zugang ist still: es kommt keine Fehlermeldung, es kommen nur keine Umsätze mehr. Die App zeigt das Ablaufdatum an jeder Verbindung; die nächtliche Wache meldet es, bevor es soweit ist.

18. Was sich seit 0.5.13 geändert hat — in einer Übersicht

Acht Fassungen in wenigen Tagen, und jede hat etwas verändert, das Sie im Betrieb merken. Hier steht es beisammen; ausführlich jeweils dort, wo es hingehört.

FassungWas neu istWas Sie tun müssen
0.5.13Zwei Prüfprotokolle: was dem Betrieb gehört, und was der Installation. Konten müssen ihre Adresse bestätigen, bevor sie sich anmeldenNichts. Bestandskonten sind nicht betroffen
0.5.14„Diesem Gerät sieben Tage vertrauen" — die Codeabfrage lässt sich je Gerät aussetzenNichts. Das Häkchen steht von Haus aus aus
0.5.15Support-Formular in der App, Meldungen landen bei IhnenVerwaltung → Support-Meldungen gelegentlich ansehen
0.5.16Ein Konto kann in mehreren Betrieben arbeitenFreischalten in der Betreuungssicht — siehe Kapitel 16
0.5.17Bankkonten über Enable Banking, Rückkehradresse am ServerZugangsdaten beschaffen — siehe Kapitel 17
0.5.18Systemmails als HTML mit Logo, Anleitungen auf Cloud umgestelltNichts
0.5.19Nur Reparaturen an den Prüfläufen — am Programm nichtsNichts
0.5.20Vergeben, nie gebaut: ein übriggebliebener Import ließ die Prüfung scheitern—
0.5.21Fremde Währungen bleiben im Zahlungsabgleich liegen, bank-abrufen auf der BefehlszeileNichts. Wer in Euro fakturiert, merkt es nie
0.5.22Die NAS-Sicherung steht unter einer Uhr; ein hängender Lauf gilt als gescheitertNichts. Die Meldung kommt jetzt auch dann, wenn das NAS schweigt
0.5.23Der Sicherungsbehälter wird beim Update mitgezogen, und überholte Abbilder werden weggeräumtNichts. update.sh macht beides von selbst
0.5.24Plattenwarnung schon ab 25 % frei, dringend ab 15 %; die Drift-Prüfung meldet, wenn sie nicht nachsehen konnteNichts
0.5.25/health/sicherungen: eine Überwachung von außen sieht, ob die nächtlichen Läufe aktuell sindMonitor darauf einrichten — siehe Alarmwege
0.5.26Nachtrag zu 0.5.25: der neue Endpunkt stand nicht im Endpunkt-VerzeichnisNichts
0.5.27Alarmwege: drei Monitore statt einem, Push endlich angehängt, Anleitung „Alarmwege“Nichts. UptimeRobot-App auf „Nicht stören durchbrechen“ stellen
0.5.28Ein entferntes Konto lässt sich wieder aufnehmen; Datenbankfehler nennen keine Spaltennamen mehrNichts
0.5.29Drei Rollen je Betrieb: Inhaber, Verwalter (bisher „Administrator“), BenutzerNichts. Je Betrieb wird der erste Verwalter zum Inhaber
0.5.30Nachtrag zu 0.5.29: die Eingabeprüfung kannte die neue Rolle nicht, und der Dienst wies den Inhaber abNichts
0.5.31Zweitbetrieb-Antrag, Benutzergrenze je Lizenz, und fünf Nachträge zur Rollentrennung — der schwerste: der Inhaber sperrte sich mit seinem eigenen Löschantrag ausNichts. Bestehende Lizenzen bekommen eine Grenze von sechs Benutzern
0.5.32Das Netz für Betriebe ohne Inhaber, und was der volle Testlauf sonst noch fandNichts

Die drei Dinge, die Ihre Aufmerksamkeit brauchen

1. Das Installations-Prüfprotokoll ist neu und gehört Ihnen. Unter Verwaltung → Installations-Prüfprotokoll steht, was zwischen Betrieben geschieht: Mandanten angelegt und gelöscht, Lizenzen ausgestellt, Support-Zugriffe, Mitgliedschaften. Bis 0.5.12 lag das im Protokoll jedes Betriebs — jeder Kunde las damit die Kundenliste seines Anbieters. Siehe DECISIONS N62.

2. Support-Meldungen brauchen einen Blick. Sie kommen als E-Mail und stehen zusätzlich in einer Liste. Der zweite Weg ist die Rückversicherung gegen den ersten: eine Mail, die im Spamordner hängt, ist weg — und der Anwender hat trotzdem „abgeschickt" gelesen. In der Liste trägt jede Zeile, ob die Mail hinausging.

3. Die NAS-Zweitsicherung meldet sich erst beim zweiten Fehlschlag. Das ist Absicht: das NAS steht in Ihrem Haus und darf abgeschaltet sein, ein Stromausfall ist kein Vorfall. Zwei Fehlschläge hintereinander sind einer. Siehe DECISIONS N61 und den Abschnitt zur Zweitsicherung weiter oben.

19. Was zum Ausrollen dazugehört — und die Drift-Warnung

Das Abbild ist nicht die ganze Installation

update.sh tauscht das Abbild und sonst nichts. Alles, was den Stapel zusammensteckt, liegt daneben auf der Platte und bleibt liegen:

DateiWas darin steht
compose.ymlwelche Dienste laufen, welche Umgebungsvariable an welchen geht, welcher Ordner wohin gehängt wird
docker/Caddyfile.standalonedie Namen, die Caddy bedient, wenn dieser Stapel die öffentlichen Ports hat
docker/Caddyfile.behind-proxydasselbe, wenn ein fremder Proxy davor steht
docker/Caddyfile.landasselbe im eigenen Netz, ohne öffentliches Zertifikat
scripts/update.shdieses Skript selbst

Die Liste steht an einer Stelle: scripts/ausrollen.txt. Wer eine Datei hinzunimmt, trägt sie dort ein — und beide Skripte kennen sie ab dann.

Nicht dabei: die .env. Sie gehört dem Server, enthält Geheimnisse und unterscheidet sich absichtlich von Haus zu Haus. Ebenso wenig die Dockerfile.* und entrypoint-api.sh: die stecken im Abbild, und was davon am Server liegt, tut nichts.

Wer sie hinüberlegt

# Website und Konfiguration in einem Zug (der uebliche Weg beim Betreiber)
./deploy/cloud/12-website.sh

Es legt compose.yml und die Caddyfiles mit hinüber, sichert vorher die bisherigen als *.vor-website und startet Caddy mit --force-recreate. Das --force-recreate ist kein Beiwerk: der Caddyfile hängt als Bind-Mount im Container, und eine geänderte Datei ändert die Dienstbeschreibung nicht — compose sähe sonst keinen Grund, etwas anzufassen, und meldete „Running".

Auf einer Installation ohne Website geht es von Hand, mit demselben Ergebnis:

scp compose.yml docker/Caddyfile.standalone benutzer@server:/opt/simpro/…
ssh … 'cd /opt/simpro && docker compose up -d --force-recreate caddy'

Die Drift-Warnung

Seit 0.5.2 bringt jedes Abbild die Prüfsummen der Konfiguration mit, mit der es gebaut wurde (/app/ausgerollt.sha256). update.sh liest sie vor dem Abbildtausch aus dem frisch gezogenen Abbild und hält sie gegen das, was am Server liegt.

Läuft alles zusammen:

07:19:03  Konfiguration am Server ist der Stand von 0.5.2.

Weicht etwas ab:

07:19:03    ! compose.yml weicht vom Stand der Fassung 0.5.2 ab

07:19:03  ACHTUNG: 1 Konfigurationsdatei(en) am Server sind nicht der Stand von 0.5.2.
07:19:03  Das Update laeuft trotzdem - ueberschrieben wird hier nichts, es koennte
07:19:03  eine bewusste Aenderung sein. Aber: was in einer neuen compose.yml oder
07:19:03  einem neuen Caddyfile steht, wirkt erst, wenn die Datei am Server liegt.

Das Update läuft weiter. Die Prüfung warnt und überschreibt nicht — am Server kann eine bewusste Änderung liegen, eine zusätzliche Kopfzeile, ein anderer Port, eine Zeile für eine Besonderheit des Hauses. Ein Skript, das die stumm überschreibt, ist schlimmer als der Drift selbst: der Drift kostet eine Stunde Suchen, das Überschreiben kostet die Änderung.

Was zu tun ist, wenn die Warnung kommt:

  1. Nachsehen, was anders ist. diff gegen die Datei aus dem Repository der ausgerollten Fassung.
  2. Ist es eine Änderung, die Sie gemacht haben und behalten wollen: die neue Fassung der Datei holen und Ihre Zeile dort wieder eintragen.
  3. Ist es keine: die Datei hinüberlegen und Caddy bzw. den betroffenen Dienst mit --force-recreate neu starten.

Zwei Zeilen, die keine Warnung sind:

· docker/Caddyfile.behind-proxy liegt hier nicht (gehoert zu einer anderen Betriebsart?)

Die drei Caddyfiles gehören zu drei Betriebsarten, und jede Installation fährt genau eine davon.

Das Abbild bringt keine Konfigurationspruefsummen mit - Drift-Pruefung uebersprungen.

Ein Abbild vor 0.5.2. Die Prüfung greift ab dem ersten Update auf 0.5.2 oder neuer.


20. Die öffentliche Website

Seit 0.5.0 gibt es unter www.simpro-software.at ein Schaufenster mit Selbstregistrierung. Es liegt auf demselben Server wie die Anwendung, aber unter einem eigenen Namen und mit eigenem Zertifikat.

NameWas dort liegt
app.simpro-software.atdie Anwendung. Gehört den Kunden
www.simpro-software.atdie Website
simpro-software.atleitet auf www weiter

Bauen und ausliefern

node website/bauen.js          # erzeugt website/dist
./deploy/cloud/12-website.sh   # baut, legt hinueber, schaltet Caddy frei

Das Skript legt den Ordner erst vollständig daneben und tauscht dann — ein halb entpacktes Verzeichnis wäre eine halbe Website unter dem Namen, den ein Kunde gerade aufruft. Danach lädt es nur Caddy neu; die API blinzelt dabei nicht.

Die Namen stehen in der .env:

WEBSITE_DOMAIN=www.simpro-software.at
WEBSITE_DOMAIN_NACKT=simpro-software.at
WEBSITE_DIR=./website
WEBSITE_URL=https://www.simpro-software.at

Leer lassen heißt: es gibt keine Website. Eine Installation beim Kunden hat kein Schaufenster und soll auch kein Zertifikat dafür beantragen. Der Caddy-Block ist dann aus.

WEBSITE_URL ist die Adresse, die in den Bestätigungs-E-Mails steht. Sie falsch zu setzen heißt: Links, die nirgendwo hinführen.

Was die Website vom Server holt

Nur drei Zweige gehen durch, alles andere ist eine Datei:

WegWofür
/api/v1/oeffentlich/*Registrierung, Bestätigung, Abo-Platzhalter
/api/v1/auth/email/bestaetigender Link für eine bestehende Adresse
/downloads/aktuellFassungsnummer und Dateien des letzten Release

/downloads/aktuell fragt einmal je Stunde bei GitHub nach und merkt sich die Antwort. Nicht der Browser des Besuchers — das wäre eine Verbindung zu einem Dritten, und die Datenschutzerklärung sagt, dass es hier keine gibt.

Die Selbstregistrierung

# An oder aus. Vorgabe: an.
REGISTRIERUNG_OFFEN=true
# Wie viele Betriebe hoechstens je Tag entstehen duerfen. Vorgabe: 20.
REGISTRIERUNG_JE_TAG=20

Der Ablauf ist zwingend dieser:

  1. Das Formular legt nur eine Bestätigungszeile an.
  2. Eine E-Mail geht hinaus, der Link gilt 48 Stunden.
  3. Erst der Klick erzeugt Mandant, Verwalter, Grunddaten und eine TEST-Lizenz über 30 Tage.

Ohne Klick verfällt die Anmeldung rückstandslos. Warum das so herum ist, steht in docs/DECISIONS.md, N46.

Gegen Automaten stehen vier Bremsen: ein Honigtopffeld, die Drossel des Endpunkts (10 je Stunde), eine Grenze je IP (5 je Stunde) und eine je E-Mail-Domäne (10 je Tag). Kein Captcha — das wäre eine Einbindung bei einem Dritten.

Selbstregistrierte Betriebe erkennen

In Verwaltung → Mandanten steht neben der Kennung „selbstregistriert am …". Sie sind nicht schlechter als andere; Sie sollen nur sehen, welche über Nacht dazugekommen sind.

Das Impressum ist noch nicht fertig

Die Seiten impressum.html, datenschutz.html und bedingungen.html tragen Platzhalter, die mit [BITTE ERGÄNZEN] gekennzeichnet sind. Eine unvollständige Offenlegung ist verwaltungsstrafbewehrt — vor dem öffentlichen Betrieb gehören sie ausgefüllt. Die Liste steht in der Abschlussmeldung zu 0.5.0 und in docs/OFFEN-website.md.


Einen Betrieb an der Maschine anlegen

Der Weg über die App ist der übliche: Verwaltung → Mandanten → Neu. Er verlangt aber eine Anmeldung samt zweitem Faktor, und den hat man an der Maschine nicht immer zur Hand. Seit 0.4.7 geht es deshalb auch dort:

# Erst zeigen, was geschaehe - ohne --ja passiert nichts
docker compose exec api node dist/cli.js mandant-anlegen \
  --name "Muster GmbH" --admin chef@muster.at

# Dann wirklich; das Erstpasswort steht danach auf stdout
docker compose exec api node dist/cli.js mandant-anlegen \
  --name "Muster GmbH" --admin chef@muster.at --ja

Der Befehl legt an, was ein Betrieb zum Arbeiten braucht: den Mandanten, den Hauptstandort, die Nummernkreise und die übrigen Grunddaten — genau wie die App. Ein Betrieb ohne Nummernkreise fällt erst bei der ersten Rechnung auf, und dann ist es zu spät; das ist der Grund, warum dieser Handgriff ein Befehl ist und keine Handarbeit in der Datenbank.

SchalterWofür
--nameAnzeigename, Pflicht
--kennungdie Kennung; ohne Angabe aus dem Namen gebildet
--farbeAkzentfarbe, etwa #2E7D32
--adminwer den Betrieb verwaltet; ein bestehendes Konto wird genommen, ein unbekanntes angelegt
--passwortErstpasswort für ein neues Konto; ohne Angabe wird eines erzeugt
--nur-hierentzieht dem Konto alle anderen Zugehörigkeiten und beendet seine Sitzungen
--jawirklich tun

--nur-hier ist der Umzug: ein Konto, das künftig nur noch in diesem einen Betrieb arbeiten soll. Die Systemverwalter-Fahne bleibt davon unberührt — sie hängt am Konto, nicht an einer Zugehörigkeit. Das Erstpasswort steht auf stdout und die Erklärung auf stderr, damit sich der Befehl in eine Datei umleiten lässt, ohne den Text mitzunehmen.

Lizenzschlüssel an der Maschine ausstellen

Derselbe Gedanke, dieselbe Form:

# Fuenf Testschluessel auf Vorrat, ein Jahr, ohne Grenzen
docker compose exec -T api node dist/cli.js \
  lizenz-ausstellen --anzahl 5 --typ TEST --tage 365 > schluessel.txt

# Oder gleich an einen Betrieb gebunden
docker compose exec api node dist/cli.js \
  lizenz-ausstellen --mandant muster-gmbh --typ ABO --tage 0

--tage 0 heißt unbefristet, ohne --mandant entsteht ein Schlüssel auf Vorrat. Die Grenze von 365 Tagen für Testlizenzen gilt hier genauso wie im Server — ein zweiter Weg an derselben Regel vorbei wäre schlimmer als keine Grenze, weil niemand ihn sucht. Jedes Ausstellen bekommt einen Eintrag im Prüfprotokoll mit dem Vermerk ueber: Kommandozeile; es geschieht ja ohne Anmeldung, also ist der Eintrag die einzige Spur.

Einen Mandanten leerräumen

Vor dem echten Betrieb steht in einem Mandanten der Schutt des Ausprobierens: Testrechnungen, erfundene Kunden, Kassenbelege aus dem Sandkasten. Denselben Handgriff braucht später jeder Testkunde-Rückbau.

# Erst zählen - ohne --ja wird nichts gelöscht
docker compose exec api node dist/cli.js mandant-bereinigen   --slug simpro-software --durch admin@simpro-software.at

# Dann wirklich
docker compose exec api node dist/cli.js mandant-bereinigen   --slug simpro-software --durch admin@simpro-software.at   --ja --bestaetigen simpro-software

Was bleibt: der Mandant selbst, die Firmenstammdaten, Nummernkreise, Steuersätze, Textbausteine, E-Mail-Vorlagen, Mahnstufen, der Kontenrahmen, die Standorte, die Benutzer, die Lizenz — und das Prüfprotokoll, das über die Bereinigung einen eigenen Eintrag bekommt.

Was geht: Belege samt Positionen, Zahlungen, Bankimporte, Kunden, Artikel und Bestand, Ausgaben und Anlagen, Buchungen, Kassenbelege, Shop-Anbindungen, Benachrichtigungen, Dateien.

Drei Sicherungen sind eingebaut, und die erste ist die wichtigste:

  • Vollständigkeit. Jede der 63 mandantengebundenen Tabellen steht ausdrücklich auf einer der beiden Listen. Kommt später ein Modell dazu, ohne eingeordnet zu werden, bricht der Befehl ab, bevor er etwas anfasst — statt es still mitzunehmen oder still stehenzulassen.
  • Wer. --durch verlangt einen Systemverwalter und steht danach im Prüfprotokoll.
  • Welchen. --bestaetigen verlangt die Kennung ein zweites Mal. Ein Tippfehler trifft sonst den falschen Betrieb.

Der Befehl ist wiederholbar: bricht er mitten drin ab, macht ein zweiter Lauf dort weiter, wo der erste stehen blieb. Vorher eine Sicherung, wie vor jedem Eingriff, der nicht rücknehmbar ist.

Eigenserver

Ein Kunde, der selbst hostet, bekommt eine Lizenz mit betriebsmodus: EIGENSERVER. Das geht nur mit gesetztem LIZENZ_EIGENSERVER_ERLAUBT, und dieser Schalter gehört bewusst umgelegt — die Begründung steht in docs/DECISIONS.md, N36.

Je Installation ein eigenes Pull-Token. Beim Ausstellen wird ein ghcr-Token hinterlegt, das nur zu dieser Lizenz gehört:

curl -X POST https://app.simpro-software.at/api/v1/lizenz/verwaltung/<id>/pull-token \
     -H 'Authorization: Bearer …' -H 'Content-Type: application/json' \
     -d '{"token":"ghp_…"}'

Die Installationsskripte holen es sich über den Lizenzschlüssel:

curl -X POST https://app.simpro-software.at/api/v1/lizenz/pull-token \
     -H 'Content-Type: application/json' \
     -d '{"schluessel":"SIMPRO-…"}'

Herausgegeben wird es nur, solange die Lizenz gilt. Damit endet mit dem Widerruf der Zugriff auf neue Abbilder; das schon gezogene bleibt, denn niemand soll über Nacht ohne Server dastehen. Mein persönliches Token wandert dabei nie auf einen fremden Server.

Die Cloud-Sicherung ist Bedingung der Freigabe. Der Kunde bekommt einen Unterzugang auf meiner Storage Box:

./deploy/cloud/lizenz-sicherungskonto.sh "Beispiel GmbH"

Der restic-Schlüssel bleibt beim Kunden. Ich sehe Blöcke, keine Rechnungen — und ohne seinen Schlüssel kann ich seine Sicherung nicht lesen. Das ist so gewollt und gehört in das Gespräch, in dem die Freigabe vereinbart wird: die Sicherung liegt bei mir, lesen kann sie nur er. Verliert er den Schlüssel, ist sie weg, und daran kann ich nichts ändern.

Wenn ein Kunde im Lesemodus anruft

Die häufigste Frage ist nicht „warum", sondern „sind meine Daten weg". Die Antwort steht schon auf seinem Bildschirm, aber sie wird beim ersten Schreck nicht gelesen:

Ansehen, suchen, filterngeht
Drucken und PDFgeht
Export, Steuerberater-Datei, Datenträgerüberlassunggeht
Sicherheitsupdateslaufen weiter
Neue Belege, Kunden, Artikelgesperrt

Nach dem Verlängern: Einstellungen → Lizenz → Jetzt nachfragen. Es geht sofort weiter, ohne Neustart.


21. Apps verteilen

Release erzeugen

Ein Release entsteht, sobald eine Marke der Form v0.2.0 gepusht wird. Der Workflow baut den Windows-Installer, das portable ZIP und die Android-APK, erzeugt die Änderungsliste und hängt alles mit Prüfsummen an das GitHub-Release. Ist Play aktiviert, wandert das App Bundle zusätzlich in den internen Testkanal.

git tag v0.2.0
git push origin v0.2.0

Welcher Auftrag wo läuft und was er kostet, steht in docs/RELEASE.md.

Windows

  • Setup-Datei vom Release herunterladen und ausführen. Ohne Code-Signatur zeigt Windows eine SmartScreen-Warnung: Weitere Informationen → Trotzdem ausführen.
  • Ist Smart App Control aktiv (Windows 11 im Auslieferungszustand), lässt sich die App gar nicht starten. Entweder Smart App Control ausschalten (Windows-Sicherheit → App- & Browsersteuerung; unumkehrbar) oder die Code-Signatur einrichten: Azure Trusted Signing, rund 10 € im Monat, als Secret im Workflow hinterlegt — Anleitung in docs/RELEASE.md.

Nachweis: ein alter Client aktualisiert sich über den Server

29./30.09.2026. Ein Windows-PC auf 0.5.49 hat sich an der Demo (Server 0.5.73) selbständig auf 0.5.73 gehoben. Der Weg führt nicht über GitHub, sondern über den eigenen Server — er holt die Datei mit seinem Token und reicht sie durch. Belegt im Zugriffsprotokoll:

Zeit (UTC)AnfrageErgebnis
05:15:07GET /api/v1/updates/latest?plattform=windows200, 1.329 Bytes
05:15:11GET /api/v1/updates/datei/0.5.73/rechnung-setup-0.5.73.exe200, 15.266.079 Bytes, 4,5 s
05:15:23GET /api/v1/updates/latest (erneut)200

Die übertragene Größe stimmt auf das Byte mit dem Release-Anhang überein. Anmeldung war erforderlich und lag vor (Authorization gesetzt); der Aufrufer meldet sich als Dart/3.13 (dart:io), also die App selbst.

Die Prüfsumme steht nicht im Serverprotokoll, und das ist richtig so. Sie wird in der App geprüft: herunterladen, SHA256 gegen die Summe aus SHA256SUMS.txt des Releases halten, und erst dann ablegen und starten. Stimmt sie nicht, bricht die App mit pruefsummeFalsch ab und startet nichts. Dass der PC danach auf 0.5.73 lief, ist damit der Nachweis, dass sie gestimmt hat — einen anderen kann es von der Serverseite aus nicht geben.

Was drei Wochen lang fehlte. Der Demo-Stapel hatte den Lesetoken nicht, und ohne ihn antwortet GitHub auf ein privates Repository mit 404. Die App zeigte dann trotzdem „es gibt eine neuere Fassung" — der Hinweis kommt aus /version und ging —, und der Knopf dahinter tat nichts, weil /updates/latest leer blieb. Zwei Quellen, eine Aussage. Wer diesen Fehler sucht, sucht ihn in der falschen Hälfte.

Android

  • Play Store, interner Test (empfohlen): In der Play Console unter Testen → Interner Test die E-Mail-Adressen der Nutzer eintragen und ihnen den Einladungslink schicken. Voraussetzung: Play-Konto verifiziert, Dienstkonto angelegt, PLAY_SERVICE_ACCOUNT_JSON als Secret und ANDROID_RELEASE_ENABLED=true als Variable in GitHub.

Das Dienstkonto für den Play-Upload

Der Schlüssel ist eine JSON-Datei aus der Google Cloud Console, mit Rechten nur auf SIMPro und nur für Testtracks — keine Produktions-Releases, kein Store-Eintrag, keine Nutzerverwaltung. Er liegt als Secret PLAY_SERVICE_ACCOUNT_JSON im Repository, eine Kopie im OFFLINE-TRESOR neben Keystore und Apple-Schlüssel.

Der Schlüssel läuft nicht ab. Google-Dienstkontoschlüssel haben kein Ablaufdatum; sie gelten, bis jemand sie in der Cloud Console löscht.

Das unterscheidet ihn von den beiden anderen Zugängen im Release-Weg: Das GitHub-Token für /updates läuft am 1. September 2027 ab und wird überwacht, der Apple-API-Schlüssel wird gedreht. Für den Play-Schlüssel gibt es deshalb bewusst keine Ablaufwarnung — eine Überwachung, die nie anschlägt, erzieht dazu, sie zu übersehen.

Rotation nur bei Verdacht — wenn der Wert in einem Protokoll, einem Bildschirmfoto oder einem Chat gestanden hat. Dann: in der Cloud Console einen neuen Schlüssel erzeugen, Secret ersetzen, danach den alten löschen. Die Reihenfolge ist wichtig; ein gelöschter Schlüssel ohne Ersatz legt den Veröffentlichungsweg still.

ANDROID_RELEASE_ENABLED bleibt ein Schalter je Lauf, kein Dauerzustand: vor dem Release auf true, danach zurück auf false. Ein Werk, das bei jeder Marke von selbst in den Store veröffentlicht, veröffentlicht irgendwann auch das, was nur ein Probelauf sein sollte.

  • APK direkt: APK vom Release am Telefon herunterladen, antippen, Installation aus dieser Quelle erlauben. Ab 2027 verlangt Android auch dafür einen verifizierten Entwickler, was mit dem Play-Konto abgedeckt ist.

Der Upload-Schlüssel

Der Ordner mit dem Android-Keystore (upload-keystore.p12 samt Passwort) darf nie verloren gehen. Ohne ihn kann keine neue Fassung auf bestehende Installationen ausgeliefert werden — weder über Play noch als APK. Er gehört an mindestens zwei Orte (Passwortmanager als Anhang, verschlüsselter USB-Stick) und bei der Übergabe an den Betreiber.


22. Organisatorisches und Rechtliches

  • Auftragsverarbeitungsvertrag (AVV). Solange der Server nicht beim Betreiber selbst steht, verarbeiten Sie dessen Kundendaten in seinem Auftrag. Vorlage kostenlos bei der WKO; einmal unterschreiben, abheften.
  • Konten auf den Betreiber. Domain, Sicherungsspeicher, SMTP — alles, was Geld kostet oder Daten hält, läuft auf seinen Namen. Sie haben Zugriff als Verwalter, nicht als Inhaber.
  • Zweiter Verwalter. Ohne einen zweiten kann niemand den zweiten Faktor des ersten zurücksetzen — und das Programm lässt den letzten aktiven Verwalter sich auch nicht selbst herabstufen oder deaktivieren. Angelegt wird er unter Einstellungen → Betrieb → Benutzer und Rollen:

Die Benutzerverwaltung mit Zählerzeile und den vier Rollen
Die Benutzerverwaltung mit Zählerzeile und den vier Rollen

  • Zugangsdaten-Übergabe. Der Betreiber braucht in seinem Passwortmanager: Server-Login (SSH oder Windows), GitHub-Zugang, restic-Passwort, Sicherungsspeicher, Keystore-Passwort, Play-Console-Zugang. Falls Ihnen etwas zustößt, muss er das System weiterbetreiben können.
  • Aufbewahrung. Belege müssen sieben Jahre aufbewahrt werden (§ 132 BAO). Das System löscht finalisierte Belege nie; die Sicherungen decken die Frist ab, solange der Speicher bezahlt wird.
  • Registrierkasse. Ab 0.3.0 kann im System selbst kassiert werden (Kapitel 9). Vor dem ersten Barumsatz muss der Betreiber die Kassenpflicht-Erklärung abgeben — die Einschätzung, ob sein Betrieb unter den Umsatzgrenzen des § 131b BAO liegt, trifft er, nicht das Programm. SIMPro rechnet beide Grenzen laufend mit, warnt ab 80 % und zeigt den Stichtag; ab ihm bleiben Bar und Karte gesperrt, bis eine Signatureinheit eingerichtet ist. Die Signatur über fiskaltrust kommt mit Stufe 2 und setzt einen Vertrag voraus.

Zwei Sätze, die in diesem Zusammenhang oft untergehen: Eine Kartenzahlung vor Ort ist ein Barumsatz (§ 131b Abs 1 Z 3 BAO), auch ohne Bargeld. Und die Belegerteilungspflicht nach § 132a BAO hängt an keiner Umsatzgrenze — sie gilt auch für den kleinsten Betrieb ohne Registrierkasse.


23. Was der Server gegen Angriffe tut

Der Server ist aus dem Internet erreichbar; die Annahme ist durchgehend, dass er angegriffen wird. Ausführlich steht das in docs/SICHERHEIT.md — Bedrohungsmodell, jede Kontrolle mit Fundstelle im Quelltext, und ausdrücklich auch, wogegen sich das Programm nicht wehrt.

Was ein Betreiber davon wissen muss:

WogegenWas greift
Passwörter durchprobieren5 Versuche / 15 min je IP, dazu eine Kontosperre, die sich mit jedem weiteren Fehlversuch verdoppelt (Deckel 24 h). fail2ban sperrt die IP nach gehäuften 401/429
Ein abgefangener ZeitcodeGilt genau einmal. Ein zweiter Versuch mit demselben Code wird abgelehnt
Ein DatenbankabzugPasswörter und Wiederherstellungscodes als argon2id, TOTP-Geheimnisse AES-256-GCM, Erneuerungstoken nur als Hash. Ein Abzug gibt keine Sitzung her
Eine hochgeladene DateiDas Logo wird aus den dekodierten Pixeln neu geschrieben. Was nicht Bild ist, überlebt das nicht — und EXIF samt GPS fällt weg
Zugriff über die MandantengrenzeZwei unabhängige Ebenen: der Anwendungsfilter und Row Level Security in der Datenbank, mit einer Rolle ohne BYPASSRLS

Was ein Konto sperrt, hebt ein Verwalter auf: Einstellungen → Betrieb → Benutzer und Rollen → Sperre aufheben. Eine Sperre, aus der nur die Zeit heraushilft, wäre die schlechtere Wahl — deshalb gibt es beides.

Bei jeder Kontosperre geht eine Mail an die Betreiberadresse.


24. Wenn etwas nicht funktioniert

SymptomWahrscheinliche UrsachePrüfen / Beheben
App: „Unter dieser Adresse antwortet kein Rechnungsserver"Server aus, Container gestoppt, DynDNS zeigt auf alte IP, Portfreigabe fehltdocker compose ps; /health im Browser; DynDNS-Status im Router
Zertifikatsfehler von außenLet's Encrypt konnte Port 443 nicht erreichendocker compose logs caddy-internal; Portfreigabe 443; zeigt die Domain auf die richtige IP?
Anmeldung geht, aber alles leerFalscher Mandant oder falsche RolleEinstellungen → Konto; ein Verwalter prüft die Mitgliedschaft
„Zu viele Versuche"Drosselung: fünf Anmeldeversuche je 15 MinutenWarten, oder ein Verwalter hebt die Sperre unter Einstellungen → Betrieb → Benutzer und Rollen auf
E-Mails kommen nicht anSMTP-Zugang falsch, Anbieter blocktdocker compose logs api nach „mail" filtern; Versandprotokoll am Beleg; Verwaltung → Server zeigt, ob SMTP überhaupt eingerichtet ist
Sicherungswarnung in der AppSpeicher nicht erreichbar, Konto abgelaufen, Platte volldocker compose logs backup; Plattenplatz; Konto beim Anbieter
Nach einem Server-Update geht die App nicht mehrdocker compose build vergessen, oder App zu altAbschnitt 7 vollständig ausführen; App aktualisieren
Container startet in Schleife neuFehler in .env (etwa Tippfehler in der Domain)docker compose logs <name>; Einrichtungsskript erneut ausführen
docker compose logs -f caddy bleibt leerDer Dienst heißt im Modus BEHIND_PROXY caddy-internalMit dem richtigen Namen aufrufen

Für alles, was hier nicht steht: die technische Dokumentation im Repository unter docs/ — INSTALL.md, NETWORK.md, BACKUP.md, RELEASE.md, STATUS.md — ist ausführlicher und wächst mit jeder Fassung.