WooCommerce
Jede Bestellung, die Ihr WooCommerce-Shop fertigstellt, kann eine ZUGFeRD- / Factur-X-E-Rechnung werden: dieselben Produkte, derselbe Versand, dieselben Gebühren, dieselbe Umsatzsteuer und dieselben Summen, als PDF/A-3 mit der Rechnung als XML darin, geprüft, bevor Sie sie erhalten. Ein Plugin ist nicht nötig. Ein Webhook-Handler und die Funktion auf dieser Seite sind die ganze Integration: Die Funktion macht aus der Bestellung von WooCommerce den _invoice-Block, und ein Rendering erzeugt die Datei.
Vorbereitung
- Eine Vorlage, die den
_invoice-Block druckt. Vorlagen → Beispiele → E-Rechnung in der App legt eine Kopie einer solchen Vorlage in Ihrem Workspace an, mit dem Sluge-rechnung, den der Handler unten rendert, sofern Sie ihr dort keinen anderen geben. Die Vorlage zeigt, was eine Bestellung ihr hinzufügt. - Ein API-Schlüssel von Formfeed. Die Standard-Scopes genügen. Beginnen Sie mit einem
ff_test_-Schlüssel: Seine E-Rechnungen sind gültige Dateien mit der Testmarkierung auf jeder Seite, bis zu einer Anzahl pro Tag, die vom Plan abhängt. Live-E-Rechnungen setzen den Starter-Plan oder höher voraus. - Ein Webhook in WooCommerce unter WooCommerce → Einstellungen → Erweitert → Webhooks: Thema Bestellung aktualisiert, API-Version WP REST API Integration v3, die Adresse Ihres Handlers als Zustell-URL und ein Secret.
- Ein REST-API-Schlüssel unter WooCommerce → Einstellungen → Erweitert → REST-API mit der Berechtigung Lesen/Schreiben: Der Handler liest die Bestellung noch einmal und vermerkt das Rendering in ihren Metadaten.
- Preise mit zwei Nachkommastellen, eingestellt unter WooCommerce → Einstellungen → Allgemein: Beträge einer E-Rechnung sind centgenau.
Die Zuordnung
wooToInvoice nimmt die Bestellung, wie die REST-API von WooCommerce sie liefert und wie auch ihre Webhooks sie zustellen, und gibt den _invoice-Block zurück. Sie übernimmt die Beträge der Bestellung und berechnet nichts, was sie falsch berechnen könnte:
- Produkte, Versand und Gebühren sind Positionen. Ein Produkt behält Namen, Menge und Artikelnummer; sein Preis ist der Preis vor den Gutscheinen, sein Nettobetrag der Betrag danach, und was ein Gutschein abgezogen hat, ist ein Nachlass an der Position. Jede Versandart und jede Gebühr ist eine eigene Position mit ihrer Umsatzsteuer; eine negative Gebühr ist eine negative Menge, und kostenloser Versand ohne Steuer entfällt.
- Die Umsatzsteuer aus den Steuersätzen von WooCommerce. Eine Position zu einem Satz über 0 % ist Kategorie
Smit diesem Satz. Ein Satz von 0 %, ein Kunde, den WooCommerce von der Umsatzsteuer befreit (wie es USt-IdNr.-Plugins für Reverse Charge tun), und eine Position ganz ohne Steuer sind Kategorien, die Sie in der Konfiguration festlegen. Ein Satz ohne Kategorie löst einen Fehler aus, statt zu raten. - Summen aus den Positionen, an denen der Bestellung geprüft. Die Umsatzsteuer je Satz ist die der Bestellung (
tax_lines), und ergeben die Positionen mit dieser Steuer nicht den Gesamtbetrag der Bestellung, löst die Funktion einen Fehler aus. - Bezahlt oder offen. Eine Bestellung, für die WooCommerce ein Zahlungsdatum hat, ist vollständig bezahlt (
prepaid). Jede andere ist offen, per Überweisung auf Ihr Konto, wenn ihre Zahlungsart Direkte Banküberweisung (bacs) ist. - Daten in der Zeitzone Ihres Shops. Rechnungsdatum und Lieferdatum sind der Tag, an dem die Bestellung fertiggestellt wurde, in den meisten Shops der Liefertag; die Lieferanschrift ist die Versandadresse der Bestellung.
- Der Käufer aus der Rechnungsadresse: die Firma oder der Name der Person, mit der Adresse und der E-Mail als elektronischer Adresse. Die USt-IdNr. kommt aus den Metadaten der Bestellung, unter dem Schlüssel, den Ihr USt-IdNr.-Plugin verwendet.
/**
* Eine WooCommerce-Bestellung als `_invoice`-Block einer E-Rechnung.
*
* `order` ist die Bestellung, wie die REST-API von WooCommerce (v3) sie
* liefert, und so stellen auch ihre Webhooks sie zu. `number` ist die
* Rechnungsnummer, die Bestellnummer, sofern Ihr Shop Rechnungen nicht selbst
* nummeriert. Jeder Betrag wird aus WooCommerce übernommen, nie aus einem
* Preis errechnet: Eine Summe, die von der Bestellung abweicht, ist ein
* Fehler, keine Rundung.
*/
export function wooToInvoice(order, config, number = order.number) {
const fail = (message) => {
throw new Error(`Bestellung ${order.number}: ${message}`);
};
// WooCommerce schreibt Beträge als Text, mit den Nachkommastellen des Shops
const cents = (text) => {
const value = String(text || '0');
if (!/^-?\d+(\.\d{1,2})?$/.test(value))
fail(
`${value} ist kein Betrag in Cent; stellen Sie zwei Nachkommastellen ein`,
);
return Math.round(Number(value) * 100);
};
const money = (amount) => (amount / 100).toFixed(2);
// ein Datum, wie WooCommerce es schreibt, in der Zeitzone des Shops
const day = (text) => text.slice(0, 10);
// WooCommerce schreibt '', wo ein Wert fehlt; der Block lässt ihn weg
const present = (object) =>
Object.fromEntries(
Object.entries(object).filter(
([, value]) => value != null && value !== '',
),
);
// und ein Objekt, das dabei leer bleibt, ebenso
const filled = (object) =>
Object.keys(object).length > 0 ? object : undefined;
const rateOf = (category, percent) =>
category === 'S' ? percent : category === 'O' ? undefined : 0;
// die Steuersätze der Bestellung, nach der ID, die ihre Positionen nennen
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} trägt ${taxes.length} Steuern`);
if (taxes.length === 0) {
// ein von WooCommerce steuerbefreiter Kunde, oder ein Shop ohne USt.
const stated = exempt ? config.exempt : config.untaxed;
if (!stated)
fail(
`${at}: ihre Kategorie gehört 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}: Steuersatz ${taxes[0].id} ist zusammengesetzt oder unbekannt`,
);
// den Prozentsatz schreibt WooCommerce ab Version 3.7 in die Bestellung
if (rate.rate_percent == null)
fail(`${at}: die Bestellung hat kein 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}: die Kategorie des Steuersatzes ${rate.rate_id} (${rate.label}) gehört in config.categories`,
);
};
const breakdown = new Map(); // ein Eintrag je Kategorie und Satz
const lines = [];
// Produkte, Versand und Gebühren sind Positionen, je mit ihrer Steuer
const reason = config.discountReason ?? 'Rabatt';
const add = ({ name, quantity, before, net, taxes, sku }) => {
const vat = vatOf(taxes, `Position ${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,
// eine negative Gebühr (ein Rabatt als Gebühr) ist eine negative Menge
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: vor den Gutscheinen, total: danach; beide ohne Umsatzsteuer
before: cents(item.subtotal),
net: cents(item.total),
taxes: item.taxes,
sku: item.sku,
});
for (const shipping of order.shipping_lines)
// kostenloser Versand ohne Steuer sagt nichts, was eine Rechnung braucht
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,
});
// die Umsatzsteuer je Satz laut Bestellung, die des Versands eingeschlossen
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(
`die Positionen ergeben ${money(grand)}, nicht ${money(cents(order.total))}; rundet der Shop die Steuer auf Zwischensummenebene?`,
);
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();
// der Tag, an dem die Bestellung fertiggestellt wurde, meist der Liefertag
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,
// eine nicht steuerbare Rechnung nennt keine USt-IdNr. (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,
}),
),
});
}
Die Konfiguration
Was WooCommerce über eine Rechnung nicht weiß, kommt aus Ihrer Konfiguration: wer Sie sind, welche Umsatzsteuerkategorie ein Satz von 0 % hat und wann eine offene Rechnung fällig ist.
export const config = {
// Sie selbst, mit den Feldern von `seller` im _invoice-Block
seller: {
name: 'Fennlor Studio GmbH',
vat_id: 'DE000000000',
legal_registration: 'HRB 00000',
// Registergericht und Ähnliches, das die Fußzeile des Beispiels druckt
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',
},
// der Metadaten-Schlüssel, unter dem Ihr USt-IdNr.-Plugin die des Käufers speichert
vatIdKey: '_billing_vat_id',
// IDs Ihrer Steuersätze von 0 % als Kategorien. Welche Kategorie ein Umsatz
// hat, ist eine Steuerfrage: Klären Sie sie mit Ihrer Steuerberatung, bevor
// Sie eine Zeile ergänzen. Sätze über 0 % sind Kategorie S.
categories: {
// 3: { category: 'Z' },
},
// Tage, bis eine offene Rechnung fällig ist, und das Konto für die Zahlung
dueDays: 14,
transfer: { iban: 'DE36 0000 0000 0000 0000 00' },
};
sellernimmt jedes Feld des Verkäufers im Block. Eine nicht steuerbare Rechnung (KategorieO) darf keine USt-IdNr. nennen, deshalb lässt die Funktion beide bei einer solchen Rechnung weg;legal_registrationidentifiziert Sie dann weiterhin.legal_infosind die weiteren rechtlichen Angaben, die eine Rechnung nennt und die das Beispiel in seiner Fußzeile druckt.vatIdKeyist der Metadaten-Schlüssel der USt-IdNr. des Käufers. WooCommerce selbst hat dafür kein Feld; Ihr USt-IdNr.-Plugin speichert sie, und die REST-Antwort einer Bestellung (GET /wp-json/wc/v3/orders/<id>) zeigt den Schlüssel untermeta_data.categoriesordnet die ID eines Steuersatzes von WooCommerce (dierate_iddertax_lineseiner Bestellung) einer Kategorie aus der Tabelle der Umsatzsteuerkategorien zu und, für jede Kategorie außerSundZ, ihrenexemption_reasonoderexemption_reason_code. Sie kann auch einen Satz über 0 % nennen, der dann nicht mehr alsSgilt.exemptist die Kategorie der Positionen einer Bestellung, deren Kunden WooCommerce von der Umsatzsteuer befreit hat (die Metadatenis_vat_exemptder Bestellung sindyes), etwa{ category: 'AE', exemption_reason_code: 'VATEX-EU-AE' }, wenn Ihr Plugin Unternehmen in der EU für Reverse Charge befreit, oderKfür innergemeinschaftliche Lieferungen von Waren. Welches von beiden ein Umsatz ist und welchen Grund die Rechnung angibt, entscheiden Sie mit Ihrer Steuerberatung.untaxedist die Kategorie einer Position ohne Steuer in einer Bestellung, die nicht befreit ist, etwa einer Gebühr ohne Umsatzsteuer, oder jeder Position eines Shops, der keine Umsatzsteuer berechnet.dueDaysist die Zahl der Tage, bis eine offene Rechnung fällig ist (0, wenn nichts angegeben ist),transferdas Konto für eine Überweisung, mit der Rechnungsnummer als Verwendungszweck.paid, optional, entscheidet, ob eine Bestellung bezahlt ist, wenn ihre Rechnung entsteht. WooCommerce setzt das Zahlungsdatum, sobald eine Bestellung In Bearbeitung oder Fertiggestellt wird, auch bei einer Überweisung, die noch nicht eingegangen ist; ein Shop, der solche Bestellungen fertigstellt, bevor das Geld da ist, sagt das hier, etwa(order) => order.payment_method !== 'bacs' && Boolean(order.date_paid).terms, optional, ist die Zahlungsbedingung, die die Rechnung nennt,discountReasonder Name des Nachlasses, zu dem ein Gutschein wird (Rabatt, wenn nichts angegeben ist). Die Funktion nimmt die Rechnungsnummer als drittes Argument, die Bestellnummer, wenn keine angegeben ist.
Der Webhook-Handler
WooCommerce sendet Bestellung aktualisiert bei jeder Änderung einer Bestellung. Der Handler prüft die Signatur, antwortet sofort und erzeugt die E-Rechnung, wenn die Bestellung fertiggestellt ist (Status 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 prüft einen neuen Webhook mit einem Ping ohne Signatur und
// behält den Webhook nur, wenn er mit 200 beantwortet wird
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) => {
// eine abgelehnte Rechnung nennt jedes Feld und jede Regel, die es verletzt
console.error(
`Keine E-Rechnung für Bestellung ${order.id}:`,
error.problem?.errors ?? error,
);
});
},
);
async function makeEinvoice(id) {
// die Bestellung neu lesen: Jede spätere Änderung einer fertiggestellten
// Bestellung sendet eine weitere Zustellung, und die Rendering-ID in ihren
// Metadaten hält sie auf
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}` },
);
// ein Rendering, das warten musste, läuft im Hintergrund weiter: Die API
// antwortet 202, und waitFor verfolgt es, bis es fertig ist
if (render.status !== 'succeeded')
render = await formfeed.renders.waitFor(render.id);
if (render.status === 'failed')
throw new Error(`${render.id} fehlgeschlagen: ${render.error?.detail}`);
await shop('PUT', `orders/${id}`, {
meta_data: [{ key: 'formfeed_render', value: render.id }],
});
// ab hier gehört die Datei Ihnen: archivieren, an den Kunden senden
await writeFile(
`${order.number}.pdf`,
await formfeed.renders.download(render),
);
}
// die REST-API von WooCommerce, mit einem Schlüssel, der Bestellungen lesen und schreiben darf
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 antwortete ${res.status} auf ${method} ${path}`,
);
return res.json();
}
app.listen(3000);
- Erst die Signatur. WooCommerce signiert jede Zustellung mit dem Secret Ihres Webhooks (
X-WC-Webhook-Signature, ein HMAC-SHA256 des Bodys in Base64). Nur der Ping, den es beim Speichern des Webhooks sendet, hat keine, und WooCommerce behält den Webhook nur, wenn der Ping ein200bekommt. - Einmal je Bestellung. Die Rendering-ID, die der Handler in die Metadaten der Bestellung schreibt, hält jede spätere Zustellung für dieselbe Bestellung auf, und das Schreiben ist ebenfalls eine Änderung, die einmal zurückkommt und dort endet. Der Idempotency-Key liefert einer Zustellung, die ankommt, bevor die Metadaten geschrieben sind, 24 Stunden lang die erste Antwort erneut. Keines von beiden fängt zwei Zustellungen ab, die ankommen, während das erste Rendering noch läuft, denn der Key hält eine Antwort erst, wenn es eine gibt; eine Warteschlange, die eine Bestellung nach der anderen bearbeitet, schließt diese Lücke. Der Key liefert auch eine Ablehnung erneut: Rendern Sie nach dem Korrigieren der Bestellung mit einem neuen Key, etwa
woocommerce-${id}-2. - Ein Rendering kann unfertig zurückkommen. Wenn es auf einen freien Render-Slot warten muss, antwortet die API mit
202und stellt es im Hintergrund fertig (der automatische Fallback).waitForfragt das Rendering dann ab, bis es fertig ist, standardmäßig bis zu zwei Minuten lang, und der Handler wirft einen Fehler, wenn es fehlgeschlagen ist, sodass die Metadaten der Bestellung nur eine fertige E-Rechnung nennen. templateist der Slug Ihrer Kopie des Beispiels;post.einvoicemacht jede Vorlage, die den Block druckt, zur E-Rechnung. Siehe die Optionen.- Die Region.
new Formfeed({ apiKey })spricht mit der Region EU, der einzigen, die heute rendert (Datenresidenz). - Behalten Sie die Datei. Formfeed löscht Ausgaben, wenn die Aufbewahrungsfrist des Workspace endet, lange bevor Rechnungen vernichtet werden dürfen. Archivieren Sie die ausgelieferte Datei und daneben den
einvoice-Bericht des Renderings: siehe Archivierung. Legen Sie die Bestell-ID im Betrieb in eine Warteschlange, stattmakeEinvoicedirekt aufzurufen, damit ein Fehler wiederholt wird.
Verletzt der Block eine Regel der EN 16931, wird das Rendering abgelehnt, bevor etwas gerendert oder berechnet wird, und einvoice_data_invalid nennt jedes Feld mit seiner Regel. Mit der Zuordnung oben heißt das: In der Bestellung fehlen Daten, meistens das Land der Rechnungsadresse.
Die Vorlage
Das Beispiel druckt die Positionen, die Umsatzsteuer je Satz, den Gesamtbetrag und einen Absatz zur Zahlung mit dem Konto, auf das überwiesen wird. Eine Bestellung unterscheidet sich in einigen Punkten von den Daten des Beispiels, und die Vorlage muss folgen:
- Gutscheine und eine schon bezahlte Bestellung, die das Beispiel nicht druckt.
- Kein Konto bei einer Bestellung, die bezahlt ist oder nicht per Überweisung bezahlt wird: Zeigen Sie den Absatz zur Zahlung nur, wenn die Rechnung eines nennt.
- Mehr als ein Steuersatz, etwa in einem Shop, der Bücher zu 7 % neben anderen Waren zu 19 % verkauft. Das Beispiel druckt die Umsatzsteuer je Satz, aber nicht die Summe der Umsatzsteuer, nach der die Sichtprüfung sucht.
Drucken Sie sie aus demselben Block, in der Schleife des Beispiels über die Positionen und unter seinen Summen. Das Beispiel ist auch eine PDF/UA-1-Datei (pdf.ua in seinen Einstellungen), deshalb behalten die Zeilen ihre Kopfzellen (th scope="row"), wie seine eigenen Zeilen, und keine Zelle einer Tabelle mit Kopfzellen darf leer bleiben: Eine strenge Prüfung lässt das Rendering mit pdfua_validation_failed scheitern. Siehe Barrierefreie 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">USt. gesamt</th>
<td class="num">{{ _invoice.totals.tax_total | money }}</td>
</tr>
{% endif %} {% if _invoice.totals.prepaid %}
<tr>
<th scope="row">Bezahlt</th>
<td class="num">{{ _invoice.totals.prepaid | money }}</td>
</tr>
<tr>
<th scope="row">Offener Betrag</th>
<td class="num">{{ _invoice.totals.due | money }}</td>
</tr>
{% endif %} {% if _invoice.payment.iban %}
<p class="pay">…</p>
{% endif %}
Die Sichtprüfung sucht im Text des PDFs die Rechnungsnummer, die Nettosumme, die Summe der Umsatzsteuer, den Gesamtbetrag, den offenen Betrag, wo er vom Gesamtbetrag abweicht, und die Umsatzsteuer je Satz, wo es mehr als einen gibt. Mit diesen Zeilen zeigt eine Kopie des Beispiels alle davon. Einen Wert, den sie nicht findet, meldet sie in einvoice.display.missing des Renderings, und das Rendering gelingt trotzdem, sofern die Vorlage nicht display_check: "strict" festlegt.
In n8n
Die Funktion braucht nichts als JavaScript, deshalb läuft sie auch in n8n:
-
Ein WooCommerce Trigger-Node für das Ereignis
order.updated, benannt WooCommerce Trigger. -
Ein If-Node, der Bestellungen durchlässt, deren
statuscompletedist. -
Ein Code-Node mit diesem Code unter der Funktion und der Konfiguration, beide ohne das Wort
exporteingefügt:Code-Nodeconst order = $('WooCommerce Trigger').first().json;return [{ json: { _invoice: wooToInvoice(order, config) } }]; -
Der Formfeed-Node: Render → Create mit Ihrer Kopie des Beispiels, die die E-Rechnung selbst festlegt, und
{{ JSON.stringify($json) }}als Data (JSON).
Damit jede Bestellung nur einmal eine E-Rechnung bekommt, schreiben Sie die Rendering-ID mit einem WooCommerce-Node (Order → Update, Metadaten-Schlüssel formfeed_render) in die Bestellung und lassen den If-Node nur Bestellungen ohne diesen Schlüssel durch.
Was die Zuordnung nicht abdeckt
- Erstattungen und Gutschriften. Eine Erstattung ändert an der Rechnung nichts; sie braucht eine Gutschrift, Dokumentart
381mit der Rechnung, die sie korrigiert, inpreceding_invoices. Die Funktion bildet die Bestellung ab, wie sie fertiggestellt wurde. - Zusammengesetzte Steuern, eine Position mit mehr als einer Steuer, Beträge mit mehr als zwei Nachkommastellen und Summen, die um einen Cent von den Positionen abweichen, wie sie die Einstellung verursachen kann, die Steuer auf Ebene der Zwischensumme statt pro Zeile zu runden, lösen einen Fehler aus, der die Bestellung nennt, statt eine E-Rechnung zu erzeugen, die von ihr abweicht.
- Geschenkkarten und Guthaben, die Plugins als Gebühren ohne Steuer buchen, sind Positionen wie jede Gebühr, in der Kategorie, die
untaxednennt; eine Zahlung per Gutschein ist kein Rabatt, prüfen Sie also, wie Ihr Plugin sie bucht, bevor Sie sich darauf verlassen. - Rechnungsnummern. Die Bestellnummer ist die Rechnungsnummer, sofern Sie keine eigene übergeben. Rechnungen brauchen eindeutige Nummern, und ein Shop, der Rechnungen gesondert nummeriert, übergibt seine Nummer.
- XRechnung wird nicht angeboten; Formfeed erzeugt ZUGFeRD / Factur-X in den Profilen
en16931undbasic. Siehe Was Formfeed nicht tut. - Die umsatzsteuerliche Behandlung. Die Funktion übernimmt die Steuern der Bestellung und schreibt die Kategorien, die Ihre Konfiguration nennt. Sie stellen die Rechnung aus und stehen für ihren Inhalt ein; Formfeed steht für das Format ein.