Documentation

Your own layout

A ZUGFeRD file is two things in one: a PDF a person reads and an XML a machine reads. Only the XML has to satisfy EN 16931. The page is yours.

jasy keeps those apart, so you can restyle the invoice as far as you like without ever risking conformance - the XML is derived from the same Invoice object no matter what the page looks like.

The built-in template, in your language

The default layout speaks German. locale switches the labels, the number format and the date format in one go:

const { bytes, xml } = await renderZugferd(invoice, { locale: "en" });

Individual labels can be overridden on top of the preset, which is enough for most house styles:

await renderZugferd(invoice, {
  locale: "en",
  labels: { vat: "Sales Tax", grandTotal: "Amount due" },
});

The keys are the parts of the page, and you override only the ones you care about:

allowance · alreadyPaid · amount · amountDue · amountsIn · bankDetails · buyerVatId · charge · contactPerson · contractReference · creditNote · customerReference · deliverTo · deliveryDate · description · dueDate · email · grandTotal · invoice · invoiceDate · invoiceNumber · itemNumber · netTotal · orderNumber · page · pageOf · payableBy · payee · payment · paymentMeans · perQuantity · phone · plusVat · position · quantity · registration · remittance · servicePeriod · subtotal · taxAmount · taxNumber · taxableBase · unitPrice · vat · vatBreakdown · vatId

resolveLabels(locale, overrides) is exported too, if you want the resolved set for a page you build yourself.

A layout you designed yourself

Hand pdf a Document and jasy uses it instead of the built-in one, then embeds the XML, the attachment, the PDF/A-3 metadata and the colour profile around it. Everything that makes the file conformant is still done for you:

import { renderZugferd } from "@jasy/e-invoice";
import { Document, Page, Column, Row, Text, Divider, Image } from "@jasy/pdf";

const { bytes, xml } = await renderZugferd(invoice, {
  pdf: Document([
    Page({ size: "A4", margin: 48, gap: 14 }, [
      Row({ justify: "between", align: "center" }, [
        Image("logo.svg", { width: 90 }),
        Text("INVOICE", { size: 26, bold: true, color: "#1450aa" }),
      ]),
      Divider({ color: "#d0d4da" }),
      Column({ gap: 6 }, [
        Text(`No. ${invoice.number}`, { bold: true }),
        Text(`Issued ${invoice.issueDate}`, { color: "gray" }),
      ]),
      // ... your table, your totals, your footer
    ]),
  ]),
});

The XML that comes back is byte-for-byte the same as with the default template. That is the whole point of the split: the appearance is a design decision, the XML is a legal one, and changing the first cannot break the second.

One thing that stays your responsibility

You are on the hook for one thing the default template does for you: German law (§14 UStG) requires certain particulars to be visible on the page, not merely present in the XML - the seller and buyer, the invoice number and date, the quantity and kind of goods, the net amount per VAT rate, the rate itself and the tax amount. Print them, and make sure a long value wraps rather than being cut: Text(value, { breakWord: true }). A truncated mandatory field is a missing one.

Starting from the built-in one

Both halves of the default are exported, so you rarely have to start from nothing:

import { defaultInvoiceTemplate, bundledFonts } from "@jasy/e-invoice";

defaultInvoiceTemplate builds the standard page from an invoice, which is a useful thing to read or to wrap. bundledFonts() returns the Liberation families keyed by the standard-14 names they substitute - Helvetica, Times, Courier. Registering them under those names is what lets a ZUGFeRD file use familiar font names and still be PDF/A valid, because PDF/A requires every font to be embedded and the standard 14 are not.

await renderToBytes(doc, { fonts: await bundledFonts() });

Invoicing periods

An invoice for a recurring service is often about a period rather than a delivery date, and §14 UStG accepts either. Both levels are supported and land in the XML as ram:BillingSpecifiedPeriod (CII) or cac:InvoicePeriod (UBL):

const invoice = {
  // ...
  period: { start: "2026-06-01", end: "2026-06-30" }, // the whole document
  lines: [
    {
      name: "Managed hosting",
      period: { start: "2026-06-01", end: "2026-06-30" }, // or per line
      quantity: 1,
      unit: "MON",
      netUnitPrice: 240,
      vat: { category: "S", ratePercent: 19 },
    },
  ],
};

The XRechnung rule wants a delivery date, or a document period, or a period on every line - and the pre-check enforces exactly that, every and not some. A period that ends before it starts is refused too.

What comes back

renderZugferd returns three things, not two:

const { bytes, xml, droppedCharacters } = await renderZugferd(invoice);

droppedCharacters holds any character no embedded font could draw. It was removed rather than written as the font's .notdef box, because PDF/A forbids referencing that glyph - so the file stays conforming and the invoice still goes out. It is empty for almost every document. Show it to whoever created the invoice rather than logging it:

if (droppedCharacters.length > 0) {
  warnUser(`removed, no font can draw them: ${droppedCharacters.join(" ")}`);
}

That is deliberately a return value and not a policy option: a missing tick mark must never stop an invoice from being produced, but a server log is not a report.

Working out the totals yourself

computeInvoice(invoice) runs the same arithmetic the template and the XML use, and hands it back: lineNets and lineTotal, allowanceTotal and chargeTotal, taxBasisTotal, the vatBreakdown per category and rate, taxTotal, grandTotal, paidAmount and duePayable. Each vatBreakdown entry carries its category and ratePercent, the taxableAmount it applies to, the taxAmount itself, and an exemption reason for the categories that need one. Use it when you draw your own page and want the numbers to agree with the XML by construction rather than by hand.

import { computeInvoice } from "@jasy/e-invoice";

const totals = computeInvoice(invoice);
Text(`Total ${totals.grandTotal.toFixed(2)} EUR`);
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