Rechnungsdaten als Domäne
Warum ein Rechnungsimport kein CSV-Job ist, sondern eine Fachdomäne mit eigenen Begriffen, Invarianten und Zeitregeln – und wie explizite Typen und ein Ablehnungs-Report aus stillen Datenfehlern erklärbare Daten machen.
Rechnungsdaten sind, fachlich betrachtet, keine Zahlenkolonnen. Eine Rechnung ist ein Beleg: ein Dokument mit Aussteller, Empfänger, Belegnummer, Positionen und Steuersätzen – und mit rechtlicher Bedeutung, die im Umsatzsteuergesetz ziemlich genau beschrieben ist. Ein Beleg hat Verwandte: die Gutschrift, die einen Betrag zurückgibt, und den Storno, der einen Beleg aufhebt und dabei zwingend auf das Original verweist. Und ein Beleg trägt Regeln in sich, die in keiner Exportdatei stehen: Die Summe der Positionen muss zur Belegsumme passen, Netto plus Steuer muss Brutto ergeben, und ein Zahlungsziel rechnet ab dem Rechnungsdatum – nicht ab dem Leistungsdatum, und erst recht nicht ab dem Tag, an dem das Geld tatsächlich fließt.
Wer eine Rechnung nur als Zeile mit Betrag liest, liest sie falsch. Genau das passiert aber, wenn man die Aufgabe „Rechnungsdaten zusammenführen" als das behandelt, wonach sie auf den ersten Blick aussieht: als Import-Job.
Ein privates Datenprojekt als Auslöser
Der Anlass war unspektakulär. Über mehrere Jahre hatten sich bei mir Rechnungs- und Belegdaten in drei Formen angesammelt: PDFs von Lieferanten, CSV-Exporte aus einem Buchhaltungswerkzeug und ein Konto-Export der Bank. Ich wollte sie in einer lokalen Datenbank zusammenführen, um endlich Fragen beantworten zu können, die keines der Einzelsysteme beantwortet: Welche Beträge wurden in Rechnung gestellt, aber nie bezahlt? Wie verteilen sich Ausgaben über Steuersätze? Welche Gutschriften gehören zu welchen Originalen? Alles anonymisiert und rein privat – es geht hier um die Struktur des Problems, nicht um konkrete Geschäftszahlen.
Die erste Version war ein Abend Arbeit: PDFs in Text wandeln, CSVs mit Papa Parse einlesen, Spalten umbenennen, ab in SQLite. Der Import lief durch, ohne eine einzige Fehlermeldung. Und genau das war das Problem.
Denn die Daten waren still kaputt. Ein Lieferant exportiert Bruttobeträge, der andere Netto – beide nennen die Spalte amount. Ein Export schreibt deutsche Dezimalkommas, der nächste Punkte. In einer Datei stand in der Datumsspalte das Leistungsdatum, in der anderen das Rechnungsdatum. Zwei Stornos tauchten als ganz normale Rechnungen mit negativem Betrag auf, ohne Bezug zum Original. Nichts davon ist ein Parser-Fehler. Jede einzelne Zeile war syntaktisch einwandfrei. Falsch war die Bedeutung – und Bedeutung prüft kein CSV-Parser.
Mir ist an diesem Punkt aufgefallen, dass ich die Aufgabe falsch einsortiert hatte. Das hier war kein Datentransport. Das war Domänenmodellierung, nur eben für Daten, die schon existieren.
Die Begriffe ernst nehmen
Der erste Schritt raus aus dem stillen Kaputtgehen war kein Code, sondern eine Begriffsliste. Welche fachlichen Dinge kommen in diesen Dateien überhaupt vor? Am Ende standen erstaunlich wenige Begriffe auf dem Zettel, aber jeder davon mit scharfer Bedeutung:
- Beleg – das Dokument als Ganzes, mit eindeutiger Belegnummer und einer Belegart
- Position – eine Zeile auf dem Beleg, mit Menge, Einzelpreis und eigenem Steuersatz
- Steuersatz – in meinem Bestand 0, 7 oder 19 Prozent, nichts anderes
- Gutschrift – ein eigenständiger Beleg mit umgekehrtem Vorzeichen
- Storno – hebt einen Beleg auf und referenziert dessen Belegnummer
- Zahlungsziel – ein Datum, abgeleitet aus Rechnungsdatum plus Frist
Diese Liste sieht banal aus. Sie hat aber sofort Entscheidungen erzwungen, die vorher unsichtbar im Spaltenmapping versteckt waren. Ist ein negativer Betrag eine Gutschrift oder ein Storno? Fachlich ein riesiger Unterschied: Die Gutschrift ist ein neuer Vorgang, der Storno macht einen alten ungültig. In meinen Rohdaten waren beide dieselbe Zeile mit Minuszeichen. Ohne Begriffsliste hätte ich das nie als Problem erkannt – die Zahl stimmt ja.
Die Beziehungen zwischen den Belegarten lassen sich klein aufzeichnen:
flowchart LR I["Rechnung<br/>INV-2023-0117"] --> P1["Position 1<br/>19 %"] I --> P2["Position 2<br/>7 %"] C["Storno<br/>CAN-2023-0119"] -- "referenziert" --> I G["Gutschrift<br/>CRN-2023-0121"] -- "bezieht sich auf" --> I
Der Storno ohne Referenz ist in diesem Bild schlicht nicht darstellbar. Genau so soll es sein: Was sich nicht zeichnen lässt, sollte sich auch nicht speichern lassen.
Invarianten: was immer gelten muss
Begriffe allein reichen nicht. Die eigentliche Substanz einer Domäne steckt in ihren Invarianten – den Aussagen, die für jeden gültigen Datensatz gelten müssen, egal woher er kommt. Für Rechnungsdaten sind das vor allem drei:
Erstens: Summen müssen aufgehen. Die Positionssummen je Steuersatz, darauf die Steuer, das Ganze addiert – das muss den Bruttobetrag des Belegs ergeben, auf den Cent. Rundung passiert je Steuersatzgruppe, nicht je Position, sonst driftet es.
Zweitens: Ein Storno referenziert ein Original. Ein Storno ohne Referenz ist kein Storno, sondern ein Datenfehler mit Minuszeichen.
Drittens – der Klassiker: Brutto und Netto dürfen nie verwechselbar sein. Ein Feld, das mal das eine und mal das andere enthält, ist schlimmer als gar kein Feld. Bei 19 Prozent Differenz fällt der Fehler in Summenauswertungen nicht einmal sofort auf; er verteilt sich als gleichmäßiger Nebel über alle Zahlen.
Mit TypeScript und Zod lässt sich das alles explizit machen. Beträge führe ich grundsätzlich als ganzzahlige Cent-Werte – Gleitkomma und Geld vertragen sich nicht:
import { z } from "zod";
const TaxRate = z.union([z.literal(0), z.literal(7), z.literal(19)]);
const InvoiceLine = z.object({
description: z.string().min(1),
quantity: z.number().positive(),
unitPriceNetCents: z.number().int(), // money as integer cents, never floats
taxRate: TaxRate,
});
const Invoice = z.object({
invoiceNumber: z.string().regex(/^(INV|CRN|CAN)-\d{4}-\d{4}$/),
kind: z.enum(["invoice", "credit_note", "cancellation"]),
referencesInvoice: z.string().optional(),
issueDate: z.string().date(), // invoice date
serviceDate: z.string().date(), // date of supply
dueDate: z.string().date(), // payment target
lines: z.array(InvoiceLine).min(1),
totalGrossCents: z.number().int(),
});
Das ist erst die Struktur. Die Invarianten kommen als Verfeinerung obendrauf, damit ein Verstoß denselben Weg nimmt wie ein Strukturfehler:
const ValidInvoice = Invoice.superRefine((inv, ctx) => {
// sum per tax rate group, round per group, then add up
const grossByRate = new Map<number, number>();
for (const line of inv.lines) {
const net = line.quantity * line.unitPriceNetCents;
const gross = Math.round(net * (1 + line.taxRate / 100));
grossByRate.set(line.taxRate, (grossByRate.get(line.taxRate) ?? 0) + gross);
}
const computedGross = [...grossByRate.values()].reduce((a, b) => a + b, 0);
if (computedGross !== inv.totalGrossCents) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: `gross mismatch: lines say ${computedGross}, document says ${inv.totalGrossCents}`,
});
}
if (inv.kind === "cancellation" && !inv.referencesInvoice) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "cancellation must reference an original invoice",
});
}
});
Der Unterschied zur ersten Version des Projekts ist grundsätzlich. Vorher hat der Import akzeptiert, was sich parsen ließ. Jetzt akzeptiert er, was fachlich stimmt. Das ist dieselbe Verschiebung, die man aus dem Domain-Driven Design kennt, nur auf der unscheinbarsten Ebene überhaupt: beim Einlesen von Dateien.
Drei Daten, drei Bedeutungen
Die Zeitregeln verdienen einen eigenen Abschnitt, weil sie der Ort sind, an dem Auswertungen am leisesten falsch werden. Auf einem Beleg stehen bis zu drei Daten, und keines davon ist „das Datum":
Das Leistungsdatum sagt, wann etwas geliefert oder geleistet wurde. Das Rechnungsdatum sagt, wann der Beleg ausgestellt wurde. Das Zahldatum – das gar nicht auf dem Beleg steht, sondern aus dem Konto-Export kommt – sagt, wann Geld geflossen ist. Zwischen Leistung und Rechnung können Wochen liegen, zwischen Rechnung und Zahlung noch einmal. Eine Auswertung „Ausgaben pro Monat" liefert je nach gewähltem Datum drei verschiedene Wahrheiten, und alle drei sind für irgendeine Frage die richtige: Das Leistungsdatum gehört zur Frage „Wann ist der Aufwand entstanden?", das Rechnungsdatum zur Umsatzsteuer, das Zahldatum zur Liquidität.
In meinen Rohdaten hatte jede Quelle genau eine Datumsspalte, und keine zwei Quellen meinten dasselbe damit. Seit die drei Daten getrennte, verpflichtende Felder sind, muss jede Auswertung sagen, welches sie meint – die Frage lässt sich nicht mehr versehentlich überspringen. Das Zahlungsziel ist dann nur noch abgeleitete Information: Rechnungsdatum plus Frist ergibt dueDate, und ein Abgleich mit dem Zahldatum zeigt, was überfällig war.
Die Grenze: ablehnen statt durchwinken
Bleibt die Frage, was mit Zeilen passiert, die die Prüfung nicht bestehen. Die übliche Antwort ist eine Log-Zeile, und die übliche Folge ist, dass niemand sie liest. Bei einem Import, der einmal im Monat läuft, ist das gleichbedeutend mit stillem Datenverlust – nur dass diesmal die schlechten Daten draußen bleiben statt drin, was besser ist, aber immer noch unsichtbar.
Die für mich brauchbare Antwort: Abgelehnte Zeilen sind ein Ergebnis des Laufs, gleichrangig mit den akzeptierten. Der Import erzeugt zwei Artefakte – die Datenbank und einen Ablehnungs-Report als Markdown-Datei, mit Quelle, Zeilennummer und dem fachlichen Grund:
type RejectedRow = {
source: string; // e.g. "supplier-a-2023.csv"
rowNumber: number;
reason: string; // human-readable, domain language
};
const rejectedRows: RejectedRow[] = [];
for (const [rowNumber, candidate] of candidates.entries()) {
const result = ValidInvoice.safeParse(candidate);
if (!result.success) {
rejectedRows.push({
source: candidate.source,
rowNumber,
reason: result.error.issues.map((i) => i.message).join("; "),
});
continue;
}
accepted.push(result.data);
}
// afterwards: write rejected-rows report next to the database, fail if quota exceeded
Der Report ist bewusst eine Datei und kein Log-Strom. Dateien kann man diffen, ablegen, im nächsten Monat mit dem Vormonat vergleichen. Läuft der Import zweimal über dieselben Eingaben, muss exakt derselbe Report entstehen – warum ich auf diese Eigenschaft so viel Wert lege, habe ich in Deterministische CLIs ausführlicher beschrieben. Und für die Werte, die aus PDFs stammen, gilt ohnehin gesteigertes Misstrauen: Was eine Texterkennung liefert, ist ein Messwert mit Unsicherheit, kein Fakt – dazu mehr in OCR mit Cache und Misstrauen.
Der komplette Fluss sieht dann so aus:
flowchart LR
A["PDF-Belege"] --> N["Normalisieren<br/>Kommas, Encoding, Spalten"]
B["CSV-Exporte<br/>Buchhaltung"] --> N
K["Konto-Export<br/>Bank"] --> N
N --> V{"Domänen-Validierung<br/>Typen + Invarianten"}
V -- "gültig" --> DB[("SQLite<br/>Belege + Positionen")]
V -- "abgelehnt" --> R["Ablehnungs-Report<br/>Markdown, diffbar"]
Wichtig an dem Bild ist die eine Raute. Es gibt genau eine Stelle, an der Rohdaten zu Domänendaten werden. Hinter dieser Grenze darf jeder Code den Feldern trauen: totalGrossCents ist brutto, in Cent, und die Summen gehen auf. Vor der Grenze ist alles verdächtig. Diese Asymmetrie macht den Rest des Projekts so angenehm – die Auswertungen enthalten keine einzige Abwehrzeile mehr, kein „falls das Feld doch leer ist", kein zweites Nachrechnen.
Auswerten, wenn die Daten sauber sind
Für die Analysen habe ich DuckDB direkt auf die Datenbank gesetzt. Sobald die Domäne stimmt, sind die interessanten Fragen erstaunlich kurze SQL-Statements. Offene Posten zum Beispiel – Belege, deren Zahlungsziel verstrichen ist, ohne dass ein Zahldatum existiert:
SELECT i.invoice_number,
i.due_date,
i.total_gross_cents / 100.0 AS gross_eur
FROM invoices i
LEFT JOIN payments p ON p.invoice_number = i.invoice_number
WHERE i.kind = 'invoice'
AND p.paid_date IS NULL
AND i.due_date < current_date
ORDER BY i.due_date;
Dass diese Abfrage stimmt, verdankt sie vollständig der Grenze davor. Mit den Rohdaten der ersten Version hätte dieselbe Abfrage plausible und falsche Zahlen geliefert – Stornos als offene Posten gezählt, Brutto und Netto gemischt, das Leistungsdatum als Fälligkeit interpretiert. Nichts davon wäre aufgefallen, weil das Ergebnis nach Geld aussieht und Geldbeträge immer irgendwie glaubwürdig wirken.
Ein Nebeneffekt, mit dem ich nicht gerechnet hatte: Der Ablehnungs-Report wurde zum besten Werkzeug, um die Quellen selbst zu verstehen. Häufen sich Ablehnungen aus einer Datei mit demselben Grund, ist das kein Rauschen, sondern eine Erkenntnis über den Exporteur – etwa dass ein Werkzeug bei Gutschriften das Vorzeichen anders setzt als bei Rechnungen. Solche Muster standen früher in keinem Log; jetzt stehen sie gesammelt in einer Datei, die ich tatsächlich lese.
Und die E-Rechnung?
Man könnte einwenden, dass sich das Problem von selbst erledigt: Mit dem Wachstumschancengesetz ist im März 2024 beschlossen worden, dass die elektronische Rechnung im deutschen B2B-Geschäft ab 2025 schrittweise verpflichtend wird – strukturierte Formate nach EN 16931 statt PDF-Prosa. Das ist eine gute Nachricht, denn genau die Felder, um die es hier ging, sind dort formal definiert: Belegart, Steuersätze je Position, Leistungs- und Rechnungsdatum.
Nur verschiebt ein strukturiertes Format die Arbeit, es beseitigt sie nicht. Ob ein XML-Feld oder eine CSV-Spalte behauptet, ein Bruttobetrag zu sein – prüfen, dass die Summen aufgehen und der Storno sein Original findet, muss weiterhin die empfangende Seite. Und die Bestandsdaten der vergangenen Jahre bleiben ohnehin, was sie sind: PDFs und Exporte mit Eigenheiten. Die Domänengrenze wird also nicht überflüssig; sie bekommt künftig nur besseres Eingangsmaterial.
Was ich daraus mitnehme
Der Reflex, Rechnungsdaten als Transportproblem zu behandeln, ist verständlich – die Dateien sind klein, die Formate bekannt, die Werkzeuge fertig. Aber die Schwierigkeit liegt nicht im Transport. Sie liegt darin, dass diese Daten eine Fachsprache haben, Invarianten, die kein Parser kennt, und drei Zeitachsen, die sich nicht in einer Datumsspalte unterbringen lassen. Wer das ignoriert, bekommt einen Import, der grün durchläuft und trotzdem falsche Zahlen produziert. Wer es modelliert, bekommt etwas Selteneres als korrekte Daten: erklärbare Daten. Jede Zahl in der Datenbank hat eine Herkunft, jede fehlende Zeile einen dokumentierten Grund.
Unterm Strich hat das kleine private Projekt damit mehr über Softwarearchitektur gesagt als mancher Servicezuschnitt: Eine Domäne erkennt man nicht an ihrer Größe, sondern daran, dass sie eigene Begriffe, eigene Regeln und eigene Zeit hat. Rechnungsdaten haben alle drei.
Weiterführende Quellen
- Zod auf GitHub – Schema-Validierung für TypeScript
- Zod-Dokumentation – Referenz zu Refinements und Fehlerbehandlung
- DuckDB-Dokumentation – lokale Analysen direkt auf Dateien und Datenbanken
- § 14 UStG – Pflichtangaben einer Rechnung im deutschen Umsatzsteuerrecht
- Papa Parse – CSV-Parsing im Browser und in Node.js
Kommentare