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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +144 -0
- data/Rakefile +42 -0
- data/fonts/DejaVuSans-Bold.ttf +0 -0
- data/fonts/DejaVuSans.ttf +0 -0
- data/fonts/LICENSE-DejaVu.txt +187 -0
- data/icc/LICENSE-Compact-ICC-Profiles.txt +537 -0
- data/icc/sRGB-v2-micro.icc +0 -0
- data/lib/en16931/branding.rb +75 -0
- data/lib/en16931/de.rb +46 -0
- data/lib/en16931/invoice.rb +60 -0
- data/lib/en16931/line.rb +27 -0
- data/lib/en16931/party.rb +27 -0
- data/lib/en16931/payment.rb +12 -0
- data/lib/en16931/pdf/a3.rb +92 -0
- data/lib/en16931/pdf/giro_code.rb +74 -0
- data/lib/en16931/pdf/renderer.rb +266 -0
- data/lib/en16931/pdf/xmp.rb +85 -0
- data/lib/en16931/pdf.rb +27 -0
- data/lib/en16931/tax.rb +35 -0
- data/lib/en16931/totals.rb +38 -0
- data/lib/en16931/validation/kosit.rb +100 -0
- data/lib/en16931/validation/report.rb +20 -0
- data/lib/en16931/validation/vera_pdf.rb +66 -0
- data/lib/en16931/validation.rb +13 -0
- data/lib/en16931/version.rb +5 -0
- data/lib/en16931/xml.rb +219 -0
- data/lib/en16931.rb +37 -0
- metadata +148 -0
|
@@ -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("&", "&").gsub("<", "<").gsub(">", ">")
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
data/lib/en16931/pdf.rb
ADDED
|
@@ -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
|
data/lib/en16931/tax.rb
ADDED
|
@@ -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
|