Skip to content
Bernhard Götzendorfer
Technical How-tos

One Word, Three Laws, One Column

Kleinunternehmer spans three laws in Austria. Why V2 kept exactly one column and reads the threshold from a date-gated value table.

TL;DR

Kleinunternehmer (the Austrian small-business status) sounds like a checkbox in a profile. In reality the word hangs off three different legal bases in Austria, with three different thresholds and three different timelines. The first version of BuchhaltGenie tried to model all three and kept a derived boolean in sync via a database trigger. The second version kept exactly one column. The threshold itself lives nowhere in the code, it lives in a date-gated table with a legal reference per row. Values that a law dictates belong in a table together with their period of validity and their legal basis, not in the code as a constant. This article describes what my schema contains and why, not what applies to anyone.

A Word That Looks Like a Boolean

is_kleinunternehmer reads like yes or no. That was precisely the problem. Behind the term sit at least three dimensions that have little to do with each other.

The first is value-added tax, governed by UStG (Umsatzsteuergesetz, the Austrian VAT act) Paragraph 6 Abs 1 Z 27. The second is social insurance, GSVG respectively SVS, with its own anchor on annual profit that is indexed every year. The third is income tax with the flat-rate scheme under EStG (Einkommensteuergesetz). Three laws, three thresholds, three timelines. A boolean can carry at most one of those dimensions and then pretends the other two do not exist.

I am deliberately being descriptive here: these three dimensions exist, and I did not model two of them in my schema. What follows from that for any actual business is a question for a tax adviser, not for my database.

What V1 Made of It

The first version meant well. The businesses table carried roughly 135 columns, among them is_kleinunternehmer, annual_revenue_limit, kleinunternehmer_estimated_revenue, several buchfuehrungspflicht_ fields, an accounting_mode and various threshold_ columns. The boolean was kept in sync by a trigger fed from multiple, partly contradictory sources.

What happened to the threshold value was worse. It existed in three places at once: as a hardcode duplicated in two functions, as the column businesses.annual_revenue_limit, and in a date-gated table. That table was empty in production. The app silently fell back to the hardcode, and behaved entirely correctly while doing so, because that is exactly what had been programmed.

The most dangerous of the three value sources was the empty one. It looked like order, and the app silently fell back to the hardcode.

A later finding completed the picture: the threshold was read in seven places in the code, in five of them incompletely. For contrast, here is what it looks like when a number exists only once: the small-invoice threshold sits today as 40,000 cents in exactly one constants file. In V1 it was duplicated five times over.

The ADR That Removes Something

On 4 July I wrote ADR-001 about this, status Accepted, attached to issue number 9. It is the rare case of an architecture decision that adds nothing and instead deletes modelling.

The decision, verbatim: the V2 MVP models exclusively the UStG dimension via the enum column businesses.vat_status as the single SSOT. No derived flags are persisted or written directly. Derived states are computed at runtime from vat_status. No threshold values as schema constants.

So my schema knows exactly one state, and it is called vat_status, with the three enum values kleinunternehmer, regelbesteuerung and opt_in. Everything else from the V1 list is gone.

The more interesting part of the ADR is the downsides I knowingly accepted. Those are in there verbatim too: the SVS exception and the EStG flat-rate scheme cannot be represented in the MVP. The simplification must not be misread as a complete representation of Kleinunternehmer status. The parenthetical afterwards notes that this is precisely the reason for the ADR.

That is the real point. An omission without a document looks like an oversight six months later. With a document it is a decision carrying a date, a rationale and a scope. The next person who sees the single column and wonders about it finds the answer instead of guessing at it.

A Table Instead of a Constant

The second half of the decision concerns the value itself. It lives in a table called compliance_values with the columns key, effective_from, effective_until, value as JSONB and legal_reference. One row per legal state.

-- Seed: the value lives in the database, with validity and legal reference
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'
);

The seed carries for 2025 the value 5,500,000 cents brutto (gross) with that legal reference. Two further rows sit before it: 3,500,000 cents netto (net) valid from 2020 to 2024, and 3,000,000 cents netto for 2015 to 2019 with a reference to StRefG 2015/16. A fourth seed row holds the VAT rates, standard 20, reduced as a list of 10 and 13, zero 0.

This construction has four properties I thought about beforehand.

First, the table is append-only, enforced by a trigger: UPDATE and DELETE raise. The header comment of the migration grounds that in BAO (Bundesabgabenordnung) Paragraph 132. A value from 2019 has to still be the same value in 2026, otherwise any look back at the past computes the wrong number.

Second, there is exactly one read path. get_compliance_value(p_key, p_reference_date DEFAULT CURRENT_DATE) is STABLE, SECURITY DEFINER, with an explicit search_path. Next to it sits get_kleinunternehmer_threshold_cents as a thin wrapper over it.

Third, and this is my favourite part: zero matches raises an exception. No COALESCE onto a magic number, no fallback. The exception text says what to do, namely that no row covers the key at the reference date, that a row must be added via migration, and never a hardcoded fallback. That silent fallback was exactly the defect in V1.

Fourth, RLS is active, meaning Row Level Security, access rules that live in the database itself: SELECT is granted to authenticated, and no write policy exists. Seeds arrive exclusively through migrations. These values are public law and apply system-wide, which is why the table deliberately has no business_id.

Above the computation in the application code sits a comment marked as a frozen contract: the threshold is never hardcoded, it is date-gated in the database and loaded at request time via RPC. An emergency fallback with 55k still exists, but it is marked deprecated and is never in use as the current value.

How One Number Becomes Four States

The RPC returns limitCents. From that the application computes a warning level, and it does so on integer cents rather than on a percentage. The reason sits in the doc comment: percentages are floating-point numbers, and a warning level that tips over on a rounding error is a bad warning level.

// Warning level on integer cents, not on percent: no float rounding errors
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';
}

Four states, then: ok, approaching, over, toleration-exceeded. Amber starts at 70 percent of the threshold, over above 100 percent, and the computation knows a tolerance band above which it grades to toleration-exceeded. For a business whose vat_status is not set to kleinunternehmer, the function returns applies: false and warning level ok, because the calculation simply does not apply then.

What those four states mean in reality is something the application does not say. It shows a colour and a number. The interpretation belongs to a tax adviser, and that is exactly why the highest level is called toleration-exceeded and not something that sounds like advice.

What I Take Away From This

  1. Domain terms that look like a boolean rarely are one. If a technical term appears in three laws, honest modelling is either three-dimensional or explicitly limited to one dimension. A single flag pretending to be all three is the worst of the available options.

  2. Two value sources are one too many, even when one is empty. The empty table was more dangerous than the hardcode because it suggested order. Anyone checking whether the values were maintained saw a structure that looked like the source.

  3. A date-gated table computes the past correctly. A constant in the code only knows today. The moment a period before a change in the law has to be recomputed, effective_from stops being a luxury and becomes the precondition for the number being right at all.

  4. An ADR is allowed to document a removal. Most architecture decisions add something. This one deletes six groups of columns and justifies the omission including its downsides. That is the only form in which leaving something out is still recognisable as intent later on.

  5. A silent fallback is worse than an exception. A COALESCE onto a magic number turns a missing configuration into a plausible wrong number. An exception is annoying exactly once, at deploy time, and never again after that.

Conclusion

Of roughly 135 columns on businesses, what remains is master data, brand and vat_status. The threshold has disappeared from the code and sits as a row with a validity range and a legal reference in the database, where it belongs. The V2 schema with vat_status as the SSOT has been unchanged since.

The rebuild was not a feature. It was the precondition for every later feature reading one number from one source. How I approach rebuilds like this is in From Prototypes to Product and in Verification, Not Typing; the tool I run these sessions with is described in Session Orchestrator. How I work is here, and what I am building is here.