<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Muhammad Awais</title>
    <description>The latest articles on DEV Community by Muhammad Awais (@muhammad_awais_).</description>
    <link>https://dev.to/muhammad_awais_</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4167515%2Fbbd3dd98-840e-4aad-9c24-cc0a2854e4a2.jpg</url>
      <title>DEV Community: Muhammad Awais</title>
      <link>https://dev.to/muhammad_awais_</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/muhammad_awais_"/>
    <language>en</language>
    <item>
      <title>I ported Germany's payroll tax flowchart to TypeScript and got it wrong three times</title>
      <dc:creator>Muhammad Awais</dc:creator>
      <pubDate>Sat, 10 Oct 2026 22:26:39 +0000</pubDate>
      <link>https://dev.to/muhammad_awais_/i-ported-germanys-payroll-tax-flowchart-to-typescript-and-got-it-wrong-three-times-9k0</link>
      <guid>https://dev.to/muhammad_awais_/i-ported-germanys-payroll-tax-flowchart-to-typescript-and-got-it-wrong-three-times-9k0</guid>
      <description>&lt;p&gt;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).&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The flowchart is called the Programmablaufplan, or PAP ("program flow plan"). The &lt;a href="https://www.bundesfinanzministerium.de/Web/DE/Themen/Steuern/Steuerarten/Lohnsteuer/Programmablaufplan/programmablaufplan.html" rel="noopener noreferrer"&gt;Federal Ministry of Finance (BMF) publishes a new one every year&lt;/a&gt;, 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 &lt;a href="https://germanydesk.com/tools/brutto-netto" rel="noopener noreferrer"&gt;salary calculator&lt;/a&gt;. To check it, I compare against eight cases from the ministry's own online calculator and allow a 5 euro difference per year.&lt;/p&gt;

&lt;p&gt;It took three rounds of fixes to get there. Here is what went wrong, in order of how much it hurt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 1: "at most" means Math.min, not Math.max
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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, &lt;a href="https://www.gesetze-im-internet.de/estg/__39b.html" rel="noopener noreferrer"&gt;§39b EStG&lt;/a&gt; says the tax on income above W1 is "höchstens 42 Prozent", meaning at most 42 percent. I read that as a minimum:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;  let st = up56(zve, formula);
  if (zve &amp;gt; w1) {
    const hoch = up56(w1, formula) + (zve - w1) * 0.42;
&lt;span class="gd"&gt;-   st = Math.max(st, hoch);
&lt;/span&gt;&lt;span class="gi"&gt;+   st = Math.min(st, hoch);
&lt;/span&gt;  }
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What finally cracked it was a dull test: raise income by one euro at a time and assert that tax never goes down.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;tax never decreases as income rises&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;taxClass&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;calcLohnsteuer&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;year&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2026&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;taxClass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;annualGross&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;gross&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
      &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;previous&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;previous&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;tax&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 2: I followed the wrong appendix
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug 3: the constants move, sometimes backwards in time
&lt;/h2&gt;

&lt;p&gt;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:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="gd"&gt;- grundfreibetrag: 11604,
&lt;/span&gt;&lt;span class="gi"&gt;+ grundfreibetrag: 11784,
&lt;/span&gt;&lt;span class="gd"&gt;- if (income &amp;lt;= 11604) return 0
&lt;/span&gt;&lt;span class="gi"&gt;+ if (income &amp;lt;= 11784) return 0
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://github.com/muhammadawais93/bmf-lohnsteuer-ts" rel="noopener noreferrer"&gt;standalone repo&lt;/a&gt; with one constants file per year.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do again
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I had an AI assistant open the whole time. It turned flowchart boxes into code quickly. It never noticed the tax going down either.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one euro I can't explain yet
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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 &lt;a href="https://germanydesk.com/tools/steuerklasse" rel="noopener noreferrer"&gt;tax class calculator&lt;/a&gt; runs the class III/V/IV splits through this code.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a hobby implementation and not tax advice. Check anything that matters against the official BMF calculator.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>typescript</category>
      <category>testing</category>
      <category>webdev</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
