en16931 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EN16931
4
+ module PDF
5
+ # The reference layout: an A4 letter with every § 14 UStG item in its
6
+ # place, dressed by a Branding (logo, accent, typeface, texts, GiroCode).
7
+ # Applications with their own design draw their own Prawn document and
8
+ # call A3.attach; this one is the default and the test bed.
9
+ class Renderer
10
+ FONT = "DejaVuSans"
11
+ BRAND_FONT = "Brand"
12
+ # The recipient block starts 45 mm below the page top (DIN 5008 window), unless the letterhead runs deeper.
13
+ ADDRESS_TOP = 45 * 72 / 25.4
14
+ GIRO_CODE_SIZE = 68
15
+ LABELS = {
16
+ de: { invoice: "Rechnung", credit_note: "Gutschrift", corrected: "Rechnungskorrektur", self_billed: "Gutschrift (Abrechnung durch den Leistungsempfänger)",
17
+ partial: "Teilrechnung", prepayment: "Anzahlungsrechnung",
18
+ number: "Rechnungsnummer", date: "Rechnungsdatum", due: "Fällig am", delivery: "Leistungsdatum", period: "Leistungszeitraum",
19
+ reference: "Ihre Referenz", preceding: "Zur Rechnung", pos: "Pos.", description: "Beschreibung", qty: "Menge", unit_price: "Einzelpreis",
20
+ net: "Netto", sum_net: "Summe netto", vat: "Umsatzsteuer", allowance: "Nachlass", charge: "Zuschlag", prepaid: "Bereits gezahlt",
21
+ gross: "Gesamtbetrag", payable: "Zahlbetrag", payment: "Zahlung", iban: "IBAN", bic: "BIC", holder: "Kontoinhaber",
22
+ reference_line: "Verwendungszweck", vat_id: "USt-IdNr.", tax_number: "Steuernummer", contact: "Kontakt",
23
+ giro_code: "GiroCode", units: { "C62" => "Stk.", "HUR" => "Std.", "DAY" => "Tage", "MON" => "Monate", "LS" => "pauschal", "E48" => "Leistung" } },
24
+ en: { invoice: "Invoice", credit_note: "Credit note", corrected: "Corrected invoice", self_billed: "Self-billed invoice",
25
+ partial: "Partial invoice", prepayment: "Prepayment invoice",
26
+ number: "Invoice number", date: "Invoice date", due: "Due date", delivery: "Delivery date", period: "Service period",
27
+ reference: "Your reference", preceding: "Refers to invoice", pos: "No.", description: "Description", qty: "Quantity", unit_price: "Unit price",
28
+ net: "Net", sum_net: "Total net", vat: "VAT", allowance: "Discount", charge: "Charge", prepaid: "Prepaid",
29
+ gross: "Total", payable: "Amount due", payment: "Payment", iban: "IBAN", bic: "BIC", holder: "Account holder",
30
+ reference_line: "Payment reference", vat_id: "VAT id", tax_number: "Tax number", contact: "Contact",
31
+ giro_code: "Scan to pay", units: { "C62" => "pcs", "HUR" => "h", "DAY" => "days", "MON" => "months", "LS" => "lump sum", "E48" => "service" } }
32
+ }.freeze
33
+
34
+ attr_reader :invoice, :locale, :branding
35
+
36
+ def self.title(invoice, locale) = "#{LABELS.fetch(locale)[invoice.type]} #{invoice.number}"
37
+
38
+ def initialize(invoice, locale: :de, branding: Branding.new)
39
+ @invoice = invoice
40
+ @locale = LABELS.key?(locale) ? locale : :de
41
+ @branding = branding
42
+ @accent = branding.accent
43
+ end
44
+
45
+ def t(key) = LABELS.fetch(locale).fetch(key)
46
+
47
+ # @return [Prawn::Document]
48
+ def document
49
+ @document ||= Prawn::Document.new(page_size: "A4", margin: [56, 50, 56, 50], compress: true, info: {}).tap do |pdf|
50
+ pdf.font_families.update(FONT => { normal: EN16931.font_path("DejaVuSans.ttf"), bold: EN16931.font_path("DejaVuSans-Bold.ttf") })
51
+ pdf.font_families.update(BRAND_FONT => branding.font) if branding.font
52
+ pdf.font(branding.font ? BRAND_FONT : FONT, size: 9.5)
53
+ draw(pdf)
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ def draw(pdf)
60
+ letterhead(pdf)
61
+ addresses(pdf)
62
+ meta(pdf)
63
+ intro(pdf)
64
+ lines_table(pdf)
65
+ totals_block(pdf)
66
+ notes(pdf)
67
+ payment_block(pdf)
68
+ closing(pdf)
69
+ footer(pdf)
70
+ end
71
+
72
+ # The seller's name, and the logo beside it when there is one. The
73
+ # logo keeps its aspect ratio and the name moves to the other side.
74
+ def letterhead(pdf)
75
+ top = pdf.cursor
76
+ name_align = :left
77
+ if (size = branding.logo_size)
78
+ width, height = size
79
+ x = branding.logo_position == :left ? pdf.bounds.left : pdf.bounds.right - width
80
+ pdf.image(StringIO.new(branding.logo), at: [x, top], width: width, height: height)
81
+ name_align = :right if branding.logo_position == :left
82
+ end
83
+ pdf.text invoice.seller.name, size: 14, style: :bold, align: name_align
84
+ pdf.move_cursor_to [pdf.cursor, top - (size ? size[1] : 0)].min
85
+ pdf.move_down 16
86
+ end
87
+
88
+ # Return address in one small line, then the recipient: a letter that
89
+ # fits a window envelope.
90
+ def addresses(pdf)
91
+ seller = invoice.seller
92
+ pdf.move_cursor_to [pdf.cursor, pdf.bounds.top - ADDRESS_TOP + 56].min
93
+ sender = [seller.name, seller.street, seller.address_line_2, [seller.postal_code, seller.city].compact.join(" ")].compact.reject(&:empty?)
94
+ pdf.text sender.join(" · "), size: 7, color: "666666"
95
+ pdf.move_down 6
96
+ buyer = invoice.buyer
97
+ pdf.text buyer.name, style: :bold
98
+ buyer.address_lines.each { |line| pdf.text line }
99
+ pdf.text country_name(buyer.country) unless buyer.country == invoice.seller.country
100
+ pdf.move_down 28
101
+ end
102
+
103
+ def meta(pdf)
104
+ pdf.text Renderer.title(invoice, locale), size: 16, style: :bold, color: @accent
105
+ pdf.move_down 8
106
+ rows = [[t(:number), invoice.number], [t(:date), date(invoice.issue_date)]]
107
+ rows << [t(:due), date(invoice.due_date)] if invoice.due_date
108
+ rows << [t(:delivery), date(invoice.delivery_date)] if invoice.delivery_date
109
+ rows << [t(:period), "#{date(invoice.period.begin)} – #{date(invoice.period.end)}"] if invoice.period
110
+ rows << [t(:reference), invoice.buyer_reference] if invoice.buyer_reference
111
+ if invoice.preceding
112
+ rows << [t(:preceding),
113
+ [invoice.preceding[:number], (date(invoice.preceding[:issue_date]) if invoice.preceding[:issue_date])].compact.join(", ")]
114
+ end
115
+ pdf.table(rows, cell_style: { borders: [], padding: [2, 0], size: 9.5 }, column_widths: [130, 250]) do
116
+ column(0).text_color = "666666"
117
+ end
118
+ pdf.move_down 18
119
+ end
120
+
121
+ def intro(pdf)
122
+ return unless branding.intro
123
+
124
+ pdf.text branding.intro, size: 9.5
125
+ pdf.move_down 12
126
+ end
127
+
128
+ def closing(pdf)
129
+ return unless branding.closing
130
+
131
+ pdf.move_down 18
132
+ pdf.text branding.closing, size: 9.5
133
+ end
134
+
135
+ def lines_table(pdf)
136
+ header = [t(:pos), t(:description), t(:qty), t(:unit_price), t(:net)]
137
+ rows = invoice.lines.each_with_index.map do |line, i|
138
+ [i + 1, [line.description, line.note, line.period && "#{date(line.period.begin)} – #{date(line.period.end)}"].compact.join("\n"),
139
+ "#{number(line.quantity, 0..2)} #{unit(line.unit)}", money(line.unit_price), money(line.net)]
140
+ end
141
+ pdf.table([header] + rows, width: pdf.bounds.width, column_widths: { 0 => 32, 2 => 80, 3 => 80, 4 => 80 },
142
+ cell_style: { borders: [:bottom], border_color: "DDDDDD", padding: [6, 4], size: 9.5 }) do
143
+ row(0).font_style = :bold
144
+ row(0).border_color = @accent
145
+ columns(2..4).align = :right
146
+ column(0).align = :right
147
+ end
148
+ pdf.move_down 10
149
+ end
150
+
151
+ def totals_block(pdf)
152
+ totals = invoice.totals
153
+ rows = []
154
+ rows << [t(:sum_net), money(totals.line_total)] unless totals.line_total == totals.net
155
+ invoice.allowances.each { |a| rows << ["#{t(:allowance)} #{a.reason}", "−#{money(a.amount)}"] }
156
+ invoice.charges.each { |c| rows << ["#{t(:charge)} #{c.reason}", money(c.amount)] }
157
+ rows << [t(:net), money(totals.net)]
158
+ totals.subtotals.each do |sub|
159
+ rows << ["#{t(:vat)} #{number(sub.tax.rate, 0..1)} %", money(sub.amount)] if sub.tax.taxed?
160
+ end
161
+ rows << [t(:gross), money(totals.gross)]
162
+ rows << [t(:prepaid), "−#{money(totals.prepaid)}"] if totals.prepaid.positive?
163
+ rows << [t(:payable), money(totals.payable)]
164
+ pdf.indent(pdf.bounds.width - 260) do
165
+ pdf.table(rows, column_widths: [160, 100], cell_style: { borders: [], padding: [3, 4], size: 9.5 }) do
166
+ column(1).align = :right
167
+ row(-1).font_style = :bold
168
+ row(-1).borders = [:top]
169
+ row(-1).border_color = @accent
170
+ end
171
+ end
172
+ pdf.move_down 14
173
+ end
174
+
175
+ # Exemption reasons are mandatory text on the invoice; invoice notes follow.
176
+ def notes(pdf)
177
+ texts = invoice.totals.subtotals.map { |s| s.tax.exemption_reason }.compact.uniq + invoice.notes
178
+ texts.each { |text| pdf.text text, size: 9 }
179
+ pdf.move_down 14 unless texts.empty?
180
+ end
181
+
182
+ def payment_block(pdf)
183
+ payment = invoice.payment
184
+ return if payment.nil?
185
+
186
+ code_bottom = giro_code(pdf)
187
+ pdf.text t(:payment), style: :bold
188
+ pdf.move_down 3
189
+ pdf.text payment.terms, size: 9 if payment.terms
190
+ rows = []
191
+ rows << [t(:holder), payment.holder] if payment.holder
192
+ rows << [t(:iban), payment.iban.scan(/.{1,4}/).join(" ")] if payment.iban
193
+ rows << [t(:bic), payment.bic] if payment.bic
194
+ rows << [t(:reference_line), payment.reference] if payment.reference
195
+ unless rows.empty?
196
+ pdf.table(rows, cell_style: { borders: [], padding: [2, 0], size: 9.5 }, column_widths: [130, 280]) do
197
+ column(0).text_color = "666666"
198
+ end
199
+ end
200
+ pdf.move_cursor_to [pdf.cursor, code_bottom].min if code_bottom
201
+ end
202
+
203
+ # The GiroCode floats at the right of the payment details and reports
204
+ # where it ends so the text below does not run into it.
205
+ def giro_code(pdf)
206
+ return nil unless branding.giro_code
207
+
208
+ payload = GiroCode.payload(invoice, remittance: Renderer.title(invoice, locale))
209
+ return nil unless payload
210
+
211
+ top = pdf.cursor
212
+ pdf.float do
213
+ pdf.bounding_box([pdf.bounds.right - GIRO_CODE_SIZE, top], width: GIRO_CODE_SIZE) do
214
+ GiroCode.draw(pdf, payload, size: GIRO_CODE_SIZE)
215
+ pdf.move_down 3
216
+ pdf.text t(:giro_code), size: 7, color: "666666", align: :center
217
+ end
218
+ end
219
+ top - GIRO_CODE_SIZE - 14
220
+ end
221
+
222
+ def footer(pdf)
223
+ seller = invoice.seller
224
+ ids = []
225
+ ids << "#{t(:vat_id)} #{seller.vat_id}" if seller.vat_id
226
+ ids << "#{t(:tax_number)} #{seller.tax_number}" if seller.tax_number
227
+ contact = [seller.contact_name, seller.phone, seller.email].compact.join(" · ")
228
+ pdf.repeat(:all) do
229
+ pdf.bounding_box([pdf.bounds.left, pdf.bounds.bottom + 26], width: pdf.bounds.width) do
230
+ pdf.stroke_color "DDDDDD"
231
+ pdf.stroke_horizontal_rule
232
+ pdf.move_down 6
233
+ pdf.text [seller.name, seller.legal_form, ids.join(" · "), ("#{t(:contact)}: #{contact}" unless contact.empty?)].compact.join(" · "), size: 7.5,
234
+ color: "666666"
235
+ end
236
+ end
237
+ end
238
+
239
+ def money(amount)
240
+ "#{number(amount, 2..2)} #{currency_symbol}"
241
+ end
242
+
243
+ def currency_symbol
244
+ { "EUR" => "€", "USD" => "$", "GBP" => "£", "CHF" => "CHF" }.fetch(invoice.currency, invoice.currency)
245
+ end
246
+
247
+ def number(value, decimals)
248
+ text = format("%.#{decimals.max}f", value)
249
+ int, frac = text.split(".")
250
+ frac = frac&.sub(/0+\z/, "") if decimals.min < decimals.max
251
+ frac = nil if frac && frac.empty?
252
+ sep = locale == :de ? [".", ","] : [",", "."]
253
+ int = int.reverse.scan(/\d{1,3}/).join(sep[0]).reverse
254
+ [int, frac].compact.join(sep[1])
255
+ end
256
+
257
+ def date(d) = locale == :de ? d.strftime("%d.%m.%Y") : d.strftime("%Y-%m-%d")
258
+ def unit(code) = LABELS.fetch(locale)[:units].fetch(code, code)
259
+
260
+ def country_name(code)
261
+ { "DE" => "Deutschland", "AT" => "Österreich", "CH" => "Schweiz", "FR" => "Frankreich", "NL" => "Niederlande",
262
+ "US" => "USA", "GB" => "Vereinigtes Königreich", "IE" => "Irland", "LU" => "Luxemburg" }.fetch(code, code)
263
+ end
264
+ end
265
+ end
266
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EN16931
4
+ module PDF
5
+ # The XMP packet a PDF/A-3b hybrid needs: PDF/A identification, the
6
+ # Dublin Core and PDF entries that mirror the Info dictionary, and the
7
+ # Factur-X/ZUGFeRD extension schema with the four document properties.
8
+ module XMP
9
+ FX_NS = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
10
+
11
+ module_function
12
+
13
+ def build(title:, author:, created_at:, producer:, creator_tool:, filename:, conformance:, document_type: "INVOICE")
14
+ stamp = created_at.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
15
+ <<~XMP
16
+ <?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>
17
+ <x:xmpmeta xmlns:x="adobe:ns:meta/">
18
+ <rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
19
+ <rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/">
20
+ <pdfaid:part>3</pdfaid:part>
21
+ <pdfaid:conformance>B</pdfaid:conformance>
22
+ </rdf:Description>
23
+ <rdf:Description rdf:about="" xmlns:dc="http://purl.org/dc/elements/1.1/">
24
+ <dc:title><rdf:Alt><rdf:li xml:lang="x-default">#{escape(title)}</rdf:li></rdf:Alt></dc:title>
25
+ <dc:creator><rdf:Seq><rdf:li>#{escape(author)}</rdf:li></rdf:Seq></dc:creator>
26
+ </rdf:Description>
27
+ <rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
28
+ <xmp:CreatorTool>#{escape(creator_tool)}</xmp:CreatorTool>
29
+ <xmp:CreateDate>#{stamp}</xmp:CreateDate>
30
+ <xmp:ModifyDate>#{stamp}</xmp:ModifyDate>
31
+ </rdf:Description>
32
+ <rdf:Description rdf:about="" xmlns:pdf="http://ns.adobe.com/pdf/1.3/">
33
+ <pdf:Producer>#{escape(producer)}</pdf:Producer>
34
+ </rdf:Description>
35
+ <rdf:Description rdf:about=""
36
+ xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/"
37
+ xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#"
38
+ xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
39
+ <pdfaExtension:schemas>
40
+ <rdf:Bag>
41
+ <rdf:li rdf:parseType="Resource">
42
+ <pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
43
+ <pdfaSchema:namespaceURI>#{FX_NS}</pdfaSchema:namespaceURI>
44
+ <pdfaSchema:prefix>fx</pdfaSchema:prefix>
45
+ <pdfaSchema:property>
46
+ <rdf:Seq>
47
+ #{property("DocumentFileName", "name of the embedded XML invoice file")}
48
+ #{property("DocumentType", "INVOICE")}
49
+ #{property("Version", "The actual version of the Factur-X XML schema")}
50
+ #{property("ConformanceLevel", "The conformance level of the embedded Factur-X data")}
51
+ </rdf:Seq>
52
+ </pdfaSchema:property>
53
+ </rdf:li>
54
+ </rdf:Bag>
55
+ </pdfaExtension:schemas>
56
+ </rdf:Description>
57
+ <rdf:Description rdf:about="" xmlns:fx="#{FX_NS}">
58
+ <fx:DocumentType>#{document_type}</fx:DocumentType>
59
+ <fx:DocumentFileName>#{filename}</fx:DocumentFileName>
60
+ <fx:Version>1.0</fx:Version>
61
+ <fx:ConformanceLevel>#{conformance}</fx:ConformanceLevel>
62
+ </rdf:Description>
63
+ </rdf:RDF>
64
+ </x:xmpmeta>
65
+ <?xpacket end="w"?>
66
+ XMP
67
+ end
68
+
69
+ def property(name, description)
70
+ <<~PROP.strip
71
+ <rdf:li rdf:parseType="Resource">
72
+ <pdfaProperty:name>#{name}</pdfaProperty:name>
73
+ <pdfaProperty:valueType>Text</pdfaProperty:valueType>
74
+ <pdfaProperty:category>external</pdfaProperty:category>
75
+ <pdfaProperty:description>#{description}</pdfaProperty:description>
76
+ </rdf:li>
77
+ PROP
78
+ end
79
+
80
+ def escape(text)
81
+ text.to_s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prawn"
4
+ require "stringio"
5
+ require "prawn/table"
6
+ require_relative "pdf/xmp"
7
+ require_relative "pdf/giro_code"
8
+ require_relative "pdf/a3"
9
+ require_relative "pdf/renderer"
10
+
11
+ module EN16931
12
+ # The hybrid: a PDF/A-3b with the invoice XML embedded (ZUGFeRD 2.x /
13
+ # Factur-X). `render` draws the reference layout; `A3.attach` turns any
14
+ # Prawn document into the hybrid.
15
+ module PDF
16
+ module_function
17
+
18
+ # @param branding [Branding] logo, colour, typeface, texts, GiroCode; the plain layout by default
19
+ # @return [String] PDF bytes
20
+ def render(invoice, profile: :xrechnung, locale: :de, created_at: Time.now, branding: Branding.new)
21
+ xml = XML.cii(invoice, profile:)
22
+ doc = Renderer.new(invoice, locale:, branding:).document
23
+ A3.attach(doc, xml, profile:, title: Renderer.title(invoice, locale), author: invoice.seller.name, created_at:)
24
+ doc.render
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EN16931
4
+ # A VAT treatment (BG-23 / BT-151): the category code, the rate, and for
5
+ # exempt categories the reason text (BT-120) and code (BT-121).
6
+ #
7
+ # Categories per UNTDID 5305 as used by EN 16931:
8
+ # S standard rate Z zero rated E exempt
9
+ # AE reverse charge K intra-community G export outside the EU
10
+ # O not subject to VAT L/M Canary Islands / Ceuta-Melilla (unused here)
11
+ Tax = Data.define(:category, :rate, :exemption_reason, :exemption_code) do
12
+ def initialize(category:, rate: 0, exemption_reason: nil, exemption_code: nil)
13
+ super(category: category.to_s, rate: BigDecimal(rate.to_s), exemption_reason:, exemption_code:)
14
+ end
15
+
16
+ def taxed? = category == "S"
17
+ def exempt? = !taxed? && category != "Z"
18
+
19
+ # Category O forbids a seller VAT identifier on the invoice (BR-O-2);
20
+ # the seller is identified by the tax number instead.
21
+ def forbids_vat_identifiers? = category == "O"
22
+
23
+ # Lines, allowances and charges in category O carry no rate (BR-O-05 to
24
+ # BR-O-07); every other exempt category carries an explicit 0.
25
+ def rate_for_xml = category == "O" ? nil : rate
26
+
27
+ def self.standard(rate) = new(category: "S", rate: rate)
28
+ def self.zero_rated = new(category: "Z")
29
+ def self.exempt(reason:, code: nil) = new(category: "E", exemption_reason: reason, exemption_code: code)
30
+ def self.reverse_charge(reason:, code: "VATEX-EU-AE") = new(category: "AE", exemption_reason: reason, exemption_code: code)
31
+ def self.intra_community(reason:, code: "VATEX-EU-IC") = new(category: "K", exemption_reason: reason, exemption_code: code)
32
+ def self.export(reason:, code: "VATEX-EU-G") = new(category: "G", exemption_reason: reason, exemption_code: code)
33
+ def self.not_subject(reason:, code: "VATEX-EU-O") = new(category: "O", exemption_reason: reason, exemption_code: code)
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EN16931
4
+ # Document totals (BG-22) and the VAT breakdown (BG-23), computed the way
5
+ # EN 16931 checks them: line nets summed, VAT rounded per category.
6
+ class Totals
7
+ Subtotal = Data.define(:tax, :taxable, :amount)
8
+
9
+ attr_reader :line_total, :allowance_total, :charge_total, :net, :vat, :gross, :prepaid, :payable, :subtotals
10
+
11
+ def self.compute(invoice) = new(invoice)
12
+
13
+ def initialize(invoice)
14
+ @line_total = invoice.lines.sum(&:net)
15
+ @allowance_total = invoice.allowances.sum(&:amount)
16
+ @charge_total = invoice.charges.sum(&:amount)
17
+ @net = @line_total - @allowance_total + @charge_total
18
+ @subtotals = breakdown(invoice)
19
+ @vat = @subtotals.sum(&:amount)
20
+ @gross = @net + @vat
21
+ @prepaid = invoice.prepaid
22
+ @payable = @gross - @prepaid
23
+ end
24
+
25
+ private
26
+
27
+ # One subtotal per distinct tax treatment; amount = taxable × rate, rounded.
28
+ def breakdown(invoice)
29
+ bases = Hash.new(BigDecimal("0"))
30
+ invoice.lines.each { |line| bases[invoice.tax_for(line)] += line.net }
31
+ invoice.allowances.each { |a| bases[a.tax || invoice.tax] -= a.amount }
32
+ invoice.charges.each { |c| bases[c.tax || invoice.tax] += c.amount }
33
+ bases.map do |tax, taxable|
34
+ Subtotal.new(tax: tax, taxable: taxable, amount: (taxable * tax.rate / 100).round(2))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require "fileutils"
6
+ require "nokogiri"
7
+ require "net/http"
8
+ require "uri"
9
+
10
+ module EN16931
11
+ module Validation
12
+ # The KoSIT validator with the XRechnung configuration: XML Schema and
13
+ # the EN 16931 + XRechnung Schematron rules for UBL and CII. Needs Java 11+.
14
+ #
15
+ # kosit = EN16931::Validation::KoSIT.new # tmp/kosit by default
16
+ # kosit.install! # downloads jar and configuration once
17
+ # report = kosit.validate(xml) # => Report
18
+ class KoSIT
19
+ VALIDATOR_VERSION = "1.6.3"
20
+ CONFIGURATION = "2026-08-31"
21
+ XRECHNUNG = "3.0.2"
22
+ VALIDATOR_URL = "https://repo1.maven.org/maven2/org/kosit/validator/#{VALIDATOR_VERSION}/validator-#{VALIDATOR_VERSION}-standalone.jar".freeze
23
+ CONFIGURATION_URL = "https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/download/v#{CONFIGURATION}/xrechnung-#{XRECHNUNG}-validator-configuration-#{CONFIGURATION}.zip".freeze
24
+ REPORT_NS = { "rep" => "http://www.xoev.de/de/validator/varl/1", "s" => "http://www.xoev.de/de/validator/framework/1/scenarios" }.freeze
25
+
26
+ attr_reader :dir
27
+
28
+ def initialize(dir: ENV.fetch("EN16931_KOSIT_DIR", "tmp/kosit"), java: ENV.fetch("JAVA", "java"))
29
+ @dir = File.expand_path(dir)
30
+ @java = java
31
+ end
32
+
33
+ def jar = File.join(dir, "validator-#{VALIDATOR_VERSION}-standalone.jar")
34
+ def scenarios = File.join(dir, "config", "scenarios.xml")
35
+ def installed? = File.exist?(jar) && File.exist?(scenarios)
36
+
37
+ def self.available?(java: "java")
38
+ _, status = Open3.capture2e(java, "-version")
39
+ status.success?
40
+ rescue Errno::ENOENT
41
+ false
42
+ end
43
+
44
+ def install!
45
+ FileUtils.mkdir_p(dir)
46
+ download(VALIDATOR_URL, jar) unless File.exist?(jar)
47
+ unless File.exist?(scenarios)
48
+ zip = File.join(dir, "configuration.zip")
49
+ download(CONFIGURATION_URL, zip)
50
+ FileUtils.mkdir_p(File.join(dir, "config"))
51
+ system("unzip", "-q", "-o", zip, "-d", File.join(dir, "config"), exception: true)
52
+ end
53
+ self
54
+ end
55
+
56
+ # @param xml [String]
57
+ # @return [Report]
58
+ def validate(xml)
59
+ install! unless installed?
60
+ Dir.mktmpdir("en16931") do |tmp|
61
+ input = File.join(tmp, "invoice.xml")
62
+ File.write(input, xml)
63
+ out, status = Open3.capture2e(@java, "-jar", jar, "-s", scenarios, "-r", File.join(dir, "config"), "-o", tmp, input)
64
+ report_path = File.join(tmp, "invoice-report.xml")
65
+ raise Error, "KoSIT validator produced no report (exit #{status.exitstatus}):\n#{out}" unless File.exist?(report_path)
66
+
67
+ parse(File.read(report_path), out)
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def parse(report_xml, console)
74
+ doc = Nokogiri::XML(report_xml)
75
+ accepted = doc.at_xpath("//rep:assessment/rep:accept", REPORT_NS)
76
+ messages = doc.xpath("//rep:message", REPORT_NS).map do |m|
77
+ level = m["level"] == "error" ? :error : :warning
78
+ Message.new(level: level, code: m["code"] || m["id"], text: m.text.strip)
79
+ end
80
+ Report.new(tool: "KoSIT #{VALIDATOR_VERSION} / XRechnung #{XRECHNUNG} (#{CONFIGURATION})", valid: !accepted.nil?, messages: messages,
81
+ raw: "#{console}\n#{report_xml}")
82
+ end
83
+
84
+ def download(url, target)
85
+ uri = URI(url)
86
+ 10.times do
87
+ response = Net::HTTP.get_response(uri)
88
+ case response
89
+ when Net::HTTPRedirection then uri = URI(response["location"])
90
+ when Net::HTTPSuccess
91
+ File.binwrite(target, response.body)
92
+ return
93
+ else raise Error, "download of #{url} failed: #{response.code}"
94
+ end
95
+ end
96
+ raise Error, "too many redirects for #{url}"
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EN16931
4
+ module Validation
5
+ Message = Data.define(:level, :code, :text)
6
+
7
+ # The outcome of one validator run.
8
+ Report = Data.define(:tool, :valid, :messages, :raw) do
9
+ def valid? = valid
10
+ def errors = messages.select { |m| m.level == :error }
11
+ def warnings = messages.select { |m| m.level == :warning }
12
+
13
+ def to_s
14
+ lines = ["#{tool}: #{valid? ? "valid" : "INVALID"}"]
15
+ messages.each { |m| lines << " [#{m.level}] #{m.code}: #{m.text}" }
16
+ lines.join("\n")
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tmpdir"
5
+ require "nokogiri"
6
+
7
+ module EN16931
8
+ module Validation
9
+ # veraPDF, the reference PDF/A validator: the `verapdf` command when it is
10
+ # installed, else the official Docker image.
11
+ class VeraPDF
12
+ IMAGE = "ghcr.io/verapdf/cli:latest"
13
+
14
+ def initialize(command: ENV.fetch("VERAPDF", nil), docker: ENV.fetch("DOCKER", "docker"))
15
+ @command = command || (system("which", "verapdf", out: File::NULL) ? "verapdf" : nil)
16
+ @docker = docker
17
+ end
18
+
19
+ def self.available?
20
+ new.available?
21
+ end
22
+
23
+ def available?
24
+ return true if @command
25
+
26
+ _, status = Open3.capture2e(@docker, "image", "inspect", IMAGE)
27
+ status.success? || Open3.capture2e(@docker, "pull", IMAGE).last.success?
28
+ rescue Errno::ENOENT
29
+ false
30
+ end
31
+
32
+ # @param pdf [String] PDF bytes
33
+ # @return [Report]
34
+ def validate(pdf, flavour: "3b")
35
+ Dir.mktmpdir("en16931") do |tmp|
36
+ path = File.join(tmp, "invoice.pdf")
37
+ File.binwrite(path, pdf)
38
+ # The container runs as another user: mktmpdir's 0700 would hide the file.
39
+ File.chmod(0o755, tmp)
40
+ File.chmod(0o644, path)
41
+ out, = if @command
42
+ Open3.capture2e(@command, "-f", flavour, "--format", "xml", path)
43
+ else
44
+ Open3.capture2e(@docker, "run", "--rm", "-v", "#{tmp}:/data", IMAGE, "-f", flavour, "--format", "xml", "/data/invoice.pdf")
45
+ end
46
+ parse(out)
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def parse(out)
53
+ xml = out[/<\?xml.*\z/m] || out
54
+ doc = Nokogiri::XML(xml)
55
+ doc.remove_namespaces!
56
+ result = doc.at_xpath("//validationReport") || doc.at_xpath("//validationResult")
57
+ compliant = result && result["isCompliant"] == "true"
58
+ messages = doc.xpath("//rule[@status='failed']").map do |rule|
59
+ Message.new(level: :error, code: "#{rule["specification"]} #{rule["clause"]} test #{rule["testNumber"]}",
60
+ text: rule.at_xpath("./description")&.text.to_s.strip)
61
+ end
62
+ Report.new(tool: "veraPDF", valid: compliant, messages: messages, raw: out)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "validation/report"
4
+ require_relative "validation/kosit"
5
+ require_relative "validation/vera_pdf"
6
+
7
+ module EN16931
8
+ # Wrappers around the official validators, meant for CI and for proving a
9
+ # layout once: KoSIT (the German XRechnung validator, Java) for the XML and
10
+ # veraPDF for the PDF/A-3b container. Neither is needed at run time.
11
+ module Validation
12
+ end
13
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EN16931
4
+ VERSION = "0.1.0"
5
+ end