EN 16931 e-invoicing for Medusa v2 — generate and validate XRechnung, Peppol BIS 3.0 and Factur-X (CII) XML from your orders, locally and for free.
EN 16931 e-invoicing for Medusa v2. When an order is placed, generate a compliant XRechnung, Peppol BIS 3.0 or Factur-X (CII) XML document from the order, validate it against the business rules before storing it, and attach the verdict to the order.
Generation and validation run locally and for free via — a zero-dependency TypeScript implementation of the rules. There is no API to sign up for, no per-invoice cost, and no network call in the default configuration.
npm install medusa-plugin-einvoice1// medusa-config.ts2module.exports = defineConfig({3 plugins: [4 {5 resolve: "medusa-plugin-einvoice",6 options: {7 seller: {8 name: "Beispiel Handels GmbH",9 vatId: "DE123456789",10 legalRegistrationId: "HRB 12345 B",11 address: {12 line1: "Hauptstraße 1",13 city: "Berlin",14 postalCode: "10115",15 countryCode: "DE",16 },17 // BR-DE-2/5/6/7: all three are mandatory for XRechnung.18 contact: {19 name: "Buchhaltung",20 email: "rechnungen@beispiel.example",21 phone: "+49 30 1234567",22 },23 electronicAddress: { schemeId: "9930", value: "DE123456789" },24 },25 // BR-DE-1: XRechnung requires payment instructions.26 payment: {27 meansCode: "58",28 iban: "DE02120300000000202051",29 accountName: "Beispiel Handels GmbH",30 },31 paymentTermDays: 30,32 },33 },34 ],35})npx medusa db:migrateThat is the whole setup. Place an order and the plugin generates the document, validates it, stores it, and writes a report to . The order details page in the admin gains an E-invoice panel with the status, any rule failures, and a download button.
Node 20.19+ or 22.12+ is required (the same floor as Medusa 2.19). This is load-bearing rather than incidental: the validation engine is an ESM-only package and a Medusa plugin compiles to CommonJS, so the plugin relies on Node's support, which is unflagged from exactly those versions.
| Buyer country | Profile () | Syntax |
|---|---|---|
| DE | UBL 2.1 | |
| FR | CII D16B | |
| anywhere else | UBL 2.1 |
Override globally with , or per country with .
For a French buyer this plugin emits the Factur-X CII XML payload. It does not build the PDF/A-3 container that makes a file a Factur-X invoice. The underlying library reads that container () and deliberately does not write one.
So: if your counterparty wants the XML — which is what portals, access points and the Chorus Pro/PPF pipeline consume — you have it. If you need the human-readable PDF with the XML embedded, you need a PDF/A-3 step this plugin does not provide. Take the XML from and embed it with a PDF library of your choice.
Nothing is stored before it is validated, and a document that fails is stored flagged and logged rule by rule:
1[einvoice] RE-2026-1042: xrechnung-ubl FAILED validation with 1 error(s).2[einvoice] BR-DE-15 (BT-10): A German public-sector buyer requires a Leitweg-ID …3[einvoice] fix: set buyerReference to the Leitweg-ID your client gave you4[einvoice] https://attestwire.com/rules/BR-DE-15The same findings land on the order:
1// order.metadata.einvoice2{3 "invoice_number": "RE-2026-1042",4 "profile": "xrechnung-ubl",5 "valid": false,6 "errors": [{ "rule": "BR-DE-15", "field": "BT-10", "fix": "…", "docs": "https://attestwire.com/rules/BR-DE-15" }],7 "notes": [{ "code": "VAT_INTRA_COMMUNITY", "level": "info", "message": "…" }],8 "reconciliation": { "matches": true, "deltas": [ /* … */ ] }9}An invoice never blocks an order. A failed generation is logged and stored; the checkout is unaffected.
This is the part worth reading before you trust it in production.
Tax-inclusive pricing. EN 16931's BT-146 is a net unit price. When is set, VAT is divided out at 8 decimals before the quantity is applied — rounding the unit price to 2 decimals first is what makes a multi-line order a cent short. The line amount is then rounded as BR-DEC-* requires.
Discounts become line allowances (BG-27), not document allowances. A Medusa promotion arrives as an on the item it discounted. A line allowance inherits that line's VAT treatment; a document allowance carries its own category and rate, so hoisting a per-item discount to document level means inventing a rate for it — and getting the VAT breakdown wrong as soon as an order mixes 19 % and 7 % goods. An adjustment that is itself tax-inclusive has VAT removed at the line's rate. A negative adjustment becomes a line charge (BG-28), because BR-41/BR-42 require both to be stated positively.
Shipping becomes a document charge (BG-21, reason code ). A shipping-method promotion becomes a document allowance (BG-20) at the shipping rate — it is the one discount with no line to sit on. Set if you would rather see freight as an invoice line.
Totals are computed, not copied. The engine derives BT-106…BT-115 from the lines, so the document is always internally consistent. Medusa's own totals are then compared and any disagreement is reported as a note. Set to write Medusa's figures into the document instead and let BR-CO-10…16 reject the mismatch outright — useful for catching an accounting bug, disruptive as a default.
VAT categories are inferred, and every inference leaves a note. Medusa knows how much tax it charged, never why it charged none. Zero can mean four different things:
| Situation | Category | Option |
|---|---|---|
| rate > 0 | — | |
| zero, same country | ||
| zero, EU buyer with a VAT id | ( for goods) | |
| zero, EU buyer without a VAT id | + warning | — |
| zero, non-EU buyer | — |
, and also put the standard exemption sentence in BT-22.
Multi-currency passes through as BT-5. If VAT must be reported in another currency (BT-6/BT-111) the plugin does not attempt it: the required exchange rate is not on the order, and inventing one would be a tax statement.
Multiple tax lines at different rates on one item are summed, because EN 16931 allows one category and one rate per line — with a warning.
Buyer identity comes from the billing address, falling back to shipping with a warning. Company name wins over person name. The buyer's VAT id and Peppol endpoint come from order metadata (, ), because Medusa has nowhere else to keep them.
Everything above works with no account. Setting adds two things, neither of which anything depends on:
1options: {2 attestwireApiKey: process.env.ATTESTWIRE_API_KEY,3 createValidationRecords: true, // default when a key is set4 checkRuleCurrency: true, // default when a key is set5}Both fail soft. An invoice is never blocked by a network problem, and the local verdict is always the one that produced your XML.
1GET /admin/orders/:id/einvoice → the stored document + report2GET /admin/orders/:id/einvoice?format=xml → download the XML3POST /admin/orders/:id/einvoice { force } → generate / regenerateProgrammatically, from anywhere in your app:
1import { generateEInvoiceWorkflow } from "medusa-plugin-einvoice/workflows"2
3const { result } = await generateEInvoiceWorkflow(container).run({4 input: { order_id: "order_123" },5})Or without Medusa at all — the mapping layer is framework-free:
1import { buildEInvoice } from "medusa-plugin-einvoice/lib"2
3const { xml, valid, errors } = buildEInvoice(order, options)| Option | Default | Meaning |
|---|---|---|
| — | Required. Name, address, VAT id, contact. | |
| Fixed profile, or country-derived. | ||
| Per-country override. | ||
| Also accepts . | ||
| Prefixed to the order's . | ||
| — | Order metadata key that overrides the number. | |
| BT-10 / Leitweg-ID. | ||
| BT-49, . | ||
| BT-48. | ||
| BT-130 unit of measure. | ||
| Domestic zero rate. | ||
| for goods. | ||
| — | BT-120 per category. has no default. | |
| Freight as a line instead of BG-21. | ||
| / / | — | BG-16, BT-20, BT-9. |
| Write Medusa's totals into BT-106…115. | ||
| Reconciliation tolerance. | ||
| Treat warnings as failures. | ||
| Keep the XML in the plugin's table. | ||
| Write the report to . | ||
| — | Enables the hosted layer. |
In scope and working: order → EN 16931 XML (UBL and CII), local validation with rule ids and docs links, storage, order metadata report, admin panel, download endpoint, workflow API, tax-inclusive pricing, promotions, shipping, multi-currency, reverse charge and export.
Not in scope, and here is what you would need instead:
1npm install2npm test # 42 mapping tests, every fixture validated by the engine3npm run typecheck4npm run build # medusa plugin:buildThe test suite's standing rule: every fixture that should produce a sendable invoice must come back from the engine, and one that should not must fail by naming the rule.
Only needed after changing the model in . The Medusa CLI builds its own MikroORM config for this command, so it reads /// rather than , and it connects to a database named , which must exist:
1DB_HOST=localhost DB_PORT=5432 DB_USERNAME=postgres DB_PASSWORD=postgres \2 npm run db:generateThe diff is taken against , which is committed next to the migrations. Keep it committed — without it the next run emits a duplicate create-table migration instead of an incremental one.
MIT