DEV Community

Vlad Cristian Alexa
Vlad Cristian Alexa

Posted on

ANAF e-Factura UBL Rejections: What EN 16931 / CIUS-RO Validation Errors Look Like and How to Catch Them Before ANAF Does

Uploading well-formed UBL XML to ANAF e-Factura is the easy part. Accepting your upload and validating your invoice are two different moments, separated by minutes or hours: ANAF returns a submission index immediately, then the validation verdict arrives later as an asynchronous message. When the verdict is a rejection, all you get is one Romanian sentence in a detalii field — no structured error list, no line number.

This is a code-first tour of what happens between "upload accepted" and "invoice rejected", grounded in a production Spring Boot integration with the ANAF SPV API: what ANAF validates (EN 16931 + CIUS-RO), the real shape of rejection answers, the pre-flight checks you can run locally, the CIF checksum trap, and the semantics of polling, retries and quota.

How ANAF validates: EN 16931 + CIUS-RO

Romanian e-Factura is built on EN 16931 (the European semantic invoice model with hundreds of business rules: BR-* document-level, BT-* field-level) plus the national extension CIUS-RO, which pins the exact CustomizationID your document must advertise and adds national requirements — a mandatory TaxCurrencyCode, and a second RON tax total for foreign-currency invoices.

The identifiers are exact strings, emitted verbatim by the production builder:

<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>   <!-- 381 for credit notes -->
<cbc:DocumentCurrencyCode>RON</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>RON</cbc:TaxCurrencyCode>
Enter fullscreen mode Exit fullscreen mode

CustomizationID is a conformance claim: writing #compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1 tells ANAF to validate you against CIUS-RO 1.0.1 — semantically, server-side. That is where most real rejections come from, because the OASIS UBL 2.1 XSD cannot express these rules: nothing in the XSD can require a specific CustomizationID, a TaxCurrencyCode, or RON totals on a foreign-currency invoice.

The builder shows which features trip semantic rules in practice:

  • Tax currency: TaxCurrencyCode is always RON. When DocumentCurrencyCode != RON, a second <cac:TaxTotal> in RON is emitted with a PaymentExchangeRate block (SourceCurrencyCode, TargetCurrencyCode=RON, CalculationRate, Date) using the BNR rate for the issue date. CIUS-RO BR-53 requires exactly this; omitting the RON tax total on a EUR invoice is a classic rejection.
  • VAT breakdown: each distinct rate gets its own <cac:TaxSubtotal> (TaxableAmount, TaxAmount, TaxCategory ID S standard / Z zero-rated, Percent, TaxScheme ID=VAT) inside TaxTotal; totals must reconcile with lines.
  • Monetary totals: LegalMonetaryTotal with LineExtensionAmount, TaxExclusiveAmount, TaxInclusiveAmount, PayableAmount — all carrying currencyID. A missing or mismatched PayableAmount is a common kill.
  • Lines: quantities use unitCode="C62"; a zero-item invoice violates EN 16931 BR-45.
  • Credit notes: type code 381, BillingReference/InvoiceDocumentReference to the original, and — for storno workflows — the original load index in the Note as index_incarcare_original: <index>.

Anatomy of a rejection: two failure moments

Upload-time (synchronous). POST /upload?standard=UBL&cif=<cif>&extern=DA&autofactura=DA returns XML, not JSON. Success:

<header ExecutionStatus="0" index_incarcare="11223344"/>
Enter fullscreen mode Exit fullscreen mode

index_incarcare — the submission ID used later to match the answer. A synchronous refusal:

<header ExecutionStatus="1" errorMessage="CIF invalid"/>
Enter fullscreen mode Exit fullscreen mode

ExecutionStatus != 0 is a permanent, non-retryable failure. A WAF/intermediary HTML page (detected via Your support ID is:), HTTP 429, a 5xx, or a non-XML body are classified retryable and go through exponential backoff.

Answer-time (asynchronous). Once index_incarcare is obtained, the invoice is submitted and real validation starts; a poller calls listaMesajeFactura?zile=60&cif=<cif> (60-day window) and matches each message by id_solicitare == index_incarcare. A message is flat JSON:

{
  "data_creare": "202604291200",
  "cif": "12345678",
  "id_solicitare": "99887766",
  "detalii": "Factura validata OK",
  "tip": "FACTURA PRIMITA",
  "id": "1001"
}
Enter fullscreen mode Exit fullscreen mode

The verdict lives entirely in detalii — free-text Romanian. Rejections observed in fixtures:

  • Erori de validare identificate la factura transmisa cu id_incarcare=5035501773
  • S-au identificat erori la validarea facturii
  • Eroare la validare: CIF invalid
  • Factura respinsa din cauza formatului
  • Invalid schema
  • eroare: format invalid

Acceptances: Factura validata OK, Factura validata cu succes!, OK validat, Status: ok, with tip values like FACTURA PRIMITA (verbatim from code and fixtures). On rejection the invoice moves submitted → rejected, the job goes failed with error ANAF rejected: <detalii>, and an audit entry answer_rejected is recorded. Note: tip is not used for classification — only detalii; a load id, when present, is embedded in the sentence itself (id_incarcare=5035501773).

Pre-flight: three layers you can run before ANAF does

Waiting for an asynchronous rejection to catch a typo is expensive — with the 5-working-day legal deadline, sometimes fatal. Run three local layers in order.

Layer 1 — semantic completeness (En16931CompletenessValidator). Runs on the canonical data before XML generation: invoice fields (currency, issuer, buyer, items, totals), party names (BT-44), issuer VAT number for CIUS-RO (BT-31), at least one line (BR-45), per-line name/quantity/unitPrice/vatRate (BT-153/BT-146/BT-147/BT-152), totals subtotal/totalVAT/total. Failures are precise, e.g.:

  • items: at least one line item is required (EN 16931 BR-45)
  • issuer.vatNumber: required for CIUS-RO compliance (BT-31, supplier VAT identifier)
  • items[0].vatRate: required (EN 16931 BT-152, VAT category rate)

Layer 2 — XSD structure + CIUS-RO programmatic checks (UblSchemaValidator). After the UBL string is built it is validated against the real OASIS UBL 2.1 XSD set (bundled on the classpath, schemas/ubl21/UBL-Invoice-2.1.xsd, resolved with a custom LSResourceResolver, secure processing on). Because XSD can't express CIUS-RO, programmatic checks run first and short-circuit with actionable messages:

  • Missing or invalid CIUS-RO CustomizationID. Expected: urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1
  • Missing or invalid ProfileID. Expected: urn:fdc:peppol.eu:2017:poacc:billing:01:1.0
  • InvoiceTypeCode must be 380 (invoice) or 381 (credit note)
  • Missing TaxCurrencyCode element — required by CIUS-RO BR-53
  • missing LegalMonetaryTotal/PayableAmount for invoices

Structural failures surface as XSD validation failed at line <L> (column <C>): <message>. In the submission job this gate is fatal before any HTTP call: the invoice goes straight to failed (UBL XSD validation failed: ...) — no retry, no quota slot consumed.

Layer 3 — identifier checksum (CifValidator). Next section.

The CIF checksum trap

Romanian CIF numbers carry a mod-11 control digit computed with multiplier 753217532 (a control value of 10 maps to 0). The validator strips an optional RO prefix, rejects non-numeric or out-of-range values, and applies the checksum — but only to numbers that start with RO (foreign VAT numbers are correctly skipped).

The trap: obvious test/placeholder numbers are almost all checksum-invalid. For RO12345678 the official check digit of base 1234567 is 4 (valid: RO12345674) — yet RO12345678 is the CIF used in countless examples, including fixtures in this codebase. It fails the algorithm, and a production API layer rejects it at invoice creation with Invalid Romanian VAT number for issuer: RO12345678 (IllegalArgumentException), long before any XML exists.

Two consequences:

  1. Never fabricate or auto-generate CIFs — even "obviously fake" ones. Run the checksum (~10 lines; same algorithm as ANAF's client libraries) and reject bad numbers at the API boundary with a clear message, instead of letting them resurface as a generic ANAF error.
  2. Checksum-valid ≠ registered. A checksum-valid but unregistered CIF (inactive, deregistered, registry typo) passes local checks and comes back from ANAF as a registry error. The recovery rules in the codebase recognize exactly this family: CIF invalid pentru cumparator, cif inactiv pentru buyer, BT-48 Buyer VAT identifier not found in registry (buyer), BT-31 ... supplier vat identifier ... (issuer) — mapped to ANAF_CIF_INVALID/BT-48/BT-31 hints on buyer.vatNumber / issuer.vatNumber, with an auto-fix that strips or adds the RO prefix (a frequent source of registry mismatches). Only ANAF can answer the registry question; the checksum answers the format question. Never conflate the two.

Answer classification: singular, plural, and keyword order

Because detalii is free text, classification is keyword matching. The reconciler lowercases and checks in this exact order: (1) acceptance first — contains ok, validat, or acceptataccepted; (2) rejection second — contains eroare, erori, respins, invalid, or neconformrejected; (3) otherwise log Unrecognised ANAF answer ... and change nothing.

Three subtleties that bite if you reimplement this:

  • Singular vs. plural. Real ANAF text uses both: Eroare la validare: CIF invalid (singular) and Erori de validare identificate la factura transmisa cu id_incarcare=5035501773 / S-au identificat erori la validarea facturii (plural). A matcher that looks for only one form silently strands invoices in submitted; the code matches both substrings explicitly.
  • Order matters. Acceptance is checked before rejection, so a detalii containing both ok and eroare would be classified accepted. Real ANAF strings don't mix them today, but test both directions.
  • Unknown text is a no-op, not a failure. The fixture Mesaj necunoscut din sistem changes nothing: the invoice stays submitted, and because polling matches by id_solicitare every six hours, the same unrecognized message is re-matched forever. Alert on repeated unmatched messages and dead-letter them manually.

Operational lessons: polling, quota semantics, retries

  • Validation is asynchronous by design. The upload answer is only an acceptance to process. Keep a submitted state and a poller — here every 6 hours (PT6H fixed delay) over 60 days, grouped per CIF, matching id_solicitare from the audit trail.
  • Handle degenerate list responses. {"eroare":"Nu exista mesaje"} is an empty result, not an error. Lista de mesaje este mai mare triggers the paginated fallback (listaMesajePaginatieFactura with startTime/endTime in epoch millis and numar_total_pagini; Pagina solicitata <N> este mai mare ... marks the last page). Treating these as failures turns a healthy poll into a false alarm.
  • Quota is refunded only on a real rejection. The tenant's quota slot is consumed at submission and decremented back only when a rejection answer lands — and only on a genuine status transition, so duplicate answers can't double-refund. An accepted credit note cancels the original invoice (status cancelled) and refunds its slot, unless the original was already rejected or cancelled. Refunding on "message seen" instead of "status changed" leaks quota.
  • Retry ≠ resubmit. Submission retries are exponential (5s × 2^(attempt-1), max 10, then dead_letter with Exceeded max retries (10)), but only transport failures are retryable: WAF pages, 429, 5xx, empty/non-XML bodies. A synchronous validation refusal (ExecutionStatus != 0) and other 4xx errors are permanent. An answer-time rejection is never retried: correct the document and resubmit as a new submission (new number, new load).
  • Keep the three failure buckets distinct in UI and metrics: transport failure (failed, retryable), submission-time validation refusal (failed, permanent), answer-time rejection (rejected, discovered hours later). The audit trail encodes it (ANAF_SUBMISSION_FAILED, UBL_SCHEMA_VALIDATION_FAILED, ANAF_ANSWER_RECEIVED), and hints even recognize the legal-deadline failure mode (ANAF: termen de depunere depasit (5 zile lucratoare)).

Pitfall checklist

  • CustomizationID must be the exact CIUS-RO string; a stale or PEPPOL-only value changes what ANAF validates you against.
  • Foreign-currency invoice without RON TaxTotal + PaymentExchangeRate → CIUS-RO BR-53 rejection.
  • TaxCurrencyCode missing or not RON; PayableAmount missing or unreconciled totals.
  • Checksum-invalid CIFs (e.g. RO12345678; real check digit 4): reject at the API, not at ANAF.
  • Checksum-valid but unregistered CIF: only ANAF's registry can tell you — map the CIF invalid/cif inactiv family to a human action.
  • Classifier matching only eroare and missing erori (or vice versa) strands invoices in submitted.
  • Treating Nu exista mesaje as an error, or refunding quota on duplicate answers.
  • Retrying permanent validation refusals instead of correcting the document.

If you'd rather not rebuild this pipeline — CIUS-RO generation, XSD gates, CIF checksum, answer polling, rejection hints — FiscalLink (autoanaf.ro) runs exactly these pre-flight checks for Romanian e-invoicing.

Top comments (0)