WingoUI
ComponentsTemplatesDocsChangelogBlogAI agents
ThemePricing
  1. Home
  2. Blog
  3. Component guides
  4. Romanian CUI Validation in TypeScript, Zod and React
  1. Blog
  2. Component guides
  3. Romanian CUI Validation in TypeScript, Zod and React
Component guides
Component guides

Romanian CUI Validation in TypeScript, Zod and React

SR
Serban Rusu · Founder of Wingo UI
Oct 9, 2026 · 11 min read

On this page

0%
  1. What is a CUI, and how does it relate to the CIF and the VAT number?
  2. How does the CUI check digit work?
  3. How do I validate a CUI in TypeScript?
  4. How do I write a zod rule for a CUI?
  5. How do I build a CUI field that validates as you type?
  6. How do I check that the company exists and pays VAT?
  7. What does a real sign-up step with a CUI lookup look like?
  8. Should I copy the CUI code or install it?
  9. FAQ
    1. What is the difference between a CUI and a CIF?
    2. Is RO part of the CUI?
    3. How is the CUI check digit calculated?
    4. Does a valid CUI mean the company exists?
    5. How do I check a Romanian VAT number for an EU cross-border invoice?

TL;DR

Romanian CUI validation is a weighted checksum: strip the optional RO prefix, right-align the digits before the last one against the key 753217532, multiply and add, then take the sum times 10 mod 11, where 10 becomes 0. The result must equal the last digit. A valid check digit only proves the number is well formed, so confirm that the company exists, is still registered and pays VAT with ANAF's public web service.

Published Oct 9, 2026

Your sign-up or invoice form has a field for a Romanian company's tax ID, and a regex for "RO plus digits" accepts every mistyped digit. Romanian CUI validation needs three things: a parser that accepts the many ways people write the number, the weighted check digit, and, when it matters, a lookup that proves the company is real. This tutorial builds all three in TypeScript, then wires them into a zod rule, a React field that validates as you type and a sign-up step that looks the company up at ANAF. The UI uses Input and Field from Wingo UI, the library we build, so we are not neutral. The checksum code is plain TypeScript you can paste anywhere.

What is a CUI, and how does it relate to the CIF and the VAT number?

They are one number with three names. The CUI (Codul Unic de Înregistrare) is the code a Romanian company gets when it registers: 2 to 10 digits, the last of which is a check digit. The CIF (cod de identificare fiscală) is the tax ID, and for a company it is the same digits. When the company is registered for VAT, the Romanian Wikipedia article on the CIF (opens in a new tab) describes the CUI becoming a CIF with the "RO" prefix.

So the Romanian VAT number format is RO followed by the same 2 to 10 digits, as the VAT identification number table on Wikipedia (opens in a new tab) lists it. In a real form you will receive RO12345674, ro 12345674, 12 345 674 and RO-12345674, and all four are the same company.

Two rules follow from that:

  • Store the digits and a VAT flag, never the string as typed. Print RO + digits when you need the VAT number.
  • The prefix someone typed is not proof of VAT status. People add RO out of habit. The VAT flag should come from ANAF, as we do further down.

How does the CUI check digit work?

The last digit is computed from the others with a fixed key, 753217532. The Romanian Wikipedia article above describes the validare CUI algorithm, and the source of python-stdnum's stdnum.ro.cui (opens in a new tab) implements the same five steps:

  1. Remove the RO prefix and any spaces, dots or dashes. What is left must be 2 to 10 digits.
  2. Set the last digit aside. That is the check digit.
  3. Pad the remaining digits with zeros on the left until there are nine, so they line up with the right end of the key. (Wikipedia reverses both strings instead, which gives the same pairs.)
  4. Multiply each digit by the key digit in the same position and add the products.
  5. Multiply the sum by 10, take the remainder of dividing by 11, and turn a remainder of 10 into 0. The CUI is valid when this equals the check digit.

Here is 12345674 worked by hand. The body is 1234567, padded to 001234567:

PositionDigitKeyProduct
1070
2050
3133
4224
5313
64728
75525
86318
97214

The sum is 95. 950 mod 11 is 4, which matches the last digit, so RO12345674 is valid and RO12345678 is not.

The case that breaks hand-written validators is the remainder of 10. For 18547290 the products add up to 111, and 1110 mod 11 is 10, so the check digit is 0. A validator that skips the fold rejects every company whose check digit lands there.

How do I validate a CUI in TypeScript?

Parse first, then validate. A parser that returns the digits, the prefix and the verdict gives the form, the zod rule and the lookup the same source of truth:

ts
// lib/cui.ts
export type CuiInfo = {
// the digits without RO: "12345674"
digits: string;
// written with RO, the way a VAT payer quotes it
vatPayer: boolean;
valid: boolean;
};
// the test key 753217532, one weight per digit before the check digit
const KEY = [7, 5, 3, 2, 1, 7, 5, 3, 2];
export function parseCui(input: string): CuiInfo | null {
const clean = input.replace(/[\s.\-/]/g, "").toUpperCase();
const match = /^(RO)?(\d{2,10})$/.exec(clean);
if (!match) return null;
const digits = match[2];
// right-align the body against the key: "1234567" -> "001234567"
const body = digits.slice(0, -1).padStart(9, "0");
let sum = 0;
for (let i = 0; i < 9; i++) sum += Number(body[i]) * KEY[i];
// times 10, mod 11, and a remainder of 10 becomes 0
const control = ((sum * 10) % 11) % 10;
return {
digits,
vatPayer: match[1] === "RO",
valid: control === Number(digits[digits.length - 1]),
};
}
export function isValidCui(input: string): boolean {
return parseCui(input)?.valid ?? false;
}
// "ro 1234 5674" -> "RO12345674"; vatPayer adds or drops the prefix
export function formatCui(input: string, options: { vatPayer?: boolean } = {}): string {
const info = parseCui(input);
if (!info) return input;
return `${(options.vatPayer ?? info.vatPayer) ? "RO" : ""}${info.digits}`;
}
ts
parseCui("RO12345674"); // { digits: "12345674", vatPayer: true, valid: true }
isValidCui("12345678"); // false, the check digit should be 4
isValidCui("RO 185 472 90"); // true, remainder 10 becomes 0
formatCui("ro 1234 5674"); // "RO12345674"
formatCui("RO12345674", { vatPayer: false }); // "12345674"

Check four things before you trust a CIF checksum JavaScript snippet from a forum:

  • It uppercases before it strips RO, or ro12345674 fails.
  • It turns a remainder of 10 into 0.
  • It lines the digits up from the right. Left alignment only works for 10 digit codes.
  • It rejects 11 digits.

python-stdnum also rejects a code that starts with 0. parseCui accepts one, so write the digit group as ([1-9]\d{1,9}) if you want the same rule.

We ran every single-digit change and every swap of two neighboring digits over a large sample of valid 8-digit codes: the check caught about 98% of both, and every missed single-digit typo came from folding 10 into 0. A random 8-digit number passes one time in ten, so a valid check digit never means the company exists.

How do I write a zod rule for a CUI?

Chain three checks and a transform, so the person sees one message at a time and your database always receives the same shape:

ts
// lib/cui-schema.ts
import { z } from "zod";
import { formatCui, isValidCui, parseCui } from "@/lib/cui";
export const cui = z
.string()
.trim()
.min(1, { error: "Enter the company's tax ID", abort: true })
.refine((value) => parseCui(value) !== null, {
error: "Use 2 to 10 digits, with or without RO",
abort: true,
})
.refine(isValidCui, { error: "This tax ID fails its check digit" })
.transform((value) => formatCui(value));
// for a form where the tax ID is optional
export const optionalCui = z.union([z.literal(""), cui]);

abort: true (zod 4) stops at the first failing check, so "abc" says what format is expected instead of also blaming the check digit. The transform turns " ro 1234 5674 " into RO12345674 on submit, so you never have to rewrite the text while someone is typing.

In Wingo UI Pro, the form-validation lib ships a CUI rule as createValidators().cui(), with one message for any invalid code in English, or in Romanian with VALIDATION_MESSAGES_RO. It does not transform, so normalize with formatCui from the invoicing lib before saving; that lib exports the same three functions as the code above, plus IBAN and CNP checks.

How do I build a CUI field that validates as you type?

Show the error after the person leaves the field, then re-check on every keystroke so it clears the moment the number is right. Show the check mark as soon as the number is valid, since it interrupts no one. React Hook Form's onTouched mode gives the error that timing, and useWatch drives the check mark:

tsx
"use client";
import { useForm, useWatch } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { CircleCheck } from "lucide-react";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { isValidCui } from "@/lib/cui";
import { cui } from "@/lib/cui-schema";
const schema = z.object({
company: z.string().trim().min(1, { error: "Enter the company name" }),
cui,
});
type Values = z.output<typeof schema>;
export function BillingForm({ onSave }: { onSave: (values: Values) => Promise<void> }) {
const form = useForm<z.input<typeof schema>, unknown, Values>({
resolver: zodResolver(schema),
// the first error waits for blur, then the field re-checks on every keystroke
mode: "onTouched",
defaultValues: { company: "", cui: "" },
});
const typed = useWatch({ control: form.control, name: "cui" });
const { errors, isSubmitting } = form.formState;
return (
<form noValidate onSubmit={form.handleSubmit(onSave)} className="flex flex-col gap-4">
<Input
label="Company name"
autoComplete="organization"
{...form.register("company")}
error={errors.company?.message}
/>
<Input
label="Tax ID (CUI)"
description="With or without RO, for example RO12345674."
placeholder="RO12345674"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
{...form.register("cui")}
error={errors.cui?.message}
rightIcon={isValidCui(typed) ? <CircleCheck className="text-success-soft-foreground" /> : undefined}
/>
<Button type="submit" loading={isSubmitting}>
Save
</Button>
</form>
);
}

The field keeps the text keyboard, because a numeric keypad has no R and no O. autoCapitalize="characters" turns "ro" into "RO" on phones, and spell check stays off so the browser does not underline a number. The Input uses 16px text on phones, so iOS does not zoom, and its error replaces the hint, sets aria-invalid and links the message with aria-describedby, the anatomy that Field gives every control. Both are free: npx wingo-ui@latest add input field.

Type RO12345678 in Company tax ID and press Tab: the check digit error appears. Change the last digit to 4 and it clears while you type
$ npx wingo-ui@latest add form-validation
ProForm validation docs

How do I check that the company exists and pays VAT?

Ask ANAF. Its public PlatitorTvaRest web service answers, for a list of CUIs and a date, whether each company is found, its name and address, and whether it was registered for VAT on that date. As of October 2026, the v9 documentation (opens in a new tab) gives these rules: a POST to https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva with a JSON array of { "cui": number, "data": "YYYY-MM-DD" }, at most 100 CUIs per request and at most one request per second per client. The answer has a found list and a notFound list.

We called it on October 8, 2026, and two results surprised us. A number with a wrong check digit lands in notFound without an error, so validating first saves a request against that limit. And our own demo number, RO12345674, came back found: a company struck off the register in 2006, with stare_inregistrare set to "RADIERE din data 29.06.2006", scpTVA false and the inactive flag statusInactivi also false. 18547290, the example from python-stdnum, came back the same way, struck off in 2016. Found does not mean active, and in both records the inactive flag stayed false, so read stare_inregistrare. An active company reads "INREGISTRAT din data" followed by a date.

Keep the call on your server. The field names are Romanian and the version sits in the path (the v5 documentation (opens in a new tab) used /PlatitorTvaRest/api/v5/ws/tva), so one route handler is the only file that changes when ANAF moves. It reads the CUI from the query string, which arrives decoded, and turns a timeout or an error page into one 502:

ts
// app/api/company/route.ts
import type { NextRequest } from "next/server";
import { parseCui } from "@/lib/cui";
const ANAF = "https://webservicesp.anaf.ro/api/PlatitorTvaRest/v9/tva";
type AnafCompany = {
date_generale: { denumire: string; adresa: string; stare_inregistrare: string };
inregistrare_scop_Tva: { scpTVA: boolean };
stare_inactiv: { statusInactivi: boolean };
};
export async function GET(request: NextRequest) {
const info = parseCui(request.nextUrl.searchParams.get("cui") ?? "");
// a typo never costs a request against the one-per-second limit
if (!info?.valid) return Response.json({ error: "invalid-cui" }, { status: 400 });
// ANAF answers for a calendar day in Romania, not in UTC
const today = new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Bucharest" }).format(new Date());
let found: AnafCompany[];
try {
const response = await fetch(ANAF, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify([{ cui: Number(info.digits), data: today }]),
signal: AbortSignal.timeout(8000),
});
if (!response.ok) throw new Error(`ANAF answered ${response.status}`);
({ found } = (await response.json()) as { found: AnafCompany[]; notFound: number[] });
} catch {
// a timeout, an error status or a page that is not JSON
return Response.json({ error: "anaf-unavailable" }, { status: 502 });
}
const company = found[0];
if (!company) return Response.json({ error: "not-found" }, { status: 404 });
return Response.json({
name: company.date_generale.denumire,
address: company.date_generale.adresa,
vatPayer: company.inregistrare_scop_Tva.scpTVA,
// "INREGISTRAT din data ..." while active, "RADIERE din data ..." once struck off
struckOff: company.date_generale.stare_inregistrare.startsWith("RADIERE"),
inactive: company.stare_inactiv.statusInactivi,
});
}

To ANAF, your server is the client, so every sign-up shares that one request per second. Cache answers by CUI, and for imports batch up to 100 CUIs per request.

ANAF tells you about Romanian VAT registration. For a Romanian VAT ID check on a cross-border invoice inside the EU, use VIES. The European Commission's VIES page (opens in a new tab) lists three reasons for an invalid answer: the number does not exist, it has not been activated for intra-EU transactions, or the registration is not finalized yet. A company can be a valid Romanian VAT payer and still be invalid in VIES.

What does a real sign-up step with a CUI lookup look like?

The Register Page block has an optional company step built on these pieces. Its CUI field validates with v.cui() and looks up only on Enter or the Search button: since one random number in ten passes the check, a lookup on every valid keystroke would also fire on half-typed numbers. An invalid check digit shows the error and never calls the lookup. A valid number goes through formatCui to your onLookupCui. A match fades in the name, the address and a VAT badge, and the field is rewritten with formatCui(cui, { vatPayer }), so the prefix comes from ANAF instead of habit. Editing the number clears the result, and a late answer for an older number is ignored.

Plugging in the route handler from the previous section takes one prop. The account, code and company saves stay simulated until you pass onRegister, onVerify and onCompany:

tsx
"use client";
import { RegisterPage } from "@/components/blocks/register-page";
export default function SignUpPage() {
return (
<RegisterPage
onLookupCui={async (cui) => {
const response = await fetch(`/api/company?cui=${encodeURIComponent(cui)}`);
if (response.status === 404) return null;
if (!response.ok) throw new Error("Lookup failed");
const company = await response.json();
// treat a struck-off company like an unknown one
if (company.struckOff) return null;
return { name: company.name, address: company.address, vatPayer: company.vatPayer };
}}
/>
);
}

Returning null opens the manual fields with a note, and throwing offers "Try again" and "Fill in by hand". The demo below answers from a sample table instead of ANAF, so its companies are made up.

Type RO12345678 and press Enter: the check digit error appears and no lookup runs. Fix the last digit to 4 and press Enter again: the sample register fills in the company and its VAT badge
$ npx wingo-ui@latest add register-page
ProRegister Page docs

The step after it asks for the 6 digit code from the sign-up email; our React OTP input guide covers what that field has to handle.

Should I copy the CUI code or install it?

Copy it when you need one field: lib/cui.ts, the zod rule and the free Input cover a billing or sign-up form. The ready Romanian kit (CUI, IBAN, CNP and VAT math in invoicing, shared messages in form-validation) and the whole sign-up flow are part of Wingo UI Pro. The other two ids have their own guides: IBAN validation in JavaScript for bank accounts and the Romanian CNP validator for people. For the rest of the form anatomy, read React form components: accessible fields, zod and mobile, or browse more component guides.

Components in this post

  • Invoicing

    Invoice math and the ids an invoice carries: VAT per rate the way e-Factura checks it, Romanian VAT rates, CUI, IBAN with its bank, CNP.

    Pro
  • Form validation

    Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks.

    Pro
  • Input

    The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels.

    Free
  • Field

    The wrapper every form control shares: label, hint, animated error and counter, fieldsets, and the one box recipe all inputs use.

    Free
  • Register Page

    The sign-up screen of a CRM: account with password rules, company details from the CUI, email code and a welcome step.

    Pro

FAQ

What is the difference between a CUI and a CIF?

For a company they are the same digits. The CUI is the registration code the company receives, and the CIF is its tax identification code, written with the RO prefix when the company is registered for VAT. People are identified by their 13 digit CNP, which has its own check digit.

Is RO part of the CUI?

No. RO marks a company registered for VAT and takes no part in the checksum. Strip it before you validate, store the digits, and add RO back when you print the VAT number of a company that ANAF lists as a VAT payer.

How is the CUI check digit calculated?

Pad the digits before the last one to nine digits on the left, multiply them by 7, 5, 3, 2, 1, 7, 5, 3, 2 and add the products. Multiply the sum by 10 and take the remainder mod 11; a remainder of 10 becomes 0, and the result must equal the last digit.

Does a valid CUI mean the company exists?

No. About one random number in ten passes the check. Query ANAF's PlatitorTvaRest web service to see whether the company exists, whether it is still registered and whether it pays VAT on a given date.

How do I check a Romanian VAT number for an EU cross-border invoice?

Search it in VIES, the European Commission's VAT number search. An invalid answer can mean the number does not exist, or that it has not been activated for intra-EU transactions.

  • CUI
  • Romania
  • Form Validation
  • Zod
  • TypeScript
  • React Hook Form

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu is the founder of Wingo UI. He builds the component library, its CLI and its MCP server, and writes about React interfaces that work well on phones and with AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

IBAN Validation JavaScript: Mod 97, Regex and a React Field

IBAN validation JavaScript that catches typos: mod 97 in TypeScript, why a regex is not enough, a React field that formats as you type and Romanian banks.

SRSerban Rusu·Oct 9, 2026·12 min read
Component guides

React Form Components: Accessible Fields, Zod and Mobile

React form components that share one anatomy (label, hint, error, counter), plug into react-hook-form and zod, and behave right on a phone, with full code.

SRSerban Rusu·Oct 9, 2026·21 min read
Component guides

Romanian CNP Validator in TypeScript, Zod and React

Build a Romanian CNP validator in TypeScript: the weighted check digit, real birth dates, the new county code 70, a zod rule, a React field and safe storage.

SRSerban Rusu·Oct 9, 2026·13 min read
Newsletter

Get new posts by email

New guides, tutorials and comparisons from the Wingo UI blog, sent when they are published.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components. Copy the source, make it yours, and let your coding agent build with it.

ComponentsTemplatesPricingBlogTheme

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities
Wingo UI