16 states, 16 tax rates: modeling German property transfer tax without hardcoding a mess
The trap: treating rates as constants
If you ever build anything touching German real estate, you will meet Grunderwerbsteuer. It's the property transfer tax, it's set per state, and the rates are not close to each other. Bavaria and Saxony sit at 3.5%. NRW, Brandenburg, Saarland, Schleswig-Holstein and Thuringia sit at 6.5%. The rest land somewhere in between. On a 400,000 euro apartment that's 14,000 versus 26,000. Same transaction, 12,000 euro difference, decided by a line on a map.
The obvious first move is a lookup object:
const GRUNDERWERBSTEUER = { BY: 0.035, NW: 0.065, /* ... */ }
This is wrong within about a year, and wrong in a way that produces silently incorrect historical data. States change these rates, and they've each changed several times since the power was devolved in 2006. If a user saved an analysis two years ago and reopens it, recalculating with today's rate gives them a number that never existed.
Rates are time-scoped facts, not constants:
type RatePeriod = { from: string; to: string | null; rate: number }
const RATES: Record<string, RatePeriod[]> = {
HH: [
{ from: '2009-01-01', to: '2022-12-31', rate: 0.045 },
{ from: '2023-01-01', to: null, rate: 0.055 },
],
// ...
}
function rateAt(state: StateCode, date: string) {
const p = RATES[state].find(r => date >= r.from && (!r.to || date <= r.to))
if (!p) throw new Error(`no rate for ${state} at ${date}`)
return p.rate
}
Every saved analysis stores its own date. Recalculation is then deterministic and reproducible, which is the whole point if anyone is making a six-figure decision off your output.
Money in floats
0.1 + 0.2 // 0.30000000000000004
You know this. It still shows up, usually as a total that's off by one cent from the sum of its parts, and a user who notices it stops trusting every other number on the page. Integer cents internally, format at the boundary. Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }) for output.
The part that's actually hard
Transfer tax is the easy input. The hard part is that "yield" has no single definition.
- Bruttomietrendite uses the asking price.
- Nettomietrendite uses the total acquisition cost including all Kaufnebenkosten.
These differ by 10 to 15%, and listing portals quote the flattering one. Then cashflow needs annuity amortization with a split between interest and principal, non-recoverable operating costs, a maintenance reserve, vacancy assumption, and AfA depreciation which itself depends on the building's construction year. Every one of those is a defensible assumption with a wide range, and the output is extremely sensitive to all of them.
Which means the interesting design question is not the math. It's whether you show your assumptions. A calculator that returns "5.4% yield" with hidden defaults is worse than useless, because it looks authoritative. A calculator that shows the vacancy rate and maintenance reserve it used, and lets you change them, is a tool.
If you want to see it built out rather than build it yourself, https://www.investbud.de/ does this for German property, with the per-state transfer tax applied automatically and the cashflow broken down monthly and annually.
Financial calculators are a good exercise, by the way. They look like CRUD and they're mostly domain modeling, which is the part most side projects let you skip.
Comments
No comments yet. Start the discussion.