Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 4 min read

I ported Germany's payroll tax flowchart to TypeScript and got it wrong three times

I expected Germany's payroll tax spec to be a formula. It's a flowchart: boxes and arrows that read and write global variables with names like RE4 (annual gross pay) and ZVE (taxable income). I went looking because expa

I expected Germany's payroll tax spec to be a formula. It's a flowchart: boxes and arrows that read and write global variables with names like RE4 (annual gross pay) and ZVE (taxable income).

I went looking because expats keep asking me what they'll really take home from a job offer. Every online calculator gives a slightly different answer, and none of them say why.

The flowchart is called the Programmablaufplan, or PAP ("program flow plan"). The Federal Ministry of Finance (BMF) publishes a new one every year, so all payroll software calculates Lohnsteuer (the wage tax taken from your paycheck) the same way. I read the PDF and rewrote it as pure functions, with no shared state and each year's numbers in their own object. That code now powers my salary calculator. To check it, I compare against eight cases from the ministry's own online calculator and allow a 5 euro difference per year.

It took three rounds of fixes to get there. Here is what went wrong, in order of how much it hurt.

Bug 1: "at most" means Math.min, not Math.max

Germany has six tax classes (Steuerklassen). Married couples can pick III/V: the higher earner gets the light withholding of class III, the other gets the heavy class V. Class VI is for a second job.

Classes V and VI have an extra three-zone procedure on top of the normal tariff, called MST5-6. Its zone limits are W1STKL5, W2STKL5 and W3STKL5. For the middle zone, Β§39b EStG says the tax on income above W1 is "hΓΆchstens 42 Prozent", meaning at most 42 percent. I read that as a minimum:

  let st = up56(zve, formula);
  if (zve > w1) {
    const hoch = up56(w1, formula) + (zve - w1) * 0.42;
-   st = Math.max(st, hoch);
+   st = Math.min(st, hoch);
  }

For class V at 24,000 euros a year, my code said 4,012 euros of tax. The ministry's calculator said 3,576. I left that as a TODO for over two weeks.

What finally cracked it was a dull test: raise income by one euro at a time and assert that tax never goes down.

it("tax never decreases as income rises", () => {
  for (const taxClass of [1, 3, 4, 5, 6]) {
    let previous = 0;
    for (let gross = 0; gross <= 300_000; gross += 1) {
      const tax = calcLohnsteuer({ year: 2026, taxClass, annualGross: gross });
      expect(tax).toBeGreaterThanOrEqual(previous);
      previous = tax;
    }
  }
});

Mine dropped by about 286 euros at W2. The same test found a second bug at W3, where tax dropped by 78,674 euros at an income my spot checks around 80k never reached. With both fixed I get 3,577 against the ministry's 3,576.

Bug 2: I followed the wrong appendix

The PAP comes in two parts. Anlage 1 is the version for machines, and it's what payroll software and the ministry's calculator run. Anlage 2 is a simplified version for people who print lookup tables. I had been following Anlage 2.

It matters for the Vorsorgepauschale, a lump-sum deduction for your insurance contributions that comes off before tax is calculated. Part of it depends on your long-term care insurance (Pflegeversicherung) rate, which is 0.6 points higher if you're 23 or older and have no children. Anlage 2 says that surcharge is "in keinem Fall berΓΌcksichtigt", never taken into account, because a printed table can't vary per person.

I only noticed because of my own payslip. Class I, no children, and my code overstated my monthly Lohnsteuer by about 14 euros. You could shrug that off as rounding. In Anlage 1 the fix is one line in the MPARA box: if PVZ (the childless flag) is set, add 0.006 to PVSATZAN, the employee's care insurance rate. That closed the gap.

Bug 3: the constants move, sometimes backwards in time

My first version used 11,604 euros as the 2024 Grundfreibetrag (the tax-free allowance), straight from the original BMF documents. Then a correction law in December 2024 raised it for the whole year, retroactively. The class V limit W1STKL5 moved with it, from 13,279 to 13,432, and every tariff zone after the first shifted too:

- grundfreibetrag: 11604,
+ grundfreibetrag: 11784,
- if (income <= 11604) return 0
+ if (income <= 11784) return 0

Formulas change as well. For 2026 the PAP rewrote how the minimum Vorsorgepauschale works, so 2024/2025 and 2026 run through different code paths. And even "final" isn't final: the 2026 PAP I used is labelled "endgΓΌltig, korrigierte Fassung", the final, corrected version.

So now everything year-specific lives in one typed object per year, and each value has a comment naming the BMF document it came from. Tests read their thresholds from that object, so adding 2027 shouldn't mean rewriting them. I moved the engine into a standalone repo with one constants file per year.

What I'd do again

Write the property tests before you go hunting for golden values. Tax never falls as income rises. Tax never jumps at a zone boundary. Neither needs an outside source, and they caught a bug that a handful of comparisons had only hinted at.

I had an AI assistant open the whole time. It turned flowchart boxes into code quickly. It never noticed the tax going down either.

The one euro I can't explain yet

For 48,000 euros in class I, I get 6,295 and the ministry gets 6,294. I round the final tax to the nearest euro, while Β§32a says abzurunden (round down). I haven't yet traced which step in the PAP causes the difference.

If you've worked with the PAP, or any other government spec shipped as a flowchart, I'd like to hear where your rounding went wrong. And if you want to try the numbers yourself, the tax class calculator runs the class III/V/IV splits through this code.

This is a hobby implementation and not tax advice. Check anything that matters against the official BMF calculator.

πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.