CUI prüfen: rumänische Steuer-ID mit TypeScript und Zod

Dein Registrierungs- oder Rechnungsformular hat ein Feld für die Steuer-ID einer rumänischen Firma, und ein Regex für „RO plus Ziffern“ lässt jede vertippte Ziffer durch. Wer einen CUI prüfen will, braucht drei Dinge: einen Parser, der die vielen Schreibweisen der Nummer versteht, die gewichtete Prüfziffer und, wenn es darauf ankommt, eine Abfrage, die beweist, dass die Firma echt ist. Dieses Tutorial baut alle drei in TypeScript und bindet sie dann in eine zod-Regel, ein React-Feld, das beim Tippen prüft, und einen Registrierungsschritt ein, der die Firma bei ANAF nachschlägt. Die Oberfläche nutzt Input und Field aus Wingo UI, der Bibliothek, die wir bauen. Wir sind also nicht neutral. Der Code für die Prüfsumme ist reines TypeScript, das du überall einfügen kannst.
Was ist ein CUI, und wie hängt er mit CIF und USt-IdNr. zusammen?
Es ist eine Nummer mit drei Namen. Der CUI (Codul Unic de Înregistrare) ist der Code, den eine rumänische Firma bei der Registrierung bekommt: 2 bis 10 Ziffern, die letzte davon eine Prüfziffer. Der CIF (cod de identificare fiscală) ist die Steuer-ID, und bei einer Firma sind es dieselben Ziffern. Ist die Firma für die Mehrwertsteuer registriert, wird laut dem rumänischen Wikipedia-Artikel zum CIF (opens in a new tab) aus dem CUI ein CIF mit dem Präfix „RO“.
Das Format der rumänischen USt-IdNr. ist also RO, gefolgt von denselben 2 bis 10 Ziffern, so wie es die Tabelle der Umsatzsteuer-Identifikationsnummern auf Wikipedia (opens in a new tab) aufführt. In einem echten Formular bekommst du RO12345674, ro 12345674, 12 345 674 und RO-12345674, und alle vier sind dieselbe Firma.
Daraus folgen zwei Regeln:
- Speichere die Ziffern und ein Mehrwertsteuer-Flag, nie den String, so wie er getippt wurde. Gib
RO+ Ziffern aus, wenn du die USt-IdNr. brauchst. - Das getippte Präfix beweist keinen Mehrwertsteuer-Status. Viele schreiben RO aus Gewohnheit davor. Das Flag sollte von ANAF kommen, so wie wir es weiter unten machen.
Wie funktioniert die CUI-Prüfziffer?
Die letzte Ziffer wird mit einem festen Schlüssel, 753217532, aus den anderen berechnet. Der rumänische Wikipedia-Artikel oben beschreibt den Algorithmus „validare CUI“, und der Quellcode von stdnum.ro.cui in python-stdnum (opens in a new tab) setzt dieselben fünf Schritte um:
- Entferne das Präfix RO und alle Leerzeichen, Punkte oder Bindestriche. Übrig bleiben müssen 2 bis 10 Ziffern.
- Leg die letzte Ziffer beiseite. Das ist die Prüfziffer.
- Füll die restlichen Ziffern links mit Nullen auf, bis es neun sind, damit sie am rechten Ende des Schlüssels ausgerichtet sind. (Wikipedia dreht stattdessen beide Strings um, was dieselben Paare ergibt.)
- Multipliziere jede Ziffer mit der Schlüsselziffer an derselben Position und addiere die Produkte.
- Multipliziere die Summe mit 10, nimm den Rest der Division durch 11 und mach aus einem Rest von 10 eine 0. Der CUI ist gültig, wenn das der Prüfziffer entspricht.
Hier ist 12345674 von Hand durchgerechnet. Der Rumpf ist 1234567, aufgefüllt zu 001234567:
Die Summe ist 95. 950 mod 11 ist 4, das passt zur letzten Ziffer, also ist RO12345674 gültig und RO12345678 nicht.
An einem Fall scheitern selbst geschriebene Validatoren: am Rest 10. Bei 18547290 ergeben die Produkte zusammen 111, und 1110 mod 11 ist 10, also ist die CUI-Prüfziffer 0. Ein Validator, der diese Umwandlung auslässt, lehnt jede Firma ab, deren Prüfziffer dort landet.
Wie prüfe ich einen CUI in TypeScript?
Erst parsen, dann prüfen. Ein Parser, der die Ziffern, das Präfix und das Ergebnis zurückgibt, ist für Formular, zod-Regel und Abfrage die gemeinsame Single Source of Truth:
Prüf vier Dinge, bevor du einem Snippet aus einem Forum vertraust, das die CIF-Prüfsumme in JavaScript berechnet:
- Es wandelt in Großbuchstaben um, bevor es
ROentfernt, sonst scheitertro12345674. - Es macht aus einem Rest von 10 eine 0.
- Es richtet die Ziffern von rechts aus. Linksbündig funktioniert nur bei 10-stelligen Codes.
- Es lehnt 11 Ziffern ab.
python-stdnum lehnt außerdem einen Code ab, der mit 0 beginnt. parseCui akzeptiert ihn, also schreib die Zifferngruppe als ([1-9]\d{1,9}), wenn du dieselbe Regel willst.
Wir haben jede Änderung einer einzelnen Ziffer und jede Vertauschung zweier benachbarter Ziffern über eine große Stichprobe gültiger 8-stelliger Codes laufen lassen: Die Prüfung hat jeweils etwa 98 % erkannt, und jeder übersehene Tippfehler bei einer einzelnen Ziffer ging auf die Umwandlung von 10 in 0 zurück. Eine zufällige 8-stellige Zahl besteht den Test in einem von zehn Fällen, also heißt eine gültige Prüfziffer nie, dass die Firma existiert.
Wie schreibe ich eine zod-Regel für einen CUI?
Verkette drei Prüfungen und eine Transformation, damit die Person immer nur eine Meldung sieht und deine Datenbank immer dieselbe Form bekommt:
abort: true (zod 4) stoppt bei der ersten fehlgeschlagenen Prüfung, also meldet die Eingabe „abc“, welches Format erwartet wird, statt zusätzlich die Prüfziffer zu bemängeln. Die Transformation macht beim Absenden aus " ro 1234 5674 " ein RO12345674. So musst du den Text nie umschreiben, während jemand tippt.
In Wingo UI Pro liefert die Lib form-validation eine CUI-Regel als createValidators().cui(), mit einer Meldung für jeden ungültigen Code auf Englisch oder mit VALIDATION_MESSAGES_RO auf Rumänisch. Sie transformiert nicht, also normalisierst du vor dem Speichern mit formatCui aus der Lib invoicing; diese Lib exportiert dieselben drei Funktionen wie der Code oben, dazu Prüfungen für IBAN und CNP.
Wie baue ich ein CUI-Feld, das beim Tippen prüft?
Zeig den Fehler, nachdem die Person das Feld verlassen hat, und prüf danach bei jedem Tastendruck neu, damit er verschwindet, sobald die Nummer stimmt. Das Häkchen zeigst du, sobald die Nummer gültig ist, denn es unterbricht niemanden. Der Modus onTouched von React Hook Form gibt dem Fehler genau dieses Timing, und useWatch steuert das Häkchen:
Das Feld behält die Texttastatur, denn ein Ziffernblock hat kein R und kein O. autoCapitalize="characters" macht auf dem Smartphone aus „ro“ ein „RO“, und die Rechtschreibprüfung bleibt aus, damit der Browser keine Nummer unterstreicht. Der Input nutzt auf Smartphones 16px Schrift, damit iOS nicht zoomt, und sein error ersetzt den Hinweis, setzt aria-invalid und verknüpft die Meldung über aria-describedby, die Anatomie, die Field jedem Control gibt. Beide sind kostenlos: npx wingo-ui@latest add input field.
$ npx wingo-ui@latest add form-validationWie prüfe ich, ob die Firma existiert und Mehrwertsteuer zahlt?
Frag ANAF. Der öffentliche Webservice PlatitorTvaRest beantwortet für eine Liste von CUIs und ein Datum, ob jede Firma gefunden wird, wie sie heißt, wo sie sitzt und ob sie an diesem Datum für die Mehrwertsteuer registriert war. Stand Oktober 2026 gelten laut der Dokumentation zu v9 (opens in a new tab) diese Regeln: ein POST an https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva mit einem JSON-Array aus { "cui": number, "data": "YYYY-MM-DD" }, höchstens 100 CUIs pro Request und höchstens ein Request pro Sekunde und Client. Die Antwort hat eine Liste found und eine Liste notFound.
Wir haben den Service am 8. Oktober 2026 aufgerufen, und zwei Ergebnisse haben uns überrascht. Eine Nummer mit falscher Prüfziffer landet ohne Fehler in notFound, also spart eine vorherige Prüfung einen Request gegen dieses Limit. Und unsere eigene Demo-Nummer, RO12345674, kam als gefunden zurück: eine Firma, die 2006 aus dem Register gelöscht wurde, mit stare_inregistrare auf „RADIERE din data 29.06.2006“, scpTVA auf false und dem Inaktiv-Flag statusInactivi ebenfalls auf false. 18547290, das Beispiel aus python-stdnum, kam genauso zurück, gelöscht 2016. Gefunden heißt nicht aktiv, und in beiden Datensätzen blieb das Inaktiv-Flag false, also lies stare_inregistrare. Bei einer aktiven Firma steht dort „INREGISTRAT din data“ und danach ein Datum.
Lass die ANAF-Abfrage auf deinem Server laufen. Die Feldnamen sind rumänisch und die Version steckt im Pfad (die Dokumentation zu v5 (opens in a new tab) nutzte /PlatitorTvaRest/api/v5/ws/tva), also musst du nur eine Datei anpassen, wenn ANAF umzieht: den Route Handler. Er liest den CUI aus dem Query-String, der schon dekodiert ankommt, und macht aus einem Timeout oder einer Fehlerseite eine einheitliche 502:
Für ANAF ist dein Server der Client, also teilen sich alle Registrierungen diesen einen Request pro Sekunde. Cache die Antworten pro CUI, und bündle bei Importen bis zu 100 CUIs pro Request.
ANAF gibt Auskunft über die rumänische Mehrwertsteuerregistrierung. Um eine rumänische USt-IdNr. für eine grenzüberschreitende Rechnung innerhalb der EU zu prüfen, nutzt du VIES. Die VIES-Seite der Europäischen Kommission (opens in a new tab) nennt drei Gründe für eine ungültige Antwort: Die Nummer existiert nicht, sie ist nicht für innergemeinschaftliche Umsätze freigeschaltet, oder die Registrierung ist noch nicht abgeschlossen. Eine Firma kann in Rumänien gültig als Mehrwertsteuerzahler geführt werden und in VIES trotzdem ungültig sein.
Wie sieht ein echter Registrierungsschritt mit CUI-Abfrage aus?
Der Block Register Page hat einen optionalen Firmenschritt, der auf diesen Teilen aufbaut. Sein CUI-Feld prüft mit v.cui() und fragt nur bei Enter oder beim Such-Button ab: Da jede zehnte zufällige Zahl den Test besteht, würde eine Abfrage bei jedem gültigen Tastendruck auch bei halb getippten Nummern feuern. Eine ungültige Prüfziffer zeigt den Fehler und ruft die Abfrage nie auf. Eine gültige Nummer geht über formatCui an dein onLookupCui. Ein Treffer blendet Name, Adresse und ein Mehrwertsteuer-Badge ein, und das Feld wird mit formatCui(cui, { vatPayer }) neu geschrieben, also kommt das Präfix von ANAF statt aus Gewohnheit. Wer die Nummer bearbeitet, löscht das Ergebnis, und eine späte Antwort zu einer älteren Nummer wird ignoriert.
Den Route Handler aus dem vorigen Abschnitt schließt du mit einem einzigen Prop an. Das Speichern von Konto, Code und Firma bleibt simuliert, bis du onRegister, onVerify und onCompany übergibst:
Gibt die Funktion null zurück, öffnen sich die manuellen Felder mit einem Hinweis, und ein Fehler bietet „Try again“ und „Fill in by hand“ an. Die Demo unten antwortet aus einer Beispieltabelle statt von ANAF, ihre Firmen sind also erfunden.
$ npx wingo-ui@latest add register-pageDer Schritt danach fragt nach dem 6-stelligen Code aus der Registrierungs-E-Mail; unser Guide zum React OTP Input zeigt, was dieses Feld können muss.
Soll ich den CUI-Code kopieren oder installieren?
Kopier ihn, wenn du ein einziges Feld brauchst: lib/cui.ts, die zod-Regel und der kostenlose Input reichen für ein Rechnungs- oder Registrierungsformular. Das fertige Rumänien-Kit (CUI, IBAN, CNP und Mehrwertsteuerberechnung in invoicing, gemeinsame Meldungen in form-validation) und der komplette Registrierungsablauf gehören zu Wingo UI Pro. Die beiden anderen IDs haben eigene Guides: IBAN prüfen in JavaScript für Bankkonten und der rumänische CNP-Validator für Personen. Für den Rest der Formular-Anatomie lies React Form Components: barrierefreie Felder, zod und Mobile, oder stöber in weiteren Guides zu Komponenten.
Häufige Fragen
Was ist der Unterschied zwischen CUI und CIF?
Bei einer Firma sind es dieselben Ziffern. Der CUI ist der Registrierungscode, den die Firma bekommt, und der CIF ist ihre Steueridentifikationsnummer, die mit dem Präfix RO geschrieben wird, wenn die Firma für die Mehrwertsteuer registriert ist. Personen werden über ihren 13-stelligen CNP identifiziert, der eine eigene Prüfziffer hat.
Gehört RO zum CUI?
Nein. RO kennzeichnet eine für die Mehrwertsteuer registrierte Firma und fließt nicht in die Prüfsumme ein. Entferne es vor der Prüfung, speichere die Ziffern und setz RO wieder davor, wenn du die USt-IdNr. einer Firma ausgibst, die ANAF als Mehrwertsteuerzahler führt.
Wie wird die Prüfziffer des CUI berechnet?
Füll die Ziffern vor der letzten links auf neun Ziffern auf, multipliziere sie mit 7, 5, 3, 2, 1, 7, 5, 3, 2 und addiere die Produkte. Multipliziere die Summe mit 10 und nimm den Rest mod 11; ein Rest von 10 wird zu 0, und das Ergebnis muss der letzten Ziffer entsprechen.
Heißt ein gültiger CUI, dass die Firma existiert?
Nein. Ungefähr jede zehnte zufällige Zahl besteht den Test. Frag den Webservice PlatitorTvaRest von ANAF ab, um zu sehen, ob die Firma existiert, ob sie noch eingetragen ist und ob sie an einem bestimmten Datum Mehrwertsteuer zahlt.
Wie prüfe ich eine rumänische USt-IdNr. für eine grenzüberschreitende Rechnung in der EU?
Prüf sie in VIES, der Abfrage der Europäischen Kommission für USt-IdNrn. Eine Antwort „ungültig“ kann heißen, dass die Nummer nicht existiert oder dass sie nicht für innergemeinschaftliche Umsätze freigeschaltet ist.
- CUI
- Rumänien
- Formularvalidierung
- Zod
- TypeScript
- React Hook Form


