# Romanian CUI Validation in TypeScript, Zod and React

> Romanian CUI validation in TypeScript: the 753217532 check digit step by step, the RO prefix, a zod rule, a React field and a company lookup at ANAF.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Oct 9, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/romanian-cui-validation-javascript

## 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.

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](https://wingo-ui.com/components/input) and [Field](https://wingo-ui.com/components/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](https://ro.wikipedia.org/wiki/Cod_de_identificare_fiscal%C4%83) 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](https://en.wikipedia.org/wiki/VAT_identification_number) 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](https://github.com/arthurdejong/python-stdnum/blob/master/stdnum/ro/cui.py) 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`:

| Position | Digit | Key | Product |
|---|---|---|---|
| 1 | 0 | 7 | 0 |
| 2 | 0 | 5 | 0 |
| 3 | 1 | 3 | 3 |
| 4 | 2 | 2 | 4 |
| 5 | 3 | 1 | 3 |
| 6 | 4 | 7 | 28 |
| 7 | 5 | 5 | 25 |
| 8 | 6 | 3 | 18 |
| 9 | 7 | 2 | 14 |

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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/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](https://wingo-ui.com/components/field) gives every control. Both are free: `npx wingo-ui@latest add input field`.

> Live demo (Form validation): 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. Try it at [Form validation](https://wingo-ui.com/components/form-validation) and install it with `npx wingo-ui@latest add form-validation`.

## 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](https://static.anaf.ro/static/10/Anaf/Informatii_R/Servicii_web/doc_WS_V9.txt) 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](https://static.anaf.ro/static/10/Anaf/Informatii_R/doc_WS_V5.txt) 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](https://europa.eu/youreurope/business/taxation/vat/check-vat-number-vies/index_en.htm) 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](https://wingo-ui.com/components/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.

> Live demo (Register Page): 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. Try it at [Register Page](https://wingo-ui.com/components/register-page) and install it with `npx wingo-ui@latest add register-page`.

The step after it asks for the 6 digit code from the sign-up email; our [React OTP input guide](https://wingo-ui.com/blog/react-otp-input) 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](https://wingo-ui.com/pricing). The other two ids have their own guides: [IBAN validation in JavaScript](https://wingo-ui.com/blog/iban-validation-javascript) for bank accounts and the [Romanian CNP validator](https://wingo-ui.com/blog/romanian-cnp-validator-javascript) for people. For the rest of the form anatomy, read [React form components: accessible fields, zod and mobile](https://wingo-ui.com/blog/react-form-components-guide), or browse more [component guides](https://wingo-ui.com/blog/category/components).

## Components in this post

- [Invoicing](https://wingo-ui.com/components/invoicing) (Pro): 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. Install: `npx wingo-ui@latest add invoicing`
- [Form validation](https://wingo-ui.com/components/form-validation) (Pro): Zod rules with one set of messages (English and Romanian), react-hook-form glue and a pretend request, for the forms of blocks. Install: `npx wingo-ui@latest add form-validation`
- [Input](https://wingo-ui.com/components/input) (Free): The single-line text field: icons and addons inside the box, a clear button, a loading spinner, a counter and floating labels. Install: `npx wingo-ui@latest add input`
- [Field](https://wingo-ui.com/components/field) (Free): The wrapper every form control shares: label, hint, animated error and counter, fieldsets, and the one box recipe all inputs use. Install: `npx wingo-ui@latest add field`
- [Register Page](https://wingo-ui.com/components/register-page) (Pro): The sign-up screen of a CRM: account with password rules, company details from the CUI, email code and a welcome step. Install: `npx wingo-ui@latest add register-page`

## 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.

---

Source: https://wingo-ui.com/blog/romanian-cui-validation-javascript
