Documentation
The Invoice model
Afraid of the official paperwork? Don't be. EN-16931 defines a long list of fields, but you need a
handful. Fill in the few that are mandatory, add detail only where your case calls for it, and the
library computes every total and writes the conformant XML underneath. Each field maps to a real
business term (its BT- / BG- code lives in the type, for when you ever need it).
The smallest invoice
This is already a complete, conformant invoice. Everything else on this page is optional.
import { type Invoice } from "@jasy/e-invoice";
const invoice: Invoice = {
number: "INV-001",
issueDate: "2026-06-21", // ISO date
currency: "EUR",
seller: {
name: "Northwind GmbH",
vatId: "DE265013614",
address: { city: "Berlin", postCode: "10115", country: "DE" },
},
buyer: {
name: "Globex Ltd",
address: { city: "Munich", postCode: "80331", country: "DE" },
},
lines: [
{
name: "Consulting",
quantity: 8,
unit: "HUR",
netUnitPrice: 120,
vat: { category: "S", ratePercent: 19 },
},
],
};
number, issueDate, currency, seller, buyer and at least one line - that is the whole
requirement. Hand it to renderZugferd and out come the PDF and the XML.
Seller and buyer
Both parties take the same shape. A name and an address (only the country is required) are
mandatory; everything else is opt-in.
seller: {
name: "Northwind GmbH", // registered legal name
vatId: "DE265013614", // needed whenever you charge VAT
address: { line1: "Hauptstrasse 1", city: "Berlin", postCode: "10115", country: "DE" },
contact: { name: "A. Schmidt", email: "billing@northwind.de", phone: "+49 30 123456" },
electronicAddress: "billing@northwind.de", // required for XRechnung / Peppol
},
Other handy fields: tradingName, taxNumber (Steuernummer), legalRegistrationId
(Handelsregisternummer) and additionalLegalInfo (BT-33, the legal footer line - share capital,
managing director). The buyer mirrors all of it.
The address takes more than the four above when you need them: line2 and line3 for a second and
third street line, and subdivision for a region, county or state (BT-39 / BT-54). Only country is
mandatory.
Lines
Each line names an item, a quantity in a unit, the net unit price and its VAT. The library multiplies and sums - you never add up a line yourself.
lines: [
{
name: "Consulting",
description: "On-site workshop", // optional
quantity: 8,
unit: "HUR", // UN/ECE Rec 20 - "HUR" hours, "C62" pieces, "DAY", "KGM" kg, "MTR" metre ...
netUnitPrice: 120,
vat: { category: "S", ratePercent: 19 },
},
{
name: "License",
quantity: 1,
unit: "C62",
netUnitPrice: 480,
vat: { category: "S", ratePercent: 19 },
},
],
More per line when you need it: sellerItemId / buyerItemId / standardItemId (a GTIN),
priceBaseQuantity (a price "per 100"), per-line allowancesCharges, and a free-text note.
And, for the customers who reconcile automatically:
{
name: "Meter type M-200",
quantity: 2,
unit: "C62",
netUnitPrice: 480,
vat: { category: "S", ratePercent: 19 },
orderLineRef: "12", // which line of their order this settles (BT-132)
objectRef: "METER-99213", // the thing being invoiced (BT-128)
buyerAccountingRef: "CC-4712", // their cost centre, for this line (BT-133)
originCountry: "CH", // country of origin (BT-159)
}
VAT
VAT sits on the line (and on any allowance or charge): a category plus its rate.
| Category | Meaning | Rate |
|---|---|---|
S | Standard rate | the percent, e.g. 19 |
Z | Zero-rated | 0 |
E | Exempt from VAT | omit |
AE | Reverse charge | omit |
K | Intra-community supply (EU) | omit |
G | Export outside the EU | omit |
O | Outside the scope of VAT | omit |
For the non-standard categories EN-16931 wants a reason. Give it once per category:
const invoice: Invoice = {
// ... every line uses { category: "AE" } ...
vatExemptionReasons: {
AE: {
text: "Reverse charge - Steuerschuldnerschaft des Leistungsempfängers (§13b UStG)",
code: "VATEX-EU-AE",
},
},
};
The optional extras
Reach for these as your case calls for them. None are required.
Dates and references
dueDate: "2026-07-05",
buyerReference: "04011000-12345-34", // the Leitweg-ID - MANDATORY for XRechnung (B2G)
purchaseOrderRef: "PO-7782", // their order number (BT-13)
salesOrderRef: "SO-1190", // yours (BT-14)
contractRef: "C-2026-09",
projectRef: "New hall 4", // project the invoice belongs to (BT-11)
tenderRef: "VgV-2026-0031", // tender or lot, for public procurement (BT-17)
objectRef: "METER-99213", // what is being invoiced, when it is a thing (BT-18)
buyerAccountingRef: "CC-4711", // their cost centre (BT-19)
notes: ["Thank you for your business."],
noteSubjectCode: "AAI", // what the notes are about, coded (BT-21)
Large customers often insist on buyerAccountingRef: it is what lets their accounting route the
invoice without a person reading it. Both parties can also carry a directory identifier - a GLN or
similar - as seller.identifier (BT-29) and buyer.identifier (BT-46).
Payment - so the buyer knows where and how to pay:
payment: {
meansCode: "58", // SEPA credit transfer (UNCL 4461); "30" is a plain credit transfer
iban: "DE89370400440532013000",
bic: "COBADEFFXXX",
accountName: "Northwind GmbH",
terms: "Payable within 14 days, net.",
reference: "RE-2026-0184", // BT-83, what the payer should quote; defaults to the invoice number
meansText: "SEPA direct debit", // BT-82, when a code alone does not say it
},
reference is the remittance information - the string the payer puts on the transfer. Leave it out
and the invoice number is used, which is what you want almost always.
Skonto - an early-payment discount
"2% within 14 days" is not a discount in EN-16931 sense, and this is the one place where the obvious workaround produces a wrong invoice. A discount that only applies if the buyer pays early must not reduce the invoice total, because at the time of issue nobody knows whether it will be taken. So it lives in the payment terms:
payment: {
iban: "DE89370400440532013000",
terms: "Payable within 30 days.",
cashDiscounts: [
{ days: 14, percent: 2 }, // 2% if paid within 14 days
{ days: 30, percent: 1 },
],
},
The base is the invoice total including VAT, which is what German practice calculates Skonto on;
override it per tier with baseAmount. The totals do not move - the discount is printed on the page
and written into BT-20 in the machine-readable form XRechnung prescribes:
#SKONTO#TAGE=14#PROZENT=2.00#
allowancesCharges entry. The amount would be deducted immediately
although it is only due on early payment - a file that passes every validator and states the wrong
sum. xrechnungProblems() looks for exactly that mistake and names it.SEPA direct debit
When you collect rather than being paid, the payer needs to recognise the debit on their statement:
payment: {
meansCode: "59", // SEPA direct debit (UNCL 4461); "58" is a SEPA credit transfer
directDebit: {
mandateReference: "MND-2024-00871", // the mandate they signed (BT-89)
creditorId: "DE98ZZZ09999999999", // your creditor identifier (BT-90)
debitedIban: "DE02120300000000202051", // the account to be debited (BT-91)
},
},
All three go on the page as well as into the XML. A collection with no mandate reference is reported by the pre-flight, because the payer cannot check it against anything.
Document-level discounts or charges - applied after the line sum, each with its own VAT:
allowancesCharges: [
{ isCharge: false, amount: 50, reason: "Loyalty discount", vat: { category: "S", ratePercent: 19 } },
{ isCharge: true, amount: 12, reason: "Shipping", vat: { category: "S", ratePercent: 19 } },
],
Each also takes a reasonCode - the coded counterpart of reason (UNCL 5189 for an allowance, UNCL
7161 for a charge). Some receivers match on the code rather than the text.
You can state a percentage instead of a fixed sum, and then "10%" survives into the XML and onto the page rather than being silently converted to euros:
allowancesCharges: [
{
isCharge: false,
baseAmount: 3980, // what the percentage applies to (BT-93)
percent: 10, // the rate (BT-94)
reason: "Framework agreement",
vat: { category: "S", ratePercent: 19 },
},
],
The amount is derived. It works the same way on a line (BT-137/138 and BT-142/143). Give an explicit
amount and a percentage and the amount wins - overruling you would hide a typo - but the
pre-flight reports the disagreement with both figures.
The page then reads Framework agreement (10% of 3,980.00) -398.00 instead of an unexplained
-398.00.
Credit notes and corrections
A credit note that does not say what it corrects cannot be matched to its original, and the original is what an auditor searches for:
const creditNote: Invoice = {
number: "CN-2026-0011",
type: 381, // credit note
precedingInvoices: [{ number: "INV-2026-0187", issueDate: "2026-08-14" }],
// ... the rest as usual
};
Several are allowed - one correction may settle more than one invoice. The date is optional but is what disambiguates a number that repeats across years.
Attachments
A timesheet, a proof of service, a delivery note. Anyone billing by effort has one, and sending it separately by e-mail loses exactly the advantage e-invoicing exists for:
supportingDocuments: [
{
reference: "TIMESHEET-09",
description: "Timesheet September",
file: {
content: readFileSync("timesheet.csv"), // Uint8Array
mimeType: "text/csv",
filename: "timesheet.csv",
},
},
// or a link instead of a file:
{ reference: "AB-2026-14", description: "Order confirmation", url: "https://…/ab.pdf" },
],
A file travels twice: base64 inside the XML where a machine finds it, and as an embedded file
in the PDF/A-3 where a person double-clicks it. Only the invoice XML is marked as the invoice data;
attachments are marked as supplements. The page names them, so nobody has to think to look in the
attachment pane.
And a few more: delivery (date, recipientName, a deliver-to address, and locationId for
a site number instead of an address), payeeName with payeeIdentifier and
payeeLegalRegistrationId (when payment goes to someone other than the seller - a factor, say),
paidAmount (already paid, subtracted to give the amount due) and type (380 a commercial
invoice, 381 a credit note).
Rounding to a clean payable
Some houses collect a round figure. roundingAmount is a signed number added to the amount due;
the invoice total itself does not move:
roundingAmount: -0.4, // 107.40 total -> 107.00 payable
The page prints the rounding line with its sign, because a payable that does not equal the total minus what was paid otherwise looks like an arithmetic error.
Invoicing in a foreign currency
If the invoice is not in euros, a German seller still owes the tax office the VAT figure in euros. Say so, or the invoice is legally deficient:
currency: "USD",
taxCurrency: "EUR", // the currency VAT is accounted in (BT-6)
taxTotalInTaxCurrency: 172.4, // the VAT total in it (BT-111)
xrechnungProblems() warns whenever the currency is not EUR and
no accounting currency is given.That is the whole model. Start from the smallest invoice and add only what you need - the totals and the XML stay correct underneath, every time.