swiss_qr 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 +27 -0
- data/LICENSE.txt +21 -0
- data/README.md +402 -0
- data/Rakefile +12 -0
- data/docs/SPEC_NOTES.md +542 -0
- data/exe/swiss_qr +12 -0
- data/lib/swiss_qr/address.rb +57 -0
- data/lib/swiss_qr/bill.rb +88 -0
- data/lib/swiss_qr/billing_information.rb +226 -0
- data/lib/swiss_qr/checksum.rb +29 -0
- data/lib/swiss_qr/cli.rb +123 -0
- data/lib/swiss_qr/countries.rb +48 -0
- data/lib/swiss_qr/errors.rb +58 -0
- data/lib/swiss_qr/iban.rb +60 -0
- data/lib/swiss_qr/parser.rb +211 -0
- data/lib/swiss_qr/reader.rb +211 -0
- data/lib/swiss_qr/reference.rb +79 -0
- data/lib/swiss_qr/validation.rb +380 -0
- data/lib/swiss_qr/version.rb +5 -0
- data/lib/swiss_qr.rb +56 -0
- data/sig/manifest.yaml +2 -0
- data/sig/swiss_qr.rbs +226 -0
- metadata +89 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwissQr
|
|
4
|
+
# Turns a Swiss QR Code payload into a Bill. Use SwissQr.parse.
|
|
5
|
+
class Parser
|
|
6
|
+
include Validation
|
|
7
|
+
|
|
8
|
+
BOM = "\uFEFF"
|
|
9
|
+
# Encodings that QR decoders commonly (and wrongly, for coding type 1)
|
|
10
|
+
# tag byte-mode segments with; valid UTF-8 bytes in them are UTF-8.
|
|
11
|
+
MISTAGGED_ENCODINGS = [Encoding::ISO_8859_1, Encoding::Windows_1252].freeze
|
|
12
|
+
# Header lines as parse recognizes them (blanks around SPC and the
|
|
13
|
+
# version are tolerated here so that looks_like_qr_bill? never skips what parse
|
|
14
|
+
# would report as an invalid QR-bill).
|
|
15
|
+
HEADER = /\A(?:\xEF\xBB\xBF)?[ \t]*SPC[ \t]*\r?\n[ \t]*\d{4}[ \t]*\r?\n/n
|
|
16
|
+
|
|
17
|
+
def self.parse(payload, strict: true)
|
|
18
|
+
new(payload).parse(strict: strict)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Cheap check whether +text+ looks like a QR-bill payload ("SPC", a
|
|
22
|
+
# line break, a four-digit version and another line break).
|
|
23
|
+
def self.looks_like_qr_bill?(text)
|
|
24
|
+
return false unless text.respond_to?(:to_str)
|
|
25
|
+
|
|
26
|
+
text = text.to_str
|
|
27
|
+
text.b.match?(HEADER) || (!text.encoding.ascii_compatible? && utf8_bytes(text).match?(HEADER))
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The UTF-8 bytes of a UTF-16/UTF-32 string, for the byte-level match.
|
|
31
|
+
def self.utf8_bytes(text)
|
|
32
|
+
text.encode(Encoding::UTF_8, invalid: :replace, undef: :replace).b
|
|
33
|
+
rescue EncodingError
|
|
34
|
+
text.b
|
|
35
|
+
end
|
|
36
|
+
private_class_method :utf8_bytes
|
|
37
|
+
|
|
38
|
+
def initialize(payload)
|
|
39
|
+
raise TypeError, "payload must be a String (got #{payload.class})" unless payload.respond_to?(:to_str)
|
|
40
|
+
|
|
41
|
+
@payload = payload.to_str
|
|
42
|
+
@errors = []
|
|
43
|
+
@warnings = []
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def parse(strict: true)
|
|
47
|
+
text = utf8(@payload)
|
|
48
|
+
@raw = text.frozen? ? text : text.dup.freeze
|
|
49
|
+
check_input_size!(text)
|
|
50
|
+
text = strip_bom(text)
|
|
51
|
+
lines = split_lines(text)
|
|
52
|
+
check_structure!(lines)
|
|
53
|
+
values = read_values(lines)
|
|
54
|
+
bill = build_bill(values)
|
|
55
|
+
raise InvalidPayload.new(bill.errors, bill: bill) if strict && !bill.valid?
|
|
56
|
+
|
|
57
|
+
bill
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def utf8(text)
|
|
63
|
+
return text if text.encoding == Encoding::UTF_8 && text.valid_encoding?
|
|
64
|
+
|
|
65
|
+
text = text.dup
|
|
66
|
+
if [Encoding::BINARY, Encoding::US_ASCII, Encoding::UTF_8].include?(text.encoding)
|
|
67
|
+
text.force_encoding(Encoding::UTF_8)
|
|
68
|
+
elsif mistagged_utf8?(text)
|
|
69
|
+
warning(:payload, "payload tagged #{text.encoding} is valid UTF-8 and was read as UTF-8 (coding type 1)")
|
|
70
|
+
text.force_encoding(Encoding::UTF_8)
|
|
71
|
+
else
|
|
72
|
+
begin
|
|
73
|
+
text = text.encode(Encoding::UTF_8)
|
|
74
|
+
rescue EncodingError
|
|
75
|
+
text.force_encoding(Encoding::UTF_8)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
return text if text.valid_encoding?
|
|
79
|
+
|
|
80
|
+
error(:payload, "payload is not valid UTF-8")
|
|
81
|
+
text.scrub("\uFFFD")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# QR byte segments without an ECI are ISO-8859-1 by default, so many
|
|
85
|
+
# decoders tag a QR-bill's UTF-8 bytes that way; transcoding them would
|
|
86
|
+
# turn "ö" into "ö".
|
|
87
|
+
def mistagged_utf8?(text)
|
|
88
|
+
MISTAGGED_ENCODINGS.include?(text.encoding) && !text.ascii_only? &&
|
|
89
|
+
text.dup.force_encoding(Encoding::UTF_8).valid_encoding?
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def strip_bom(text)
|
|
93
|
+
return text unless text.start_with?(BOM)
|
|
94
|
+
|
|
95
|
+
warning(:payload, "byte order mark (BOM) removed")
|
|
96
|
+
text.delete_prefix(BOM)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# Drops the final line break and trailing empty (or blank) lines that
|
|
100
|
+
# many generators (SIX's own samples included) leave behind, then splits
|
|
101
|
+
# on CR LF or LF and checks the separators between the remaining lines.
|
|
102
|
+
def split_lines(text)
|
|
103
|
+
body = text.sub(/(?:\r\n|\n|\r)\s*\z/, "")
|
|
104
|
+
@crlf = body.include?("\r\n")
|
|
105
|
+
if @crlf && body.match?(/(?<!\r)\n/)
|
|
106
|
+
error(:payload, "CR LF and LF line separators are mixed; IG 4.1.4 requires the same separator throughout")
|
|
107
|
+
end
|
|
108
|
+
error(:payload, "a lone CR is not a permitted line separator (use CR LF or LF)") if body.match?(/\r(?!\n)/)
|
|
109
|
+
|
|
110
|
+
body.split(/\r?\n/, -1)
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def check_structure!(lines)
|
|
114
|
+
header = lines.first&.strip
|
|
115
|
+
if header != QR_TYPE
|
|
116
|
+
message = header.nil? ? "empty payload" : %(QR type must be "#{QR_TYPE}" (got #{header[0, 20].inspect}))
|
|
117
|
+
raise InvalidPayload, @errors + [Problem.new(field: :header, message: message)]
|
|
118
|
+
end
|
|
119
|
+
return if lines.size >= MIN_LINES
|
|
120
|
+
|
|
121
|
+
message = "only #{lines.size} lines (not counting trailing empty lines); " \
|
|
122
|
+
"a QR-bill has #{MIN_LINES} to #{MAX_LINES} lines ending with the trailer #{TRAILER.inspect}"
|
|
123
|
+
raise InvalidPayload, @errors + [Problem.new(field: :payload, message: message)]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Checks character set, padding and overall size; returns the stripped
|
|
127
|
+
# line values with empty lines as nil. Lines beyond MAX_LINES are
|
|
128
|
+
# reported by check_size and otherwise ignored.
|
|
129
|
+
def read_values(lines)
|
|
130
|
+
check_size(lines)
|
|
131
|
+
lines = lines.first(MAX_LINES)
|
|
132
|
+
fields = line_fields(lines)
|
|
133
|
+
lines.each_with_index.map do |line, index|
|
|
134
|
+
field = fields.fetch(index, :payload)
|
|
135
|
+
status_a = index >= MIN_LINES
|
|
136
|
+
check_characters(field, line, status_a: status_a)
|
|
137
|
+
value = line.strip
|
|
138
|
+
check_padding(field, line, value, status_a: status_a) if value != line
|
|
139
|
+
value.empty? ? nil : value.freeze
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def check_size(lines)
|
|
144
|
+
if lines.size > MAX_LINES
|
|
145
|
+
error(:payload, "#{lines.size} lines; at most #{MAX_LINES} are permitted (31 + billing information + " \
|
|
146
|
+
"2 alternative procedures), the extra lines are ignored")
|
|
147
|
+
end
|
|
148
|
+
length = lines.sum(&:length) + lines.size - 1 # separators count as one character each (LF)
|
|
149
|
+
length += lines.size - 1 if @crlf
|
|
150
|
+
return if length <= MAX_PAYLOAD_LENGTH
|
|
151
|
+
|
|
152
|
+
error(:payload, "payload is #{length} characters including separators; at most #{MAX_PAYLOAD_LENGTH} are permitted")
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def line_fields(lines)
|
|
156
|
+
%i[header version coding_type iban] +
|
|
157
|
+
address_fields(:creditor, lines[4]&.strip) +
|
|
158
|
+
address_fields(:ultimate_creditor, lines[11]&.strip) +
|
|
159
|
+
%i[amount currency] +
|
|
160
|
+
address_fields(:debtor, lines[20]&.strip) +
|
|
161
|
+
%i[reference_type reference unstructured_message trailer billing_information
|
|
162
|
+
alternative_procedures alternative_procedures]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Fields are validated in line order so that errors come out in that order.
|
|
166
|
+
def build_bill(values)
|
|
167
|
+
check_header(values[1], values[2])
|
|
168
|
+
attributes = { version: values[1], coding_type: values[2] }
|
|
169
|
+
attributes.merge!(parties_and_amount(values))
|
|
170
|
+
attributes.merge!(reference_attributes(values, attributes))
|
|
171
|
+
attributes.merge!(additional_information(values, attributes[:amount]))
|
|
172
|
+
Bill.new(**attributes, errors: @errors.dup.freeze, warnings: @warnings.dup.freeze, raw: @raw)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def parties_and_amount(values)
|
|
176
|
+
iban = values[3] && Iban.normalize(values[3]).freeze
|
|
177
|
+
check_iban(values[3], iban)
|
|
178
|
+
{
|
|
179
|
+
iban: iban,
|
|
180
|
+
qr_iban: !iban.nil? && Iban.qr_iid?(iban),
|
|
181
|
+
creditor: build_address(:creditor, values[4, 7], required: true),
|
|
182
|
+
ultimate_creditor: build_ultimate_creditor(values[11, 7]),
|
|
183
|
+
amount: parse_amount(values[18]),
|
|
184
|
+
currency: values[19].tap { |currency| check_currency(currency) },
|
|
185
|
+
debtor: build_address(:debtor, values[20, 7], required: false)
|
|
186
|
+
}
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def reference_attributes(values, attributes)
|
|
190
|
+
reference_type = parse_reference_type(values[27])
|
|
191
|
+
check_iban_reference_type(attributes[:iban], attributes[:qr_iban], reference_type, attributes[:currency])
|
|
192
|
+
check_reference(reference_type, values[28])
|
|
193
|
+
{ reference_type: reference_type, reference: values[28] }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def additional_information(values, amount)
|
|
197
|
+
message, trailer, raw_billing_information = values[29, 3]
|
|
198
|
+
alternative_procedures = values[32, 2] || []
|
|
199
|
+
check_notification(amount, message)
|
|
200
|
+
check_trailer(trailer)
|
|
201
|
+
check_additional_information(message, raw_billing_information)
|
|
202
|
+
billing_information = parse_billing_information(raw_billing_information)
|
|
203
|
+
check_alternative_procedures(alternative_procedures)
|
|
204
|
+
{
|
|
205
|
+
unstructured_message: message, trailer: trailer, raw_billing_information: raw_billing_information,
|
|
206
|
+
billing_information: billing_information,
|
|
207
|
+
alternative_procedures: alternative_procedures.compact.freeze
|
|
208
|
+
}
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# QR code readers and SwissQr.read / SwissQr.read_all.
|
|
4
|
+
module SwissQr
|
|
5
|
+
# Pluggable QR code readers: turn an image or PDF into the texts of the QR
|
|
6
|
+
# codes on it. SwissQr.read and SwissQr.read_all use SwissQr.reader, which
|
|
7
|
+
# defaults to the +qr_reader+ gem; any other decoder can be plugged in.
|
|
8
|
+
#
|
|
9
|
+
# A reader is either
|
|
10
|
+
# - an object responding to <tt>decode_all(input)</tt> and returning an
|
|
11
|
+
# Array of Strings (the +QrReader+ module itself qualifies), or
|
|
12
|
+
# - a callable (Proc, Method, any object with #call) returning a String,
|
|
13
|
+
# an Array of Strings, or nil when nothing was found.
|
|
14
|
+
#
|
|
15
|
+
# Elements may also be result objects with a #text method (such as
|
|
16
|
+
# QrReader::Result); nil elements are dropped.
|
|
17
|
+
#
|
|
18
|
+
# The input handed to a reader is never image bytes in a String: a String
|
|
19
|
+
# is a file path (image bytes come as an IO such as StringIO.new(bytes)).
|
|
20
|
+
module Reader
|
|
21
|
+
# Leading bytes of the image formats a String of image bytes most likely
|
|
22
|
+
# has; together with NUL bytes (never in a path) they reveal image data
|
|
23
|
+
# passed where a path belongs.
|
|
24
|
+
IMAGE_MAGIC = ["\x89PNG".b, "\xFF\xD8\xFF".b, "GIF8".b, "%PDF-".b, "II*\0".b, "MM\0*".b,
|
|
25
|
+
"\0\0\0\x0CjP".b, "\xFF\x4F\xFF\x51".b].freeze
|
|
26
|
+
|
|
27
|
+
INSTALL_HINT = <<~HINT.tr("\n", " ").strip
|
|
28
|
+
Reading QR codes from images and PDFs needs the qr_reader gem: run
|
|
29
|
+
"gem install qr_reader" or "bundle add qr_reader", or plug in another
|
|
30
|
+
decoder with SwissQr.reader = ->(input) { ... } (or pass reader: to
|
|
31
|
+
SwissQr.read).
|
|
32
|
+
HINT
|
|
33
|
+
|
|
34
|
+
# The default reader: the +qr_reader+ gem, required on first use (not
|
|
35
|
+
# when swiss_qr is loaded). Raises ReaderUnavailable when it cannot be
|
|
36
|
+
# loaded; the next call tries again.
|
|
37
|
+
#
|
|
38
|
+
# +options+ are passed on to qr_reader (<tt>try_harder:</tt>,
|
|
39
|
+
# <tt>inverted:</tt>):
|
|
40
|
+
#
|
|
41
|
+
# SwissQr.reader = SwissQr::Reader::QrReaderAdapter.new(try_harder: false)
|
|
42
|
+
class QrReaderAdapter
|
|
43
|
+
# The oldest qr_reader this adapter works with. Older releases sniff
|
|
44
|
+
# Strings (image bytes or path), raise for PDFs without any code, and
|
|
45
|
+
# crash ruby.wasm on PNGs.
|
|
46
|
+
MINIMUM_QR_READER = "0.0.3"
|
|
47
|
+
|
|
48
|
+
attr_reader :options
|
|
49
|
+
|
|
50
|
+
def initialize(**options)
|
|
51
|
+
@options = options.freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def decode_all(input)
|
|
55
|
+
reader = qr_reader
|
|
56
|
+
reader.decode_all(input, **options)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Yields the text of each code as soon as it is decoded, so that
|
|
60
|
+
# SwissQr.read can stop at the first QR-bill of a long PDF.
|
|
61
|
+
def each_text(input, &)
|
|
62
|
+
reader = qr_reader
|
|
63
|
+
reader.scan(input, **options) { |result| yield result.text }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inspect
|
|
67
|
+
shown = options.empty? ? "" : " #{options.inspect}"
|
|
68
|
+
"#<#{self.class.name}#{shown} (qr_reader gem, loaded on first use)>"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
def qr_reader
|
|
74
|
+
begin
|
|
75
|
+
require "qr_reader"
|
|
76
|
+
rescue LoadError => e
|
|
77
|
+
raise ReaderUnavailable, "#{INSTALL_HINT} (#{e.message})"
|
|
78
|
+
end
|
|
79
|
+
check_version(::QrReader::VERSION)
|
|
80
|
+
::QrReader
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Compared without Gem::Version, so that this also works where
|
|
84
|
+
# RubyGems is not loaded (ruby.wasm, --disable-gems).
|
|
85
|
+
def check_version(version)
|
|
86
|
+
numbers = ->(v) { v.to_s.split(".").map(&:to_i) }
|
|
87
|
+
return if (numbers.call(version) <=> numbers.call(MINIMUM_QR_READER)) >= 0
|
|
88
|
+
|
|
89
|
+
raise ReaderUnavailable, "qr_reader #{version} is too old: swiss_qr needs qr_reader " \
|
|
90
|
+
"#{MINIMUM_QR_READER} or newer (gem update qr_reader / bundle update qr_reader)"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Wraps any supported reader in an object answering #decode_all and
|
|
95
|
+
# #each_text with plain Strings.
|
|
96
|
+
class Adapter
|
|
97
|
+
attr_reader :reader
|
|
98
|
+
|
|
99
|
+
def initialize(reader)
|
|
100
|
+
@reader = reader
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def decode_all(input)
|
|
104
|
+
texts = []
|
|
105
|
+
each_text(input) { |text| texts << text }
|
|
106
|
+
texts
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def each_text(input, &)
|
|
110
|
+
return reader.each_text(input, &) if reader.is_a?(QrReaderAdapter)
|
|
111
|
+
|
|
112
|
+
result = reader.respond_to?(:decode_all) ? reader.decode_all(input) : reader.call(input)
|
|
113
|
+
Reader.texts(result).each(&)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
module_function
|
|
118
|
+
|
|
119
|
+
# Refuses image bytes passed as a String, where a path belongs: a String
|
|
120
|
+
# input is always a file path. Without this, bytes from a client that
|
|
121
|
+
# spell a path on the server would be read from disk.
|
|
122
|
+
def check_input(input)
|
|
123
|
+
return input unless input.is_a?(String)
|
|
124
|
+
|
|
125
|
+
bytes = input.b
|
|
126
|
+
return input if !bytes.include?("\0") && File.file?(input)
|
|
127
|
+
if bytes.include?("\0") || IMAGE_MAGIC.any? { |magic| bytes.start_with?(magic) }
|
|
128
|
+
raise ArgumentError, "expected a file path, got a String that looks like image data " \
|
|
129
|
+
"(#{bytes.bytesize} bytes); pass image bytes as an IO: StringIO.new(bytes)"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
input
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# True when +reader+ can be used as a reader.
|
|
136
|
+
def reader?(reader)
|
|
137
|
+
reader.respond_to?(:decode_all) || reader.respond_to?(:call)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Wraps +reader+ (see Reader) in an Adapter; nil means SwissQr.reader.
|
|
141
|
+
def adapt(reader)
|
|
142
|
+
reader = SwissQr.reader if reader.nil?
|
|
143
|
+
return reader if reader.is_a?(Adapter)
|
|
144
|
+
raise ArgumentError, "a reader must respond to #decode_all or #call (got #{reader.inspect})" unless reader?(reader)
|
|
145
|
+
|
|
146
|
+
Adapter.new(reader)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Normalises what a reader returned into an Array of Strings. A single
|
|
150
|
+
# String or result object (with #text) counts as one code; it is not
|
|
151
|
+
# splatted with Array() (a Struct result would turn into its fields).
|
|
152
|
+
def texts(result)
|
|
153
|
+
items = result.respond_to?(:to_str) || result.respond_to?(:text) ? [result] : Array(result)
|
|
154
|
+
items.filter_map { |item| text(item) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def text(item)
|
|
158
|
+
return if item.nil?
|
|
159
|
+
return item.to_str if item.respond_to?(:to_str)
|
|
160
|
+
return item.text&.to_s if item.respond_to?(:text)
|
|
161
|
+
|
|
162
|
+
raise TypeError, "a reader must return Strings (or objects with #text), got #{item.class}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
@reader = nil
|
|
167
|
+
@reader_lock = Mutex.new
|
|
168
|
+
|
|
169
|
+
class << self
|
|
170
|
+
# The reader used by read and read_all: what was assigned with reader=,
|
|
171
|
+
# or else a Reader::QrReaderAdapter for the +qr_reader+ gem (which is
|
|
172
|
+
# only required when a code is actually read).
|
|
173
|
+
def reader
|
|
174
|
+
@reader || @reader_lock.synchronize { @reader ||= Reader::QrReaderAdapter.new }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Replaces the reader for the whole process: an object with
|
|
178
|
+
# <tt>decode_all(input)</tt> or a callable (see Reader). +nil+ restores
|
|
179
|
+
# the default (+qr_reader+).
|
|
180
|
+
#
|
|
181
|
+
# SwissQr.reader = ->(path) { `zbarimg --quiet --raw #{path}`.split("\n") }
|
|
182
|
+
def reader=(reader)
|
|
183
|
+
unless reader.nil? || Reader.reader?(reader)
|
|
184
|
+
raise ArgumentError, "a reader must respond to #decode_all or #call (got #{reader.inspect})"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
@reader_lock.synchronize { @reader = reader }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Reads the QR codes in +input+ - a file path (String or Pathname), an
|
|
191
|
+
# IO with the image or PDF (StringIO.new(bytes), an upload's Tempfile),
|
|
192
|
+
# or whatever else the reader accepts, such as a ChunkyPNG image - and
|
|
193
|
+
# returns the first QR-bill as a Bill, or nil when there is none. A
|
|
194
|
+
# String is always a path: image bytes in a String raise ArgumentError.
|
|
195
|
+
# Codes that are not QR-bills (see looks_like_qr_bill?) are skipped. +strict+ is
|
|
196
|
+
# passed on to parse.
|
|
197
|
+
def read(input, reader: self.reader, strict: true)
|
|
198
|
+
Reader.adapt(reader).each_text(Reader.check_input(input)) do |text|
|
|
199
|
+
return parse(text, strict: strict) if looks_like_qr_bill?(text)
|
|
200
|
+
end
|
|
201
|
+
nil
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# Like read, but returns every QR-bill found (all pages), in the order
|
|
205
|
+
# the reader reported them; an empty Array when there is none.
|
|
206
|
+
def read_all(input, reader: self.reader, strict: true)
|
|
207
|
+
texts = Reader.adapt(reader).decode_all(Reader.check_input(input))
|
|
208
|
+
texts.select { |text| looks_like_qr_bill?(text) }.map { |text| parse(text, strict: strict) }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SwissQr
|
|
4
|
+
# Payment reference helpers: the 27-digit QR reference (QRR, used with a
|
|
5
|
+
# QR-IBAN) and the ISO 11649 creditor reference (SCOR, "RF..." with an
|
|
6
|
+
# ordinary IBAN).
|
|
7
|
+
#
|
|
8
|
+
# All predicates ignore whitespace, so display-formatted references are
|
|
9
|
+
# accepted. The parser itself is stricter and rejects spaces in a payload.
|
|
10
|
+
module Reference
|
|
11
|
+
QRR_PATTERN = /\A\d{27}\z/
|
|
12
|
+
SCOR_PATTERN = /\ARF\d{2}[A-Z0-9]{1,21}\z/
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def normalize(reference)
|
|
17
|
+
reference.to_s.gsub(/[[:space:]]/, "")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# True for 27 digits with a correct modulo-10 recursive check digit that
|
|
21
|
+
# are not all zeros (IG 2.3).
|
|
22
|
+
def valid_qrr?(reference)
|
|
23
|
+
value = normalize(reference)
|
|
24
|
+
value.match?(QRR_PATTERN) && !value.match?(/\A0+\z/) &&
|
|
25
|
+
Checksum.mod10_recursive(value[0, 26]) == value[26].to_i
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# True for a 5-25 character creditor reference "RF" + 2 check digits +
|
|
29
|
+
# up to 21 alphanumerics with valid MOD 97-10 check digits. Case-insensitive.
|
|
30
|
+
def valid_scor?(reference)
|
|
31
|
+
value = normalize(reference).upcase
|
|
32
|
+
value.match?(SCOR_PATTERN) && Checksum.mod97(value) == 1
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Appends the check digit to up to 26 digits (left-padded with zeros):
|
|
36
|
+
# <tt>build_qrr("21000000000313947143000901")</tt> => "210000000003139471430009017".
|
|
37
|
+
def build_qrr(digits)
|
|
38
|
+
body = normalize(digits)
|
|
39
|
+
raise ArgumentError, "QR reference body must be 1-26 digits" unless body.match?(/\A\d{1,26}\z/)
|
|
40
|
+
|
|
41
|
+
body = body.rjust(26, "0")
|
|
42
|
+
"#{body}#{Checksum.mod10_recursive(body)}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Builds a creditor reference from up to 21 alphanumerics:
|
|
46
|
+
# <tt>build_scor("539007547034")</tt> => "RF18539007547034".
|
|
47
|
+
def build_scor(payload)
|
|
48
|
+
body = normalize(payload).upcase
|
|
49
|
+
raise ArgumentError, "creditor reference body must be 1-21 alphanumerics" unless body.match?(/\A[A-Z0-9]{1,21}\z/)
|
|
50
|
+
|
|
51
|
+
check = 98 - Checksum.mod97("RF00#{body}")
|
|
52
|
+
Kernel.format("RF%<check>02d%<body>s", check: check, body: body)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Display form of a QR reference: "21 00000 00003 13947 14300 09017"
|
|
56
|
+
# (groups of five from the right).
|
|
57
|
+
def format_qrr(reference)
|
|
58
|
+
value = normalize(reference)
|
|
59
|
+
value.reverse.scan(/.{1,5}/).join(" ").reverse
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Display form of a creditor reference: "RF18 5390 0754 7034".
|
|
63
|
+
def format_scor(reference)
|
|
64
|
+
normalize(reference).upcase.scan(/.{1,4}/).join(" ")
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Display form chosen by shape (QRR if all digits, otherwise SCOR style).
|
|
68
|
+
def format(reference, type = nil)
|
|
69
|
+
return nil if reference.nil?
|
|
70
|
+
|
|
71
|
+
type ||= normalize(reference).match?(/\A\d+\z/) ? :qrr : :scor
|
|
72
|
+
case type
|
|
73
|
+
when :qrr then format_qrr(reference)
|
|
74
|
+
when :scor then format_scor(reference)
|
|
75
|
+
else reference.to_s
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|