Ein Wort, drei Gesetze, eine Spalte
Kleinunternehmer ist in Österreich dreidimensional. Warum V2 genau eine Spalte behielt und die Grenze aus einer datums-gegateten Tabelle kommt.
TL;DR
Kleinunternehmer klingt nach einem Häkchen im Profil. Tatsächlich hängen an dem Wort in Österreich drei verschiedene Rechtsgrundlagen mit drei verschiedenen Schwellen und drei verschiedenen Zeitachsen. Die erste Fassung von BuchhaltGenie hat versucht, alle drei zu modellieren, und hielt ein abgeleitetes Boolean per Datenbank-Trigger synchron. Die zweite Fassung hat genau eine Spalte behalten. Die Grenze selbst steht nirgends im Code, sondern in einer datums-gegateten Tabelle mit Rechtsgrundlage pro Zeile. Werte, die ein Gesetz vorgibt, gehören mit Gültigkeitszeitraum und Rechtsgrundlage in eine Tabelle, nicht als Konstante in den Code. Dieser Artikel beschreibt, was in meinem Schema steht und warum, nicht was für irgendjemanden gilt.
Ein Wort, das wie ein Boolean aussieht
is_kleinunternehmer liest sich wie ja oder nein. Genau das war das Problem. Hinter dem Begriff stecken mindestens drei Dimensionen, die miteinander wenig zu tun haben.
Die erste ist die Umsatzsteuer, geregelt in UStG Paragraph 6 Abs 1 Z 27. Die zweite ist die Sozialversicherung, GSVG beziehungsweise SVS, mit einem eigenen Anker beim Jahresgewinn, der jährlich valorisiert wird. Die dritte ist die Einkommensteuer mit der Pauschalierung nach EStG. Drei Gesetze, drei Schwellen, drei Zeitachsen. Ein Boolean kann davon höchstens eine Dimension abbilden und tut dann so, als gäbe es die anderen beiden nicht.
Ich schreibe hier bewusst deskriptiv: dass es diese drei Dimensionen gibt, und dass ich zwei davon in meinem Schema nicht modelliert habe. Was daraus für einen konkreten Betrieb folgt, ist eine Frage an die Steuerberatung und nicht an meine Datenbank.
Was V1 daraus gemacht hat
Die erste Fassung hat es gut gemeint. Die Tabelle businesses trug rund 135 Spalten, darunter is_kleinunternehmer, annual_revenue_limit, kleinunternehmer_estimated_revenue, mehrere buchfuehrungspflicht_-Felder, ein accounting_mode und diverse threshold_-Spalten. Das Boolean wurde per Trigger aus mehreren, teils widersprüchlichen Quellen synchron gehalten.
Schlimmer war, was mit dem Grenzwert passierte. Er lag zeitgleich an drei Stellen vor: als Hardcode in zwei Funktionen, als Spalte businesses.annual_revenue_limit, und in einer datums-gegateten Tabelle. Diese Tabelle war in Produktion leer. Die App fiel still auf den Hardcode zurück und hat sich dabei völlig korrekt verhalten, denn genau das war einprogrammiert.
Die gefährlichste der drei Wertquellen war die leere. Sie sah nach Ordnung aus, und die App fiel still auf den Hardcode zurück.
Ein späterer Befund hat das Bild vervollständigt: die Grenze wurde an sieben Stellen im Code gelesen, an fünf davon unvollständig. Zum Vergleich, wie es aussieht, wenn eine Zahl nur einmal existiert: die Schwelle für die Kleinbetragsrechnung steht heute genau einmal als 40.000 Cent in einer Konstantendatei. In V1 war sie fünfmal dupliziert.
Die ADR, die etwas entfernt
Am 4. Juli habe ich dazu ADR-001 geschrieben, Status Accepted, angehängt an Issue Nummer 9. Es ist der seltene Fall einer Architekturentscheidung, die nichts hinzufügt, sondern Modellierung streicht.
Die Entscheidung im Wortlaut: das V2-MVP modelliert ausschließlich die UStG-Dimension über die Enum-Spalte businesses.vat_status als einzigen SSOT. Keine abgeleiteten Flags werden persistiert oder direkt beschrieben. Abgeleitete Zustände werden zur Laufzeit aus vat_status berechnet. Keine Grenzwerte als Schema-Konstanten.
Mein Schema kennt also genau einen Zustand, und der heißt vat_status, mit den drei Enum-Werten kleinunternehmer, regelbesteuerung und opt_in. Alles andere aus der V1-Liste ist weg.
Der interessantere Teil der ADR sind die Nachteile, die ich bewusst in Kauf genommen habe. Auch die stehen wörtlich drin: SVS-Ausnahme und EStG-Pauschalierung sind im MVP nicht abbildbar. Die Vereinfachung darf aber nicht als vollständige Kleinunternehmer-Abbildung fehlgelesen werden. Der Klammerzusatz dahinter lautet, dass genau das der Grund für diese ADR ist.
Das ist der eigentliche Punkt: ein Weglassen ohne Dokument sieht in sechs Monaten aus wie ein Versäumnis. Mit Dokument ist es eine Entscheidung mit Datum, Begründung und Umfang. Der nächste Mensch, der die eine Spalte sieht und sich wundert, findet die Antwort, statt sie zu erraten.
Eine Tabelle statt einer Konstante
Die zweite Hälfte der Entscheidung betrifft den Wert selbst. Er lebt in einer Tabelle namens compliance_values mit den Spalten key, effective_from, effective_until, value als JSONB und legal_reference. Eine Zeile pro Rechtsstand.
-- Seed: der Wert steht in der Datenbank, mit Gueltigkeit und Rechtsgrundlage
INSERT INTO compliance_values (key, effective_from, effective_until, value, legal_reference)
VALUES (
'kleinunternehmer_threshold',
'2025-01-01',
NULL,
'{"amount_cents": 5500000, "basis": "brutto"}'::jsonb,
'UStG Paragraph 6 Abs 1 Z 27, ab 2025 idF AbgAEG 2024'
);
Der Seed trägt für 2025 den Wert 5.500.000 Cent brutto mit dieser Rechtsgrundlage. Davor liegen zwei weitere Zeilen: 3.500.000 Cent netto mit Gültigkeit 2020 bis 2024, und 3.000.000 Cent netto für 2015 bis 2019 mit dem Verweis auf das StRefG 2015/16. Eine vierte Seed-Zeile hält die USt-Sätze, standard 20, reduced als Liste mit 10 und 13, zero 0.
Diese Konstruktion hat vier Eigenschaften, die ich mir vorher überlegt habe.
Erstens ist die Tabelle append-only, per Trigger erzwungen: UPDATE und DELETE werfen. Begründet ist das im Kopfkommentar der Migration mit BAO Paragraph 132. Ein Wert von 2019 muss 2026 noch derselbe sein wie damals, sonst rechnet eine Rückschau falsch.
Zweitens gibt es genau einen Lesepfad. get_compliance_value(p_key, p_reference_date DEFAULT CURRENT_DATE) ist STABLE, SECURITY DEFINER, mit gesetztem search_path. Daneben steht get_kleinunternehmer_threshold_cents als dünner Wrapper darüber.
Drittens, und das ist mir das Liebste: null Treffer wirft eine Exception. Kein COALESCE auf eine Magic Number, kein Fallback. Der Text der Exception sagt, was zu tun ist, nämlich dass keine Zeile den Key zum Stichtag abdeckt, dass eine Zeile per Migration ergänzt werden muss, und niemals ein hartkodierter Fallback. Genau dieser stille Fallback war der Defekt in V1.
Viertens ist RLS aktiv, also Row Level Security, Zugriffsregeln direkt in der Datenbank: SELECT ist für authenticated erlaubt, eine Schreib-Policy existiert nicht. Seeds kommen ausschließlich über Migrationen. Diese Werte sind öffentliches Recht und gelten systemweit, deshalb hat die Tabelle bewusst keine business_id.
Über der Berechnung im Anwendungscode steht ein Kommentar, der als frozen contract markiert ist: die Grenze ist niemals hartkodiert, sie ist datums-gegatet in der Datenbank und wird zur Request-Zeit via RPC geladen. Ein Notfall-Fallback mit 55k existiert noch, ist aber als deprecated markiert und nie als aktueller Wert im Einsatz.
Wie aus einer Zahl vier Zustände werden
Die RPC liefert limitCents. Daraus berechnet die Anwendung eine Warnstufe, und zwar auf Integer-Cent statt auf einem Prozentwert. Der Grund steht im Doc-Kommentar: Prozentwerte sind Fließkommazahlen, und eine Warnstufe, die an einem Rundungsfehler kippt, ist eine schlechte Warnstufe.
// Warnstufe auf Integer-Cent, nicht auf Prozent: keine Float-Rundungsfehler
const KU_APPROACHING_FACTOR = 0.7;
const KU_TOLERANCE_FACTOR = 1.1;
function warnLevel(revenueCents: number, limitCents: number): RevenueWarnLevel {
if (revenueCents > limitCents * KU_TOLERANCE_FACTOR) return 'toleration-exceeded';
if (revenueCents > limitCents) return 'over';
if (revenueCents >= limitCents * KU_APPROACHING_FACTOR) return 'approaching';
return 'ok';
}
Vier Zustände also: ok, approaching, over, toleration-exceeded. Amber beginnt bei 70 Prozent der Grenze, over oberhalb von 100 Prozent, und die Berechnung kennt ein Toleranzband, oberhalb dessen sie auf toleration-exceeded stuft. Für einen Betrieb, dessen vat_status nicht auf kleinunternehmer steht, ergibt die Funktion applies: false und Warnstufe ok, weil die Rechnung dann schlicht nicht zutrifft.
Was diese vier Zustände in der Realität bedeuten, sagt die Anwendung nicht. Sie zeigt eine Farbe und eine Zahl. Die Einordnung ist Sache der Steuerberatung, und genau deshalb heißt die höchste Stufe toleration-exceeded und nicht irgendetwas, das nach einer Auskunft klingt.
Was ich daraus mitnehme
-
Domänenbegriffe, die wie ein Boolean aussehen, sind selten eins. Wenn ein Fachwort in drei Gesetzen vorkommt, ist die ehrliche Modellierung entweder dreidimensional oder ausdrücklich auf eine Dimension beschränkt. Ein einzelnes Flag, das so tut, als wäre es alle drei, ist die schlechteste der Möglichkeiten.
-
Zwei Wertquellen sind eine zu viel, auch wenn eine leer ist. Die leere Tabelle war gefährlicher als der Hardcode, weil sie Ordnung suggerierte. Wer prüfen wollte, ob die Werte gepflegt sind, sah eine Struktur, die den Eindruck machte, sie sei die Quelle.
-
Eine datums-gegatete Tabelle rechnet Vergangenheit korrekt nach. Eine Konstante im Code kennt nur das Heute. Sobald einmal ein Zeitraum vor einer Gesetzesänderung nachgerechnet werden muss, ist
effective_fromkein Luxus mehr, sondern die Bedingung dafür, dass die Zahl überhaupt stimmen kann. -
Eine ADR darf eine Entfernung dokumentieren. Die meisten Architekturentscheidungen fügen etwas hinzu. Diese hier streicht sechs Spaltengruppen und begründet den Verzicht inklusive seiner Nachteile. Das ist die einzige Form, in der ein Weglassen später noch als Absicht erkennbar ist.
-
Ein stiller Fallback ist schlimmer als eine Exception. Ein COALESCE auf eine Magic Number verwandelt eine fehlende Konfiguration in eine plausible falsche Zahl. Eine Exception nervt genau einmal, beim Deploy, und danach nie wieder.
Fazit
Von rund 135 Spalten auf businesses sind Stammdaten, Brand und vat_status übrig geblieben. Der Grenzwert ist aus dem Code verschwunden und liegt als Zeile mit Gültigkeitszeitraum und Rechtsgrundlage in der Datenbank, wo er hingehört. Das Schema V2 mit vat_status als SSOT ist seither unverändert.
Der Umbau war kein Feature. Er war die Voraussetzung dafür, dass jedes spätere Feature nur noch eine Zahl aus einer Quelle liest. Wie ich solche Umbauten überhaupt angehe, steht in Von Prototypen zum Produkt und in Verifikation statt Tippen; wie das Werkzeug aussieht, mit dem ich diese Sessions fahre, in Session-Orchestrator. Wie ich arbeite, steht hier, und woran ich baue, hier.