prawn-swiss_qr_bill 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
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