EU-Umsatzsteuer - alles nicht so einfach

Posted by jonas on Saturday, May 16, 2026

Vor einiger Zeit hat Estland seinen Umsatzsteuersatz geändert - und wir haben es verpennt und mussten daher die Angaben nachträglich einpflegen und Belege korrigieren. Damit das nicht noch einmal passiert und wir überhaupt den Prozess des Trackings von Umsatzsteuersätzen mal etwas mehr von “Handarbeit” wegbekommen, haben wir uns damit beschäftigt, wie wir automatisiert an aktuelle Daten kommen. Mal schauen, was die EU da so zu bieten hat!

Kurz zum Hintergrund: Auch wenn unsere Server in Deutschland stehen, genauer gesagt in Frankfurt am Main, so gilt für Webhosting-Leistungen, dass bei der Rechnungsstellung der Umsatzsteuersatz des Landes zur Anwendung kommen muss, in dem der oder die Leistungsempfänger*in sitzt. Das ergibt sich aus § 3a Abs. 5 Satz 2 Nr. 3 UStG und soll in erster Linie dazu dienen, dass nicht einfach alle Hostingunternehmen ihre Server in dem Land unterbringen, das den niedrigsten Umsatzsteuersatz hat. Das heißt aber auch, dass wir diese Umsatzsteuersätze natürlich kennen müssen, damit wir die Rechnungen korrekt ausstellen können. Wie geht das?

Von Handarbeit…

Als wir vor vielen, vielen Jahren mit Uberspace angefangen haben, war die einzige Übersicht, die man direkt von der EU erhalten hat, ein PDF-Dokument mit einer Tabelle, aus dem wir die Werte dann per Copy&Paste übernommen haben. Dass das kein automatisierbarer Prozess ist, liegt auf der Hand, aber wir mussten ja erstmal anfangen und “Wie halten wir die Liste der Umsatzsteuersätze aktuell” war ein Problem, das sich gut vertagen ließ. Dementsprechend hielten sich die Updates dieser Liste dann auch eher in Grenzen und passierten eben dann, wenn wir es mitbekommen haben; wer will schon ständig offizielle Amtsblätter von gut zwei Dutzend EU-Mitgliedsstaaten verfolgen. Das lief im Ergebnis… nicht so gut.

Eine Abhängigkeit von irgendeinem Drittanbieter, der diese Recherche übernimmt, wollten wir nicht unbedingt eingehen. Zwar bekommt man die Infos zum Beispiel von DATEV oder auch von der einen oder anderen IHK, aber das sind dann eben jeweils HTML-Seiten, die dafür gemacht wurden, von Menschen gelesen zu werden, schön mit Sternchen und viel Fließtext. Wir suchten aber nach Automation. Dass wir die Änderung des USt-Satzes von Estland verschlafen haben, war ein guter Auslöser, sich damit nochmal zu beschäftigen.

… zu Automation

Und nun gibt es gute Nachrichten: Die EU stellt inzwischen eine API bereit, die Taxes in Europe Database v5. Realisiert ist sie als SOAP-Webservice mit einer WSDL-Definition, die sich insofern relativ leicht abfragen lässt; hier mal ein Codebeispiel in Perl (die WSDL- und XSD-Dateien hatte ich zuvor heruntergeladen), um die Umsatzsteuersätze zu ermitteln, die am heutigen Tag in Deutschland, Österreich und der Schweiz gelten:

    [...]
    my $wsdl = XML::Compile::WSDL11->new( ‘VatRetrievalService.wsdl' );

    $wsdl->importDefinitions( ‘VatRetrievalServiceMessage.xsd' );
    $wsdl->importDefinitions( ‘VatRetrievalServiceType.xsd' );

    $wsdl->compileCalls;

    my ( $answer, $trace ) = $wsdl->call(
        retrieveVatRates => {
            memberStates => { isoCode => [‘DE’, ‘AT’, ‘CH’] },
            from         =>2026-05-12’,
            to           =>2026-05-12’,
        }
    );
    [...]

Das funktioniert erstmal ganz hübsch: Man bekommt eine Liste aller geltenden Steuersätze zurück, und zwar nicht nur den Standard-Steuersatz, sondern auch den reduzierten und alle Spezialsteuersätze wie z.B. für Antiquitäten oder medizinisches Gerät, was für unseren Betrachtungsfall egal ist. Ein Steuersatz kann mit dem Typ “DEFAULT” markiert sein; das ist das, was wir wollen. Und ein Steuersatz kann auch einen Kommentar haben; da wird’s gleich unterhaltsam.

Für Deutschland liefert die API dann überraschenderweise zwei Steuersätze, die als “DEFAULT” markiert sind. Die einzige Unterscheidung ist, dass einer davon noch einen Kommentar enthält, nämlich: “VAT - Import - “. Als Mensch kann ich den Kommentar natürlich lesen, aber wie ich programmatisch/automatisiert entscheiden soll, welcher jetzt der Richtige für mich ist: Gute Frage. Immerhin lauten sie beide auf 19 Prozent.

Aber natürlich gibt es Sonderfälle

Es gibt aber noch ein zweites Land, das gleich zwei mit “DEFAULT” markierte Steuersätze liefert, nämlich Spanien. Da liegt einer bei 21, und der andere aber bei 7 - mit dem Kommentar “VAT - Canary Islands - “. Es reicht also plötzlich nicht mehr, für die Berechnung der Umsatzsteuer auf das Land zu schauen; es gibt da auch noch Ausnahmeregeln und für die Kanarischen Inseln sollen wir 7 Prozent berechnen? Es wird aber noch lustiger: Die 7% sind nämlich entgegen des Kommentars überhaupt keine Umsatzsteuer. Die Europäische Kommission erläutert:

Die Kanarischen Inseln gehören in Bezug auf die MwSt nicht zum Gebiet der Gemeinschaft (Artikel 6 der Mehrwertsteuerrichtlinie).

Stattdessen gibt es dort die Allgemeine Indirekte Steuer der Kanarischen Inseln, “Impuesto General Indirecto Canario”, kurz IGIC - das sind die besagten 7%. Wie wir das abbilden können, da sind wir noch unschlüssig. Alles in unseren Datenbank- und Codestrukturen ist auf die Länderkennung abgestellt - auch das Reporting an das Bundeszentralamt für Steuern, was Buchungen von Menschen auf den Kanarischen Inseln dann eben Spanien zuordnet und damit 21% Umsatzsteuer berechnet, die wir via Mini-One-Stop-Shop-Verfahren an Spanien abführen. Vielleicht ist das dann eben auch einfach so.

Unstrukturierte Felder in strukturierten Datenbanken

Lesen wir doch mal ein paar weitere Kommentare. Belgien versieht seinen Standardsteuersatz mit etwas Pathos:

<p>Article 1, paragraph 1, of the Royal Decree n\x{b0} 20 of 20 July 1970</p>

Frankreich ist es ganz wichtig, einen Link zur Rechtsgrundlage mit etwas übertriebener Garnitur zu liefern:

<p><a href="http://bofip.impots.gouv.fr/bofip/1379-PGP" target="_blank" rel="noopener noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer noopener noreferrer">http://bofip.impots.gouv.fr/bofip/1379-PGP</a></p>
<p>Article 278 of the General Tax Code</p>

Und von Irland bekommen wir eine kleine Geschichtsstunde im Kommentar:

<p>The Standard Rate of VAT was temporarily reduced from 23% to 21% from 1 September 2020 to 28 February 2021. It has been restored to 23% from 1 March 2021.</p>

Ja, das ist natürlich sehr wichtig zu wissen, wenn ich nach dem Steuersatz frage, der am 12.05.2026 gilt.

Ein Änderungsdatum - aber wofür?

Was für uns auch noch wichtig zu wissen wäre, ist, seit wann denn der jeweilige Steuersatz gilt. Dafür liefert die API das Element situationOn mit einem Datum. Nur ist dieses Datum bei 21 von 27 Ländern der 01.01.2026, auch wenn sich an diesem Tag der Steuersatz überhaupt nicht geändert hat. Das wäre aber hilfreich, denn wir haben bei uns eine Tabelle, wo die Historie nachvollziehbar bleibt, Beispiel Finnland:

MariaDB [uber]> select * from vat_rate where country='FI';
+---------+------------+-------+
| country | since      | rate  |
+---------+------------+-------+
| FI      | 2013-01-01 | 24.00 |
| FI      | 2024-09-01 | 25.50 |
+---------+------------+-------+
2 rows in set (0.001 sec)

So können wir auch später noch, wenn für eine Buchung aus 2022 nochmal ein Beleg generiert werden muss, programmatisch korrekt den damaligen Umsatzsteuersatz von 24% berechnen. Nun könnten wir natürlich die Tabelle auch mit weiteren Einträgen befüllen, wie eben country=”FI”, since=”2026-01-01”, rate=”25.50”, um zu dokumentieren, dass ab diesem Zeitpunkt immer noch 25.5% gelten. Schadet nicht, ist aber nicht so ganz schön. Was genau situationOn bedeutet: Unklar. Die Dokumentation besagt “situationOn: the date on which the rate start to be applicable”, aber da sie ja schon vorher mit identischem Wert applicable war, kann das nicht so ganz stimmen.

Unklar ist außerdem, ob wir denn verlässlich anstehende Änderungen auf diese Weise erfahren. Auch dazu schweigt sich die Dokumentation aus. Stand heute stehen keine an. Wir hoffen, wir können einfach periodisch nach einem Datum z.B. 1 Monat in der Zukunft fragen und dann direkt erfahren, ob und ab welchem genauen Datum ein neuer Satz gilt. Die Aussicht sind unklar, erinnere ich mich doch daran, dass ich in der Vergangenheit auch schon mal vom Bundeszentralamt für Steuern auf dem Postweg darüber informiert wurde, dass man es leider erst dreieinhalb Monate nach deren Inkrafttreten geschafft habe, die geänderten Umsatzsteuersätze in das hauseigene Onlineportal einzupflegen:

Ein Ausschnitt aus einem eingescannten Brief des Bundeszentralamts für Steuern, in dem mitgeteilt wird, dass Umsatzsteuer-Änderungen dreier Länder, die ab dem 01.01.2024 gelten, erst ab 17.04.2024 in deren Systeme eingepflegt werden konnten.

Hoffen wir mal, dass die Taxes in Europe Database da etwas fixer unterwegs ist.

Welche Staaten sind eigentlich gerade EU-Mitglieder?

Wo wir aber gerade bei Automation sind: Es wäre doch bei der Gelegenheit sicher auch sinnvoll, die Liste der EU-Mitgliedsstaaten nicht statisch zu hinterlegen, sondern ebenfalls aus einer API zu ziehen. Vorzugsweise aus einer der Europäischen Union. Und tatsächlich, die gibt es! Die EU betreibt nämlich eine Datenbank namens Cellar.

Cellar is the common data repository of the Publications Office of the European Union. Digital publications and metadata are stored in and disseminated via Cellar, in order to be used by humans and machines. Aiming to transparently serve users, Cellar stores multilingual publications and metadata, it is open to all EU citizens and provides machine-readable data.

Ein Weg, um auf diese Daten zugreifen zu können, ist der SPARQL Query Editor. Da muss man nur eben erstmal wissen, wie eine Query aussehen könnte. Zum Glück hilft das Wiki im GitLab der EU (ja, wirklich!) weiter und stellt unter Current EU member states, their authority code, preferred labels in English and their ISO codes eine Query bereit, um eine Liste der Mitgliedsstaaten mitsamt ihrer ISO-Codes (die ich ja dann wiederum für die VAT-API brauche!) zu beziehen. Die Antwort lässt sich zum Beispiel als JSON zu beziehen. Nochmal etwas Perl-Code gefällig? Eingedampt auf’s Wesentliche; Fehlerbehandlungen mal außen vor gelassen:

    my $http = HTTP::Tiny->new( timeout => 10 );

    my $response = $http->get(
'https://publications.europa.eu/webapi/rdf/sparql?default-graph-uri=&query=PREFIX+dct%3A+%3Chttp%3A%2F%2Fpurl.org%2Fdc%2Fterms%2F%3E+%0D%0APREFIX+euvoc%3A+%3Chttp%3A%2F%2Fpublica
tions.europa.eu%2Fontology%2Feuvoc%23%3E%0D%0APREFIX+owl%3A+%3Chttp%3A%2F%2Fwww.w3.org%2F2002%2F07%2Fowl%23%3E%0D%0APREFIX+rdf%3A+%3Chttp%3A%2F%2Fwww.w3.org%2F1999%2F02%2F22-rdf-
syntax-ns%23%3E%0D%0APREFIX+rdfs%3A+%3Chttp%3A%2F%2Fwww.w3.org%2F2000%2F01%2Frdf-schema%23%3E%0D%0APREFIX+skos%3A+%3Chttp%3A%2F%2Fwww.w3.org%2F2004%2F02%2Fskos%2Fcore%23%3E%0D%0A
PREFIX+skosxl%3A+%3Chttp%3A%2F%2Fwww.w3.org%2F2008%2F05%2Fskos-xl%23%3E%0D%0APREFIX+xsd%3A+%3Chttp%3A%2F%2Fwww.w3.org%2F2001%2FXMLSchema%23%3E%0D%0APREFIX+org%3A+%3Chttp%3A%2F%2F
www.w3.org%2Fns%2Forg%23%3E%0D%0A%0D%0ASELECT+%3Fcountry_uri+%3Fnamed_authority_code+%3Fcountry_en+%3FISO_31661_alpha2+%0D%0A+++++++%3FISO_31661_alpha3+%3FISO_31661_num+++%0D%0A%
0D%0A++++from+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fcountry%3E%0D%0A%0D%0A++++WHERE+%7B++++%0D%0A++++%23+Retrieving+the+countries+and+their+English+pref
erred+labels+%0D%0A++++%3Fcountry_uri+a+skos%3AConcept+.%0D%0A++++%3Fcountry_uri+skos%3AtopConceptOf+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fcountry%3E+.%
0D%0A++++%3Fcountry_uri+skos%3AprefLabel+%3Fc_label_en+.%0D%0A++++Bind%28str%28%3Fc_label_en%29+as+%3Fcountry_en%29+.%0D%0A++++filter%28lang%28%3Fc_label_en%29+%3D+%22en%22%29%0D
%0A++++%0D%0A++++%23+Filtering+on+the+current+memberhip+to+EU%0D%0A++++%3Fcountry_uri+org%3AhasMembership+%3Fmembership+.%0D%0A++++%3Fmembership+org%3Aorganization+%3Chttp%3A%2F%
2Fpublications.europa.eu%2Fresource%2Fauthority%2Fcorporate-body%2FEURUN%3E+.%0D%0A++++%3Fmembership+org%3Arole+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fro
le%2FMEMBER%3E++.%0D%0A++++filter+not+exists+%7B%3Fmembership+euvoc%3AexitDate+%3Fend+%7D%0D%0A++%0D%0A++++%23+Retrieval+of+the+authority+code%0D%0A++++%3Fcountry_uri+euvoc%3AxlN
otation+%3Fnot_authority+.%0D%0A++++%3Fnot_authority+rdf%3Avalue+%3Fnamed_authority_code+.%0D%0A++++%3Fnot_authority+dct%3Atype+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%
2Fauthority%2Fnotation-type%2FNAC%3E+.%0D%0A++++%0D%0A++++%23+Retrieval+of+the+2+lettes+ISO+code%0D%0A++++optional+%7B%0D%0A++++%3Fcountry_uri+euvoc%3AxlNotation+%3Fnot_alpha2+.%0D%0A++++%3Fnot_alpha2+rdf%3Avalue+%3FISO_31661_alpha2+.%0D%0A++++%3Fnot_alpha2+dct%3Atype+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fnotation-type%2FISO_3166_1_ALPHA_2%3E+.%0D%0A++++%7D%0D%0A++%0D%0A++++%23+Retrieval+of+the+3+letter+ISO+code%0D%0A++++optional+%7B%0D%0A++++%3Fcountry_uri+euvoc%3AxlNotation+%3Fnot_alpha3+.%0D%0A++++%3Fnot_alpha3+rdf%3Avalue+%3FISO_31661_alpha3+.%0D%0A++++%3Fnot_alpha3+dct%3Atype+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fnotation-type%2FISO_3166_1_ALPHA_3%3E+.%0D%0A++++%7D%0D%0A++%0D%0A++++%23+Retrieval+of+the+3+digit+ISO+code%0D%0A++++optional+%7B%0D%0A++++%3Fcountry_uri+euvoc%3AxlNotation+%3Fnot_num+.%0D%0A++++%3Fnot_num+rdf%3Avalue+%3FISO_31661_num+.%0D%0A++++%3Fnot_num+dct%3Atype+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fnotation-type%2FISO_3166_1_NUM%3E+.%0D%0A++++%7D%0D%0A++++%23+Filtering+out+the+deprecated+and+the+retired+concepts%0D%0A++++filter+not+exists+%7B%3Fcountry_uri+owl%3Adeprecated+%3Fdep%7D%0D%0A++++filter+not+exists+%7B%0D%0A++++%3Fcountry_uri+euvoc%3Astatus+%3Chttp%3A%2F%2Fpublications.europa.eu%2Fresource%2Fauthority%2Fconcept-status%2FRETIRED%3E%0D%0A++++%7D%0D%0A%0D%0A%7D+order+by+%3Fcountry_uri%0D%0A&format=application%2Fsparql-results%2Bjson&should-sponge=&timeout=0&signal_void=on&signal_unconnected=on'
    );
    my $decoded = decode_json $response->{content};
    my @iso_codes = map { $_->{ISO_31661_alpha2}{value} } @{ $decoded->{results}{bindings};

Das sieht jetzt nicht so schön aus, aber es funktioniert und ist eine offizielle Quelle. Wobei, “funktioniert”: In gefühlt 2 von 3 Fällen grätscht die “Amazon Web Application Firewall” dazwischen und blockiert einen Request einfach mal mit einer Challenge. Da weiß man dann auch gleich, wo’s offenbar gehostet wird.

Nun bekomme ich also eine Liste von ISO-Codes von EU-Mitgliedsstaaten und ersetze das statische “[ ‘DE’, ‘AT’, ‘CH’ ]” in meinem obigen Codebeispiel durch die dynamisch ermittelte Liste. Uuuuund… es ist kaputt. “TEDB-ERR-2 - Request is not valid”. Ich nehme Land für Land raus aus der Liste, um zu schauen, an welchem es hakt, und ermittele so: Es liegt an Griechenland. Wieso bitte liegt es an Griechenland?

Na, ganz einfach. Griechenland hat zwar den ISO-Code “GR”. Aber für umsatzsteuerliche Zwecke benutzt es… “EL”! Sagt uns nicht, das hättet ihr gewusst (wenn ihr nicht gerade aus Griechenland kommt). Also noch ein bisschen Extra-Code:

    # Greece (ISO code "GR") uses "EL" for VAT purposes
    # see https://en.wikipedia.org/wiki/VAT_identification_number#Structure
    my @vat_codes = map { $_ eq 'GR' ? 'EL' : $_ } @iso_codes;

Aber damit nicht genug: Aus dem obigen Wikipedia-Artikel erfährt man gleich noch eine zweite Besonderheit!

Northern Ireland, which uses the code XI when trading with the EU

Der Brexit hat eh schon so viele Dinge so unendlich kompliziert gemacht, aber nun auch das: Ob Nordirland denn nun ein “Land”, eine “Provinz” oder eine “Region” ist, darüber lässt sich trefflich streiten, und jeder Begriff trägt unweigerlich auch ein politisches Statement als Konnotation. Was man aber als Fakt benennen kann: Nordirland steht de facto nicht auf der ISO 3166-1 Länderliste und wird damit auch nicht von “Cellar” als Land zurückgeliefert. Aber wenn man mit Nordirland Handel treibt, wird es eben doch wie ein EU-Mitglied behandelt und hat einen Steuersatz! Also noch ein bisschen Extra-Code:

    my @iso_codes = $self->retrieve_eu_member_states;

    # we need to add XI="Northern Ireland" which is not an EU member country but VAT-wise is treated as one
    my $vat_rates = $self->retrieve_vat_rates( localtime->ymd, @iso_codes, 'XI' );

Und nun endlich setzt sich alles zusammen und wir können uns in einem Aufwasch die aktuellen Umsatzsteuersätze beziehen - inklusive künftig vielleicht neu dazukommender Länder. Der Job läuft dann jetzt einfach einmal monatlich und das reicht dann hoffentlich - sofern die Taxes in Europe Database hält, was sie verspricht.

Foto von The New York Public Library auf Unsplash