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.
Diese Anleitung beschreibt beides. Wo es einen Unterschied gibt, steht er dabei; wo keiner steht, gilt beides.
Der Dienst heißt
caddy-internal, wenn der Stapel hinter einem vorhandenen Webserver läuft (DEPLOY_MODE=BEHIND_PROXY), undcaddyim eigenständigen Betrieb. Werdocker compose logs -f caddyauf 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-ocrsamt deutschem Sprachmodell undpoppler-utilsmit — rund 60 MB mehr im Abbild. Sie laufen auf diesem Rechner; es geht kein Beleg hinaus (siehedocs/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-langsDort müssen
deuundengstehen. 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
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
- PowerShell als Verwalter:
wsl --install, danach Neustart. winget install Docker.DockerDesktop, Docker Desktop starten, WSL2-Backend bestätigen, kein Konto nötig.winget install Git.Git.- 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.
- Energieoptionen: Ruhezustand und Standby auf Nie.
Docker Desktop legt seine Abbilder in einer VHDX-Datei auf C: ab, auch wenn
DATA_DIRwoanders 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:
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:

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
- Feste IP für den Server. Im Router unter Heimnetz/Netzwerk das Gerät suchen und „immer dieselbe IPv4-Adresse zuweisen" aktivieren.
- 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.netund wird als CNAME-Ziel der eigenen Domain hinterlegt. - Portfreigabe. Internet → Freigaben → Portfreigabe → Gerät wählen → neue Freigabe: Protokoll TCP, Port 443 auf 443. Sonst nichts.
- Prüfen. Vom Telefon aus im Mobilfunknetz (WLAN aus)
https://rechnung.firma.at/healthaufrufen. 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:
- 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.
- 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: Editfür genau diese Zone darf. Ein Token mit mehr Rechten hat auf einem Router nichts verloren. - Warten und nachsehen.
nslookup rechnung.neue-domain.atmuss die richtige Adresse liefern, bevor es weitergeht. Ein Zertifikat für einen Namen, der noch woandershin zeigt, gibt es nicht. - 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 an127.0.0.1:8080— steht einmal inrechnung-weiterleitung.incund wird zweimal eingebunden. Zwei Kopien desselben Blocks wären der sichere Weg, dass eines Tages nur eine davon gepflegt wird. - 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.
- Prüfen. Beide Namen müssen
200liefern 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 ``
- 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 --renewerledigt das für alle Zertifikate auf einmal; nach jeder Verlängerung ruftacme-reload.cmdeinnginx -s reloadauf, 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:

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:
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
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.mdsteht.
Das restic-Passwort
Das restic-Passwort aus der
.envist 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 snapshotsund nichtbackup-now. Beides steht in älteren Notizen und beides scheitert:resticallein kennt das Repository nicht („Please specify repository location"), undbackup-nowgibt 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
d27dc8b0aus der lokalen Kopie unda84f70cavon 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 BelegER-2026-0001vom 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 indocs/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:
- 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.
- Abbild ziehen — vor dem Anhalten. Wer erst anhält und dann zieht, steht bei einem Netzfehler mit einem toten Server da.
- Anhalten, starten. Die Migrationen laufen beim Start des Containers.
/health— mit Zeitlimit (UPDATE_HEALTH_TIMEOUT, Vorgabe 180 s).- 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:
- Eine App in einem Netz, das nur den eigenen Server erreichen darf, kommt trotzdem an ihre neue Fassung.
- Ein privates Repository braucht ein Token auf dem Server statt eines auf jedem Arbeitsplatz.
- Ohne Token antwortet GitHub mit
404, nicht mit403. 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-01aus 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.txtaus 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 /NORESTARTdurch.
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".
8. Laufender Betrieb
Was regelmäßig zu prüfen ist
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:
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
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:
- Im Shop-Admin: Einstellungen → Apps und Vertriebskanäle → App entwickeln
- 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.
- 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:
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.
Nach einer Änderung: docker compose up -d --force-recreate api.
APP_SECRETzu tauschen beendet jede laufende Sitzung. Das ist die schnellste Notbremse, wenn etwas passiert ist, und kostet nichts als eine erneute Anmeldung aller. Ausführlich indocs/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, brauchtOptimize-VHDoder ein Zurücksetzen von Docker Desktop.
Umzug auf einen anderen Rechner
- Auf dem alten Server:
docker compose down, dannDATA_DIRund.envauf den neuen Rechner kopieren. - Auf dem neuen Rechner: Docker installieren, Quellcode holen,
.envhineinkopieren, denDATA_DIR-Pfad darin anpassen,docker compose up -d. - DynDNS bzw. Portfreigabe auf den neuen Rechner umstellen.
- 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).
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:
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:
KASSE_TESTMODUS=1in der.env, und- 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
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
Schritt 1 — das fiskaltrust-Konto
- Auf portal-sandbox.fiskaltrust.at registrieren (Sandkasten) oder auf portal.fiskaltrust.at (Produktion).
- Als Land Österreich wählen.
- 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:
- Admin → Benutzer → Benutzer anlegen
- Als Typ Webservice-Benutzer wählen
- Benutzer-Identifikation und PIN vergeben und notieren — die PIN ist danach nicht mehr abrufbar
- 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:
- Konfiguration → CashBox → Neue CashBox
- Eine Queue für Österreich hinzufügen, Speicherart SQLite
- Eine Signaturerstellungseinheit hinzufügen — im Sandkasten das Demo-Zertifikat, in der Produktion die vertraglich vereinbarte
- Eine Kassenidentifikationsnummer vergeben. Sie steht später auf jedem Beleg; etwas Sprechendes ist besser als eine Zufallsnummer.
- 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, danndocker 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:
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
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
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
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:
- Ein Viva-Händlerkonto und die App Viva.com Terminal (
com.vivawallet.spoc.payapp) auf demselben Android-Gerät, installiert und angemeldet. - Die Anwendungskennung (
appId), die Viva für die aufrufende Anwendung vergibt. Ohne sie bleibt der Weg gesperrt, und SIMPro sagt das. - 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
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.
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-1trägt die Anwendung,simpro-cloud-2das 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
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
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:
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 vonSaschaServer(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 überdocker 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:
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:
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:
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
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:
- Antwortkörper ansehen — er nennt die Kennung. Das ist die ganze Diagnose.
docker exec rechnung-db-1 psql ...aufBackupRun, die letzten Zeilen.- Erst wenn beides nichts hergibt: an einen Serverausfall denken. Und dann
/healthfragen, nicht diesen Endpunkt.
Erledigt am 23.09.2026. Der Monitor heißt jetzt „SIMPro Nachtlauf-Zustand (NICHT Erreichbarkeit)" (Kennung 803915364), der Erreichbarkeits-Monitor auf
/healthheißt „SIMPro Erreichbarkeit app (/health)" (803892090) — es gab ihn bereits, mit 5-Minuten-Takt und beiden Alarmkontakten. Beides übernode scripts/uptimerobot.jsgesetzt 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
/healthund 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
-fund-parbeiten (docker compose -f /opt/simpro/demo/compose.demo.yml -p rechnung-demo) und vorherconfig --serviceslesen. Eindocker composeohne 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:
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:
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:
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.
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:
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
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/./healthist 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.
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.
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:
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=passsteht 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
535und beim richtigen525, 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.orgnachsehen — 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):525heisst „Zugangsdaten gut, Adresse gesperrt",535heisst „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
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 SQLbackup/.stage-nas/stapel/—.env,compose.yml, die Caddyfilesdata/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:
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:
- 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. - Die Aktualisierung hatte
/volume1/simpro-sicherungauf Rechte 000 gesetzt. Eigentuemer und Gruppe stimmten, aber niemand durfte hinein. Auch nach einem Start kam der Behaelter nur bistouch: /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:
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-sicherungintakt? Wenn nicht: wiederherstellen - beide Behaelter oben? Wenn nicht: starten
- antwortet Port 8000 mit 401, also mit Zugangsschutz?
- steht
PermitRootLoginaufprohibit-password? Wenn nicht: setzen — seit 22.09.2026 repariert die Aufsteh-Meldung auch das, nicht nur die Rechte. Mit Sicherungskopie,sshd -tals Entscheider undreloadstattrestart; ist die Konfiguration ungültig, wird sie nicht angefasst, sondern nur gemeldet. Warum die Grenze verschoben wurde, steht indocs/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:
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
unlockrufen. Eine Sperre kann auch bedeuten, dass gerade wirklich ein Lauf arbeitet. Erstrestic list locksansehen; 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.
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
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ürserverundapp, 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.shstehen lassen, dort das Thema ausRechnung-privat/cloud/simpro-cloud-2-zugang.txtabonnieren. 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.
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.shlaufen lassen — es zieht die Kopfzeilen nach und meldetheadersals 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
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
Die Monatsroutine
Einmal im Monat, zehn Minuten:
Punkt 5 ist der, den alle auslassen, und der einzige, dessen Fehlen man erst im Ernstfall bemerkt.
Was der Betrieb kostet
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
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.ymldurchreichen. Bis 0.4.5 standLIZENZSERVERin der.envund 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 imenvironment:-Block des Dienstesapisteht, 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
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:
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 —
@Rolesgilt 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:
- Die Firma — Name, Kurzzeichen, Farbe, wahlweise Grunddaten aus einer bestehenden übernommen.
- Den ersten Verwalter — E-Mail und Name. Er bekommt ein Erstpasswort, das genau einmal angezeigt wird und beim ersten Anmelden gewechselt werden muss.
- 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.
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:
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 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:
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:
- 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. - 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)

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
- Der Anwender wählt in der App seine Bank und drückt Verbinden.
- 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.
- 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. - 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.
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:
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:
- Nachsehen, was anders ist.
diffgegen die Datei aus dem Repository der ausgerollten Fassung. - Ist es eine Änderung, die Sie gemacht haben und behalten wollen: die neue Fassung der Datei holen und Ihre Zeile dort wieder eintragen.
- Ist es keine: die Datei hinüberlegen und Caddy bzw. den betroffenen Dienst mit
--force-recreateneu 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.
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:
/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:
- Das Formular legt nur eine Bestätigungszeile an.
- Eine E-Mail geht hinaus, der Link gilt 48 Stunden.
- 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.
--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.
--durchverlangt einen Systemverwalter und steht danach im Prüfprotokoll. - Welchen.
--bestaetigenverlangt 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:
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:
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
/versionund ging —, und der Knopf dahinter tat nichts, weil/updates/latestleer 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_JSONals Secret undANDROID_RELEASE_ENABLED=trueals 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.p12samt 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:

- 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:
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
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.