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.

CategoryMeaningRate
SStandard ratethe percent, e.g. 19
ZZero-rated0
EExempt from VATomit
AEReverse chargeomit
KIntra-community supply (EU)omit
GExport outside the EUomit
OOutside the scope of VATomit

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#
Do not model Skonto as an 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)
The conversion is yours, not ours. Which exchange rate applies - the supply date, the invoice date, the monthly average - is a tax question, and putting a number on an invoice that nobody chose would be worse than asking for it. 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.

jasypdf

Declarative PDFs in pure TypeScript. ZUGFeRD & XRechnung compliant, with no headless browser and no Java.

Resources

© 2026 Florian Heuberger · MIT License

Built with Nuxt · self-hosted fonts · no trackers