DEV Community

Vlad Cristian Alexa
Vlad Cristian Alexa

Posted on

From Stripe Checkout to a Compliant Romanian E-Invoice: A JSON UBL Field Report

E-invoicing from a payment event is a mapping problem, not an XML problem. Stripe hands you a checkout.session.completed whose amount_total 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 rate, totals must reconcile across four cbc:*Amount 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.

Architecture: webhook → canonical JSON → UBL

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

Stripe checkout.session.completed ──┐
                                   ├─▶ canonical InvoiceSchema JSON ─▶ UBL 2.1 CIUS-RO XML ─▶ ANAF SPV
WooCommerce order.completed ────────┘        (POST /v1/invoices)          (JobRunr async)
Enter fullscreen mode Exit fullscreen mode

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

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: InvoiceService.createAndEnqueue runs CifValidator checksum checks and En16931CompletenessValidator — a completeness gate that rejects with HTTP 400 before anything is persisted — then builds the UBL synchronously and enqueues a JobRunr ANAF_SUBMISSION job with exponential backoff (5s × 2^(attempt-1), max 10).

The mapping that matters

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

Three decisions in that table are load-bearing:

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

// AbstractInvoiceBuilder.computeRateSubtotals — one TaxSubtotal per rate (EN 16931 BR-45)
for (Map<String, Object> item : items) {
    BigDecimal qty       = BigDecimalUtils.toBigDecimal(item.get("quantity"));
    BigDecimal unitPrice = BigDecimalUtils.toBigDecimal(item.get("unitPrice"));
    BigDecimal vatRate   = BigDecimalUtils.toBigDecimal(item.getOrDefault("vatRate", 19));

    BigDecimal lineSubtotal = qty.multiply(unitPrice).setScale(2, RoundingMode.HALF_UP);
    BigDecimal lineVat      = lineSubtotal.multiply(vatRate)
            .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
    result.merge(vatRate, new BigDecimal[]{lineSubtotal, lineVat}, /* … */);
}
Enter fullscreen mode Exit fullscreen mode

Totals come from the order, not from summing lines. Rounding at line level makes Σlines drift from the checkout total. Both mappers therefore trust the provider's order-level subtotal/total_tax/total when present and only fall back to recomputation (computeTotals in the WooCommerce controller does exactly this). Provider totals feed LegalMonetaryTotal; 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).

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

// WooCommerceWebhookController.mapLineItems — rate resolution order
BigDecimal vatRate = defaultVatRate;                       // from order tax_lines[]
if (li.has("taxes") && li.path("taxes").isArray() && li.path("taxes").size() > 0) {
    JsonDecimal ld = tryReadDecimal(li.path("taxes").get(0).path("rate_percent"));
    if (ld != null && ld.value != null) { vatRate = ld.value; }
}
Enter fullscreen mode Exit fullscreen mode

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

Three bugs that only staging caught

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:

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

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

// class-flwc-submit.php — only an explicit processed:true means done
if ( $code >= 200 && $code < 300 ) {
    $body = wp_remote_retrieve_body( $response );
    $parsed = json_decode( $body, true );
    if ( is_array( $parsed ) && false === ( $parsed['processed'] ?? true ) ) {
        $this->bump_failures( $order_id );   // 2xx but NOT processed → retry
        return false;
    }
    update_post_meta( $order_id, self::META_DONE, current_time( 'mysql', true ) );
    delete_post_meta( $order_id, self::META_FAILURES );
    return true;
}
Enter fullscreen mode Exit fullscreen mode

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

Idempotency & retries

There are three idempotency layers, and they must not be confused:

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

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-processed:truetreated as transient (bug #2). Handled-but-failed events must still get a fast 2xx, or providers replay the whole batch into a broken endpoint.

What the UBL actually contains

<cbc:UBLVersionID>2.1</cbc:UBLVersionID>
<cbc:CustomizationID>urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1</cbc:CustomizationID>
<cbc:ProfileID>urn:fdc:peppol.eu:2017:poacc:billing:01:1.0</cbc:ProfileID>
<cbc:ID>WC-11</cbc:ID>                        <!-- or INV-STRIPE-1759000000, FL-1F2E3A4B -->
<cbc:IssueDate>2026-09-02</cbc:IssueDate>
<cbc:InvoiceTypeCode>380</cbc:InvoiceTypeCode> <!-- 381 = credit note -->
<cbc:DocumentCurrencyCode>RON</cbc:DocumentCurrencyCode>
<cbc:TaxCurrencyCode>RON</cbc:TaxCurrencyCode>
Enter fullscreen mode Exit fullscreen mode
  • CustomizationID is the CIUS-RO contract — the exact string urn:cen.eu:en16931:2017#compliant#urn:efactura.mfinante.ro:CIUS-RO:1.0.1, alongside the PEPPOL BIS-3 ProfileID. If either is wrong, the document is out of scope before validation starts.
  • Invoice numbers arrive already prefixed by the source so origin is debuggable at a glance: WC-{orderId}, INV-STRIPE-{unixTs}, with FL-XXXXXXXX as the core's fallback.
  • Currency. TaxCurrencyCode is always RON even when the checkout was in EUR. For any DocumentCurrencyCode != RON, CIUS-RO (BR-53) demands a second cac:TaxTotal in RON converted at the BNR reference rate for the issue date, plus a cac:PaymentExchangeRate block. Never assume "the checkout currency is the invoice currency."
  • VAT categories. Positive rates emit category S; zero-rate lines emit the profile's zero category via getZeroRateCategoryId() (default Z — the France/Chorus Pro adapter overrides it to E). CIUS-RO validates the consistency of category, percent, and amounts, so whatever your zero-rate policy is, it must be applied in the one builder, not per mapper.

Pitfalls checklist

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

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.

Top comments (0)