using PeachPDF;
using PeachPDF.Layout;
using s2industries.ZUGFeRD;
using System.Globalization;
// A ZUGFeRD / Factur-X e-invoice, built end to end in C#:
//
// 1. One invoice model - the single source of truth.
// 2. The invoice's structured data, as Cross Industry Invoice (CII) XML, from the open-source
// ZUGFeRD-csharp library. PeachPDF embeds XML you give it; it does not generate it.
// 3. The human-readable invoice, laid out with PeachPDF's declarative API from the same model - so the
// pages and the XML carry the same information, which is what lets the XML be embedded as an
// "Alternative" representation of the PDF.
// 4. PdfGenerateConfig.FacturX embeds the XML in a PDF/A-3 document and marks it as an e-invoice in the
// XMP metadata. The profile (EN 16931 or XRECHNUNG here) is read from the XML itself.
//
// Open the PDF's attachments panel to see the embedded factur-x.xml (or xrechnung.xml).
static class ZugferdInvoiceShowcase
{
sealed record Party(string Name, string Street, string Postcode, string City, string Email);
sealed record Line(string Item, decimal Quantity, decimal UnitPrice, decimal VatRate)
{
public decimal Total => Round(Quantity * UnitPrice);
}
const string InvoiceNumber = "2026-0042";
const string SellerVatId = "DE123456789";
const string Iban = "DE02120300000000202051";
const string Bic = "BYLADEM1001";
// Germany's Leitweg-ID: how an invoice to a public body names the department it is for. XRechnung
// requires it as the invoice's buyer reference.
const string LeitwegId = "04011000-12345-34";
static readonly DateTime IssueDate = new(2026, 9, 21);
static readonly DateTime DueDate = IssueDate.AddDays(30);
static readonly Party Seller = new("Sonnenschein Bakery GmbH", "Hauptstrasse 12", "10115", "Berlin", "invoices@sonnenschein.example");
static readonly Party Buyer = new("Musterstadt Utilities", "Rathausplatz 1", "80331", "Munich", "inbox@musterstadt-utilities.example");
static readonly Line[] Lines =
[
new("Sourdough loaf, 750 g", 120, 3.20m, 7),
new("Pretzel assortment, box of 24", 15, 18.50m, 7),
new("Catering service, staff canteen event", 1, 640.00m, 19),
new("Delivery", 1, 25.00m, 19),
];
// Invoice amounts round half away from zero (46.305 -> 46.31), not .NET's default half-to-even; the
// format's validators recompute every total the way an accountant would.
static decimal Round(decimal amount) => Math.Round(amount, 2, MidpointRounding.AwayFromZero);
static string Money(decimal amount) => amount.ToString("N2", CultureInfo.InvariantCulture) + " EUR";
/// <summary>The VAT breakdown: net amount and VAT per rate.</summary>
static IEnumerable<(decimal Rate, decimal Net, decimal Vat)> VatBreakdown() =>
Lines.GroupBy(line => line.VatRate).OrderBy(group => group.Key)
.Select(group =>
{
var net = group.Sum(line => line.Total);
return (group.Key, net, Round(net * group.Key / 100));
});
/// <summary>The invoice as CII XML, in the EN 16931 profile or, for a public-sector buyer, XRECHNUNG.</summary>
static byte[] CreateInvoiceXml(bool xRechnung)
{
var net = Lines.Sum(line => line.Total);
var vat = VatBreakdown().Sum(rate => rate.Vat);
var invoice = InvoiceDescriptor.CreateInvoice(InvoiceNumber, IssueDate, CurrencyCodes.EUR);
invoice.Type = InvoiceType.Invoice;
// The delivery (or service) date. Besides being a German requirement, it makes the library write the
// delivery section the EN 16931 schema insists on.
invoice.ActualDeliveryDate = IssueDate;
invoice.SetSeller(Seller.Name, Seller.Postcode, Seller.City, Seller.Street, CountryCodes.DE);
invoice.AddSellerTaxRegistration(SellerVatId, TaxRegistrationSchemeID.VA);
invoice.SetSellerContact("Anna Baecker", null, Seller.Email, "+49 30 1234567");
invoice.SetSellerElectronicAddress(Seller.Email, ElectronicAddressSchemeIdentifiers.ElectronicMailSmtp);
invoice.SetBuyer(Buyer.Name, Buyer.Postcode, Buyer.City, Buyer.Street, CountryCodes.DE);
invoice.SetBuyerElectronicAddress(Buyer.Email, ElectronicAddressSchemeIdentifiers.ElectronicMailSmtp);
if (xRechnung)
{
invoice.ReferenceOrderNo = LeitwegId;
// BT-23: XRechnung requires the business process, and the Peppol billing process is the standard one.
invoice.BusinessProcess = "urn:fdc:peppol.eu:2017:poacc:billing:01:1.0";
}
invoice.SetPaymentMeans(PaymentMeansTypeCodes.SEPACreditTransfer);
invoice.AddCreditorFinancialAccount(Iban, Bic);
invoice.AddTradePaymentTerms($"Payable within 30 days, by {DueDate:yyyy-MM-dd}.", DueDate);
foreach (var line in Lines)
{
invoice.AddTradeLineItem(
name: line.Item, netUnitPrice: line.UnitPrice, unitCode: QuantityCodes.C62, billedQuantity: line.Quantity,
taxType: TaxTypes.VAT, categoryCode: TaxCategoryCodes.S, taxPercent: line.VatRate);
}
foreach (var (rate, rateNet, rateVat) in VatBreakdown())
invoice.AddApplicableTradeTax(rateNet, rate, rateVat, TaxTypes.VAT, TaxCategoryCodes.S);
invoice.SetTotals(lineTotalAmount: net, taxBasisAmount: net, taxTotalAmount: vat, grandTotalAmount: net + vat, duePayableAmount: net + vat);
using var stream = new MemoryStream();
invoice.Save(stream, ZUGFeRDVersion.Version23, xRechnung ? Profile.XRechnung : Profile.Comfort);
return stream.ToArray();
}
public static async Task<PeachPdfDocument> BuildAsync(PdfGenerator generator, bool xRechnung)
{
var net = Lines.Sum(line => line.Total);
var vat = VatBreakdown().Sum(rate => rate.Vat);
var config = new PdfGenerateConfig
{
// PDF/A-3 is the container Factur-X requires; the accessible "A" level is the one the
// specification recommends, and needs a document language.
PageSize = PageSize.A4,
PdfAConformance = PdfAConformance.PdfA3A,
DefaultLanguage = "en",
Metadata = new PdfDocumentMetadata
{
Title = $"Invoice {InvoiceNumber}",
Author = Seller.Name,
// An XMP stream needs a creation date; the declarative API has no <meta> tags to read one from.
CreationDate = new DateTimeOffset(IssueDate, TimeSpan.Zero),
},
// The profile is left unset: it is derived from the XML's own guideline identifier.
FacturX = new FacturXOptions { Xml = CreateInvoiceXml(xRechnung) },
};
return await generator.CreateDocument(doc =>
{
doc.Page(page =>
{
page.Size(PageSize.A4);
page.Margin(36);
page.DefaultTextStyle(style => style.FontFamily("Arial", "sans-serif").FontSize(10));
page.Footer(footer => footer.Text(t =>
{
t.Alignment(TextAlignment.Center);
t.Span($"Invoice {InvoiceNumber} - Page ");
t.CurrentPageNumber();
t.Span(" of ");
t.TotalPages();
}));
page.Content(content => content.Column(column =>
{
column.Spacing(16);
column.Item().Text(t => t.Span("Invoice").Bold().FontSize(22));
column.Item().Row(row =>
{
row.Spacing(24);
row.Item().Grow().Column(from => Address(from, "From", Seller, $"VAT ID {SellerVatId}"));
row.Item().Grow().Column(to => Address(to, "To", Buyer));
row.Item().Grow().Column(details =>
{
details.Spacing(2);
details.Item().Text(t => t.Span("Details").Bold());
details.Item().Text($"Invoice no. {InvoiceNumber}");
details.Item().Text($"Date {IssueDate:yyyy-MM-dd}");
details.Item().Text($"Due {DueDate:yyyy-MM-dd}");
if (xRechnung)
details.Item().Text($"Buyer reference (Leitweg-ID) {LeitwegId}");
});
});
column.Item().Table(table =>
{
table.Columns(columns =>
{
columns.RelativeColumn(5);
columns.RelativeColumn(1);
columns.RelativeColumn(2);
columns.RelativeColumn(1);
columns.RelativeColumn(2);
});
table.Header(header =>
{
header.Cell().Padding(4).Text(t => t.Span("Item").Bold());
header.Cell().Padding(4).AlignRight().Text(t => t.Span("Qty").Bold());
header.Cell().Padding(4).AlignRight().Text(t => t.Span("Unit price").Bold());
header.Cell().Padding(4).AlignRight().Text(t => t.Span("VAT").Bold());
header.Cell().Padding(4).AlignRight().Text(t => t.Span("Total").Bold());
});
foreach (var line in Lines)
{
table.Row(row =>
{
row.Cell().Padding(4).Text(line.Item);
row.Cell().Padding(4).AlignRight().Text(line.Quantity.ToString("0", CultureInfo.InvariantCulture));
row.Cell().Padding(4).AlignRight().Text(Money(line.UnitPrice));
row.Cell().Padding(4).AlignRight().Text($"{line.VatRate:0}%");
row.Cell().Padding(4).AlignRight().Text(Money(line.Total));
});
}
});
column.Item().Width(240).Column(totals =>
{
totals.Spacing(2);
totals.Item().Text($"Net total: {Money(net)}");
foreach (var (rate, rateNet, rateVat) in VatBreakdown())
totals.Item().Text($"VAT {rate:0}% on {Money(rateNet)}: {Money(rateVat)}");
totals.Item().Text(t => t.Span($"Amount due: {Money(net + vat)}").Bold().FontSize(12));
});
column.Item().Text($"Please pay by SEPA credit transfer to {Iban} (BIC {Bic}) within 30 days, by {DueDate:yyyy-MM-dd}.");
column.Item().Text(t => t.Span(
$"This invoice is a hybrid e-invoice: the same data is embedded as structured XML ({(xRechnung ? "xrechnung.xml" : "factur-x.xml")}, " +
$"{(xRechnung ? "XRECHNUNG" : "EN 16931")} profile) in this PDF/A-3 file.").FontColor(PdfColor.FromHex("#555555")));
}));
});
}, config);
}
static void Address(IColumnDescriptor column, string heading, Party party, string? extraLine = null)
{
column.Spacing(2);
column.Item().Text(t => t.Span(heading).Bold());
column.Item().Text(party.Name);
column.Item().Text(party.Street);
column.Item().Text($"{party.Postcode} {party.City}");
if (extraLine is not null)
column.Item().Text(extraLine);
}
}