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.
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
@@ -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