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)
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}, /* … */);
}
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; }
}
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;
}
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_failurescounts attempts (max 5).on_order_completedreturns immediately if the meta exists, and the hourlyflwc_retry_pendingcron re-pushes only orders that have failures, no done-marker, and attempts left. This makes theprocessing → completedstatus cycle and webhook redelivery safe. -
HTTP level.
POST /v1/invoicessupports Stripe-styleIdempotency-Keyheaders (IdempotencyFilter), but webhook receivers are explicitly excluded from that filter —shouldNotFilterreturns true for/v1/webhooks/— because each provider has its own redelivery semantics and its own natural key (Stripeevent.id, WooCommerce order id). Reusing one generic mechanism for both hides bugs; keep them separate. -
Job level. Once an invoice exists, the
ANAF_SUBMISSIONjob retries with exponential backoff and dies intofailed/dead_letterafter 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:true → treated 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>
-
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-3ProfileID. 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}, withFL-XXXXXXXXas the core's fallback. -
Currency.
TaxCurrencyCodeis always RON even when the checkout was in EUR. For anyDocumentCurrencyCode != RON, CIUS-RO (BR-53) demands a secondcac:TaxTotalin RON converted at the BNR reference rate for the issue date, plus acac:PaymentExchangeRateblock. 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 viagetZeroRateCategoryId()(defaultZ— the France/Chorus Pro adapter overrides it toE). 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
-
2xx ≠ processed— require the explicitprocessed: truein the response body before marking anything terminal. - Never send tax as a monetary amount; the UBL wants per-line rates, and document VAT is derived from them (BR-45/BR-CO).
- Webhook events arrive unexpanded — retrieve the Stripe session with
expand: ['line_items', 'customer']before mapping; same forexpand[]=payloadon Stripe Secret Store reads (without it, reads returnnullforever and every merchant looks disconnected). - Verify signatures over the raw request bytes you captured, not a re-stringified body.
- Idempotency belongs at the order/event level for webhooks (post-meta, event id); keep the HTTP
Idempotency-Keymechanism for your own API. - Trust provider order totals; only derive per-line amounts, then prove Σlines == total in tests.
- Treat 400s from webhooks as alarms, not noise — they are configuration deaths, and "permanent error" bookkeeping makes them silent.
- 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)