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
|
Binary file
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prawn"
|
|
4
|
+
|
|
5
|
+
module EN16931
|
|
6
|
+
# How the reference layout looks for one seller: a logo, an accent colour,
|
|
7
|
+
# an own typeface, an opening and a closing line, the GiroCode. All optional;
|
|
8
|
+
# `Branding.new` is the plain layout. Everything here stays inside PDF/A-3b:
|
|
9
|
+
# fonts are embedded, images are RGB, the QR code is drawn as vectors.
|
|
10
|
+
#
|
|
11
|
+
# @param logo [String, IO, nil] a PNG or JPEG: a path, the file's bytes, or an IO
|
|
12
|
+
# @param logo_position [:left, :right] where the logo sits in the letterhead
|
|
13
|
+
# @param logo_height [Numeric] the logo's height in points (its width follows, capped at logo_max_width)
|
|
14
|
+
# @param accent [String] six hex digits, used for the title and the rules
|
|
15
|
+
# @param font [String, Hash, nil] a TrueType file, or { normal:, bold: } paths; DejaVu Sans otherwise
|
|
16
|
+
# @param intro [String, nil] text above the lines, e.g. a salutation
|
|
17
|
+
# @param closing [String, nil] text below the payment details, e.g. thanks and a signature line
|
|
18
|
+
# @param giro_code [Boolean] print the EPC QR code (GiroCode) next to the bank details when the invoice can carry one
|
|
19
|
+
Branding = Data.define(:logo, :logo_position, :logo_height, :logo_max_width, :accent, :font, :intro, :closing, :giro_code) do
|
|
20
|
+
def initialize(logo: nil, logo_position: :left, logo_height: 40, logo_max_width: 180, accent: "15161A", font: nil,
|
|
21
|
+
intro: nil, closing: nil, giro_code: true)
|
|
22
|
+
raise ArgumentError, "logo_position must be :left or :right" unless %i[left right].include?(logo_position)
|
|
23
|
+
raise ArgumentError, "accent must be six hex digits, e.g. \"FF5A1F\"" unless accent.to_s.match?(/\A\h{6}\z/)
|
|
24
|
+
|
|
25
|
+
super(logo: Branding.image_bytes(logo), logo_position:, logo_height:, logo_max_width:, accent: accent.to_s.upcase,
|
|
26
|
+
font: Branding.font_files(font), intro:, closing:, giro_code:)
|
|
27
|
+
logo_info # fail at construction, not at render time
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The parsed image: width, height and, for a JPEG, the channel count.
|
|
31
|
+
# CMYK images would need a CMYK output intent, so they are refused.
|
|
32
|
+
def logo_info
|
|
33
|
+
return nil unless logo
|
|
34
|
+
|
|
35
|
+
Prawn.image_handler.find(logo).new(logo).tap do |info|
|
|
36
|
+
raise ArgumentError, "CMYK JPEG logos are not PDF/A-safe with the sRGB output intent; use an RGB image" if info.respond_to?(:channels) && info.channels == 4
|
|
37
|
+
raise ArgumentError, "interlaced PNGs are not supported; export the logo without interlacing" if info.respond_to?(:interlace_method) && info.interlace_method != 0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The logo's drawn size: scaled to logo_height, narrower if it would exceed logo_max_width.
|
|
42
|
+
def logo_size
|
|
43
|
+
info = logo_info
|
|
44
|
+
return nil unless info
|
|
45
|
+
|
|
46
|
+
scale = [logo_height.to_f / info.height, logo_max_width.to_f / info.width].min
|
|
47
|
+
[info.width * scale, info.height * scale]
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The logo bytes are not worth printing.
|
|
51
|
+
def inspect = "#<data EN16931::Branding #{to_h.merge(logo: logo && "<#{logo.bytesize} bytes>").map { |k, v| "#{k}=#{v.inspect}" }.join(", ")}>"
|
|
52
|
+
|
|
53
|
+
def self.image_bytes(logo)
|
|
54
|
+
case logo
|
|
55
|
+
when nil then nil
|
|
56
|
+
when String
|
|
57
|
+
logo.b.start_with?("\x89PNG".b, "\xFF\xD8".b) ? logo.b : File.binread(logo)
|
|
58
|
+
else
|
|
59
|
+
logo.rewind if logo.respond_to?(:rewind)
|
|
60
|
+
logo.read.b
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def self.font_files(font)
|
|
65
|
+
case font
|
|
66
|
+
when nil then nil
|
|
67
|
+
when String then { normal: font, bold: font }
|
|
68
|
+
when Hash
|
|
69
|
+
normal = font[:normal] || font["normal"] or raise ArgumentError, "font needs at least a :normal file"
|
|
70
|
+
{ normal:, bold: font[:bold] || font["bold"] || normal }
|
|
71
|
+
else raise ArgumentError, "font must be a file path or { normal:, bold: }"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
data/lib/en16931/de.rb
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EN16931
|
|
4
|
+
# German VAT presets. The texts are the customary invoice notes; whether a
|
|
5
|
+
# case applies to a transaction is a question for the tax adviser, not for
|
|
6
|
+
# this library.
|
|
7
|
+
module DE
|
|
8
|
+
STANDARD_RATE = 19
|
|
9
|
+
REDUCED_RATE = 7
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Regelbesteuerung, 19 %.
|
|
14
|
+
def standard = Tax.standard(STANDARD_RATE)
|
|
15
|
+
|
|
16
|
+
# Ermäßigter Satz, 7 %.
|
|
17
|
+
def reduced = Tax.standard(REDUCED_RATE)
|
|
18
|
+
|
|
19
|
+
# Kleinunternehmer nach § 19 UStG: no VAT, mandatory note on the invoice.
|
|
20
|
+
def small_business
|
|
21
|
+
Tax.exempt(reason: "Gemäß § 19 UStG wird keine Umsatzsteuer berechnet.")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reverse charge for a business customer in another EU member state
|
|
25
|
+
# (§ 13b UStG); both VAT identifiers go on the invoice.
|
|
26
|
+
def reverse_charge
|
|
27
|
+
Tax.reverse_charge(reason: "Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge, § 13b UStG).")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# A service to a business outside the EU: the place of supply is abroad
|
|
31
|
+
# (§ 3a Abs. 2 UStG), nothing is taxable in Germany.
|
|
32
|
+
def not_taxable_abroad
|
|
33
|
+
Tax.not_subject(reason: "Nicht im Inland steuerbar; der Leistungsort liegt beim Leistungsempfänger (§ 3a Abs. 2 UStG).")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Goods to a business in another EU member state (§ 4 Nr. 1b UStG).
|
|
37
|
+
def intra_community_supply
|
|
38
|
+
Tax.intra_community(reason: "Steuerfreie innergemeinschaftliche Lieferung (§ 4 Nr. 1b UStG).")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Goods exported outside the EU (§ 4 Nr. 1a UStG).
|
|
42
|
+
def export_supply
|
|
43
|
+
Tax.export(reason: "Steuerfreie Ausfuhrlieferung (§ 4 Nr. 1a UStG).")
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EN16931
|
|
4
|
+
# The invoice as the application knows it: who, to whom, what, under which
|
|
5
|
+
# VAT treatment. Amounts are BigDecimal, money in the document currency.
|
|
6
|
+
class Invoice
|
|
7
|
+
TYPES = {
|
|
8
|
+
invoice: "380",
|
|
9
|
+
credit_note: "381",
|
|
10
|
+
corrected: "384",
|
|
11
|
+
self_billed: "389",
|
|
12
|
+
partial: "326",
|
|
13
|
+
prepayment: "386"
|
|
14
|
+
}.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :number, :issue_date, :due_date, :seller, :buyer, :lines, :tax, :currency, :buyer_reference,
|
|
17
|
+
:type, :payment, :period, :notes, :delivery_date, :preceding, :allowances, :charges, :prepaid
|
|
18
|
+
|
|
19
|
+
# @param preceding [Hash, nil] { number:, issue_date: } of the invoice a correction refers to (BG-3)
|
|
20
|
+
# @param period [Range<Date>, nil] the invoicing period (BG-14), e.g. a retainer month
|
|
21
|
+
def initialize(number:, issue_date:, seller:, buyer:, lines:, tax:, type: :invoice, due_date: nil, currency: "EUR",
|
|
22
|
+
buyer_reference: nil, payment: nil, period: nil, notes: [], delivery_date: nil, preceding: nil,
|
|
23
|
+
allowances: [], charges: [], prepaid: 0)
|
|
24
|
+
raise ArgumentError, "unknown type #{type.inspect}" unless TYPES.key?(type)
|
|
25
|
+
raise ArgumentError, "an invoice needs at least one line" if lines.empty?
|
|
26
|
+
|
|
27
|
+
@number = number
|
|
28
|
+
@issue_date = issue_date
|
|
29
|
+
@due_date = due_date
|
|
30
|
+
@seller = seller
|
|
31
|
+
@buyer = buyer
|
|
32
|
+
@lines = lines
|
|
33
|
+
@tax = tax
|
|
34
|
+
@type = type
|
|
35
|
+
@currency = currency
|
|
36
|
+
@buyer_reference = buyer_reference
|
|
37
|
+
@payment = payment
|
|
38
|
+
@period = period
|
|
39
|
+
@notes = Array(notes)
|
|
40
|
+
@delivery_date = delivery_date
|
|
41
|
+
@preceding = preceding
|
|
42
|
+
# The list decides the direction; the flag follows it.
|
|
43
|
+
@allowances = allowances.map { |a| a.with(charge: false) }
|
|
44
|
+
@charges = charges.map { |c| c.with(charge: true) }
|
|
45
|
+
@prepaid = BigDecimal(prepaid.to_s)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def type_code = TYPES.fetch(type)
|
|
49
|
+
def credit_note? = type == :credit_note
|
|
50
|
+
def correction? = type == :corrected
|
|
51
|
+
|
|
52
|
+
def totals = @totals ||= Totals.compute(self)
|
|
53
|
+
|
|
54
|
+
# The tax of a line: its own, else the invoice's.
|
|
55
|
+
def tax_for(line) = line.tax || tax
|
|
56
|
+
|
|
57
|
+
# Every tax treatment that appears on the invoice, invoice-level first.
|
|
58
|
+
def taxes = ([tax] + lines.map(&:tax) + (allowances + charges).map(&:tax)).compact.uniq
|
|
59
|
+
end
|
|
60
|
+
end
|
data/lib/en16931/line.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EN16931
|
|
4
|
+
# One invoice line (BG-25). Quantity times unit price, rounded to cents,
|
|
5
|
+
# is the line net (BT-131). The tax defaults to the invoice's tax.
|
|
6
|
+
#
|
|
7
|
+
# Common unit codes (UN/ECE Recommendation 20): C62 piece, HUR hour,
|
|
8
|
+
# DAY day, MON month, LS lump sum, E48 service unit.
|
|
9
|
+
Line = Data.define(:description, :quantity, :unit, :unit_price, :tax, :note, :sellers_id, :period) do
|
|
10
|
+
def initialize(description:, unit_price:, quantity: 1, unit: "C62", tax: nil, note: nil, sellers_id: nil, period: nil)
|
|
11
|
+
super(description:, quantity: BigDecimal(quantity.to_s), unit:, unit_price: BigDecimal(unit_price.to_s),
|
|
12
|
+
tax:, note:, sellers_id:, period:)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def net = (quantity * unit_price).round(2)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# A document-level allowance (discount) or charge (BG-20 / BG-21).
|
|
19
|
+
AllowanceCharge = Data.define(:amount, :reason, :charge, :reason_code, :tax) do
|
|
20
|
+
def initialize(amount:, reason:, charge: false, reason_code: nil, tax: nil)
|
|
21
|
+
super(amount: BigDecimal(amount.to_s), reason:, charge:, reason_code:, tax:)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def charge? = charge
|
|
25
|
+
def signed_amount = charge? ? amount : -amount
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EN16931
|
|
4
|
+
# Seller (BG-4) or buyer (BG-7). XRechnung needs for the seller a contact
|
|
5
|
+
# (name, phone, email), an electronic address and a VAT id or tax number;
|
|
6
|
+
# for the buyer an electronic address. Email is the electronic address
|
|
7
|
+
# (scheme EM) unless another one is given.
|
|
8
|
+
Party = Data.define(:name, :street, :postal_code, :city, :country, :vat_id, :tax_number, :email, :phone,
|
|
9
|
+
:contact_name, :legal_form, :registration_id, :trading_name, :electronic_address,
|
|
10
|
+
:electronic_address_scheme, :identifier, :address_line_2) do
|
|
11
|
+
def initialize(name:, country:, street: nil, postal_code: nil, city: nil, vat_id: nil, tax_number: nil, email: nil,
|
|
12
|
+
phone: nil, contact_name: nil, legal_form: nil, registration_id: nil, trading_name: nil,
|
|
13
|
+
electronic_address: nil, electronic_address_scheme: nil, identifier: nil, address_line_2: nil)
|
|
14
|
+
super
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def electronic_address_value = electronic_address || email
|
|
18
|
+
def electronic_address_scheme_value = electronic_address_scheme || "EM"
|
|
19
|
+
|
|
20
|
+
# The lines of a postal address as printed.
|
|
21
|
+
def address_lines
|
|
22
|
+
[[postal_code, city].compact.join(" ").then { |s| s.empty? ? nil : s }, street, address_line_2].then do |(place, st, extra)|
|
|
23
|
+
[st, extra, place].compact
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EN16931
|
|
4
|
+
# Payment instructions (BG-16): SEPA credit transfer (code 58) by default.
|
|
5
|
+
# `terms` is the payment terms text (BT-20); `reference` the remittance
|
|
6
|
+
# information the buyer should quote (BT-83).
|
|
7
|
+
Payment = Data.define(:iban, :bic, :holder, :means_code, :reference, :terms) do
|
|
8
|
+
def initialize(iban: nil, bic: nil, holder: nil, means_code: nil, reference: nil, terms: nil)
|
|
9
|
+
super(iban: iban&.delete(" "), bic: bic&.delete(" "), holder:, means_code: means_code || (iban ? "58" : "1"), reference:, terms:)
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module EN16931
|
|
7
|
+
module PDF
|
|
8
|
+
# Turns a Prawn document into a PDF/A-3b with the invoice XML embedded,
|
|
9
|
+
# in pure Ruby: PDF 1.7, document identifier, Info dictionary mirrored
|
|
10
|
+
# in XMP, sRGB output intent, the XML as an associated file (AF) with
|
|
11
|
+
# the relationship the profile asks for. Fonts must be TrueType, since
|
|
12
|
+
# PDF/A forbids the unembedded base-14 fonts; the renderer uses DejaVu.
|
|
13
|
+
module A3
|
|
14
|
+
ICC_PROFILE = File.join(ROOT, "icc", "sRGB-v2-micro.icc")
|
|
15
|
+
PRODUCER = "en16931 #{VERSION} (Prawn)".freeze
|
|
16
|
+
|
|
17
|
+
# Filename and AF relationship per ZUGFeRD 2.x profile.
|
|
18
|
+
PROFILES = {
|
|
19
|
+
xrechnung: { conformance: "XRECHNUNG", filename: "xrechnung.xml", relationship: :Alternative },
|
|
20
|
+
en16931: { conformance: "EN 16931", filename: "factur-x.xml", relationship: :Alternative },
|
|
21
|
+
extended: { conformance: "EXTENDED", filename: "factur-x.xml", relationship: :Alternative },
|
|
22
|
+
basic: { conformance: "BASIC", filename: "factur-x.xml", relationship: :Alternative }
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
module_function
|
|
26
|
+
|
|
27
|
+
# @param pdf [Prawn::Document] a document drawn with embedded TrueType fonts
|
|
28
|
+
# @param xml [String] the CII invoice XML
|
|
29
|
+
# @return [Prawn::Document] the same document, ready to `render`
|
|
30
|
+
def attach(pdf, xml, title:, author:, profile: :xrechnung, created_at: Time.now, creator_tool: PRODUCER, document_type: "INVOICE")
|
|
31
|
+
spec = PROFILES.fetch(profile) { raise ArgumentError, "unknown profile #{profile.inspect}" }
|
|
32
|
+
pdf.renderer.min_version(1.7)
|
|
33
|
+
set_info(pdf, title:, author:, created_at:, creator_tool:)
|
|
34
|
+
set_identifier(pdf, xml, created_at)
|
|
35
|
+
add_output_intent(pdf)
|
|
36
|
+
filespec = embed_file(pdf, xml, spec, created_at)
|
|
37
|
+
pdf.renderer.names.data[:EmbeddedFiles] = pdf.ref!(Names: [::PDF::Core::LiteralString.new(spec[:filename]), filespec])
|
|
38
|
+
pdf.state.store.root.data[:AF] = [filespec]
|
|
39
|
+
add_metadata(pdf, XMP.build(title:, author:, created_at:, producer: PRODUCER, creator_tool:,
|
|
40
|
+
filename: spec[:filename], conformance: spec[:conformance], document_type:))
|
|
41
|
+
pdf
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set_info(pdf, title:, author:, created_at:, creator_tool:)
|
|
45
|
+
pdf.state.store.info.data.merge!(
|
|
46
|
+
Title: title, Author: author, Creator: creator_tool, Producer: PRODUCER,
|
|
47
|
+
CreationDate: created_at, ModDate: created_at
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# PDF/A-2 and -3 require the file identifier in the trailer.
|
|
52
|
+
def set_identifier(pdf, xml, created_at)
|
|
53
|
+
id = ::PDF::Core::LiteralString.new(Digest::MD5.digest("#{xml}#{created_at.to_f}"))
|
|
54
|
+
pdf.state.trailer[:ID] = [id, id]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# DeviceRGB content needs an output intent; sRGB covers screens and print.
|
|
58
|
+
def add_output_intent(pdf)
|
|
59
|
+
icc = pdf.ref!(N: 3)
|
|
60
|
+
icc << File.binread(ICC_PROFILE)
|
|
61
|
+
icc.stream.compress!
|
|
62
|
+
intent = pdf.ref!(
|
|
63
|
+
Type: :OutputIntent, S: :GTS_PDFA1,
|
|
64
|
+
OutputConditionIdentifier: ::PDF::Core::LiteralString.new("sRGB"),
|
|
65
|
+
Info: ::PDF::Core::LiteralString.new("sRGB IEC61966-2.1"),
|
|
66
|
+
RegistryName: ::PDF::Core::LiteralString.new("http://www.color.org"),
|
|
67
|
+
DestOutputProfile: icc
|
|
68
|
+
)
|
|
69
|
+
pdf.state.store.root.data[:OutputIntents] = [intent]
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def embed_file(pdf, xml, spec, created_at)
|
|
73
|
+
bytes = xml.b
|
|
74
|
+
file = pdf.ref!(Type: :EmbeddedFile, Subtype: :"text/xml", Params: { ModDate: created_at, Size: bytes.bytesize })
|
|
75
|
+
file << bytes
|
|
76
|
+
file.stream.compress!
|
|
77
|
+
pdf.ref!(
|
|
78
|
+
Type: :Filespec, F: ::PDF::Core::LiteralString.new(spec[:filename]), UF: spec[:filename],
|
|
79
|
+
Desc: "Invoice data (EN 16931, #{spec[:conformance]})", AFRelationship: spec[:relationship],
|
|
80
|
+
EF: { F: file, UF: file }
|
|
81
|
+
)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# XMP goes in uncompressed so any reader can find it.
|
|
85
|
+
def add_metadata(pdf, xmp)
|
|
86
|
+
metadata = pdf.ref!(Type: :Metadata, Subtype: :XML)
|
|
87
|
+
metadata << xmp.b
|
|
88
|
+
pdf.state.store.root.data[:Metadata] = metadata
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rqrcode_core"
|
|
4
|
+
|
|
5
|
+
module EN16931
|
|
6
|
+
module PDF
|
|
7
|
+
# The EPC QR code ("GiroCode"): scanned by a banking app, it prefills a
|
|
8
|
+
# SEPA credit transfer with the seller's IBAN, the amount and the
|
|
9
|
+
# remittance text. Version 002 of the EPC guideline; euro only.
|
|
10
|
+
module GiroCode
|
|
11
|
+
NAME_LENGTH = 70
|
|
12
|
+
REMITTANCE_LENGTH = 140
|
|
13
|
+
MAX_AMOUNT = BigDecimal("999999999.99")
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# @return [String, nil] the payload, or nil when the invoice cannot carry a GiroCode
|
|
18
|
+
def payload(invoice, remittance:)
|
|
19
|
+
payment = invoice.payment
|
|
20
|
+
return nil unless payment&.iban && invoice.currency == "EUR"
|
|
21
|
+
|
|
22
|
+
amount = invoice.totals.payable
|
|
23
|
+
return nil unless amount.positive? && amount <= MAX_AMOUNT
|
|
24
|
+
|
|
25
|
+
name = (payment.holder || invoice.seller.name).to_s[0, NAME_LENGTH]
|
|
26
|
+
[
|
|
27
|
+
"BCD", "002", "1", "SCT",
|
|
28
|
+
payment.bic.to_s,
|
|
29
|
+
name,
|
|
30
|
+
payment.iban,
|
|
31
|
+
"EUR#{format("%.2f", amount)}",
|
|
32
|
+
"",
|
|
33
|
+
"",
|
|
34
|
+
(payment.reference || remittance).to_s[0, REMITTANCE_LENGTH]
|
|
35
|
+
].join("\n")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Draws the code as filled rectangles (no raster image, no transparency),
|
|
39
|
+
# top-left corner at the current cursor inside the current bounds.
|
|
40
|
+
def draw(pdf, payload, size:)
|
|
41
|
+
qr = RQRCodeCore::QRCode.new(payload, level: :m)
|
|
42
|
+
count = qr.module_count
|
|
43
|
+
unit = size.to_f / count
|
|
44
|
+
x0 = pdf.bounds.left
|
|
45
|
+
y0 = pdf.cursor
|
|
46
|
+
previous = pdf.fill_color
|
|
47
|
+
pdf.fill_color "000000"
|
|
48
|
+
count.times do |row|
|
|
49
|
+
runs(qr, row, count).each do |start, length|
|
|
50
|
+
pdf.fill_rectangle [x0 + (start * unit), y0 - (row * unit)], length * unit, unit
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
pdf.fill_color previous
|
|
54
|
+
pdf.move_down size
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Consecutive dark modules in a row as [start, length] pairs: fewer objects in the PDF.
|
|
58
|
+
def runs(qr, row, count)
|
|
59
|
+
result = []
|
|
60
|
+
col = 0
|
|
61
|
+
while col < count
|
|
62
|
+
if qr.checked?(row, col)
|
|
63
|
+
start = col
|
|
64
|
+
col += 1 while col < count && qr.checked?(row, col)
|
|
65
|
+
result << [start, col - start]
|
|
66
|
+
else
|
|
67
|
+
col += 1
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|