// portfolio

// Fallstudie

  • #Softwarearchitektur
  • #Hexagonale Architektur
  • #Typst
  • #MCP
  • #SvelteKit

Offerten und Rechnungen als Code: Typst statt InDesign

Eine Dokumenten-App für kleine Betriebe, in weniger als vier Wochen mit Agents gebaut. Was sie trägt, sind klare Regeln: für Geld, für den Renderer und für die Architektur.

9
Dokumenttypen Von der Offerte bis zur Mahnung, aus 3 Typst-Templates
982
Tests In 60 Dateien, alle grün, inklusive Datenbank- und Typst-Integration
< 4 Wochen
Entwicklungszeit 46 Commits vom 28. August bis 23. September 2026
12
Werkzeuge für Agents Lesende MCP-Tools über denselben Kern wie die App
Rollen
Architektur und Regeln Templates und Typografie Review und Betrieb

Tooling

App
SvelteKit mit Remote Functions Svelte 5 (Runes) Bun valibot
Dokumente
Typst 0.15 (CLI) Eigene Templates CSV-Export pro Quartal
Daten
SurrealDB 23 Migrationen
Agents und Tests
Claude Code Model Context Protocol (stdio) Vitest
Betrieb
Pokkum (Container-Image) Dokploy auf Infomaniak (Schweiz)

Ausgangslage

Offerten, Verträge und Rechnungen sind Arbeit, die niemand gern macht: bei meinen eigenen Aufträgen nicht und bei den befreundeten Gärtnern, für die ich schon eine Bestell-App gebaut habe, auch nicht. Die Idee, diese Dokumente aus Daten zu erzeugen, hatte ich früh. Ich habe sie zurückgestellt, bis ich selbst Dokumente brauchte.

Das eigentliche Hindernis war die Gestaltung. Meine Frau und eine der Gärtnerinnen beherrschen InDesign, der Gärtner und ich nicht. Jede neue Offerte, jede Anpassung an einer Vorlage hätte eine der beiden gebraucht. Ich wollte Dokumente, die gut aussehen und die wir trotzdem selbst ändern können.

Die Rahmenbedingungen:

  • Schweizer Rechnungen: MwSt mit mehreren Sätzen, Beträge in Franken und Rappen, ein Zahlteil nach Schweizer Norm.
  • Eine Person entwickelt und betreibt alles. Die Architektur muss auch Code von Agents zusammenhalten.
  • Die App ist zum Teil ein CRM und teilt sich die Kunden mit der Bestell-App der Gärtner.

Meine Rolle

Architektur, Datenmodell, Templates und alle Entscheide liegen bei mir. Den Code haben grösstenteils Claude-Agents geschrieben: 27 der 46 Commits tragen ausdrücklich einen Co-Autor-Vermerk. Meine Arbeit ist dabei vor allem, Regeln so festzulegen, dass sie auch dann gelten, wenn ich gerade nicht hinschaue: als Tests, als Architekturprüfung und als Anweisungen im Repo. Eine davon: Kein Text, der an Kundschaft geht, enthält Gedankenstriche, weil ich sie nicht schreibe und ein Dokument sonst nicht nach seinem Absender klingt.

Die Domäne im Modell: Neun Dokumenttypen teilen sich drei Templates: Vorschlag und Offerte samt Auftragsbestätigung, Vertrag und Übergabe, Rechnung samt Teilrechnung, Gutschrift und Mahnung. Dazu kommen vier Preismodelle: Pauschale, nach Aufwand, Kostenrahmen und Paketstufen.

Deep Dives

// Typst statt InDesign

Dokumente aus Daten und Templates

Die App liefert reine Daten, ein Typst-Template setzt sie. Layout und Typografie liegen als Code im Repo und lassen sich ohne Layoutprogramm ändern.

Typst ist ein Satzsystem wie LaTeX, aber mit einer modernen Sprache und einem einzelnen, schnellen Binary. Für die App heisst das: Ein Dokument ist eine Funktion von Daten.

  • Drei Templates, neun Typen. Ein Template schaltet anhand des Dokumenttyps um. Eine Auftragsbestätigung ist die Seite der Offerte mit anderem Titel, anderer Nummer und einem bestätigenden Satz anstelle der Gültigkeitsdauer.
  • Themes statt Kopien. Farben, Schriften und Abstände liegen in einem Theme. Für den Gartenbaubetrieb gibt es ein eigenes, ohne das Rechnungslayout zu duplizieren.
  • Text, wie er eingegeben wurde. Zeilenumbrüche aus der App bleiben im PDF erhalten, weil Adressen und Grussformeln sie brauchen. Das ist bewusst so und dokumentiert, damit es niemand für einen Fehler hält.
  • Fixtures als Referenz. Für die meisten Typen liegt ein Beispiel mit synthetischen Daten im Repo, aus dem sich das PDF jederzeit neu erzeugen lässt.

Der Gewinn zeigt sich im Alltag: Wenn eine Formulierung auf der Offerte nicht passt, ändere ich das Template, und die nächste Offerte sieht anders aus. Niemand muss dafür InDesign öffnen.

// Fünf Regeln, jede tragend

Ein Renderer, der Kundentext verarbeitet

Typst läuft als eigener Prozess und verarbeitet Namen und Beschreibungen, die Kundschaft mitgeprägt hat. Der Renderer ist deshalb so gebaut, als wäre er öffentlich.

Vom Dokument zum PDF

Klick auf «PDF»

  1. Dokument Positionen, Kunde, Steuersätze aus der Datenbank
  2. Render-Payload Reine Daten als JSON: Beträge in Rappen, MwSt pro Satz berechnet
  3. Cache Schlüssel aus Payload, Template-Version und Typst-Version
  4. Typst Eigener Prozess: max. 3 parallel, Abbruch nach 20 s, nur im eigenen Stammverzeichnis
  5. PDF Wird bei Bedarf neu erzeugt. Ein Archiv beim Ausstellen ist geplant

PDF im Browser

Die App ist intern und liegt hinter einem Login am Reverse Proxy. Trotzdem ist der Renderer so gebaut, als wäre er öffentlich, weil die Kosten dafür klein sind:

  • Semaphor: Typst ist rechenintensiv. Ohne Obergrenze genügen ein paar Tabs, die gleichzeitig neu laden, um den Server auszubremsen. Maximal drei Prozesse laufen parallel, der Rest wartet.
  • Timeout: Ein Prozess, der nie endet, würde einen Platz für immer blockieren. Nach 20 Sekunden wird er beendet, und der Platz wird in jedem Fall freigegeben.
  • Cache: Der Schlüssel besteht aus den kanonisierten Daten, der Template-Version und der Version des Typst-Binarys. Ein geändertes Template kann so nie ein altes PDF liefern.
  • Temp-Datei statt Argument: Linux begrenzt ein einzelnes Kommandozeilenargument auf 128 KiB. Ein Dokument mit langem Arbeitsprotokoll erreicht das. Die Daten gehen deshalb als Datei an Typst.
  • Keine Shell: Typst wird mit einer Argumentliste gestartet. Kein Wert aus Kundendaten wird je von einer Shell interpretiert.

Ein Detail, das erst im Container auffiel: Das Image, das Pokkum baut, ist schreibgeschützt. Typst darf aber nur Dateien in seinem Stammverzeichnis lesen, und dort muss die Temp-Datei liegen. Die naheliegende Lösung, das Stammverzeichnis auf «/» zu setzen, hätte Typst das ganze Dateisystem geöffnet. Stattdessen kopiert der Renderer beim Start genau die Schriften, Templates und statischen Dateien, die Typst ohnehin lesen durfte, in ein beschreibbares Verzeichnis. Die Beschränkung bleibt, sie liegt nur woanders.

// Geld, Schichten, MCP

Architektur, die Agents nicht aufweichen können

Regeln, die in Code stehen, gelten für jeden Agent und jede Sitzung: ganze Rappen statt Kommazahlen, eine geprüfte Schichtenarchitektur und ein MCP-Server auf demselben Kern.

Geld ohne Rundungsfehler. Alle Beträge sind ganze Rappen, Steuersätze ganze Basispunkte. Die MwSt wird pro Satz berechnet und auf dem Dokument als eigene Zeile pro Satz ausgewiesen. Ein Steuersatz, den es nicht gibt, wird schon bei der Validierung abgewiesen.

Hexagonale Architektur als Test. Jedes Feature, etwa Dokumente, Rechnungen, Verträge, Kunden oder Spesen, hat dieselben Schichten: Domäne, Ports, Server, Remote-Funktionen, Oberfläche. Ein Skript prüft bei jedem Lauf, dass die Domäne nichts ausser Domäne importiert, Ports nur die Domäne kennen und kein Feature in die Interna eines anderen greift. Die einzige allgemeine Ausnahme ist bewusst gewählt und im Skript begründet: Eine Oberfläche darf die öffentlichen Remote-Funktionen eines anderen Features aufrufen, etwa um aus einer angenommenen Offerte ein Projekt zu machen. Aktueller Lauf: 270 Dateien, keine Verletzung.

Ein MCP-Server auf demselben Kern. Zwölf lesende Werkzeuge geben Agents Zugriff auf Dokumente, Rechnungen, Verträge, Kunden und den Katalog, bis zum Rendern eines PDFs. Sie rufen dieselben Use-Cases auf wie die Web-App. Weil die Schichten sauber getrennt sind, brauchte der MCP-Server keine eigene Geschäftslogik.

Tests: 982 Tests in 60 Dateien, darunter Integrationstests gegen SurrealDB und gegen das echte Typst-Binary. Der ganze Lauf dauert lokal rund 9 Sekunden.

Stand & Ausblick

  • Deployt: Meine Instanz läuft als Container-Image auf einem Schweizer Server. Eine eigene Instanz für den Gartenbaubetrieb auf dessen Server ist geplant.
  • Swiss QR-Rechnung: Der Zahlteil ist als Layout nach Norm vorbereitet, der QR-Code selbst fehlt noch. Meine eigene Bank bietet das Verfahren nicht an. Für den Gartenbaubetrieb hängt die Umsetzung davon ab, was dessen Bank unterstützt, allenfalls mit einer Anbindung an deren Schnittstelle.
  • Buchhaltung: Ein Export pro Quartal als CSV für den Treuhänder ist gebaut und durch Unit-Tests abgedeckt, im Alltag aber noch nicht erprobt.
  • Archiv: PDFs werden heute bei Bedarf neu erzeugt. Eine unveränderliche Fassung beim Ausstellen einer Rechnung ist der nächste grössere Schritt.
  • Offen: eine eigene Anmeldung in der App statt nur am Reverse Proxy und eine CI-Pipeline. Tests und Architekturprüfung laufen bisher lokal.

Lessons Learned

Was ich in ein Team mitbringe

  • Architektur, die Tempo aushält: Ich setze Schichten und Regeln so, dass auch schnell geschriebener Code von Agents in der Form bleibt, und prüfe sie automatisch.
  • Sicherheit im Kleinen: Auch interne Werkzeuge baue ich so, dass ein einzelner Fehler nicht das System öffnet, und schreibe die Begründung neben den Code.
  • KI-Werkzeuge mit sauberem Anschluss: Ein MCP-Server, der denselben Kern nutzt wie die App, statt einer zweiten Implementierung.
  • Gespür für Dokumente: Typografie, Schweizer Rechnungsnormen und die Frage, wer ein Dokument später ändern können muss.