prawn-swiss_qr_bill 0.4.1

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.
Files changed (36) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +5 -0
  3. data/README.md +124 -0
  4. data/config/locales/de.yml +15 -0
  5. data/config/locales/en.yml +15 -0
  6. data/config/locales/fr.yml +15 -0
  7. data/config/locales/it.yml +15 -0
  8. data/lib/prawn/swiss_qr_bill/bill.rb +20 -0
  9. data/lib/prawn/swiss_qr_bill/corner_box.rb +79 -0
  10. data/lib/prawn/swiss_qr_bill/cutting_lines.rb +74 -0
  11. data/lib/prawn/swiss_qr_bill/debug_section.rb +140 -0
  12. data/lib/prawn/swiss_qr_bill/extension.rb +18 -0
  13. data/lib/prawn/swiss_qr_bill/helpers/box_helper.rb +14 -0
  14. data/lib/prawn/swiss_qr_bill/helpers/number_helper.rb +16 -0
  15. data/lib/prawn/swiss_qr_bill/iban.rb +84 -0
  16. data/lib/prawn/swiss_qr_bill/images/scissor.png +0 -0
  17. data/lib/prawn/swiss_qr_bill/images/swiss_cross.png +0 -0
  18. data/lib/prawn/swiss_qr_bill/padded_box.rb +52 -0
  19. data/lib/prawn/swiss_qr_bill/qr/data.rb +105 -0
  20. data/lib/prawn/swiss_qr_bill/sections/payment_amount.rb +88 -0
  21. data/lib/prawn/swiss_qr_bill/sections/payment_further_information.rb +14 -0
  22. data/lib/prawn/swiss_qr_bill/sections/payment_information.rb +59 -0
  23. data/lib/prawn/swiss_qr_bill/sections/payment_title.rb +18 -0
  24. data/lib/prawn/swiss_qr_bill/sections/qr_code.rb +102 -0
  25. data/lib/prawn/swiss_qr_bill/sections/receipt_acceptance.rb +18 -0
  26. data/lib/prawn/swiss_qr_bill/sections/receipt_amount.rb +86 -0
  27. data/lib/prawn/swiss_qr_bill/sections/receipt_information.rb +51 -0
  28. data/lib/prawn/swiss_qr_bill/sections/receipt_title.rb +18 -0
  29. data/lib/prawn/swiss_qr_bill/sections/section.rb +88 -0
  30. data/lib/prawn/swiss_qr_bill/sections.rb +29 -0
  31. data/lib/prawn/swiss_qr_bill/specifications.rb +78 -0
  32. data/lib/prawn/swiss_qr_bill/specs.yml +106 -0
  33. data/lib/prawn/swiss_qr_bill/version.rb +7 -0
  34. data/lib/prawn/swiss_qr_bill.rb +43 -0
  35. data/prawn-swiss_qr_bill.gemspec +40 -0
  36. metadata +218 -0
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ # Attach padded_box to Prawn
5
+ #
6
+ # padded_box(point, options = {}, &block)
7
+ #
8
+ # NOTE: not in use, not working properly. it is not easy to stroke the outer box.
9
+ # was an idea for DebugSection, maybe deprecate/remove or make it work later.
10
+ #
11
+ # :nocov:
12
+ class Document
13
+ def padded_box(point, *args, &block)
14
+ init_padded_box(block) do |parent_box|
15
+ point = map_to_absolute(point)
16
+ @bounding_box = PaddedBox.new(self, parent_box, point, *args)
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def init_padded_box(user_block)
23
+ # binding.pry
24
+ parent_box = @bounding_box
25
+
26
+ yield(parent_box)
27
+
28
+ self.y = @bounding_box.absolute_top
29
+
30
+ pad = @bounding_box.padding
31
+ bounding_box([@bounding_box.left + pad, @bounding_box.top - pad],
32
+ width: @bounding_box.width - (2 * pad),
33
+ height: @bounding_box.height - (2 * pad)) do
34
+ user_block.call
35
+ end
36
+
37
+ @bounding_box = parent_box
38
+ end
39
+ end
40
+
41
+ # Bounding box with padding
42
+ class PaddedBox < Prawn::Document::BoundingBox
43
+ attr_reader :padding
44
+
45
+ def initialize(document, parent, point, options = {})
46
+ super
47
+
48
+ @padding = options[:padding] || 0
49
+ end
50
+ end
51
+ # :nocov:
52
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module QR
6
+ # The data of the Swiss QR-bill
7
+ #
8
+ # References:
9
+ # https://www.paymentstandards.ch/dam/downloads/ig-qr-bill-en.pdf
10
+ # * Chapter 4, Page 25
11
+ #
12
+ # TODO: implement skippable (alt schemas, bill info. fields after trailer)
13
+ # TODO: check if addr-type has to be mentioned if not given?
14
+ # OPTIMIZE: implement non-changable?
15
+ class Data
16
+ # Simple field structure:
17
+ # * :default => default value to be set, if key is not given
18
+ # * :format => Proc to call when generating output
19
+ # * :skippable => Do not output in QR data if not given
20
+ Field = Struct.new(:default, :format, :skippable)
21
+
22
+ # Available fields of the QR code data, ordered.
23
+ FIELDS = {
24
+ # fixed: SPC
25
+ qr_type: Field.new('SPC'),
26
+ version: Field.new('0200'),
27
+ # fixed: 1
28
+ coding: Field.new('1'),
29
+ # TODO: check for valid iban
30
+ # iban: Field.new(nil, ->(value) { value.delete(' ') }),
31
+ iban: Field.new,
32
+ # enum: S, K
33
+ creditor_address_type: Field.new('K'),
34
+ creditor_address_name: Field.new,
35
+ creditor_address_line1: Field.new,
36
+ creditor_address_line2: Field.new,
37
+ creditor_address_postal_code: Field.new,
38
+ creditor_address_city: Field.new,
39
+ creditor_address_country: Field.new,
40
+ # omit ultimate_creditor_* => for future use
41
+ # enum: S, K
42
+ ultimate_creditor_address_type: Field.new,
43
+ ultimate_creditor_address_name: Field.new,
44
+ ultimate_creditor_address_line1: Field.new,
45
+ ultimate_creditor_address_line2: Field.new,
46
+ ultimate_creditor_address_postal_code: Field.new,
47
+ ultimate_creditor_address_city: Field.new,
48
+ ultimate_creditor_address_country: Field.new,
49
+ amount: Field.new(nil, ->(value) { value && format('%.2f', value) }),
50
+ # enum: CHF, EUR
51
+ currency: Field.new('CHF'),
52
+ # enum: S, K
53
+ debtor_address_type: Field.new,
54
+ debtor_address_name: Field.new,
55
+ debtor_address_line1: Field.new,
56
+ debtor_address_line2: Field.new,
57
+ debtor_address_postal_code: Field.new,
58
+ debtor_address_city: Field.new,
59
+ debtor_address_country: Field.new,
60
+ # enum: QRR, SCOR, NON
61
+ reference_type: Field.new('NON'),
62
+ reference: Field.new(nil, ->(value) { value && value.delete(' ') }),
63
+ unstructured_message: Field.new,
64
+ # fixed: EPD
65
+ trailer: Field.new('EPD'),
66
+
67
+ # additional:
68
+
69
+ bill_information: Field.new(nil, nil, true),
70
+ # key-value pairs:
71
+ alternative_parameters: Field.new(nil, nil, true)
72
+ }.freeze
73
+
74
+ # TODO: check if all fields can be changed by user?
75
+ attr_accessor(*FIELDS.keys)
76
+
77
+ def initialize(fields = {})
78
+ # set defaults
79
+ FIELDS.each_key do |field|
80
+ instance_variable_set("@#{field}", FIELDS[field].default)
81
+ end
82
+
83
+ # set given
84
+ fields.each_key do |field|
85
+ instance_variable_set("@#{field}", fields[field])
86
+ end
87
+ end
88
+
89
+ def generate
90
+ stack = []
91
+ FIELDS.keys.map do |k|
92
+ var = instance_variable_get("@#{k}")
93
+
94
+ next if FIELDS[k][:skippable] && var.nil?
95
+
96
+ var = FIELDS[k][:format].call(var) if FIELDS[k][:format].is_a?(Proc)
97
+
98
+ stack << var
99
+ end
100
+ stack.join("\r\n")
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Amount section at payment
7
+ #
8
+ # TODO: allow EUR payment
9
+ # TODO: find solution to put measurements to specs.yml
10
+ # OPTIMIZE: refactor with receipt_amount
11
+ class PaymentAmount < Section
12
+ KEY = 'payment.amount'
13
+
14
+ # width of the currency field with enough space for both variants
15
+ CURRENCY_WIDTH = 14.mm
16
+
17
+ # width of amount when amount has to be displayed
18
+ AMOUNT_WIDTH = 29.mm
19
+
20
+ # height of the amount label when corner-box has to be displayed
21
+ AMOUNT_LABEL_HEIGHT = 3.5.mm
22
+
23
+ # measurement of amount corner-box
24
+ AMOUNT_BOX_WIDTH = 40.mm
25
+ AMOUNT_BOX_HEIGHT = 15.mm
26
+
27
+ include Helpers::NumberHelper
28
+ include Helpers::BoxHelper
29
+
30
+ def draw
31
+ box do
32
+ draw_currency
33
+ draw_amount
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def draw_currency
40
+ doc.bounding_box([doc.bounds.left, doc.bounds.top],
41
+ width: CURRENCY_WIDTH, height: specs.height) do
42
+ label I18n.t('currency', scope: i18n_scope)
43
+ content 'CHF'
44
+ end
45
+ end
46
+
47
+ def draw_amount
48
+ if @data.key?(:amount)
49
+ draw_amount_label_and_value
50
+ else
51
+ draw_amount_label
52
+ draw_amount_corner_box
53
+ end
54
+ end
55
+
56
+ def draw_amount_label_and_value
57
+ left = specs.width - AMOUNT_WIDTH
58
+
59
+ doc.bounding_box([left, doc.bounds.top],
60
+ width: AMOUNT_WIDTH, height: specs.height) do
61
+ label I18n.t('amount', scope: i18n_scope)
62
+ # format_with_delimiter => NumberHelper
63
+ content format_with_delimiter(@data[:amount])
64
+ end
65
+ end
66
+
67
+ def draw_amount_label
68
+ # amount label is not left aligned with the corner-box, subtract
69
+ # the currency width for alignment
70
+ left = CURRENCY_WIDTH
71
+
72
+ # width is calculated by the leftover space
73
+ width = specs.width - CURRENCY_WIDTH
74
+ doc.bounding_box([left, doc.bounds.top],
75
+ width: width, height: AMOUNT_LABEL_HEIGHT) do
76
+ label I18n.t('amount', scope: i18n_scope)
77
+ end
78
+ end
79
+
80
+ def draw_amount_corner_box
81
+ corner_box(doc, [specs.width - AMOUNT_BOX_WIDTH, doc.bounds.top - AMOUNT_LABEL_HEIGHT],
82
+ width: AMOUNT_BOX_WIDTH,
83
+ height: AMOUNT_BOX_HEIGHT)
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Further information section at payment
7
+ class PaymentFurtherInformation < Section
8
+ KEY = 'payment.further_information'
9
+
10
+ def draw; end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Information section at payment
7
+ class PaymentInformation < Section
8
+ KEY = 'payment.information'
9
+
10
+ include Helpers::BoxHelper
11
+
12
+ def draw
13
+ box do
14
+ draw_payable_to
15
+ draw_reference if @data.key?(:reference)
16
+ draw_additional_information if @data.key?(:additional_information)
17
+ draw_payable_by
18
+ end
19
+ end
20
+
21
+ private
22
+
23
+ def draw_payable_to
24
+ doc.pad_top(2.1) { label I18n.t('creditor', scope: i18n_scope) }
25
+ content [@data[:creditor][:iban],
26
+ build_address(@data[:creditor][:address])].join("\n")
27
+
28
+ line_spacing
29
+ end
30
+
31
+ def draw_reference
32
+ label I18n.t('reference', scope: i18n_scope)
33
+ content @data[:reference]
34
+
35
+ line_spacing
36
+ end
37
+
38
+ def draw_additional_information
39
+ label I18n.t('additional_info', scope: i18n_scope)
40
+ content @data[:additional_information]
41
+
42
+ line_spacing
43
+ end
44
+
45
+ def draw_payable_by
46
+ if @data.key?(:debtor)
47
+ label I18n.t('debtor', scope: i18n_scope)
48
+ content build_address(@data[:debtor][:address])
49
+ else
50
+ label I18n.t('debtor_blank', scope: i18n_scope)
51
+
52
+ doc.move_down 2
53
+ corner_box(doc, [0, doc.cursor], width: 65.mm, height: 25.mm)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Title section at payment
7
+ class PaymentTitle < Section
8
+ KEY = 'payment.title'
9
+
10
+ def draw
11
+ box do
12
+ content I18n.t('payment', scope: i18n_scope)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # QR code section
7
+ #
8
+ # The swiss cross is applied directly on the QR PNG. The PNG needs to be
9
+ # resized down, that the swiss cross overlaps exactly 9x9 modules for a
10
+ # QR-code of compression level :m.
11
+ #
12
+ # swiss_cross.png => 166px ~> 7.mm
13
+ # QR-code: 1090px <~ 46.mm
14
+ #
15
+ # TODO: iban check -> raise exception if configured
16
+ class QRCode < Section
17
+ KEY = 'payment.qr_code'
18
+
19
+ QR_PX_SIZE = 1160
20
+
21
+ SWISS_CROSS_FILE = File.expand_path("#{__dir__}/../images/swiss_cross.png")
22
+
23
+ MAPPING = {
24
+ creditor_address_type: %i[creditor address type],
25
+ creditor_address_name: %i[creditor address name],
26
+ creditor_address_line1: %i[creditor address line1],
27
+ creditor_address_line2: %i[creditor address line2],
28
+ creditor_address_postal_code: %i[creditor address postal_code],
29
+ creditor_address_country: %i[creditor address country],
30
+ debtor_address_type: %i[debtor address type],
31
+ debtor_address_name: %i[debtor address name],
32
+ debtor_address_line1: %i[debtor address line1],
33
+ debtor_address_line2: %i[debtor address line2],
34
+ debtor_address_postal_code: %i[debtor address postal_code],
35
+ debtor_address_city: %i[debtor address city],
36
+ debtor_address_country: %i[debtor address country],
37
+ amount: [:amount],
38
+ currency: [:currency],
39
+ reference: [:reference],
40
+ reference_type: [:reference_type]
41
+ }.freeze
42
+
43
+ def draw
44
+ png = qr_as_png(@data)
45
+ add_swiss_cross!(png)
46
+
47
+ io = StringIO.new(png.to_blob)
48
+
49
+ box do
50
+ doc.image io, width: specs.width
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def qr_as_png(data)
57
+ qr_data = generate_qr_data(data)
58
+ qr = RQRCode::QRCode.new(qr_data, level: :m)
59
+ png = qr.as_png(module_px_size: 20, border_modules: 0)
60
+ png.resize(QR_PX_SIZE, QR_PX_SIZE)
61
+ end
62
+
63
+ def add_swiss_cross!(png)
64
+ swiss_cross = ChunkyPNG::Image.from_file(SWISS_CROSS_FILE)
65
+
66
+ png.compose!(
67
+ swiss_cross,
68
+ (png.width - swiss_cross.width) / 2,
69
+ (png.height - swiss_cross.height) / 2
70
+ )
71
+ end
72
+
73
+ def generate_qr_data(data)
74
+ options = {}
75
+ MAPPING.each_key do |key|
76
+ # check if the exists
77
+ next unless deep_key?(data, MAPPING[key])
78
+
79
+ options[key] = data.dig(*MAPPING[key])
80
+ end
81
+
82
+ iban = IBAN.new(data[:creditor][:iban])
83
+ qr_data = QR::Data.new(options.merge(iban: iban.code))
84
+ qr_data.generate
85
+ end
86
+
87
+ # hash: hash to test key path for
88
+ # key_path: array of symbols to search the key
89
+ def deep_key?(hash, key_path)
90
+ path = key_path.dup
91
+
92
+ return false if path.empty?
93
+ return hash.key?(path[0]) if path.length == 1
94
+
95
+ last_key = path.pop
96
+
97
+ !!hash.dig(*path)&.key?(last_key)
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Acceptance point section at receipt
7
+ class ReceiptAcceptance < Section
8
+ KEY = 'receipt.acceptance'
9
+
10
+ def draw
11
+ box do
12
+ label I18n.t('acceptance', scope: i18n_scope), align: :right
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Amount section at receipt
7
+ #
8
+ # TODO: allow EUR payment
9
+ # TODO: find solution to put measurements to specs.yml
10
+ # OPTIMIZE: refactor with payment_amount
11
+ class ReceiptAmount < Section
12
+ KEY = 'receipt.amount'
13
+
14
+ # width of the currency field with enough space for both variants
15
+ CURRENCY_WIDTH = 11.mm
16
+
17
+ # measurement of the box for the amount as value and as corner-box
18
+ AMOUNT_BOX_WIDTH = 30.mm
19
+ AMOUNT_BOX_HEIGHT = 10.mm
20
+
21
+ # padding of the amount label to corner-box
22
+ AMOUNT_LABEL_PAD = 1.mm
23
+
24
+ include Helpers::NumberHelper
25
+ include Helpers::BoxHelper
26
+
27
+ def draw
28
+ box do
29
+ draw_currency
30
+ draw_amount
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ def draw_currency
37
+ # use the half width of the available size: space for amount-box
38
+ doc.bounding_box([doc.bounds.left, doc.bounds.top],
39
+ width: CURRENCY_WIDTH, height: specs.height) do
40
+ doc.pad_top(1.4) { label I18n.t('currency', scope: i18n_scope) }
41
+ doc.pad_top(2.5) { content 'CHF' }
42
+ end
43
+ end
44
+
45
+ def draw_amount
46
+ if @data.key?(:amount)
47
+ draw_amount_label_and_value
48
+ else
49
+ draw_amount_label
50
+ draw_amount_corner_box
51
+ end
52
+ end
53
+
54
+ def draw_amount_label_and_value
55
+ # amount value is placed where the box would be, same width/height
56
+ left = specs.width - AMOUNT_BOX_WIDTH
57
+ doc.bounding_box([left, doc.bounds.top],
58
+ width: AMOUNT_BOX_WIDTH, height: specs.height) do
59
+ doc.pad_top(1.4) { label I18n.t('amount', scope: i18n_scope) }
60
+ # format_with_delimiter => NumberHelper
61
+ doc.pad_top(2.5) { content format_with_delimiter(@data[:amount]) }
62
+ end
63
+ end
64
+
65
+ def draw_amount_label
66
+ # amount label is pushed to left, right after the currency box,
67
+ # which is the currency width
68
+ left = CURRENCY_WIDTH
69
+
70
+ # width is calculated by the left over space - 1.mm padding
71
+ width = specs.width - CURRENCY_WIDTH - AMOUNT_BOX_WIDTH - AMOUNT_LABEL_PAD
72
+ doc.bounding_box([left, doc.bounds.top],
73
+ width: width, height: specs.height) do
74
+ doc.pad_top(1.4) { label I18n.t('amount', scope: i18n_scope), align: :right }
75
+ end
76
+ end
77
+
78
+ def draw_amount_corner_box
79
+ corner_box(doc, [specs.width - AMOUNT_BOX_WIDTH, doc.bounds.top - 1.4],
80
+ width: AMOUNT_BOX_WIDTH,
81
+ height: AMOUNT_BOX_HEIGHT)
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Information section at receipt
7
+ class ReceiptInformation < Section
8
+ KEY = 'receipt.information'
9
+
10
+ include Helpers::BoxHelper
11
+
12
+ def draw
13
+ box do
14
+ draw_payable_to
15
+ draw_reference if @data.key?(:reference)
16
+ draw_payable_by
17
+ end
18
+ end
19
+
20
+ private
21
+
22
+ def draw_payable_to
23
+ label I18n.t('creditor', scope: i18n_scope)
24
+ content [@data[:creditor][:iban],
25
+ build_address(@data[:creditor][:address])].join("\n")
26
+
27
+ line_spacing
28
+ end
29
+
30
+ def draw_reference
31
+ label I18n.t('reference', scope: i18n_scope)
32
+ content @data[:reference]
33
+
34
+ line_spacing
35
+ end
36
+
37
+ def draw_payable_by
38
+ if @data.key?(:debtor)
39
+ label I18n.t('debtor', scope: i18n_scope)
40
+ content build_address(@data[:debtor][:address])
41
+ else
42
+ label I18n.t('debtor_blank', scope: i18n_scope)
43
+
44
+ doc.move_down 2
45
+ corner_box(doc, [0, doc.cursor], width: 52.mm, height: 20.mm)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module SwissQRBill
5
+ module Sections
6
+ # Title section at receipt
7
+ class ReceiptTitle < Section
8
+ KEY = 'receipt.title'
9
+
10
+ def draw
11
+ box do
12
+ content I18n.t('receipt', scope: i18n_scope)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end