WooCommerce
Every order your WooCommerce shop completes can become a ZUGFeRD / Factur-X e-invoice: the same products, shipping, fees, VAT and totals, as a PDF/A-3 with the invoice as XML inside, validated before you receive it. No plugin is needed. A webhook handler and the function on this page are the whole integration: the function turns WooCommerce's order into the _invoice block, and one render makes the file.
Before you start
- A template that prints the
_invoiceblock. Templates → Examples → E-invoice in the app puts a copy of one into your workspace, with the sluge-rechnungthat the handler below renders, unless you give it another one there. The template shows what an order adds to it. - A Formfeed API key. The default scopes are enough. Start with a
ff_test_key: its e-invoices are valid files with the test mark on every page, up to a number a day that depends on the plan. Live e-invoices need the Starter plan or higher. - A webhook in WooCommerce under WooCommerce → Settings → Advanced → Webhooks: topic Order updated, API version WP REST API Integration v3, your handler's address as delivery URL, and a secret.
- A REST API key under WooCommerce → Settings → Advanced → REST API with Read/Write permission: the handler reads the order again and notes the render in its meta data.
- Prices with two decimals, which WooCommerce → Settings → General sets: e-invoice amounts are exact to the cent.
The mapping
wooToInvoice takes the order as WooCommerce's REST API returns it, which is also what its webhooks deliver, and returns the _invoice block. It copies the order's amounts and computes nothing it could get wrong:
- Products, shipping and fees are lines. A product keeps its name, quantity and SKU; its price is the price before coupons, its net amount the amount after them, and what a coupon took off is an allowance on the line. Each shipping method and each fee is a line of its own with the VAT it carries; a negative fee is a negative quantity, and free shipping without tax is left out.
- VAT from WooCommerce's own rates. A line taxed at a rate above 0 % is category
Swith that rate. A rate of 0 %, a customer WooCommerce exempts from VAT (as VAT number plugins do for reverse charge) and a line without any tax are categories you state in the configuration. A rate with no category throws rather than guessing. - Totals from the lines, held against the order's. The VAT of each rate is the order's own (
tax_lines), and when the lines and that VAT do not come to the order's total, the function throws. - Paid or due. An order WooCommerce has a payment date for is paid in full (
prepaid). Any other is due, by bank transfer to your account when its payment method is Direct bank transfer (bacs). - Dates in your shop's time zone. The issue date and the delivery date are the day the order was completed, which is the day of delivery in most shops; the delivery address is the order's shipping address.
- The buyer from the billing address: the company, or the person's name, with the address and the e-mail as electronic address. The VAT ID comes from the order's meta data under the key your VAT number plugin uses.
/**
* A WooCommerce order as the `_invoice` block of a Formfeed e-invoice.
*
* `order` is the order as WooCommerce's REST API (v3) returns it, which is
* also what its webhooks deliver. `number` is the invoice number, the order
* number unless your shop numbers its invoices on its own. Every amount is
* copied from WooCommerce, never worked out from a price: a total that
* differs from the order's is an error, not a rounding.
*/
export function wooToInvoice(order, config, number = order.number) {
const fail = (message) => {
throw new Error(`Order ${order.number}: ${message}`);
};
// WooCommerce writes amounts as text, with the shop's number of decimals
const cents = (text) => {
const value = String(text || '0');
if (!/^-?\d+(\.\d{1,2})?$/.test(value))
fail(`${value} is not an amount in cents; set the shop to two decimals`);
return Math.round(Number(value) * 100);
};
const money = (amount) => (amount / 100).toFixed(2);
// a date as WooCommerce writes it, in the shop's time zone
const day = (text) => text.slice(0, 10);
// WooCommerce writes '' where a value is missing; the block leaves it out
const present = (object) =>
Object.fromEntries(
Object.entries(object).filter(
([, value]) => value != null && value !== '',
),
);
// and an object that is left empty is left out as well
const filled = (object) =>
Object.keys(object).length > 0 ? object : undefined;
const rateOf = (category, percent) =>
category === 'S' ? percent : category === 'O' ? undefined : 0;
// the order's tax rates, by the id its lines name them by
const rates = new Map(order.tax_lines.map((tax) => [tax.rate_id, tax]));
const exempt = order.meta_data.some(
(meta) => meta.key === 'is_vat_exempt' && meta.value === 'yes',
);
const vatOf = (taxes, at) => {
if (taxes.length > 1) fail(`${at} carries ${taxes.length} taxes`);
if (taxes.length === 0) {
// a customer WooCommerce exempted from VAT, or a shop that charges none
const stated = exempt ? config.exempt : config.untaxed;
if (!stated)
fail(
`${at}: state its VAT category in config.${exempt ? 'exempt' : 'untaxed'}`,
);
return { ...stated, rate: rateOf(stated.category, 0) };
}
const rate = rates.get(taxes[0].id);
if (!rate || rate.compound)
fail(`${at}: tax rate ${taxes[0].id} is compound or unknown`);
// WooCommerce writes the percentage into the order from version 3.7 on
if (rate.rate_percent == null) fail(`${at}: the order has no rate_percent`);
const stated = config.categories?.[rate.rate_id];
if (stated)
return { ...stated, rate: rateOf(stated.category, rate.rate_percent) };
if (rate.rate_percent > 0)
return { category: 'S', rate: rate.rate_percent };
fail(
`${at}: state the VAT category of tax rate ${rate.rate_id} (${rate.label}) in config.categories`,
);
};
const breakdown = new Map(); // one entry per VAT category and rate
const lines = [];
// products, shipping and fees are lines alike, each with the VAT it carries
const reason = config.discountReason ?? 'Discount';
const add = ({ name, quantity, before, net, taxes, sku }) => {
const vat = vatOf(taxes, `line ${lines.length + 1}`);
const key = `${vat.category} ${vat.rate}`;
const entry = breakdown.get(key) ?? { vat, basis: 0, rates: new Set() };
entry.basis += net;
for (const tax of taxes) entry.rates.add(tax.id);
breakdown.set(key, entry);
const price = Math.abs(before) / 100 / quantity;
lines.push(
present({
id: String(lines.length + 1),
name,
// a negative fee (a discount booked as a fee) is a negative quantity
quantity: net < 0 ? -quantity : quantity,
unit_code: 'C62',
net_price: String(Number(price.toFixed(6))),
net_amount: money(net),
allowances:
before === net
? undefined
: [{ amount: money(before - net), reason }],
tax: present({ category: vat.category, rate: vat.rate }),
seller_item_id: sku,
}),
);
};
for (const item of order.line_items)
add({
name: item.name,
quantity: item.quantity,
// subtotal: before the coupons, total: after them; neither has the VAT
before: cents(item.subtotal),
net: cents(item.total),
taxes: item.taxes,
sku: item.sku,
});
for (const shipping of order.shipping_lines)
// free shipping without tax says nothing an invoice needs
if (cents(shipping.total) !== 0 || shipping.taxes.length > 0)
add({
name: shipping.method_title,
quantity: 1,
before: cents(shipping.total),
net: cents(shipping.total),
taxes: shipping.taxes,
});
for (const fee of order.fee_lines)
add({
name: fee.name,
quantity: 1,
before: cents(fee.total),
net: cents(fee.total),
taxes: fee.taxes,
});
// the VAT of each rate as the order states it, shipping's included
const vatOfRates = (ids) =>
order.tax_lines
.filter((tax) => ids.has(tax.rate_id))
.reduce(
(sum, tax) =>
sum + cents(tax.tax_total) + cents(tax.shipping_tax_total),
0,
);
const entries = [...breakdown.values()].map((entry) => ({
...entry,
amount: vatOfRates(entry.rates),
}));
const lineNet = entries.reduce((sum, entry) => sum + entry.basis, 0);
const taxTotal = entries.reduce((sum, entry) => sum + entry.amount, 0);
const grand = lineNet + taxTotal;
if (taxTotal !== cents(order.total_tax) || grand !== cents(order.total))
fail(
`the lines come to ${money(grand)}, not ${money(cents(order.total))}; does the shop round tax at subtotal level?`,
);
const paid = config.paid ? config.paid(order) : Boolean(order.date_paid);
const issued = day(
order.date_completed ?? order.date_paid ?? order.date_created,
);
const due = new Date(`${issued}T00:00:00Z`);
due.setUTCDate(due.getUTCDate() + (config.dueDays ?? 0));
const transfer =
!paid && order.payment_method === 'bacs' ? config.transfer : null;
const vatId = order.meta_data.find(
(meta) => meta.key === config.vatIdKey,
)?.value;
const outOfScope = entries.some(({ vat }) => vat.category === 'O');
const { billing, shipping } = order;
const person = `${billing.first_name} ${billing.last_name}`.trim();
// the day the order was completed, which is the day of delivery in most shops
const delivery = present({
date: order.date_completed ? day(order.date_completed) : undefined,
address: shipping?.address_1
? present({
street: shipping.address_1,
street2: shipping.address_2,
postcode: shipping.postcode,
city: shipping.city,
subdivision: shipping.state,
country: shipping.country,
})
: undefined,
});
return present({
number: String(number),
issue_date: issued,
currency: order.currency,
seller: present({
...config.seller,
// an invoice that is not subject to VAT names no VAT ID (BR-O-02)
vat_id: outOfScope ? undefined : config.seller.vat_id,
}),
buyer: present({
name: billing.company || person,
vat_id: outOfScope ? undefined : vatId,
address: present({
street: billing.address_1,
street2: billing.address_2,
postcode: billing.postcode,
city: billing.city,
subdivision: billing.state,
country: billing.country,
}),
contact: billing.company && person ? { name: person } : undefined,
electronic_address: billing.email,
}),
delivery: filled(delivery),
lines,
tax: {
breakdown: entries.map(({ vat, basis, amount }) =>
present({ ...vat, basis: money(basis), amount: money(amount) }),
),
},
totals: present({
line_net: money(lineNet),
tax_basis: money(lineNet),
tax_total: money(taxTotal),
grand: money(grand),
prepaid: paid ? money(grand) : undefined,
due: money(paid ? 0 : grand),
}),
payment: filled(
present({
means_code: transfer ? '58' : undefined,
iban: transfer?.iban,
bic: transfer?.bic,
reference: transfer ? String(number) : undefined,
due_date: paid ? undefined : due.toISOString().slice(0, 10),
terms: config.terms,
}),
),
});
}
The configuration
What WooCommerce does not know about an invoice comes from your configuration: who you are, which VAT category a rate of 0 % is, and when an unpaid invoice is due.
export const config = {
// you, with the fields of `seller` in the _invoice block
seller: {
name: 'Fennlor Studio GmbH',
vat_id: 'DE000000000',
legal_registration: 'HRB 00000',
// register court and the like, which the example's footer prints
legal_info: 'Amtsgericht Musterstadt, HRB 00000',
address: {
street: 'Musterstraße 1',
postcode: '12345',
city: 'Musterstadt',
country: 'DE',
},
contact: { email: 'rechnung@fennlor.example' },
electronic_address: 'rechnung@fennlor.example',
},
// the order meta key your VAT number plugin keeps the buyer's VAT ID under
vatIdKey: '_billing_vat_id',
// ids of your tax rates of 0 % as VAT categories. Which category a sale
// falls under is a tax question: settle it with your tax adviser before
// you add a line. Rates above 0 % are category S.
categories: {
// 3: { category: 'Z' },
},
// days until an unpaid invoice is due, and the account it is paid into
dueDays: 14,
transfer: { iban: 'DE36 0000 0000 0000 0000 00' },
};
sellertakes every field of the block's seller. An invoice that is not subject to VAT (categoryO) must not name a VAT ID, so the function leaves both out of such an invoice;legal_registrationthen still identifies you.legal_infois the further legal information an invoice states, which the example prints in its footer.vatIdKeyis the meta key of the buyer's VAT ID. WooCommerce itself has no such field; your VAT number plugin stores it, and an order's REST answer (GET /wp-json/wc/v3/orders/<id>) shows the key undermeta_data.categoriesmaps the id of a WooCommerce tax rate (therate_idof an order'stax_lines) to a category of the VAT categories table and, for every category butSandZ, itsexemption_reasonorexemption_reason_code. It may also name a rate above 0 %, which then is no longer counted asS.exemptis the category of the lines of an order whose customer WooCommerce exempted from VAT (the order's metais_vat_exemptisyes), for example{ category: 'AE', exemption_reason_code: 'VATEX-EU-AE' }when your plugin exempts EU businesses for reverse charge, orKfor intra-community supplies of goods. Which one a sale is, and what the invoice gives as the reason, is for you and your tax adviser to decide.untaxedis the category of a line without tax in an order that is not exempt, such as a fee you charge no VAT on, or every line of a shop that charges no VAT.dueDaysis the number of days until an unpaid invoice is due (0 if left out),transferthe account a bank transfer goes to, with the invoice number as reference.paid, optional, decides whether an order is paid when its invoice is made. WooCommerce sets the payment date when an order becomes Processing or Completed, also for a bank transfer that has not arrived yet; a shop that completes such orders before the money comes says so here, for example(order) => order.payment_method !== 'bacs' && Boolean(order.date_paid).terms, optional, is the payment term the invoice states,discountReasonthe name of the allowance a coupon becomes (Discountby default). The function takes the invoice number as a third argument, the order number when it is left out.
The webhook handler
WooCommerce sends Order updated for every change of an order. The handler checks the signature, answers at once, and makes the e-invoice when the order is completed:
import { createHmac, timingSafeEqual } from 'node:crypto';
import { writeFile } from 'node:fs/promises';
import express from 'express';
import { Formfeed } from '@formfeed/sdk';
import { wooToInvoice } from './woo-to-invoice.js';
import { config } from './config.js';
const formfeed = new Formfeed({ apiKey: process.env.FORMFEED_API_KEY });
const app = express();
app.post(
'/woocommerce/webhook',
express.raw({ type: () => true }),
(req, res) => {
const signature = req.get('x-wc-webhook-signature');
// WooCommerce tries a new webhook with one ping that is not signed, and
// keeps the webhook only when it answers 200
if (!signature)
return res.sendStatus(
req.body.toString().startsWith('webhook_id=') ? 200 : 401,
);
const expected = createHmac('sha256', process.env.WOO_WEBHOOK_SECRET)
.update(req.body)
.digest();
const sent = Buffer.from(signature, 'base64');
if (sent.length !== expected.length || !timingSafeEqual(sent, expected))
return res.sendStatus(401);
res.sendStatus(200);
const order = JSON.parse(req.body);
if (order.status !== 'completed') return;
makeEinvoice(order.id).catch((error) => {
// a refused invoice names every field and rule it breaks
console.error(
`No e-invoice for order ${order.id}:`,
error.problem?.errors ?? error,
);
});
},
);
async function makeEinvoice(id) {
// read the order again: every later change of a completed order sends one
// more delivery, and the render id in its meta data stops them
const order = await shop('GET', `orders/${id}`);
if (order.meta_data.some((meta) => meta.key === 'formfeed_render')) return;
let render = await formfeed.renders.create(
{
template: 'e-rechnung',
data: { _invoice: wooToInvoice(order, config) },
post: { einvoice: {} },
filename: `${order.number}.pdf`,
meta: { woocommerce_order: id },
},
{ idempotencyKey: `woocommerce-${id}` },
);
// a render that had to wait goes on in the background: the API answers
// 202, and waitFor follows it until it is done
if (render.status !== 'succeeded')
render = await formfeed.renders.waitFor(render.id);
if (render.status === 'failed')
throw new Error(`${render.id} failed: ${render.error?.detail}`);
await shop('PUT', `orders/${id}`, {
meta_data: [{ key: 'formfeed_render', value: render.id }],
});
// the file is yours from here: archive it, send it to the customer
await writeFile(
`${order.number}.pdf`,
await formfeed.renders.download(render),
);
}
// WooCommerce's REST API, with a key that may read and write orders
async function shop(method, path, body) {
const key = `${process.env.WOO_KEY}:${process.env.WOO_SECRET}`;
const res = await fetch(`${process.env.WOO_URL}/wp-json/wc/v3/${path}`, {
method,
headers: {
authorization: `Basic ${Buffer.from(key).toString('base64')}`,
'content-type': 'application/json',
},
body: body && JSON.stringify(body),
});
if (!res.ok)
throw new Error(`WooCommerce answered ${res.status} to ${method} ${path}`);
return res.json();
}
app.listen(3000);
- The signature first. WooCommerce signs every delivery with your webhook's secret (
X-WC-Webhook-Signature, an HMAC-SHA256 of the body in Base64). Only the ping it sends when the webhook is saved has none, and WooCommerce keeps the webhook only when the ping gets a200. - Once per order. The render id that the handler writes into the order's meta data stops every later delivery for the same order, and writing it is an update too, which comes back once and stops there. The idempotency key returns the first answer again for 24 hours to a delivery that arrives before the meta data is written. Neither catches two deliveries that arrive while the first render is still running, because the key holds an answer only once there is one; a queue that handles one order at a time closes that gap. The key also returns a refusal again: after correcting the order, render with a new key, such as
woocommerce-${id}-2. - A render can come back unfinished. When it has to wait for a free render slot, the API answers
202and finishes it in the background (the automatic fallback).waitForthen polls the render until it is done, for up to two minutes by default, and the handler throws when it failed, so the order's meta data only ever names a finished e-invoice. templateis the slug of your copy of the example;post.einvoicemakes any template that prints the block an e-invoice. See the options.- The region.
new Formfeed({ apiKey })talks to the EU region, the only one that renders today (data residency). - Keep the file. Formfeed deletes outputs when the workspace's retention ends, long before invoices may be destroyed. Archive the delivered file and, beside it, the render's
einvoicereport: see archiving. In production, put the order id on a queue rather than callingmakeEinvoicedirectly, so a failure is retried.
When the block breaks a rule of EN 16931, the render is refused before anything is rendered or charged, and einvoice_data_invalid lists every field with its rule. With the mapping above that means data missing in the order, most often a billing address without a country.
The template
The example prints the lines, the VAT of each rate, the total and a payment paragraph with the account to pay into. An order differs from the example's data in a few ways, and the template has to follow:
- Coupons and an order that is paid already, which the example does not print.
- No account on an order that is paid or not paid by bank transfer: show the payment paragraph only when the invoice names one.
- More than one VAT rate, as in a shop that sells books at 7 % beside other goods at 19 %. The example prints the VAT of each rate but not the VAT total, which the display check looks for.
Print them from the same block, inside the example's loop over the lines and under its totals. The example is a PDF/UA-1 file too (pdf.ua in its settings), so the rows keep their header cells (th scope="row"), as its own rows do, and no cell of a table with header cells may stay empty: a strict check fails the render with pdfua_validation_failed. See Accessible PDFs.
<td>
{{ line.name }} {% for allowance in line.allowances %}
<div class="muted">
{{ allowance.reason }} −{{ allowance.amount | money }}
</div>
{% endfor %}
</td>
{% if _invoice.tax.breakdown | length > 1 %}
<tr>
<th scope="row">VAT total</th>
<td class="num">{{ _invoice.totals.tax_total | money }}</td>
</tr>
{% endif %} {% if _invoice.totals.prepaid %}
<tr>
<th scope="row">Paid</th>
<td class="num">{{ _invoice.totals.prepaid | money }}</td>
</tr>
<tr>
<th scope="row">Amount due</th>
<td class="num">{{ _invoice.totals.due | money }}</td>
</tr>
{% endif %} {% if _invoice.payment.iban %}
<p class="pay">…</p>
{% endif %}
The display check looks in the text of the PDF for the invoice number, the net total, the VAT total, the total, the amount due where it differs from the total, and the VAT of each rate where there is more than one. With these rows a copy of the example shows all of them. A value it does not find is reported in the render's einvoice.display.missing, and the render still succeeds unless the template sets display_check: "strict".
In n8n
The function needs nothing but JavaScript, so n8n runs it too:
-
A WooCommerce Trigger node for the event
order.updated, named WooCommerce Trigger. -
An If node that lets through orders whose
statusiscompleted. -
A Code node with this code under the function and the configuration, both pasted without the word
export:Code nodeconst order = $('WooCommerce Trigger').first().json;return [{ json: { _invoice: wooToInvoice(order, config) } }]; -
The Formfeed node: Render → Create with your copy of the example, which declares the e-invoice itself, and
{{ JSON.stringify($json) }}as Data (JSON).
To make each order once, write the render's id into the order's meta data with a WooCommerce node (Order → Update, meta key formfeed_render) and let the If node pass only orders without that key.
What the mapping does not cover
- Refunds and credit notes. A refund changes nothing on the invoice; it needs a credit note, which is document type
381with the invoice it corrects inpreceding_invoices. The function maps the order as it was completed. - Compound taxes, a line with more than one tax, amounts with more than two decimals, and totals that differ from the lines by a cent, which the setting Round tax at subtotal level, instead of rounding per line can cause, throw an error that names the order, rather than making an e-invoice that differs from it.
- Gift cards and store credit that plugins book as fees without tax are lines like any fee, of the category
untaxednames; a payment by voucher is no discount, so check how your plugin books it before you rely on that. - Invoice numbers. The order number is the invoice number unless you pass your own. Invoices need numbers that are unique, and a shop that numbers invoices separately passes its number in.
- XRechnung is not offered; Formfeed makes ZUGFeRD / Factur-X in the profiles
en16931andbasic. See what Formfeed does not do. - The VAT treatment. The function copies the order's taxes and writes the categories your configuration names. You issue the invoice and answer for its content; Formfeed answers for the format.