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>
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:
TaxCurrencyCodeis alwaysRON. WhenDocumentCurrencyCode != RON, a second<cac:TaxTotal>in RON is emitted with aPaymentExchangeRateblock (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,TaxCategoryIDSstandard /Zzero-rated,Percent,TaxScheme ID=VAT) insideTaxTotal; totals must reconcile with lines. -
Monetary totals:
LegalMonetaryTotalwithLineExtensionAmount,TaxExclusiveAmount,TaxInclusiveAmount,PayableAmount— all carryingcurrencyID. A missing or mismatchedPayableAmountis a common kill. -
Lines: quantities use
unitCode="C62"; a zero-item invoice violates EN 16931 BR-45. -
Credit notes: type code
381,BillingReference/InvoiceDocumentReferenceto the original, and — for storno workflows — the original load index in theNoteasindex_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"/>
index_incarcare — the submission ID used later to match the answer. A synchronous refusal:
<header ExecutionStatus="1" errorMessage="CIF invalid"/>
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"
}
The verdict lives entirely in detalii — free-text Romanian. Rejections observed in fixtures:
Erori de validare identificate la factura transmisa cu id_incarcare=5035501773S-au identificat erori la validarea facturiiEroare la validare: CIF invalidFactura respinsa din cauza formatuluiInvalid schemaeroare: 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.1Missing or invalid ProfileID. Expected: urn:fdc:peppol.eu:2017:poacc:billing:01:1.0InvoiceTypeCode must be 380 (invoice) or 381 (credit note)Missing TaxCurrencyCode element — required by CIUS-RO BR-53- missing
LegalMonetaryTotal/PayableAmountfor 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:
- 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.
-
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 toANAF_CIF_INVALID/BT-48/BT-31hints onbuyer.vatNumber/issuer.vatNumber, with an auto-fix that strips or adds theROprefix (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 acceptat → accepted; (2) rejection second — contains eroare, erori, respins, invalid, or neconform → rejected; (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) andErori 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 insubmitted; the code matches both substrings explicitly. -
Order matters. Acceptance is checked before rejection, so a
detaliicontaining bothokanderoarewould be classifiedaccepted. 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 sistemchanges nothing: the invoice stayssubmitted, and because polling matches byid_solicitareevery 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
submittedstate and a poller — here every 6 hours (PT6Hfixed delay) over 60 days, grouped per CIF, matchingid_solicitarefrom the audit trail. -
Handle degenerate list responses.
{"eroare":"Nu exista mesaje"}is an empty result, not an error.Lista de mesaje este mai maretriggers the paginated fallback (listaMesajePaginatieFacturawithstartTime/endTimein epoch millis andnumar_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, thendead_letterwithExceeded 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
-
CustomizationIDmust 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. -
TaxCurrencyCodemissing or notRON;PayableAmountmissing or unreconciled totals. - Checksum-invalid CIFs (e.g.
RO12345678; real check digit4): reject at the API, not at ANAF. - Checksum-valid but unregistered CIF: only ANAF's registry can tell you — map the
CIF invalid/cif inactivfamily to a human action. - Classifier matching only
eroareand missingerori(or vice versa) strands invoices insubmitted. - Treating
Nu exista mesajeas 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)