Skip to main content

Romania — RO e-Factura (ANAF)

Research doc for the Romania fiscalization adapter. Consumed by the implementation bead. No code in this bead.

Status: Research — no adapter code written yet. Strategic context: Phase-1 Central/Adriatic cluster, alongside HU, SI, HR, GR, PL. Chosen because: €0 cert (qualified cert is paid commercially but ANAF access via SPV is free for taxpayers who already hold one for tax filing), REST surface, EU member, Hungarian border (cluster effect with HU), strongly forced-buy event for ISVs after the 2024-07 mandate. ~700K active VAT-registered businesses. See docs/strategy/thesis-api-wedge.md (fi-awx) and apps/docs-internal/docs/ops/country-prioritization.md.

Note — out of scope. RO e-Transport (the separate ANAF system that tracks movement of goods of high fiscal risk) is not part of this adapter. If the implementation bead discovers a coupling — e.g. customers asking to submit e-Factura and e-Transport in the same flow — file a follow-up bead, do not absorb the work here.


1. Regulatory scope

What must be reported

Romania's RO e-Factura (operated by ANAF — Agenția Națională de Administrare Fiscală, the National Agency for Fiscal Administration) is a centralised B2B and B2G electronic invoicing system. The applicable legislation is OUG 120/2021 (creating the system) as amended by Legea 139/2022, OUG 130/2021 (B2G mandate), Legea 296/2023, and OUG 115/2023 (B2B universal mandate plus the 2024-07 reporting/enforcement step).

Three successive expansions define current scope:

Effective dateScope change
2022-07-01B2G mandatory. All economic operators issuing invoices to public-sector entities (autorități contractante) must transmit them through e-Factura. Defined by Legea 139/2022 art. 2 and OUG 130/2021.
2022-07-01B2B for high-fiscal-risk products. Invoices for products on the high-risk list (alcohol, tobacco, mineral water, salt, lemons, vegetables, fruit, clothing, footwear) must transit e-Factura even before the universal B2B mandate.
2024-01-01B2B reporting universal. All B2B invoices between Romanian-established taxable persons must be transmitted to e-Factura within 5 working days. Penalties suspended until 2024-04-01 (grace period).
2024-07-01B2B as the sole legal channel. A B2B invoice issued between Romanian-established taxable persons that is not registered in e-Factura within the deadline is no longer a legal document for VAT deduction at the recipient. The invoice exists in law only when it has a seal from the Ministry of Finance.
2025-01-01B2C reporting mandatory for B2B-or-mixed sellers (extends e-Factura to consumer invoices issued by VAT-registered taxable persons in RO). Originally optional under OUG 115/2023; OUG 69/2024 made it mandatory from 2025.
2026-01-01(Per OUG 69/2024 and subsequent rectifying ordinances) Pre-filled VAT return (e-TVA) decision-support cross-check based on e-Factura data. Does not change reporting obligations on issuers but increases auditing pressure on data quality.

Confirm before writing the adapter: the precise B2C status for the period the adapter ships. As of the 2026-04 freeze, B2C invoices issued by VAT-registered sellers are in scope; B2C cash receipts (case de marcat / fiscal printers) are not — those run through a separate AMEF/ANAF cash-register pipeline.

Who must report

  • Romanian-established taxable persons issuing B2B or B2G invoices.
  • Includes non-resident taxable persons VAT-registered in RO (with a RO VAT prefix) for transactions taxable in RO.
  • Excludes purely foreign-to-foreign trade — the test is whether the supply is taxable in RO under the VAT Code (Codul Fiscal art. 268–278).

Exemptions

  • Invoices issued between non-RO entities for non-RO supplies (out of scope).
  • Receipts from electronic cash registers — handled by AMEF/MF, not e-Factura.
  • Public utility periodic billings issued in bulk through aggregator providers may use a separate XML schema profile; we do not target this. Document if a customer asks.

Thresholds

  • None. No de minimis threshold for the reporting obligation. Every in-scope invoice, including 0 RON, must be reported.
  • The 2024-07 mandate explicitly targets legal effect for VAT deduction: a recipient cannot deduct VAT on an invoice that wasn't reported on time, no matter the amount.

2. API surface

Base URLs

EnvironmentAPI base
Sandbox (test)https://api.anaf.ro/test/FCTEL/rest/
Productionhttps://api.anaf.ro/prod/FCTEL/rest/
OAuth issuer (test + prod share)https://logincert.anaf.ro/anaf-oauth2/v1/

The test and prod issuers use the same OAuth2 server but different client_id registrations (one per environment). Cited from the ANAF Manual de înregistrare oameni de IT (developer-onboarding guide) and the Specificatii tehnice e-Factura PDF. The sandbox accepts arbitrary Romanian CIFs and never emits a real Ministry-of-Finance signature; goldens validate XSDs but not legal status.

API version

There is no version segment in the URL. ANAF advertises the wire format as RO_CIUS (the Romanian Core Invoice Usage Specification, currently aligned to EN 16931-1:2017+A1:2019 — the European semantic invoice standard). The transport schema is UBL 2.1 (urn:oasis:names:specification:ubl:schema:xsd:Invoice-2) plus the CIUS rules expressed as Schematron and code-list constraints. The XSD itself does not change between RO_CIUS revisions; only the Schematron (*.sch) file does. Latest CIUS at the 2026-04 freeze is RO_CIUS_2.1.1 (2024-09).

Endpoints

All endpoints under …/FCTEL/rest/ are HTTPS; uploads are POSTs with Content-Type: text/plain + raw XML body, and queries are GETs with query parameters. All requests require an OAuth2 bearer token (see section 3).

Method + PathPurposeNotes
POST /upload?cif={cif}&standard={standard}Upload one invoice XML. standard=UBL for invoice/credit-note, standard=CN for OASIS UBL CreditNote-2.1 explicitly, standard=CII for UN/CEFACT Cross-Industry Invoice (rarely used in RO), standard=RASP for response messages. Returns index_incarcare (upload index).One invoice per call. Body 1 MB max. The cif is the issuer's tax ID.
POST /uploadb2c?cif={cif}&standard=UBLB2C variant of /upload. Same wire format but the recipient leg uses BT-46 0 ("not VAT registered") with consumer code list.Mandatory since 2025-01-01 for VAT-registered RO sellers issuing B2C.
GET /stareMesaj?id_incarcare={index}Poll status of one upload. Returns nok (rejected), in prelucrare (processing), ok (accepted, signature applied), XML cu erori nepreluat de sistem (terminal reject — XSD/Schematron failure).The async receipt step. Poll until terminal.
GET /listaMesajeFactura?zile={N}&cif={cif}&filtru={F}List all messages (sent + received) for the issuer over the last {N} days (max 60). filtru ∈ {E=errors, T=sent, P=received, R=responses}.Used by both issuers and recipients. Used to enumerate inbound for buyer-side flow.
GET /listaMesajePaginatieFactura?startTime={ms}&endTime={ms}&cif={cif}&pagina={n}&filtru={F}Paginated variant of the above. startTime/endTime are millisecond Unix timestamps.Use this when 60-day window is insufficient or to incrementally reconcile.
GET /descarcare?id={message_id}Download response/receipt ZIP for a given message. The ZIP contains the original invoice XML, the ANAF response XML (including digital seal), and any error report.id is the id from listaMesajeFactura, not index_incarcare. Confusing — see section 4.

Adapter hot path: OAuth2 token (cached) → POST /uploadGET /stareMesaj (polled until terminal) → GET /descarcare (only on ok, to retrieve the sealed PDF/XML for the merchant's archive).

Rate limits

ANAF's published guidance is "reasonable use"; in practice the adapter must honour 429 and 5xx with exponential backoff. The community-observed cap is ~1 request/second per CIF on stareMesaj and ~10 uploads/minute, but no hard SLO is documented. Treat any sustained throttling as our bug to back off harder.


3. Authentication

Credentials (one-time, out-of-band)

ANAF e-Factura uses OAuth2 authorization-code with cert-bound client authentication. The qualified electronic certificate (semnătură electronică calificată) acts as the client authenticator — it does not sign the invoice payload (signing is done by the Ministry of Finance after acceptance, see section 5). The certificate must be:

  1. Qualified under EU Regulation 910/2014 (eIDAS) and listed on the trust list (https://www.anaf.ro/anaf/internet/ANAF/asistenta_contribuabili/...). Romanian-recognised CAs at the 2026-04 freeze: certSIGN, DigiSign, Trans Sped, Alfasign. EU-recognised CAs from other member states are accepted in theory but the SPV onboarding flow expects a RO CA; we recommend defaulting to certSIGN for our managed-cert offering.
  2. Linked to a natural person who has a CNP (personal identifier), and that person must be designated as a representative of the CIF in SPV (Spațiul Privat Virtual — the ANAF self-service portal).
  3. Pre-registered at SPV through Înregistrare în SPV, attaching the Form 150 (cerere utilizare certificat digital). ANAF approves manually in 1–10 business days.

Cost: A 1-year qualified cert is RON ~70–150 (€15–30) from RO CAs. SPV access itself is free. Our adapter never holds the private key — see below.

OAuth2 client registration (developer side)

Separate from per-merchant cert: the adapter itself must register as an OAuth2 client with ANAF's IT developer programme:

  1. Email iturl@anaf.ro (or use the form at https://logincert.anaf.ro/) declaring the client's redirect URIs and intended use.
  2. ANAF returns a client_id + client_secret for test, and after one round of round-trip testing a separate pair for production.
  3. The client_secret is rotated only on demand; treat it as a long-lived shared secret.

Authorization-code flow

The merchant authorises the adapter once, then the adapter holds a refresh token thereafter. The cert authenticates the merchant during the front-channel authorisation step.

Step 1 — front-channel /authorize
Browser GET https://logincert.anaf.ro/anaf-oauth2/v1/authorize
?response_type=code
&client_id={our_client_id}
&redirect_uri={our_redirect}
&state={csrf}
&token_content_type=jwt // optional; we want JWTs

Browser must present a client cert during TLS handshake.
ANAF authenticates the user against the cert+SPV linkage,
redirects to {our_redirect}?code={code}&state={csrf}.

Step 2 — back-channel /token (initial)
POST https://logincert.anaf.ro/anaf-oauth2/v1/token
(basic-auth: client_id:client_secret)
grant_type=authorization_code
code={code}
redirect_uri={our_redirect}
token_content_type=jwt

→ { access_token, refresh_token, expires_in: 7776000 (90d), token_type: Bearer }

Step 3 — refresh
POST https://logincert.anaf.ro/anaf-oauth2/v1/token
(basic-auth: client_id:client_secret)
grant_type=refresh_token
refresh_token={rt}

→ { access_token (new, 90d), refresh_token (rotated) }

Token lifetimes:

  • Access token: 90 days (7,776,000 seconds) — confirmed from ANAF's developer guide. Long-lived because the front-channel cert step is operationally heavy.
  • Refresh token: ~1 year, single-use (rotated on each refresh).
  • Implication: the adapter's refresh-token store is the long-lived secret per merchant; access-tokens are cached and refreshed on a daily cron, well before expiry.

Why this is Cloud-OK

  • The cert is only used during the front-channel authorisation flow, in the end-user's browser TLS handshake. No private-key material crosses our servers.
  • After step 2, the adapter holds only OAuth2 tokens — plain bearer secrets with no cryptographic obligation on us.
  • Therefore a Cloud-deployed FiscalAPI tenant can mint tokens for any RO merchant who has clicked through the SPV-cert authorisation page; the merchant's cert never leaves their browser/USB-token.

Token usage on data-plane requests

POST /test/FCTEL/rest/upload?cif=12345678&standard=UBL HTTP/1.1
Host: api.anaf.ro
Authorization: Bearer eyJhbGciOiJSUzI1NiI…
Content-Type: text/plain

<?xml version="1.0" encoding="UTF-8"?>
<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" …>

</Invoice>

No request signing. No nonce. No X- headers. The bearer token is the entire authentication.


4. Payload shape

Wire format: UBL 2.1 + RO_CIUS

The on-the-wire XML is OASIS UBL 2.1 (UBL-Invoice-2.1.xsd / UBL-CreditNote-2.1.xsd) constrained by RO_CIUS (Romanian CIUS) Schematron rules. RO_CIUS is itself derived from the EU CIUS for EN 16931 — so a valid Peppol BIS Billing 3.0 invoice plus the RO-specific extensions is valid for e-Factura. Conversely, a generic UBL 2.1 invoice that does not satisfy RO_CIUS business rules (e.g. missing cbc:CustomizationID) will be nok even though it is XSD-valid.

Mandatory invocation

<Invoice xmlns="urn:oasis:names:specification:ubl:schema:xsd:Invoice-2"
xmlns:cac="urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2"
xmlns:cbc="urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2">
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1</cbc:CustomizationID>
<cbc:ID>FA-2026-001</cbc:ID>
<cbc:IssueDate>2026-04-20</cbc:IssueDate>
<cbc:DueDate>2026-05-20</cbc:DueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode>
<cbc:DocumentCurrencyCode>RON</cbc:DocumentCurrencyCode>
<!-- TaxCurrencyCode required if DocumentCurrencyCode != RON; must be RON -->

</Invoice>

Key constraints:

  • cbc:CustomizationID must be the RO_CIUS string above (current is 1.0.1; Schematron has been re-versioned but the CustomizationID is held stable for validation tooling). Source: ANAF RO_CIUS — Documentaţie tehnică.
  • cbc:DocumentCurrencyCode is the invoice currency. If not RON, the invoice must also include cbc:TaxCurrencyCode = RON and provide RON tax totals.
  • cbc:InvoiceTypeCode selects the document semantics; see table below.

Document-type codes (UN/CEFACT 1001 subset, RO_CIUS narrowed)

CodeMeaningSchemaUN/CEFACT 1001
380Standard invoice (factură)UBL-Invoice-2.1.xsdCommercial invoice
381Credit note (factură de stornare / credit)UBL-CreditNote-2.1.xsdCredit note
383Debit note (factură de debit)UBL-Invoice-2.1.xsdDebit note
384Corrective invoice (factură rectificativă)UBL-Invoice-2.1.xsdCorrected invoice
389Self-billed invoice (autofactură)UBL-Invoice-2.1.xsdSelf-billed invoice
751Invoice information for accounting purposes (rare, B2G framework)UBL-Invoice-2.1.xsdInvoice info

RO_CIUS does not restrict to a single code; the Schematron checks the combination of InvoiceTypeCode + presence of cac:BillingReference (for corrections) + absence of PaymentTerms for credit notes, etc.

High-impact RO_CIUS rules

  • cbc:CustomizationID exact match (rule BR-RO-010).
  • cac:AccountingSupplierParty must include cbc:CompanyID in the form RO12345678 (with prefix) when supplier is VAT-registered, and cbc:CompanyLegalForm (e.g. S.R.L.).
  • Postal codes use cbc:PostalZone with the 6-digit CodPS (e.g. 010101). The county uses cbc:CountrySubentity with RO-{XX} code (RO-B for București, RO-CJ for Cluj, etc.) — ISO 3166-2:RO.
  • Line VAT category code comes from a constrained subset of UN/CEFACT 5305:
    • S — standard rate
    • AA — reduced rate (lower)
    • Z — zero rate
    • E — exempt with right to deduct
    • K — intra-community supply (reverse-charge)
    • AE — VAT reverse charge (domestic; e.g. construction, scrap, electronics under RO Cod Fiscal art. 331)
    • O — outside scope of VAT
  • Reverse-charge lines must have cbc:Percent = 0.00 and the document-level cac:TaxTotal must include a per-category subtotal with the matching category code.
  • Multi-VAT invoices are required to carry one cac:TaxSubtotal per distinct (category, percent) pair.

Code lists

  • Currency: ISO 4217 (RON, EUR, USD, …).
  • Country: ISO 3166-1 alpha-2 (RO, HU, DE, …).
  • Payment means (cbc:PaymentMeansCode): UN/CEFACT 4461 — 30 (credit transfer), 48 (bank card), 54 (credit card), 42 (debit transfer), 10 (cash).
  • VAT category: UN/CEFACT 5305 (subset above).
  • Item classification: CPV (B2G) — optional but Peppol-recommended.

Sizes and limits

  • Single XML upload max 1 MB.
  • Single line item count: ANAF imposes no documented hard cap; RO_CIUS rule BR-CO-25 only requires that monetary totals tie. Practical cap observed: ~1,000 lines per invoice before merchants split.
  • Invoice ID (cbc:ID) max length 200 chars; we recommend ≤32 for cross-system safety.

5. Ordering and sequencing

Issuer-side numbering

Romanian VAT law (Codul Fiscal art. 319) requires invoice numbers to be sequential and unbroken per series, with the issuer free to choose the series scheme (year-prefixed, location-prefixed, etc.). e-Factura does not enforce a hash chain (unlike Spain Verifactu) and does not mint numbers on the issuer's behalf — cbc:ID is sourced from the issuer.

Adapter rule: Transaction.document_number is the source of truth and flows into cbc:ID verbatim. We do not gap-fill or re-mint.

Async server-side identifiers

Each successful upload produces three distinct identifiers that the adapter must thread through:

IdentifierWhen generatedWhere it appearsPurpose
index_incarcare (upload index)On POST /upload acceptResponse body of /upload; query parameter on /stareMesajThe receipt for "ANAF received this byte stream"; not yet an accepted invoice.
id_solicitare (request ID)On POST /upload acceptInside <header> of /stareMesaj responseMirrors index_incarcare; used in some helpdesk flows.
id (message ID)On terminal okReturned by /listaMesajeFactura; required by /descarcarePermanent reference to the sealed message.

The adapter must persist index_incarcare immediately on upload accept (this is the only way to recover state if our process dies before polling). The id is harvested later, when polling completes with ok.

Idempotency

ANAF does not offer client-side idempotency keys. A retry of /upload with the same XML body produces a new index_incarcare and the recipient sees a duplicate. The adapter must implement idempotency itself:

  • Client computes a content hash on the XML body (SHA-256, hex, lowercased).
  • A (cif, content_hash) row is inserted with ON CONFLICT DO NOTHING before the network call. If a prior row exists with a non-null index_incarcare, return that.
  • If the content row exists but index_incarcare is null (prior call errored mid-flight), the adapter retries using the same content; if ANAF accepts, store the new index_incarcare. If ANAF rejects with a duplicate-content business error (rare; ANAF doesn't currently enforce this), reconcile via listaMesajeFactura.

Submission ordering across multiple invoices

There is no required order between invoices. Each POST /upload is independent, scoped to a single document. The adapter parallelises uploads freely up to per-CIF rate caps.

Storno semantics

A "storno" (cancellation) is a new credit-note document referencing the original via cac:BillingReference/cac:InvoiceDocumentReference/cbc:ID. The original invoice stays on file; ANAF tracks the reversal as a relationship, not a deletion. Adapter rule: building a credit note requires the original's cbc:ID and cbc:IssueDate; we capture these in Transaction.related_document at preparation time.


6. Submission window

IssuerWindow
RO-established taxable persons issuing B2B invoices5 working days from the invoice issue date (cbc:IssueDate). Per OUG 115/2023 art. III. Working days exclude Romanian public holidays.
RO-established VAT-registered sellers issuing B2C invoices5 working days from issue (since 2025-01). Same rule.
Public-sector receivers (B2G)Issuers have the same 5-working-day rule; the public-sector buyer has its own internal cycle but does not interact with our adapter.

Operational position: the adapter targets same-day or near-real-time submission for both robustness and cash-flow purposes (recipient cannot deduct VAT until accepted), with a fallback queue that retries on failure for up to the legal 5-working-day window. SubmissionWindow::Days(5) in the CountryAdapter semantics.

If the merchant's issue-date drifts more than 5 working days from now() at preparation time (e.g. a backfill job), the adapter logs a warning but still attempts submission — ANAF accepts late submissions and applies the penalty/seal status accordingly; the legal consequence is on the merchant, not our pipeline.


7. Sandbox access

Onboarding

  1. Apply to the ANAF e-Factura developer programme by emailing iturl@anaf.ro from a corporate mailbox, declaring intended use and requesting test credentials.
  2. ANAF returns:
    • A test client_id + client_secret (basic-auth credentials).
    • A list of test CIFs you can use as merchant tax IDs.
    • The whitelisted redirect URIs you submitted. Onboarding takes 5–15 business days.
  3. Use the test issuer at https://logincert.anaf.ro/anaf-oauth2/v1/authorize with response_type=code and a real qualified cert (the cert is real even in sandbox; ANAF doesn't issue test certs because the SPV linkage is the actual point of the OAuth flow).
  4. Submit invoices to https://api.anaf.ro/test/FCTEL/rest/upload. Sandbox accepts arbitrary RO CIFs and returns a test seal that is not legally binding. XSD + Schematron validation behaves identically to production.

Test data quirks

  • Sandbox /listaMesajeFactura only returns the last 60 days; expired test data is purged.
  • Sandbox stareMesaj results return faster than production (typically seconds vs. up to minutes), so polling intervals tuned in sandbox should be doubled before going live.
  • The sandbox seal is a fixed dummy certificate; do not parse the seal in tests as if it were the production MF cert.

What we'll need

  • A pair of test client credentials checked into the password vault under RO_ANAF_TEST_CLIENT_ID / RO_ANAF_TEST_CLIENT_SECRET.
  • One real qualified cert (the team can use a single shared cert for the CI service account; certSIGN is cheapest).
  • An .env template with RO_ANAF_BASE_URL, RO_ANAF_OAUTH_BASE_URL, RO_ANAF_CLIENT_ID, RO_ANAF_CLIENT_SECRET, RO_ANAF_REDIRECT_URI, RO_ANAF_REFRESH_TOKEN (per-merchant), RO_ANAF_DEFAULT_CIF.

8. Error model

Three error tiers

ANAF's error surfaces split cleanly into transport, upload-time, and business-validation tiers. The adapter must reflect this split through to the merchant.

Transport tier (HTTP / OAuth)

  • 401 invalid_token — refresh and retry.
  • 401 invalid_client — credential bug, escalate.
  • 403 on data-plane — token scope mismatch (test token against prod URL or vice versa). Config bug.
  • 429 — back off; ANAF doesn't expose Retry-After reliably, use exponential.
  • 5xx — retry with same payload (same content hash → same idempotency guard).

Upload-time tier (XSD + Schematron, returned in /upload response)

The /upload response is itself UBL — an <header> element with status attributes:

<header xmlns="mfp:anaf:dgti:spv:respUploadFisier:v1"
ExecutionStatus="0"
index_incarcare="123456789"
dateResponse="202604201040"
Errors_count="0"/>
ExecutionStatusMeaningAction
0Accepted for processingPersist index_incarcare; poll stareMesaj.
1Rejected at upload timeXSD or top-level Schematron failure. Read <Errors errorMessage="…"/> children. Not retryable as-is.

Upload-time errors mean the bytes were structurally invalid. Examples:

  • XML cu erori — XSD validation failed.
  • CIF necalibrat / inactiv — issuer CIF doesn't match the OAuth identity.
  • Limita marimii fisierului — body > 1 MB.
  • Numarul de documente trimise depăşeşte limita — rate cap hit.

Adapter mapping: AdapterError::ValidationFailed with the parsed errors.

Async business tier (Schematron + ANAF business rules, returned in stareMesaj + descarcare)

After acceptance, ANAF runs the full RO_CIUS Schematron pack and ministry business rules (VAT registration lookup, BillingReference resolution, …) asynchronously. Outcomes are visible via /stareMesaj?id_incarcare=...:

stareMeaningAction
in prelucrareProcessing — keep polling (1–60s in sandbox; up to minutes in prod).Continue polling.
okAccepted, MF seal applied. The id from listaMesajeFactura is the permanent ID.Mark accepted; download via /descarcare.
nokRejected. Errors live in the descarcare ZIP under _errors_*.xml.Map to AdapterError::Rejected with errors; not retryable as-is.
XML cu erori nepreluat de sistemTerminal: XML had errors that were detected post-acceptance.Map to AdapterError::Rejected; not retryable as-is.

Result-code taxonomy

LevelExamplesWhat it means for us
Transport401, 5xx, timeout, DNSStandard retries with backoff; refresh token on 401 invalid_token.
Upload (ExecutionStatus=1)XML cu erori, CIF inactiv, Limita marimiiOur payload is bad. Fix and resubmit (will get a new index_incarcare).
Async business (stareMesaj=nok)BR-RO-010 violation, Cod TVA cumparator inexistent, Suma TVA incorectaEither our composition bug or merchant-data bug. Surface to merchant; not retryable as-is.
Async terminal happy path (ok)Success; download seal via /descarcare.

Key distinction: ExecutionStatus=1 vs stare=nok

  • ExecutionStatus=1 at upload time — request never entered the async processing queue. The adapter's job is to surface a structural error. Always our or our schema generator's bug; merchant data was probably fine except for fields we forgot to map.
  • stare=nok after polling — request entered processing and was rejected by RO_CIUS Schematron or business rules. ~60% of these in practice are merchant-data bugs (e.g. recipient CIF not VAT-registered when we tagged S rate); the rest are gaps in the adapter's RO_CIUS coverage.

Retry policy matrix

EventRetry same upload?Retry with new payload?Human ticket?
401 invalid_tokenAfter refresh_token round-trip
5xx / timeout✅ (idempotency guard handles dedup)
429After backoff
ExecutionStatus=1 (XSD/Schematron)Only after fixing payload✅ if blocks mainline; otherwise auto-fix and replay
stareMesaj=nok (business)Only after fixing data; new cbc:ID✅ surface to merchant
stareMesaj=okN/A — success

9. Implementation plan

Crate layout

rust/adapters/romania/
├── Cargo.toml # name = "romania-efactura"
└── src/
├── lib.rs # RomaniaAdapter (impl CountryAdapter)
├── config.rs # RomaniaConfig (cif, oauth refresh, ...)
├── auth.rs # OAuth2 token exchange + refresh + cache
├── client.rs # reqwest client, retry, polling for stareMesaj
├── ubl_invoice.rs # Transaction → UBL Invoice (380, 383, 384)
├── ubl_credit_note.rs # Transaction → UBL CreditNote (381)
├── cius.rs # RO_CIUS rule helpers (CompanyID format, county codes, ...)
├── code_lists.rs # static maps for VAT category, payment means, country, currency
├── errors.rs # AdapterError variants for the three tiers
├── poll.rs # stareMesaj polling state machine
└── tests.rs # XSD + golden conformance against testdata/conformance/romania/efactura

Trait fit

CountryAdapter::prepare() is the natural two-phase split:

  • prepare() — build UBL XML in-memory (quick-xml), apply CIUS canonical ordering, compute the SHA-256 idempotency hash, mint the deterministic fiscal_id = cif:invoice_number, return PreparedRecord with the XML in content. Fast, no network. Call XSD validation here so errors surface before any network hop.
  • submit()OAuth2 token (cache hit ≥99% of the time) → POST /upload → on ExecutionStatus=0 return SubmitResult { upstream_id: index_incarcare, status: Submitted, terminal: false }. Does not wait for the async processing result.
  • A separate poll() job — runs every 30 s on a background queue, iterates submissions in Submitted state, calls /stareMesaj, and promotes them to Accepted{seal_id} or Rejected{errors}.

DeploymentMode::Cloud is the primary target; Embedded works equivalently (no on-device crypto). SubmissionWindow::Days(5) with operational target of same-day.

Config shape

pub struct RomaniaConfig {
/// RO CIF (without the "RO" prefix, since SPV uses the bare digits).
/// e.g. "12345678".
pub cif: String,

/// OAuth client_id (stable per-deployment, not per-merchant).
pub oauth_client_id: String,

/// OAuth client_secret (stable per-deployment).
pub oauth_client_secret: String,

/// OAuth redirect URI registered with ANAF (stable per-deployment).
pub oauth_redirect_uri: String,

/// Per-merchant refresh token (long-lived, ~1y, single-use).
/// Stored encrypted at rest; rotated on each /token call.
pub oauth_refresh_token: String,

/// Per-merchant access token cache (90d TTL).
pub oauth_access_token: Option<CachedToken>,

/// true => api.anaf.ro/test, false => api.anaf.ro/prod.
pub sandbox: bool,

/// Optional VAT-group representative CIF, when the issuer is a member.
pub vat_group_representative: Option<String>,

/// Whether the merchant's seller registration is on the "high fiscal
/// risk products" list (controls some Schematron checks).
pub high_risk_products: bool,
}

Dependencies (expected)

  • quick-xml — UBL XML build + parse (already in workspace via Spain adapters).
  • reqwest — already in workspace.
  • serde / serde_json — input model parsing (already).
  • chrono — date handling for IssueDate / DueDate / Romanian working days.
  • sha2 — SHA-256 idempotency hash.
  • base64 — already in workspace.
  • Optional: xsd-validator or in-process libxml2 binding for XSD check in prepare() (we already pull xmlschema for Italy).
  • Optional: schematron-rs if it lands; otherwise, run RO_CIUS Schematron in CI only (xsltproc) and rely on production fallback rejection at runtime.

No native signing libs, no XAdES, no SOAP. The interface is plain XML over HTTPS with bearer auth.

Open questions (for implementation bead)

  1. Schematron coverage in prepare(). XSD validation is cheap (~5 ms), but RO_CIUS Schematron has 200+ rules and is slow in pure Rust. Do we (a) ship a Rust port of the rule set, (b) shell out to xsltproc + skeleton.xsl, or (c) skip Schematron in prepare() and rely on ANAF's async feedback? Recommend (c) for v1 — let ANAF be the validator — and add a CI gate that runs Schematron over all goldens to keep our generators honest.

  2. County code source of truth. RO_CIUS requires cbc:CountrySubentity to be RO-{XX} ISO 3166-2:RO. Our Location.address doesn't carry an ISO subdivision today. Either (a) add a structured field, or (b) derive from postal code at prepare() time using a lookup table. Recommend (a) — implicit derivation will silently misreport for cross-county branches.

  3. Idempotency window. (cif, content_hash) dedup needs a TTL — too short and replays after a long outage will double-submit; too long and we'll block legitimate "same invoice content, new sequence" cases (e.g. a recurring monthly invoice the merchant accidentally ran twice). Recommend: no TTL on the (cif, content_hash, invoice_number) triple. Plain content hash without the invoice number is wrong because two different invoices in different sequences can be byte-identical.

  4. Polling cadence. /stareMesaj returns within seconds in sandbox but up to minutes in production. A poll-every-30s job is wasteful in sandbox and slow in production. Recommend: linear backoff per submission (5s, 15s, 30s, 60s, …, capped at 5 min), reset on every transition.

  5. What if ANAF returns nok but the seal store says we already accepted? Race condition: the upload was retried after a network blip and the second index_incarcare got nok while the first got ok. listaMesajeFactura is the tiebreaker — call it on conflict, prefer the ok record, and discard the duplicate index_incarcare silently.

  6. Cross-border B2B (intra-community supply). Recipient is a non-RO EU VAT taxpayer; cbc:CompanyID carries the destination VAT (HU12345678, DE…). The Schematron requires a K line category and document-level cbc:Note referencing the legal basis (Codul Fiscal art. 294). Our adapter must inject the legal-basis note from a config-supplied template when the supply is detected as intra-community.

What could force us off Cloud-OK?

Nothing identified in the spec. No HSM, no on-device cert handling for the signing of payloads — the cert is only the user-side TLS authenticator during front-channel auth. The data plane is plain bearer token, no request signature. ANAF, not the issuer, applies the seal post-acceptance.

This is consistent with the Phase-1 thesis: Romania, like Hungary, is a green light for fiscal-cloud deployment.

Async receipt model — confirmed dovetail with fi-b7q

The submit() → poll() → finalize() split required by Romania's async acceptance maps cleanly onto fi-b7q's status-polling loop. The only adapter- specific contribution is the index_incarcare → id_message two-step identifier promotion (section 5). The poller already supports two-step finalisation for similar reasons (Italy SDI's metadata-file flow and France NF525's batch acks).

Testing strategy

  • Unit (in-adapter): UBL builder property tests (CIUS rule coverage), OAuth refresh token rotation, idempotency hash determinism. Golden XSD validation on all 6 fixture triplets using xmllint (CI tool already used by the workspace).
  • Conformance: the 6 golden triplets validated as part of cargo test -p romania-adapters. RO_CIUS Schematron is run in a separate CI step using Schematron-skeleton.xsl + xsltproc.
  • Integration (against sandbox): full upload → poll → descarcare round trip. Gated behind RO_ANAF_SANDBOX_REFRESH_TOKEN env var so CI without a refresh token still passes unit tests.

Rollout sequence (for the implementation bead)

  1. Day 1–2: ubl_invoice.rs + ubl_credit_note.rs builders, XSD validate against all 6 goldens.
  2. Day 3: auth.rs — OAuth2 token + refresh, mocked against a recorded /token response, real flow gated behind sandbox env var.
  3. Day 4: client.rs + /upload happy path, parse <header> envelope, persist index_incarcare.
  4. Day 5: poll.rsstareMesaj polling state machine + linear backoff + descarcare ZIP unpack.
  5. Day 6: Error model (three tiers) + retry policies + listaMesajeFactura reconciler for orphan uploads.
  6. Day 7: Cross-border + credit-note + multi-VAT edge cases against goldens.
  7. Day 8: Soak test against sandbox: 200 invoices, observe rate-limit edges, tune backoff.

10. Citations

All facts above are sourced from one of:

Per-fixture citations live in each meta.json under testdata/conformance/romania/efactura/golden/.