Zugferd 🐴 invoices with Ruby and Prawn 🦐

Does your Rails app send invoices to german businesses? Here is how to make it conform to the Zugferd standard!
What is Zugferd?
It might all be a little confusing, but here is how I understand it:
Zugferd is a German word composition meaning “Zentraler User Guide des Forums elektronische Rechnung Deutschland” (probably inspired by this album) describing a standard for a PDF/A-3 that embeds a special XML for automatic invoice processing. It is mandatory since 2025 with a grace period in certain cases. So if you send a Zugferd invoice to some other party, they can extract the XML and process it automatically in a standardized way. I think you could also send the PDF and the XML separately, but this seems to be less common.
As it took me a while to figure out how to get a valid Zugferd invoice I will add all the links I found useful to get it working. So here is how I adjusted the automatic invoice creation in a Ruby on Rails app using the Prawn gem.
The Challenges
To get a valid Zugferd PDF you need a valid PDF/A-3 and a valid embedded XML. To create the XML, I used a gem called Secretariat - it made the creation of the XML a breeze. I created a class that takes my invoice model and creates a XML from it. Best is to look at the tests in the repo for examples to adjust for your use case.
class Xrechnung
def initialize(invoice)
@invoice = invoice
end
def xml
amount = BigDecimal(@invoice.total.to_s)
seller = Secretariat::TradeParty.new(
...
)
buyer = Secretariat::TradeParty.new(
...
)
line_item = Secretariat::LineItem.new(
...
)
Secretariat::Invoice.new(
id: @invoice.invoice_no,
...
).to_xml(version: 2)
end
end
But how to embed the XML in your invoice PDF? For attaching a file to a PDF with Prawn there is also already a gem available, prawn-attachment. The problem is, that attaching the files did not result in a valid Zugferd PDF. As pointed out in this PR there are some properties missing, especially the AFRelationship
, but also the MimeType. But that is still not enough - using Mustang CLI to validate the generated file there is one final issue from the validator to fix:
The additional information provided for associated files as well as the usage requirements for associated files indicate the relationship between the embedded file and the PDF document or the part of the PDF document with which it is associated
The error sounds complicated but it turned out to be easy enough to fix - there needs to be another dictionary called /AF
in the PDF that points to all the attached file specs. I made a fork with the changes. I hope that everything gets merged at some point so that the process becomes easier.
So with using this fork, attaching the XML is as easy as calling
class InvoicePdf < Prawn::Document
...
attach "factur-x.xml", Xrechnung.new(invoice).xml,
{
description: "Factur-X/ZUGFeRD-Rechnung",
relationship: :Alternative,
mime_type: "text/xml",
}
...
in your prawn pdf generation code (the Xrechnung class for generating the XML is roughly shown above…).
Still not valid
Turns out, this document is still not a valid PDF/A-3 Zugferd document. You need to add metadata and a color profile to your PDF! Luckily someone already figured out how to do that with Prawn. The only thing missing with this is the Zugferd meta data, that I snatched from another valid document:
module InvoiceExtensions
XMP_DATE_FORMAT = '%Y-%m-%dT%H:%M:%S%:z'
def add_extensions(invoice)
@invoice = invoice
add_output_intent
add_xmp_metadata
add_trailer_data
end
def add_output_intent
icc_profile_path = File.join(File.dirname(__FILE__), 'sRGB2014.icc')
icc_profile_ref = ref!({ N: 3 })
icc_profile_ref << IO.binread(icc_profile_path)
output_intent = {
S: :GTS_PDFA1,
OutputConditionIdentifier: "sRGB2014.icc",
Info: "sRGB2014.icc",
DestOutputProfile: icc_profile_ref
}
root = state.store.root
root.data[:OutputIntents] = [output_intent]
end
def add_xmp_metadata
xmp_metadata = ref!(Type: :Metadata, Subtype: :XML)
xmp_metadata << xmp.force_encoding('BINARY')
root = state.store.root
root.data[:Metadata] = xmp_metadata
end
def add_trailer_data
# See: https://stackoverflow.com/questions/20085899/what-is-the-id-field-in-a-pdf-file
# The value of the ID entry is not a string but instead an array of two strings.
# And the string values are not arbitrary but instead unique values recommended to be obtained by hashing.
# Thus they especially must not be re-used for different documents
# Added with https://github.com/prawnpdf/pdf-core/pull/16
state.trailer[:ID] = [
# not sure if ByteString is needed here...
PDF::Core::ByteString.new("myDocument"),
PDF::Core::ByteString.new(SecureRandom.uuid)
]
end
def xmp
<<~XMP
<?xpacket begin="\u{FEFF}" id="#{SecureRandom.uuid.tr('-', '')}"?>
<x:xmpmeta xmlns:x="adobe:ns:meta/">
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<rdf:Description rdf:about=""
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:xmp="http://ns.adobe.com/xap/1.0/"
xmlns:pdf="http://ns.adobe.com/pdf/1.3/"
xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
<dc:title>
<rdf:Alt>
<rdf:li xml:lang="x-default">Invoice</rdf:li>
</rdf:Alt>
</dc:title>
<dc:creator>
<rdf:Seq>
<rdf:li>Acme</rdf:li>
</rdf:Seq>
</dc:creator>
<dc:subject>
<rdf:Bag>
<rdf:li>Your invoice</rdf:li>
</rdf:Bag>
</dc:subject>
<dc:description>
<rdf:Alt>
<rdf:li xml:lang="x-default">Invoice</rdf:li>
</rdf:Alt>
</dc:description>
<xmp:CreatorTool>Prawn</xmp:CreatorTool>
<xmp:CreateDate>#{@invoice.created_at.strftime(XMP_DATE_FORMAT)}</xmp:CreateDate>
<xmp:ModifyDate>#{@invoice.updated_at.strftime(XMP_DATE_FORMAT)}</xmp:ModifyDate>
<pdf:Producer>Prawn</pdf:Producer>
<pdfaid:part>3</pdfaid:part>
<pdfaid:conformance>B</pdfaid:conformance>
</rdf:Description>
<rdf:Description xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
xmlns:pdfaField="http://www.aiim.org/pdfa/ns/field#"
xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#"
xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
xmlns:pdfaType="http://www.aiim.org/pdfa/ns/type#"
rdf:about="">
<pdfaExtension:schemas>
<rdf:Bag>
<rdf:li rdf:parseType="Resource">
<pdfaSchema:schema>ZUGFeRD PDFA Extension Schema</pdfaSchema:schema>
<pdfaSchema:namespaceURI>urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0#</pdfaSchema:namespaceURI>
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
<pdfaSchema:property>
<rdf:Seq>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>DocumentFileName</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>name of the embedded XML invoice file</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>DocumentType</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>INVOICE</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>Version</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The actual version of the ZUGFeRD data</pdfaProperty:description>
</rdf:li>
<rdf:li rdf:parseType="Resource">
<pdfaProperty:name>ConformanceLevel</pdfaProperty:name>
<pdfaProperty:valueType>Text</pdfaProperty:valueType>
<pdfaProperty:category>external</pdfaProperty:category>
<pdfaProperty:description>The conformance level of the ZUGFeRD data</pdfaProperty:description>
</rdf:li>
</rdf:Seq>
</pdfaSchema:property>
</rdf:li>
</rdf:Bag>
</pdfaExtension:schemas>
</rdf:Description>
<rdf:Description xmlns:fx="urn:zugferd:pdfa:CrossIndustryDocument:invoice:2p0#"
fx:ConformanceLevel="BASIC"
fx:DocumentFileName="factur-x.xml"
fx:DocumentType="INVOICE"
fx:Version="1.0"
rdf:about=""/>
</rdf:RDF>
</x:xmpmeta>
<?xpacket end="r"?>
XMP
end
end
Inside the prawn document, I call it like
class InvoicePdf < Prawn::Document
include InvoiceExtensions
def initialize(invoice)
add_extensions invoice
...
end
end
You can download the color profile here by accepting the terms and put it next to your code.
And there it was, a valid Zugferd PDF 🥳 BTW: I tried several online validators. winball was really generous and accepted an invoice that just had the XML inside without the PDF/A-3 adjustments, so it is probably too generous. Portinvoice turned out to be much stricter and matched the Mustang validation that I used during my checks running
java -Xmx1G -Dfile.encoding=UTF-8 -jar /Users/me/Downloads/Mustang-CLI-2.16.4.jar --action validate --source /Users/me/Downloads/42000003-invoice-3.pdf
To sum up here are the steps again:
- Generate the XML with whatever works for you, a template or a gem like Secretariat
- For now use the forked prawn attachment gem
- Add the color profile and meta data as shown above
And that’s it! I wish you good luck on your own Zugferd journey!