<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Vlad Cristian Alexa</title>
    <description>The latest articles on DEV Community by Vlad Cristian Alexa (@fiscal-link).</description>
    <link>https://dev.to/fiscal-link</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4108279%2F653001c4-37cc-4ac1-b7b1-b1e2ac27db23.png</url>
      <title>DEV Community: Vlad Cristian Alexa</title>
      <link>https://dev.to/fiscal-link</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/fiscal-link"/>
    <language>en</language>
    <item>
      <title>From Stripe Checkout to a Compliant Romanian E-Invoice: A JSON UBL Field Report</title>
      <dc:creator>Vlad Cristian Alexa</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:10:55 +0000</pubDate>
      <link>https://dev.to/fiscal-link/from-stripe-checkout-to-a-compliant-romanian-e-invoice-a-json-ubl-field-report-3b0i</link>
      <guid>https://dev.to/fiscal-link/from-stripe-checkout-to-a-compliant-romanian-e-invoice-a-json-ubl-field-report-3b0i</guid>
      <description>&lt;p&gt;E-invoicing from a payment event is a mapping problem, not an XML problem. Stripe hands you a &lt;code&gt;checkout.session.completed&lt;/code&gt; whose &lt;code&gt;amount_total&lt;/code&gt; is a single integer in minor units, with no tax breakdown and no legal identity. ANAF's e-Factura portal, on the other end, accepts only UBL 2.1 documents pinned to the Romanian CIUS-RO profile, where every line carries a VAT &lt;em&gt;rate&lt;/em&gt;, totals must reconcile across four &lt;code&gt;cbc:*Amount&lt;/code&gt; elements, and the issuer's fiscal code is validated down to its checksum. Between those two worlds sit irreversible mapping decisions — and, as staging proved, the silent failures live in the webhook contract, not in the XSD. This is that field report: three production codebases, one canonical JSON shape in the middle, three contract breaks that only staging caught.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: webhook → canonical JSON → UBL
&lt;/h2&gt;

&lt;p&gt;The core rule: &lt;strong&gt;webhooks never touch UBL&lt;/strong&gt;. Every inbound provider (Stripe app event, WooCommerce order webhook, direct API call) is mapped into one canonical &lt;code&gt;InvoiceSchema&lt;/code&gt; JSON document — a validated Java record with &lt;code&gt;invoiceNumber&lt;/code&gt;, &lt;code&gt;issueDate&lt;/code&gt;, &lt;code&gt;currency&lt;/code&gt;, &lt;code&gt;issuer&lt;/code&gt;/&lt;code&gt;buyer&lt;/code&gt; parties, &lt;code&gt;items[]&lt;/code&gt;, and &lt;code&gt;totals&lt;/code&gt;. Persistence, validation, and XML generation all consume that shape; the &lt;code&gt;UBLInvoiceBuilderService&lt;/code&gt; reads a &lt;code&gt;Map&amp;lt;String, Object&amp;gt;&lt;/code&gt; produced from it and is the &lt;em&gt;only&lt;/em&gt; place that knows the CIUS-RO dialect.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Stripe checkout.session.completed ──┐
                                   ├─▶ canonical InvoiceSchema JSON ─▶ UBL 2.1 CIUS-RO XML ─▶ ANAF SPV
WooCommerce order.completed ────────┘        (POST /v1/invoices)          (JobRunr async)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For Stripe, the chain is two hops. The Stripe App backend receives the event at &lt;code&gt;POST /hooks/app&lt;/code&gt; (one developer-configured endpoint listening to &lt;em&gt;connected accounts&lt;/em&gt;), verifies the &lt;code&gt;Stripe-Signature&lt;/code&gt; with &lt;code&gt;stripe.webhooks.constructEvent(raw, sig, STRIPE_WEBHOOK_SECRET)&lt;/code&gt; over the exact raw bytes, reads the merchant's FiscalLink API key from the Stripe Apps Secret Store scoped by &lt;code&gt;event.account&lt;/code&gt;, and then calls &lt;code&gt;POST /v1/invoices&lt;/code&gt;. WooCommerce pushes the order JSON directly to &lt;code&gt;POST /v1/webhooks/woocommerce/{tenantId}&lt;/code&gt;, HMAC-signed.&lt;/p&gt;

&lt;p&gt;This indirection buys two things. First, adding a third source (Xero, QuickBooks) means writing one mapper, not one XML dialect. Second, validation failure modes concentrate in one service: &lt;code&gt;InvoiceService.createAndEnqueue&lt;/code&gt; runs &lt;code&gt;CifValidator&lt;/code&gt; checksum checks and &lt;code&gt;En16931CompletenessValidator&lt;/code&gt; — a completeness gate that rejects with HTTP 400 &lt;em&gt;before&lt;/em&gt; anything is persisted — then builds the UBL synchronously and enqueues a JobRunr &lt;code&gt;ANAF_SUBMISSION&lt;/code&gt; job with exponential backoff (&lt;code&gt;5s × 2^(attempt-1)&lt;/code&gt;, max 10).&lt;/p&gt;

&lt;h2&gt;
  
  
  The mapping that matters
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Payment/order source&lt;/th&gt;
&lt;th&gt;Canonical &lt;code&gt;InvoiceSchema&lt;/code&gt;
&lt;/th&gt;
&lt;th&gt;UBL 2.1 / CIUS-RO element&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;session.amount_total&lt;/code&gt; (minor units) ÷ 100&lt;/td&gt;
&lt;td&gt;&lt;code&gt;totals.total&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cbc:TaxInclusiveAmount&lt;/code&gt;, &lt;code&gt;cbc:PayableAmount&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;total × 100/(100+19)&lt;/code&gt; rounded&lt;/td&gt;
&lt;td&gt;&lt;code&gt;totals.subtotal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;cbc:LineExtensionAmount&lt;/code&gt;, &lt;code&gt;cbc:TaxExclusiveAmount&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;total − subtotal&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;totals.totalVAT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cac:TaxTotal/cbc:TaxAmount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;line_items[].amount_total ÷ 100 ÷ qty&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;items[].unitPrice&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;cac:Price/cbc:PriceAmount&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;merchant metadata + stored CIF&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;issuer.name&lt;/code&gt;, &lt;code&gt;issuer.vatNumber&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;AccountingSupplierParty → PartyLegalEntity&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WC &lt;code&gt;taxes[0].rate_percent&lt;/code&gt; / &lt;code&gt;tax_lines[]&lt;/code&gt; default&lt;/td&gt;
&lt;td&gt;&lt;code&gt;items[].vatRate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;ClassifiedTaxCategory cbc:Percent&lt;/code&gt; (ID &lt;code&gt;S&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;customer_details.name&lt;/code&gt; / WC billing&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;buyer.name&lt;/code&gt; (+ optional &lt;code&gt;vatNumber&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AccountingCustomerParty&lt;/code&gt;, &lt;code&gt;PostalAddress&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;WC order &lt;code&gt;subtotal&lt;/code&gt;/&lt;code&gt;total_tax&lt;/code&gt;/&lt;code&gt;total&lt;/code&gt; — trusted, never re-summed&lt;/td&gt;
&lt;td&gt;&lt;code&gt;totals&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;LegalMonetaryTotal&lt;/code&gt;, per-rate &lt;code&gt;TaxSubtotal&lt;/code&gt;s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Three decisions in that table are load-bearing:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tax travels as a rate, never as a monetary amount.&lt;/strong&gt; Stripe checkout events have no tax breakdown at all, so the Stripe mapper grosses up at the standard 19% (&lt;code&gt;subtotal = amountTotal * 100 / (100 + 19)&lt;/code&gt;). WooCommerce &lt;em&gt;does&lt;/em&gt; send tax amounts, but the mapper deliberately ignores &lt;code&gt;taxes[].total&lt;/code&gt; as the rate source. The canonical line carries &lt;code&gt;vatRate&lt;/code&gt; (a percent) plus &lt;code&gt;vatAmount&lt;/code&gt; as a derived convenience field; the UBL builder recomputes everything from the rate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// AbstractInvoiceBuilder.computeRateSubtotals — one TaxSubtotal per rate (EN 16931 BR-45)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Map&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;Object&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;item&lt;/span&gt; &lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;qty&lt;/span&gt;       &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimalUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toBigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"quantity"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;unitPrice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimalUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toBigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"unitPrice"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;vatRate&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;BigDecimalUtils&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toBigDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getOrDefault&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"vatRate"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;19&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;qty&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unitPrice&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;setScale&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;lineVat&lt;/span&gt;      &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;lineSubtotal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;multiply&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vatRate&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
            &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;divide&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;valueOf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;RoundingMode&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;HALF_UP&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;merge&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;vatRate&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;BigDecimal&lt;/span&gt;&lt;span class="o"&gt;[]{&lt;/span&gt;&lt;span class="n"&gt;lineSubtotal&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;lineVat&lt;/span&gt;&lt;span class="o"&gt;},&lt;/span&gt; &lt;span class="cm"&gt;/* … */&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Totals come from the order, not from summing lines.&lt;/strong&gt; Rounding at line level makes Σlines drift from the checkout total. Both mappers therefore trust the provider's order-level &lt;code&gt;subtotal&lt;/code&gt;/&lt;code&gt;total_tax&lt;/code&gt;/&lt;code&gt;total&lt;/code&gt; when present and only fall back to recomputation (&lt;code&gt;computeTotals&lt;/code&gt; in the WooCommerce controller does exactly this). Provider totals feed &lt;code&gt;LegalMonetaryTotal&lt;/code&gt;; line arithmetic only feeds per-rate subtotals — which is why a bad line rate can corrupt the document even when totals are right (bug #3 below).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rates resolve per line, with a fallback chain.&lt;/strong&gt; WooCommerce sends VAT per line as &lt;code&gt;taxes[].rate_percent&lt;/code&gt;; the mapper tries that first, then the order-level &lt;code&gt;tax_lines[].rate_percent&lt;/code&gt; as the shop-wide default, then 0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// WooCommerceWebhookController.mapLineItems — rate resolution order&lt;/span&gt;
&lt;span class="nc"&gt;BigDecimal&lt;/span&gt; &lt;span class="n"&gt;vatRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;defaultVatRate&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;                       &lt;span class="c1"&gt;// from order tax_lines[]&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;has&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taxes"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taxes"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isArray&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taxes"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;size&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;JsonDecimal&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tryReadDecimal&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;li&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"taxes"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"rate_percent"&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ld&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;vatRate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ld&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;value&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plugin mirrors that contract when it builds the payload: per-line &lt;code&gt;total_tax&lt;/code&gt; plus &lt;code&gt;taxes:[{rate_percent}]&lt;/code&gt;, where the rate itself is read from the WooCommerce tax-rate table (&lt;code&gt;WC_Tax_Rates::get_rate($rate_id)-&amp;gt;tax_rate&lt;/code&gt;), falling back to &lt;code&gt;tax_total / total * 100&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three bugs that only staging caught
&lt;/h2&gt;

&lt;p&gt;A dockerized WordPress staging harness (WP 6.7 + WooCommerce 11.0.1) pushed real orders to the live core and exposed three silent contract breaks in the original plugin — three ways to drop or corrupt an invoice while returning 2xx:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Missing &lt;code&gt;issuerName&lt;/code&gt;/&lt;code&gt;issuerVat&lt;/code&gt; → HTTP 400, and the plugin classified it as permanent.&lt;/strong&gt; Core binds &lt;code&gt;@RequestParam String issuerName&lt;/code&gt; on the webhook — a required query parameter — so Spring rejects the request &lt;em&gt;before&lt;/em&gt; the handler runs when the merchant never configured their company name. The plugin's old error handling wrote &lt;code&gt;_flwc_submitted = 'permanent_error:400'&lt;/code&gt; and never retried: one misconfiguration, zero invoices, no alert. Lesson: anything the merchant must configure to make the invoice &lt;em&gt;legal&lt;/em&gt; belongs in setup-time validation, and a 400 on a webhook should page someone — it is never transient.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. No &lt;code&gt;X-WC-Webhook-Topic&lt;/code&gt; header → silent &lt;code&gt;processed:false&lt;/code&gt;.&lt;/strong&gt; Core's receiver returns &lt;code&gt;200 {"received":true,"processed":false,"reason":"No topic header"}&lt;/code&gt; for events it deliberately ignores (it also acks 2xx when invoice creation fails, to stop provider retry storms). The plugin treated &lt;em&gt;any&lt;/em&gt; 2xx as success and stamped the order as submitted. The fix is the contract that now ships in the plugin:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// class-flwc-submit.php — only an explicit processed:true means done&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nv"&gt;$code&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nv"&gt;$body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;wp_remote_retrieve_body&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$response&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nv"&gt;$parsed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;json_decode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nb"&gt;is_array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$parsed&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$parsed&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'processed'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nv"&gt;$this&lt;/span&gt;&lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;&lt;span class="nf"&gt;bump_failures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$order_id&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;// 2xx but NOT processed → retry&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="nf"&gt;update_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;META_DONE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;current_time&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="s1"&gt;'mysql'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;delete_post_meta&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt; &lt;span class="nv"&gt;$order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;self&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;META_FAILURES&lt;/span&gt; &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Tax sent as &lt;code&gt;taxes:[{total:19}]&lt;/code&gt; instead of &lt;code&gt;rate_percent&lt;/code&gt; → invalid UBL.&lt;/strong&gt; This is the nastiest one: everything returned 200 and an invoice was created. The mapper's &lt;code&gt;tryReadDecimal(rate_percent)&lt;/code&gt; found nothing (&lt;code&gt;total&lt;/code&gt; is not &lt;code&gt;rate_percent&lt;/code&gt;), the order carried no &lt;code&gt;tax_lines&lt;/code&gt;, so lines were stored at &lt;code&gt;vatRate 0, vatAmount 0&lt;/code&gt; — while &lt;code&gt;computeTotals&lt;/code&gt; kept the order's real VAT 19.00. The UBL then declared &lt;code&gt;cbc:Percent 0&lt;/code&gt; on every line against a &lt;code&gt;TaxTotal/TaxAmount 19.00&lt;/code&gt;: a textbook EN 16931 BR-45/BR-CO consistency break that ANAF would reject. The database told the story: corrupt invoices &lt;code&gt;WC-9001&lt;/code&gt; (line &lt;code&gt;vatRate 0&lt;/code&gt;) next to correct &lt;code&gt;WC-11&lt;/code&gt;, &lt;code&gt;WC-12&lt;/code&gt;, &lt;code&gt;WC-13&lt;/code&gt; (line &lt;code&gt;vatRate 19&lt;/code&gt;, UBL percents &lt;code&gt;['19','19']&lt;/code&gt;). Lesson: the amount-based tax shape makes you think you sent 19% — you sent 19 &lt;em&gt;currency units&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Idempotency &amp;amp; retries
&lt;/h2&gt;

&lt;p&gt;There are three idempotency layers, and they must not be confused:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Order/event level (source of truth).&lt;/strong&gt; The WooCommerce plugin guards submission with post meta: &lt;code&gt;_flwc_submitted&lt;/code&gt; (timestamp) means done, &lt;code&gt;_flwc_failures&lt;/code&gt; counts attempts (max 5). &lt;code&gt;on_order_completed&lt;/code&gt; returns immediately if the meta exists, and the hourly &lt;code&gt;flwc_retry_pending&lt;/code&gt; cron re-pushes only orders that have failures, no done-marker, and attempts left. This makes the &lt;code&gt;processing → completed&lt;/code&gt; status cycle and webhook redelivery safe.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;HTTP level.&lt;/strong&gt; &lt;code&gt;POST /v1/invoices&lt;/code&gt; supports Stripe-style &lt;code&gt;Idempotency-Key&lt;/code&gt; headers (&lt;code&gt;IdempotencyFilter&lt;/code&gt;), but webhook receivers are explicitly excluded from that filter — &lt;code&gt;shouldNotFilter&lt;/code&gt; returns true for &lt;code&gt;/v1/webhooks/&lt;/code&gt; — because each provider has its own redelivery semantics and its own natural key (Stripe &lt;code&gt;event.id&lt;/code&gt;, WooCommerce order id). Reusing one generic mechanism for both hides bugs; keep them separate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Job level.&lt;/strong&gt; Once an invoice exists, the &lt;code&gt;ANAF_SUBMISSION&lt;/code&gt; job retries with exponential backoff and dies into &lt;code&gt;failed&lt;/code&gt;/&lt;code&gt;dead_letter&lt;/code&gt; after 10 attempts; non-retryable results (ANAF HTTP 403, validation failures) skip retries entirely. Submission answers are reconciled later by a polling job.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The failure-mode matrix matters as much as the keys: 4xx → permanent (mark and stop, or you hammer a misconfiguration for days), 5xx/network → transient (retry), 2xx-without-&lt;code&gt;processed:true&lt;/code&gt; → &lt;em&gt;treated as transient&lt;/em&gt; (bug #2). Handled-but-failed events must still get a fast 2xx, or providers replay the whole batch into a broken endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the UBL actually contains
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;cbc:UBLVersionID&amp;gt;&lt;/span&gt;2.1&lt;span class="nt"&gt;&amp;lt;/cbc:UBLVersionID&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:CustomizationID&amp;gt;&lt;/span&gt;urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1&lt;span class="nt"&gt;&amp;lt;/cbc:CustomizationID&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:ProfileID&amp;gt;&lt;/span&gt;urn:fdc:peppol.eu:2017:poacc:billing:01:1.0&lt;span class="nt"&gt;&amp;lt;/cbc:ProfileID&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:ID&amp;gt;&lt;/span&gt;WC-11&lt;span class="nt"&gt;&amp;lt;/cbc:ID&amp;gt;&lt;/span&gt;                        &lt;span class="c"&gt;&amp;lt;!-- or INV-STRIPE-1759000000, FL-1F2E3A4B --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:IssueDate&amp;gt;&lt;/span&gt;2026-09-02&lt;span class="nt"&gt;&amp;lt;/cbc:IssueDate&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:InvoiceTypeCode&amp;gt;&lt;/span&gt;380&lt;span class="nt"&gt;&amp;lt;/cbc:InvoiceTypeCode&amp;gt;&lt;/span&gt; &lt;span class="c"&gt;&amp;lt;!-- 381 = credit note --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:DocumentCurrencyCode&amp;gt;&lt;/span&gt;RON&lt;span class="nt"&gt;&amp;lt;/cbc:DocumentCurrencyCode&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:TaxCurrencyCode&amp;gt;&lt;/span&gt;RON&lt;span class="nt"&gt;&amp;lt;/cbc:TaxCurrencyCode&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CustomizationID&lt;/strong&gt; is the CIUS-RO contract — the exact string &lt;code&gt;urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1&lt;/code&gt;, alongside the PEPPOL BIS-3 &lt;code&gt;ProfileID&lt;/code&gt;. If either is wrong, the document is out of scope before validation starts.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invoice numbers&lt;/strong&gt; arrive already prefixed by the source so origin is debuggable at a glance: &lt;code&gt;WC-{orderId}&lt;/code&gt;, &lt;code&gt;INV-STRIPE-{unixTs}&lt;/code&gt;, with &lt;code&gt;FL-XXXXXXXX&lt;/code&gt; as the core's fallback.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Currency.&lt;/strong&gt; &lt;code&gt;TaxCurrencyCode&lt;/code&gt; is &lt;em&gt;always&lt;/em&gt; RON even when the checkout was in EUR. For any &lt;code&gt;DocumentCurrencyCode != RON&lt;/code&gt;, CIUS-RO (BR-53) demands a second &lt;code&gt;cac:TaxTotal&lt;/code&gt; in RON converted at the BNR reference rate for the issue date, plus a &lt;code&gt;cac:PaymentExchangeRate&lt;/code&gt; block. Never assume "the checkout currency is the invoice currency."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VAT categories.&lt;/strong&gt; Positive rates emit category &lt;code&gt;S&lt;/code&gt;; zero-rate lines emit the profile's zero category via &lt;code&gt;getZeroRateCategoryId()&lt;/code&gt; (default &lt;code&gt;Z&lt;/code&gt; — the France/Chorus Pro adapter overrides it to &lt;code&gt;E&lt;/code&gt;). CIUS-RO validates the &lt;em&gt;consistency&lt;/em&gt; of category, percent, and amounts, so whatever your zero-rate policy is, it must be applied in the one builder, not per mapper.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pitfalls checklist
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;2xx ≠ processed&lt;/code&gt; — require the explicit &lt;code&gt;processed: true&lt;/code&gt; in the response body before marking anything terminal.&lt;/li&gt;
&lt;li&gt;Never send tax as a monetary amount; the UBL wants per-line &lt;em&gt;rates&lt;/em&gt;, and document VAT is derived from them (BR-45/BR-CO).&lt;/li&gt;
&lt;li&gt;Webhook events arrive unexpanded — retrieve the Stripe session with &lt;code&gt;expand: ['line_items', 'customer']&lt;/code&gt; before mapping; same for &lt;code&gt;expand[]=payload&lt;/code&gt; on Stripe Secret Store reads (without it, reads return &lt;code&gt;null&lt;/code&gt; forever and every merchant looks disconnected).&lt;/li&gt;
&lt;li&gt;Verify signatures over the raw request bytes you captured, not a re-stringified body.&lt;/li&gt;
&lt;li&gt;Idempotency belongs at the order/event level for webhooks (post-meta, event id); keep the HTTP &lt;code&gt;Idempotency-Key&lt;/code&gt; mechanism for your own API.&lt;/li&gt;
&lt;li&gt;Trust provider order totals; only derive per-line amounts, then prove Σlines == total in tests.&lt;/li&gt;
&lt;li&gt;Treat 400s from webhooks as alarms, not noise — they are configuration deaths, and "permanent error" bookkeeping makes them silent.&lt;/li&gt;
&lt;li&gt;Issuer CIF must be checksum-valid &lt;em&gt;and&lt;/em&gt; authorized in the SPV; buyer CIF stays optional for B2C — don't invent one.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same pipeline — canonical JSON in the middle, one CIUS-RO builder, explicit processed semantics — is what FiscalLink for ANAF runs in production (autoanaf.ro); these lessons cost us a staging week.&lt;/p&gt;

</description>
      <category>api</category>
      <category>architecture</category>
      <category>backend</category>
      <category>json</category>
    </item>
    <item>
      <title>ANAF e-Factura UBL Rejections: What EN 16931 / CIUS-RO Validation Errors Look Like and How to Catch Them Before ANAF Does</title>
      <dc:creator>Vlad Cristian Alexa</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:10:14 +0000</pubDate>
      <link>https://dev.to/fiscal-link/-anaf-e-factura-ubl-rejections-what-en-16931-cius-ro-validation-errors-look-like-and-how-to-4a7m</link>
      <guid>https://dev.to/fiscal-link/-anaf-e-factura-ubl-rejections-what-en-16931-cius-ro-validation-errors-look-like-and-how-to-4a7m</guid>
      <description>&lt;p&gt;Uploading well-formed UBL XML to ANAF e-Factura is the easy part. &lt;em&gt;Accepting your upload&lt;/em&gt; and &lt;em&gt;validating your invoice&lt;/em&gt; 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 &lt;code&gt;detalii&lt;/code&gt; field — no structured error list, no line number.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  How ANAF validates: EN 16931 + CIUS-RO
&lt;/h2&gt;

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

&lt;p&gt;The identifiers are exact strings, emitted verbatim by the production builder:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;cbc:UBLVersionID&amp;gt;&lt;/span&gt;2.1&lt;span class="nt"&gt;&amp;lt;/cbc:UBLVersionID&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:CustomizationID&amp;gt;&lt;/span&gt;urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1&lt;span class="nt"&gt;&amp;lt;/cbc:CustomizationID&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:ProfileID&amp;gt;&lt;/span&gt;urn:fdc:peppol.eu:2017:poacc:billing:01:1.0&lt;span class="nt"&gt;&amp;lt;/cbc:ProfileID&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:InvoiceTypeCode&amp;gt;&lt;/span&gt;380&lt;span class="nt"&gt;&amp;lt;/cbc:InvoiceTypeCode&amp;gt;&lt;/span&gt;   &lt;span class="c"&gt;&amp;lt;!-- 381 for credit notes --&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:DocumentCurrencyCode&amp;gt;&lt;/span&gt;RON&lt;span class="nt"&gt;&amp;lt;/cbc:DocumentCurrencyCode&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;cbc:TaxCurrencyCode&amp;gt;&lt;/span&gt;RON&lt;span class="nt"&gt;&amp;lt;/cbc:TaxCurrencyCode&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CustomizationID&lt;/code&gt; is a conformance &lt;em&gt;claim&lt;/em&gt;: writing &lt;code&gt;#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1&lt;/code&gt; 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 &lt;code&gt;CustomizationID&lt;/code&gt;, a &lt;code&gt;TaxCurrencyCode&lt;/code&gt;, or RON totals on a foreign-currency invoice.&lt;/p&gt;

&lt;p&gt;The builder shows which features trip semantic rules in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tax currency&lt;/strong&gt;: &lt;code&gt;TaxCurrencyCode&lt;/code&gt; is always &lt;code&gt;RON&lt;/code&gt;. When &lt;code&gt;DocumentCurrencyCode != RON&lt;/code&gt;, a second &lt;code&gt;&amp;lt;cac:TaxTotal&amp;gt;&lt;/code&gt; in RON is emitted with a &lt;code&gt;PaymentExchangeRate&lt;/code&gt; block (&lt;code&gt;SourceCurrencyCode&lt;/code&gt;, &lt;code&gt;TargetCurrencyCode=RON&lt;/code&gt;, &lt;code&gt;CalculationRate&lt;/code&gt;, &lt;code&gt;Date&lt;/code&gt;) 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;VAT breakdown&lt;/strong&gt;: each distinct rate gets its own &lt;code&gt;&amp;lt;cac:TaxSubtotal&amp;gt;&lt;/code&gt; (&lt;code&gt;TaxableAmount&lt;/code&gt;, &lt;code&gt;TaxAmount&lt;/code&gt;, &lt;code&gt;TaxCategory&lt;/code&gt; &lt;code&gt;ID&lt;/code&gt; &lt;code&gt;S&lt;/code&gt; standard / &lt;code&gt;Z&lt;/code&gt; zero-rated, &lt;code&gt;Percent&lt;/code&gt;, &lt;code&gt;TaxScheme ID=VAT&lt;/code&gt;) inside &lt;code&gt;TaxTotal&lt;/code&gt;; totals must reconcile with lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monetary totals&lt;/strong&gt;: &lt;code&gt;LegalMonetaryTotal&lt;/code&gt; with &lt;code&gt;LineExtensionAmount&lt;/code&gt;, &lt;code&gt;TaxExclusiveAmount&lt;/code&gt;, &lt;code&gt;TaxInclusiveAmount&lt;/code&gt;, &lt;code&gt;PayableAmount&lt;/code&gt; — all carrying &lt;code&gt;currencyID&lt;/code&gt;. A missing or mismatched &lt;code&gt;PayableAmount&lt;/code&gt; is a common kill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lines&lt;/strong&gt;: quantities use &lt;code&gt;unitCode="C62"&lt;/code&gt;; a zero-item invoice violates EN 16931 BR-45.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Credit notes&lt;/strong&gt;: type code &lt;code&gt;381&lt;/code&gt;, &lt;code&gt;BillingReference&lt;/code&gt;/&lt;code&gt;InvoiceDocumentReference&lt;/code&gt; to the original, and — for storno workflows — the original load index in the &lt;code&gt;Note&lt;/code&gt; as &lt;code&gt;index_incarcare_original: &amp;lt;index&amp;gt;&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Anatomy of a rejection: two failure moments
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Upload-time (synchronous).&lt;/strong&gt; &lt;code&gt;POST /upload?standard=UBL&amp;amp;cif=&amp;lt;cif&amp;gt;&amp;amp;extern=DA&amp;amp;autofactura=DA&lt;/code&gt; returns XML, not JSON. Success:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;header&lt;/span&gt; &lt;span class="na"&gt;ExecutionStatus=&lt;/span&gt;&lt;span class="s"&gt;"0"&lt;/span&gt; &lt;span class="na"&gt;index_incarcare=&lt;/span&gt;&lt;span class="s"&gt;"11223344"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;index_incarcare&lt;/code&gt; — the submission ID used later to match the answer. A synchronous refusal:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight xml"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;header&lt;/span&gt; &lt;span class="na"&gt;ExecutionStatus=&lt;/span&gt;&lt;span class="s"&gt;"1"&lt;/span&gt; &lt;span class="na"&gt;errorMessage=&lt;/span&gt;&lt;span class="s"&gt;"CIF invalid"&lt;/span&gt;&lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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

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

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data_creare"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"202604291200"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"cif"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"12345678"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id_solicitare"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"99887766"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"detalii"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Factura validata OK"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"tip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FACTURA PRIMITA"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"1001"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The verdict lives entirely in &lt;code&gt;detalii&lt;/code&gt; — free-text Romanian. Rejections observed in fixtures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;Erori de validare identificate la factura transmisa cu id_incarcare=5035501773&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;S-au identificat erori la validarea facturii&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Eroare la validare: CIF invalid&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Factura respinsa din cauza formatului&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;Invalid schema&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;eroare: format invalid&lt;/code&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;h2&gt;
  
  
  Pre-flight: three layers you can run before ANAF does
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

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

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

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

&lt;p&gt;Structural failures surface as &lt;code&gt;XSD validation failed at line &amp;lt;L&amp;gt; (column &amp;lt;C&amp;gt;): &amp;lt;message&amp;gt;&lt;/code&gt;. In the submission job this gate is fatal &lt;em&gt;before any HTTP call&lt;/em&gt;: the invoice goes straight to &lt;code&gt;failed&lt;/code&gt; (&lt;code&gt;UBL XSD validation failed: ...&lt;/code&gt;) — no retry, no quota slot consumed.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Layer 3 — identifier checksum (&lt;code&gt;CifValidator&lt;/code&gt;).&lt;/strong&gt; Next section.&lt;/p&gt;

&lt;h2&gt;
  
  
  The CIF checksum trap
&lt;/h2&gt;

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

&lt;p&gt;The trap: obvious test/placeholder numbers are almost all &lt;strong&gt;checksum-invalid&lt;/strong&gt;. For &lt;code&gt;RO12345678&lt;/code&gt; the official check digit of base &lt;code&gt;1234567&lt;/code&gt; is &lt;code&gt;4&lt;/code&gt; (valid: &lt;code&gt;RO12345674&lt;/code&gt;) — yet &lt;code&gt;RO12345678&lt;/code&gt; 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 &lt;code&gt;Invalid Romanian VAT number for issuer: RO12345678&lt;/code&gt; (&lt;code&gt;IllegalArgumentException&lt;/code&gt;), long before any XML exists.&lt;/p&gt;

&lt;p&gt;Two consequences:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Never fabricate or auto-generate CIFs&lt;/strong&gt; — 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.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checksum-valid ≠ registered.&lt;/strong&gt; 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: &lt;code&gt;CIF invalid pentru cumparator&lt;/code&gt;, &lt;code&gt;cif inactiv pentru buyer&lt;/code&gt;, &lt;code&gt;BT-48 Buyer VAT identifier not found in registry&lt;/code&gt; (buyer), &lt;code&gt;BT-31 ... supplier vat identifier ...&lt;/code&gt; (issuer) — mapped to &lt;code&gt;ANAF_CIF_INVALID&lt;/code&gt;/&lt;code&gt;BT-48&lt;/code&gt;/&lt;code&gt;BT-31&lt;/code&gt; hints on &lt;code&gt;buyer.vatNumber&lt;/code&gt; / &lt;code&gt;issuer.vatNumber&lt;/code&gt;, with an auto-fix that strips or adds the &lt;code&gt;RO&lt;/code&gt; prefix (a frequent source of registry mismatches). Only ANAF can answer the registry question; the checksum answers the format question. Never conflate the two.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Answer classification: singular, plural, and keyword order
&lt;/h2&gt;

&lt;p&gt;Because &lt;code&gt;detalii&lt;/code&gt; is free text, classification is keyword matching. The reconciler lowercases and checks in this exact order: (1) acceptance first — contains &lt;code&gt;ok&lt;/code&gt;, &lt;code&gt;validat&lt;/code&gt;, or &lt;code&gt;acceptat&lt;/code&gt; → &lt;code&gt;accepted&lt;/code&gt;; (2) rejection second — contains &lt;code&gt;eroare&lt;/code&gt;, &lt;code&gt;erori&lt;/code&gt;, &lt;code&gt;respins&lt;/code&gt;, &lt;code&gt;invalid&lt;/code&gt;, or &lt;code&gt;neconform&lt;/code&gt; → &lt;code&gt;rejected&lt;/code&gt;; (3) otherwise log &lt;code&gt;Unrecognised ANAF answer ...&lt;/code&gt; and change nothing.&lt;/p&gt;

&lt;p&gt;Three subtleties that bite if you reimplement this:&lt;/p&gt;

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

&lt;h2&gt;
  
  
  Operational lessons: polling, quota semantics, retries
&lt;/h2&gt;

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

&lt;h2&gt;
  
  
  Pitfall checklist
&lt;/h2&gt;

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

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>api</category>
      <category>backend</category>
      <category>java</category>
      <category>programming</category>
    </item>
    <item>
      <title>ANAF SPV E-Invoicing from Java/Spring Boot: OAuth2, JWT Access Tokens, and a Refresh Window That Never Rests</title>
      <dc:creator>Vlad Cristian Alexa</dc:creator>
      <pubDate>Thu, 03 Sep 2026 15:03:04 +0000</pubDate>
      <link>https://dev.to/fiscal-link/anaf-spv-e-invoicing-from-javaspring-boot-oauth2-jwt-access-tokens-and-a-refresh-window-that-53ha</link>
      <guid>https://dev.to/fiscal-link/anaf-spv-e-invoicing-from-javaspring-boot-oauth2-jwt-access-tokens-and-a-refresh-window-that-53ha</guid>
      <description>&lt;h1&gt;
  
  
  ANAF SPV E-Invoicing from Java/Spring Boot: OAuth2, JWT Access Tokens, and a Refresh Window That Never Rests
&lt;/h1&gt;

&lt;p&gt;Integrating with ANAF's SPV API for Romanian e-invoicing has a personality: sparse documentation, error messages in Romanian, a login flow that needs a human with a digital certificate, and JWT access tokens that expire after roughly ten minutes. The OAuth2 machinery itself is textbook — authorization code flow with client authentication — but every quirk around it decides whether you ship in a day or debug for a week. Below is the flow exactly as it runs in production Spring Boot code, including an honest look at what the refresh logic actually does with ANAF's short-lived tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  The flow ANAF actually runs
&lt;/h2&gt;

&lt;p&gt;ANAF SPV (Sistemul Privat Virtual) e-Factura is the mandatory channel for B2B e-invoices in Romania, and machine access is not a plain API key: it is OAuth2 authorization code, after which every REST call carries &lt;code&gt;Authorization: Bearer &amp;lt;jwt&amp;gt;&lt;/code&gt;. Two details make it special. First, &lt;code&gt;token_content_type=jwt&lt;/code&gt; must appear on the authorize request &lt;em&gt;and&lt;/em&gt; on the token exchange — omit it and ANAF returns a legacy token format. Second, the "user" authentication happens in a browser, where a person logs in with their digital certificate; your backend only ever sees the one-time &lt;code&gt;code&lt;/code&gt;, exchanged over HTTPS.&lt;/p&gt;

&lt;p&gt;The full dance: your app redirects the operator to ANAF's login page → the operator authenticates with the certificate → ANAF redirects to your callback with &lt;code&gt;?code=...&lt;/code&gt; → your backend exchanges the code at the token endpoint and receives &lt;code&gt;access_token&lt;/code&gt;, &lt;code&gt;refresh_token&lt;/code&gt;, &lt;code&gt;expires_in&lt;/code&gt; → every subsequent SPV call uses the JWT.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration that drives everything
&lt;/h2&gt;

&lt;p&gt;Real endpoints, straight from &lt;code&gt;application.yml&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;anaf&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;api-key&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_API_KEY:}&lt;/span&gt;
    &lt;span class="na"&gt;api-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_API_URL:https://api.anaf.ro/prod/FCTEL/rest}&lt;/span&gt;
    &lt;span class="na"&gt;oauth&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;client-id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_CLIENT_ID:}&lt;/span&gt;
      &lt;span class="na"&gt;client-secret&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_CLIENT_SECRET:}&lt;/span&gt;
      &lt;span class="na"&gt;authorization-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_OAUTH_AUTHORIZATION_URL:https://logincert.anaf.ro/anaf-oauth2/v1/authorize}&lt;/span&gt;
      &lt;span class="na"&gt;token-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_OAUTH_TOKEN_URL:https://logincert.anaf.ro/anaf-oauth2/v1/token}&lt;/span&gt;
      &lt;span class="na"&gt;callback-url&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${ANAF_OAUTH_CALLBACK_URL:https://api.fiscallink.io/v1/auth/anaf/callback}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These land in &lt;code&gt;AnafTokenManager&lt;/code&gt; as &lt;code&gt;@Value&lt;/code&gt; fields, and every lookup applies the same precedence rule: database settings first, environment variables second — &lt;code&gt;firstNonBlank(s.getAnafClientId(), envClientId)&lt;/code&gt;. Operators can reconnect a new ANAF account without a redeploy. Note the two hosts: &lt;code&gt;logincert.anaf.ro&lt;/code&gt; is the OAuth server, &lt;code&gt;api.anaf.ro&lt;/code&gt; serves the SPV data endpoints and the test &lt;code&gt;hello&lt;/code&gt; endpoint.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1 — build the authorize URL
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AnafAuthController&lt;/code&gt; exposes &lt;code&gt;GET /v1/auth/anaf/authorize&lt;/code&gt;: hit it from a browser (&lt;code&gt;Accept: text/html&lt;/code&gt;) and it 302-redirects you to ANAF; call it from an API client and it returns the URL as JSON. The URL is assembled in &lt;code&gt;AnafTokenManager.getAuthorizationUrl(state)&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;StringBuilder&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;StringBuilder&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;authorizationUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"?response_type=code"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;amp;client_id="&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;amp;redirect_uri="&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callbackUrl&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;amp;token_content_type=jwt"&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"&amp;amp;state="&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;append&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;token_content_type=jwt&lt;/code&gt; is already in the query string, and &lt;code&gt;callbackUrl&lt;/code&gt; is a single configuration value used everywhere — it must match byte-for-byte the redirect URI registered for your client in ANAF SPV. Scheme, host, path: one character off and the login dies with a redirect URI mismatch. The controller also generates CSRF &lt;code&gt;state&lt;/code&gt; (&lt;code&gt;UUID.randomUUID().toString()&lt;/code&gt;) and logs it — but see the pitfalls: this callback accepts &lt;code&gt;state&lt;/code&gt; without ever verifying it, so add that check in yours.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2 — exchange the code
&lt;/h2&gt;

&lt;p&gt;ANAF redirects the operator's browser to &lt;code&gt;GET /v1/auth/anaf/callback?code=...&amp;amp;state=...&lt;/code&gt;. The controller hands the code to &lt;code&gt;exchangeCodeForToken(code)&lt;/code&gt;, which posts to the token endpoint using HTTP Basic client authentication and a form-urlencoded body:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;formBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"grant_type=authorization_code"&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;code="&lt;/span&gt;         &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;redirect_uri="&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;callbackUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;token_content_type=jwt"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;

&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tokenUrl&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTHORIZATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;basicAuth&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;clientId&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clientSecret&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;APPLICATION_FORM_URLENCODED&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;formBody&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;

&lt;span class="n"&gt;parseAndStore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;basicAuth()&lt;/code&gt; is just base64 of &lt;code&gt;clientId + ":" + clientSecret&lt;/code&gt; — client credentials travel in the &lt;code&gt;Authorization: Basic&lt;/code&gt; header, not in the body, a classic gotcha if you are used to other providers. &lt;code&gt;redirect_uri&lt;/code&gt; here is the identical string sent on authorize, and &lt;code&gt;token_content_type=jwt&lt;/code&gt; is mandatory again. The &lt;code&gt;code&lt;/code&gt; is single-use: exchange it immediately and never log it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3 — store what ANAF returns
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;parseAndStore&lt;/code&gt; reads the token JSON and persists it into the &lt;code&gt;AppSettings&lt;/code&gt; row (id &lt;code&gt;"default"&lt;/code&gt;):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;JsonNode&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;objectMapper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;readTree&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;jsonResponse&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;accessToken&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"access_token"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"refresh_token"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asText&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;long&lt;/span&gt;   &lt;span class="n"&gt;expiresIn&lt;/span&gt;    &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;path&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"expires_in"&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;asLong&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAnafApiKey&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;accessToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAnafRefreshToken&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;refreshToken&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setAnafTokenExpiresAt&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;plusSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expiresIn&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;appSettingsRepository&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;save&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two bookkeeping fields drive everything later: the refresh token and the expiry instant. If &lt;code&gt;expires_in&lt;/code&gt; is missing the fallback is 3600 seconds; when a token is pasted manually via &lt;code&gt;POST /v1/auth/anaf/token&lt;/code&gt; (&lt;code&gt;storeToken&lt;/code&gt;), the fallback is 600 seconds — the same order of magnitude as ANAF's real JWT lifetime, which is the number that matters next.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4 — getValidAccessToken, or the refresh window that never rests
&lt;/h2&gt;

&lt;p&gt;Every SPV call funnels through &lt;code&gt;AnafTokenManager.getValidAccessToken()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nd"&gt;@Transactional&lt;/span&gt;
&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="nf"&gt;getValidAccessToken&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="nc"&gt;AppSettings&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;getOrCreate&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafRefreshToken&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafRefreshToken&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;firstNonBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafApiKey&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;envAnafApiKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shouldRefresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;))&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;s&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;performRefresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Exception&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ANAF token refresh failed — continuing with current token: {}"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getMessage&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;firstNonBlank&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafApiKey&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;envAnafApiKey&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;private&lt;/span&gt; &lt;span class="kt"&gt;boolean&lt;/span&gt; &lt;span class="nf"&gt;shouldRefresh&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;AppSettings&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafTokenExpiresAt&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;Instant&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;now&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;plusSeconds&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="no"&gt;REFRESH_WINDOW_SECONDS&lt;/span&gt;&lt;span class="o"&gt;).&lt;/span&gt;&lt;span class="na"&gt;isAfter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafTokenExpiresAt&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;with &lt;code&gt;private static final long REFRESH_WINDOW_SECONDS = 86_400L;&lt;/code&gt; — the class comment says tokens are "auto-refreshed 24h before expiry". Be honest about what that means against ANAF's real token lifetime: the check refreshes whenever the token expires within the next 86,400 seconds, and a JWT that lives ~600–3600 seconds is &lt;em&gt;always&lt;/em&gt; inside that window. So with a refresh token configured, &lt;strong&gt;every call&lt;/strong&gt; to &lt;code&gt;getValidAccessToken()&lt;/code&gt; triggers &lt;code&gt;performRefresh()&lt;/code&gt; first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;formBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"grant_type=refresh_token"&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;refresh_token="&lt;/span&gt;      &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;enc&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getAnafRefreshToken&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;
        &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="s"&gt;"&amp;amp;token_content_type=jwt"&lt;/span&gt;&lt;span class="o"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;// POST to the same token URL, same HTTP Basic header, then parseAndStore(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(no &lt;code&gt;redirect_uri&lt;/code&gt; on the refresh grant — ANAF does not expect it there). That 24-hour window makes sense for a provider issuing 24h+ tokens, which ANAF is not; as implemented, the refresh never "rests": each SPV call first pays for a token-exchange round-trip, then executes the real request with a fresh JWT. Consequences to plan for: added latency per call, and no in-memory caching of the access token — every call re-reads &lt;code&gt;AppSettings&lt;/code&gt;. If your traffic is bursty, add a small in-memory cache keyed by the stored expiry and refresh at most once per minute, keeping this class the single source of truth. And when a refresh fails, the code logs a warning and continues with the existing token; the next SPV call then fails with HTTP 401, which &lt;code&gt;AnafClient&lt;/code&gt; surfaces as a result, not an exception.&lt;/p&gt;

&lt;h3&gt;
  
  
  Concurrency: transactional, single instance, last-write-wins
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;getValidAccessToken()&lt;/code&gt;, &lt;code&gt;exchangeCodeForToken()&lt;/code&gt;, and &lt;code&gt;storeToken()&lt;/code&gt; are &lt;code&gt;@Transactional&lt;/code&gt;, and there is no token state shared between threads — every call re-reads the database row. On one instance, two concurrent requests can both observe "should refresh" and both hit the token endpoint; each exchange saves its result and the last write wins. If ANAF invalidates a refresh token after use, the losing thread's next refresh fails with &lt;code&gt;invalid_grant&lt;/code&gt; — swallowed by the catch-and-continue path, then recovered on the next successful refresh. In practice: keep refresh cheap, run a single instance for the ANAF worker (or add single-flight locking around refresh), and treat the token store as shared state you serialize.&lt;/p&gt;

&lt;p&gt;There is also a deliberate dev-skip convention: when nothing is configured, &lt;code&gt;getValidAccessToken()&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;AnafClient&lt;/code&gt; answers &lt;code&gt;SubmissionResult.devSkip()&lt;/code&gt; — a synthetic success carrying the id &lt;code&gt;"DEV-SKIP"&lt;/code&gt; — so local development never touches ANAF. No token configured, no network call, no crash.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing your token: the hello endpoint
&lt;/h2&gt;

&lt;p&gt;ANAF publishes a smoke-test endpoint, separate from the data API: &lt;code&gt;GET https://api.anaf.ro/TestOauth/jaxrs/hello?name=...&lt;/code&gt; with your Bearer token. &lt;code&gt;testConnection()&lt;/code&gt; wraps exactly that:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;get&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"https://api.anaf.ro/TestOauth/jaxrs/hello?name=fiscallink"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTHORIZATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"connected"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;put&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"response"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Exposed through &lt;code&gt;GET /v1/auth/anaf/status&lt;/code&gt;, it is the fastest way to tell "token expired or wrong client" (HTTP 401) apart from "ANAF is down" (timeout, 5xx, or a WAF page). &lt;code&gt;POST /v1/auth/anaf/token&lt;/code&gt; even runs this check before answering, so manual injections are verified immediately.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then the real calls
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;AnafClient&lt;/code&gt; is the single integration point for SPV: it takes the Bearer token from the manager, never throws, and returns result records — &lt;code&gt;SubmissionResult&lt;/code&gt;, &lt;code&gt;MessageListResult&lt;/code&gt;, &lt;code&gt;AnswerDownloadResult&lt;/code&gt; — that callers inspect via &lt;code&gt;success()&lt;/code&gt; / &lt;code&gt;errorMessage()&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;tokenManager&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getValidAccessToken&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;isBlank&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warn&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"ANAF API key not configured — skipping submission for VAT {} (dev mode)"&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;vatNumber&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;SubmissionResult&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;devSkip&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="c1"&gt;// ...&lt;/span&gt;
&lt;span class="nc"&gt;String&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;restClient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;post&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toString&lt;/span&gt;&lt;span class="o"&gt;())&lt;/span&gt;                       &lt;span class="c1"&gt;// /upload?standard=UBL&amp;amp;cif=...&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;header&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;HttpHeaders&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;AUTHORIZATION&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"Bearer "&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;contentType&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;MediaType&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;TEXT_PLAIN&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;// raw UBL XML — not multipart, not application/xml&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;xmlToSend&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;retrieve&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt;
        &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;class&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Submission is &lt;code&gt;POST {api-url}/upload?standard=UBL&amp;amp;cif=RO12345607[&amp;amp;extern=DA][&amp;amp;autofactura=DA]&lt;/code&gt; with the raw UBL 2.1 XML as &lt;code&gt;text/plain&lt;/code&gt; (the &lt;code&gt;xsi:schemaLocation&lt;/code&gt; is stripped first — ANAF is picky). The response is XML, and acceptance means &lt;code&gt;ExecutionStatus="0"&lt;/code&gt; plus an &lt;code&gt;index_incarcare&lt;/code&gt; id; business rejections arrive as HTTP 200 with an &lt;code&gt;errorMessage&lt;/code&gt; attribute. Answers are polled with &lt;code&gt;GET /listaMesajeFactura?zile=...&amp;amp;cif=...&amp;amp;filtru=E|T|P|R&lt;/code&gt;, falling back automatically to paginated &lt;code&gt;GET /listaMesajePaginatieFactura?startTime=&amp;lt;epochMillis&amp;gt;&amp;amp;endTime=...&amp;amp;cif=...&amp;amp;pagina=...&lt;/code&gt;, and downloaded from &lt;code&gt;GET /descarcare?id=...&lt;/code&gt; as a ZIP (the code checks the &lt;code&gt;0x50 0x4B&lt;/code&gt; magic bytes before trusting it). Error payloads come back in Romanian — &lt;code&gt;"Lista de mesaje este mai mare..."&lt;/code&gt;, &lt;code&gt;"nu exista mesaje"&lt;/code&gt;, &lt;code&gt;"Pagina solicitata ... este mai mare"&lt;/code&gt; — matched by string and mapped onto structured results. HTTP 429 and 5xx are flagged &lt;code&gt;retryable&lt;/code&gt; so the job layer backs off; business rejections are not retried.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;token_content_type=jwt&lt;/code&gt; on authorize &lt;strong&gt;and&lt;/strong&gt; on the code exchange &lt;strong&gt;and&lt;/strong&gt; on refresh. Forgetting it changes the token format ANAF hands you.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;redirect_uri&lt;/code&gt; must equal the ANAF-registered URI exactly and stay identical across authorize and exchange; keep it in one config value.&lt;/li&gt;
&lt;li&gt;ANAF JWTs are short-lived (~10 minutes). Verify the CSRF &lt;code&gt;state&lt;/code&gt; on the callback — this codebase logs it but does not check it; yours should.&lt;/li&gt;
&lt;li&gt;Never log the &lt;code&gt;code&lt;/code&gt;, tokens, or &lt;code&gt;Authorization&lt;/code&gt; headers. Even error paths should trim raw token responses — the "missing access_token" exception embeds the whole response body, so truncate it in production.&lt;/li&gt;
&lt;li&gt;Expect HTML sometimes: ANAF sits behind a WAF. Detect pages containing &lt;code&gt;Your support ID is:&lt;/code&gt; and treat them as infrastructure errors, not business answers.&lt;/li&gt;
&lt;li&gt;Timeouts matter: connect 10 s, read 30 s (&lt;code&gt;app.anaf.connect-timeout-ms&lt;/code&gt;, &lt;code&gt;app.anaf.read-timeout-ms&lt;/code&gt;); slow days at ANAF are real.&lt;/li&gt;
&lt;li&gt;Answer downloads are ZIPs — validate the PK magic before unzipping anything.&lt;/li&gt;
&lt;li&gt;One-time codes: exchange immediately, never retry with the same &lt;code&gt;code&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;A static &lt;code&gt;ANAF_API_KEY&lt;/code&gt; fallback is a rotation dead-end: without a refresh token, nothing ever auto-refreshes. Prefer the full OAuth flow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The token manager and client above are the same code path that files real e-Factura invoices every day in FiscalLink's production ANAF integration — quirks included.&lt;/p&gt;

</description>
      <category>api</category>
      <category>java</category>
      <category>oauth</category>
      <category>springboot</category>
    </item>
  </channel>
</rss>
