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,380 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "bigdecimal"
|
|
4
|
+
|
|
5
|
+
module SwissQr
|
|
6
|
+
# Field rules of the SIX "Swiss Implementation Guidelines for the QR-bill"
|
|
7
|
+
# (IG 2.3, with IG 2.2 rules for the legacy combined address type "K").
|
|
8
|
+
# See docs/SPEC_NOTES.md for the sources of every rule.
|
|
9
|
+
#
|
|
10
|
+
# Mixed into Parser; every rule records a Problem via #error or #warning.
|
|
11
|
+
module Validation
|
|
12
|
+
QR_TYPE = "SPC"
|
|
13
|
+
VERSION = "0200"
|
|
14
|
+
CODING_TYPE = "1"
|
|
15
|
+
TRAILER = "EPD"
|
|
16
|
+
CURRENCIES = %w[CHF EUR].freeze
|
|
17
|
+
REFERENCE_TYPES = { "QRR" => :qrr, "SCOR" => :scor, "NON" => :non }.freeze
|
|
18
|
+
|
|
19
|
+
MIN_LINES = 31
|
|
20
|
+
MAX_LINES = 34
|
|
21
|
+
MAX_PAYLOAD_LENGTH = 997
|
|
22
|
+
MAX_INPUT_BYTES = 4096
|
|
23
|
+
MAX_MESSAGE_LENGTH = 140 # unstructured message + billing information together
|
|
24
|
+
MAX_ALTERNATIVE_PROCEDURE_LENGTH = 100
|
|
25
|
+
|
|
26
|
+
# Permitted characters since IG 2.3: Basic Latin, Latin-1 Supplement,
|
|
27
|
+
# Latin Extended-A, Ș ș Ț ț and the euro sign.
|
|
28
|
+
INVALID_CHARACTER = /[^ -~ -ſȘ-ț€]/
|
|
29
|
+
AMOUNT = /\A(?:0|[1-9]\d{0,8})\.\d{2}\z/
|
|
30
|
+
|
|
31
|
+
# Texts that turn a bill with amount 0.00 into a "DO NOT USE FOR PAYMENT"
|
|
32
|
+
# notification (IG 4.4; Romansh added in IG 2.4).
|
|
33
|
+
NOTIFICATION_TEXTS = [
|
|
34
|
+
"NICHT ZUR ZAHLUNG VERWENDEN",
|
|
35
|
+
"NE PAS UTILISER POUR LE PAIEMENT",
|
|
36
|
+
"NON UTILIZZARE PER IL PAGAMENTO",
|
|
37
|
+
"DO NOT USE FOR PAYMENT",
|
|
38
|
+
"BETG DUVRAR PER IL PAJAMENT"
|
|
39
|
+
].freeze
|
|
40
|
+
|
|
41
|
+
# Elements with a fixed format, where blanks are never part of the value.
|
|
42
|
+
FIXED_FORMAT_FIELDS = %i[header version coding_type iban amount currency reference_type reference
|
|
43
|
+
trailer].freeze
|
|
44
|
+
|
|
45
|
+
STRUCTURED_FIELDS = %i[type name street building_number postal_code town country].freeze
|
|
46
|
+
COMBINED_FIELDS = %i[type name address_line1 address_line2 postal_code town country].freeze
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def error(field, message)
|
|
51
|
+
@errors << Problem.new(field: field, message: message)
|
|
52
|
+
nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def warning(field, message)
|
|
56
|
+
@warnings << Problem.new(field: field, message: message)
|
|
57
|
+
nil
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# A QR code holds at most 2953 bytes, so a longer text cannot have come
|
|
61
|
+
# from one; it is rejected before any line is looked at.
|
|
62
|
+
def check_input_size!(text)
|
|
63
|
+
return if text.bytesize <= MAX_INPUT_BYTES
|
|
64
|
+
|
|
65
|
+
message = "payload is #{text.bytesize} bytes; a QR code holds at most 2953 and a QR-bill at most " \
|
|
66
|
+
"#{MAX_PAYLOAD_LENGTH} characters"
|
|
67
|
+
raise InvalidPayload, @errors + [Problem.new(field: :payload, message: message)]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def check_length(field, value, max, label)
|
|
71
|
+
return if value.nil? || value.length <= max
|
|
72
|
+
|
|
73
|
+
error(field, "#{label} longer than #{max} characters (#{value.length})")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Records a character-set problem in a raw (unstripped) line. Problems in
|
|
77
|
+
# status A lines (billing information, alternative procedures) are
|
|
78
|
+
# warnings because the IG forbids rejecting a bill for them.
|
|
79
|
+
def check_characters(field, line, status_a: false)
|
|
80
|
+
bad = line.scan(INVALID_CHARACTER).uniq
|
|
81
|
+
return if bad.empty?
|
|
82
|
+
|
|
83
|
+
listed = bad.first(3).map { |c| "#{c.inspect} (U+#{c.ord.to_s(16).upcase.rjust(4, "0")})" }.join(", ")
|
|
84
|
+
kind = if bad.all? do |c|
|
|
85
|
+
c.match?(/[[:cntrl:]]/)
|
|
86
|
+
end
|
|
87
|
+
"control character not permitted"
|
|
88
|
+
else
|
|
89
|
+
"characters outside the permitted Latin character set"
|
|
90
|
+
end
|
|
91
|
+
status_a ? warning(field, "#{kind}: #{listed}") : error(field, "#{kind}: #{listed}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# "It is not permitted to fill in the elements with blanks" (IG 4.1.3).
|
|
95
|
+
# Blanks around a fixed-format element (header, IBAN, amount, codes,
|
|
96
|
+
# reference, country), and a line of blanks instead of an empty line, are
|
|
97
|
+
# errors. Around free text (names, street, town, message) they are only
|
|
98
|
+
# warnings, because SIX's own Style Guide samples have a trailing blank
|
|
99
|
+
# after a street name. Status A lines only ever get warnings.
|
|
100
|
+
def check_padding(field, line, value, status_a:)
|
|
101
|
+
if status_a
|
|
102
|
+
warning(field, "leading or trailing blanks removed")
|
|
103
|
+
elsif value.empty?
|
|
104
|
+
error(field, "blanks instead of an empty line are not permitted (IG 4.1.3)")
|
|
105
|
+
elsif fixed_format?(field)
|
|
106
|
+
error(field, "leading or trailing blanks are not permitted (IG 4.1.3; got #{line.inspect})")
|
|
107
|
+
else
|
|
108
|
+
warning(field, "leading or trailing blanks removed (IG 4.1.3 does not permit padding)")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def fixed_format?(field)
|
|
113
|
+
FIXED_FORMAT_FIELDS.include?(field) || field.end_with?(".type", ".country")
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def check_header(version, coding_type)
|
|
117
|
+
if version != VERSION
|
|
118
|
+
detail = if version.to_s.match?(/\A02\d\d\z/)
|
|
119
|
+
%(only "#{VERSION}" is permitted in master version 02)
|
|
120
|
+
else
|
|
121
|
+
%(version must be "#{VERSION}")
|
|
122
|
+
end
|
|
123
|
+
error(:version, "#{detail} (got #{version.inspect})")
|
|
124
|
+
end
|
|
125
|
+
error(:coding_type, %(coding type must be "#{CODING_TYPE}" (got #{coding_type.inspect}))) if coding_type != CODING_TYPE
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def check_iban(raw, iban)
|
|
129
|
+
return error(:iban, "IBAN is mandatory") if raw.nil?
|
|
130
|
+
|
|
131
|
+
error(:iban, "IBAN must be given without spaces") if raw.match?(/[[:space:]]/)
|
|
132
|
+
return error(:iban, "IBAN may only contain letters and digits") if raw.match?(/[^A-Za-z0-9[:space:]]/)
|
|
133
|
+
|
|
134
|
+
warning(:iban, "IBAN should be in upper case") if raw.match?(/[a-z]/)
|
|
135
|
+
if !iban.match?(/\A[A-Z0-9]+\z/)
|
|
136
|
+
error(:iban, "IBAN may only contain letters and digits")
|
|
137
|
+
elsif iban.match?(/\A[A-Z]{2}/) && !Iban::COUNTRIES.include?(iban[0, 2])
|
|
138
|
+
error(:iban, "only CH and LI IBANs are permitted (got #{iban[0, 2]})")
|
|
139
|
+
elsif iban.length != 21
|
|
140
|
+
error(:iban, "IBAN must be exactly 21 characters (got #{iban.length})")
|
|
141
|
+
elsif !iban.match?(Iban::QR_BILL_PATTERN)
|
|
142
|
+
error(:iban, "malformed CH/LI IBAN: country, 2 check digits, 5-digit institution id, 12-character account")
|
|
143
|
+
elsif Checksum.mod97(iban) != 1
|
|
144
|
+
error(:iban, "IBAN check digits are wrong (mod 97 != 1)")
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def address_fields(group, code)
|
|
149
|
+
fields = code == "K" ? COMBINED_FIELDS : STRUCTURED_FIELDS
|
|
150
|
+
fields.map { |attr| :"#{group}.#{attr}" }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Validates and builds an address from its seven stripped lines.
|
|
154
|
+
def build_address(group, values, required:)
|
|
155
|
+
if values.all?(&:nil?)
|
|
156
|
+
error(group, "the #{group} (address type, name, postal code, town, country) is mandatory") if required
|
|
157
|
+
return nil
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
code, name, line1, line2, postal_code, town, country = values
|
|
161
|
+
field = ->(attr) { :"#{group}.#{attr}" }
|
|
162
|
+
check_address_type(field[:type], code)
|
|
163
|
+
error(field[:name], "name is mandatory") if name.nil?
|
|
164
|
+
check_length(field[:name], name, 70, "name")
|
|
165
|
+
if code == "K"
|
|
166
|
+
check_combined_lines(field, line1, line2, postal_code, town)
|
|
167
|
+
else
|
|
168
|
+
check_structured_lines(field, line1, line2, postal_code, town)
|
|
169
|
+
check_postal_code_prefix(field[:postal_code], postal_code, country) if postal_code
|
|
170
|
+
end
|
|
171
|
+
check_country(field[:country], country)
|
|
172
|
+
Address.from_lines(values)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def check_address_type(field, code)
|
|
176
|
+
case code
|
|
177
|
+
when "S" then nil
|
|
178
|
+
when "K"
|
|
179
|
+
warning(field, 'address type "K" (combined address) was removed in IG 2.3 (Nov 2025); ' \
|
|
180
|
+
'only structured addresses ("S") are permitted')
|
|
181
|
+
when nil then error(field, 'address type is mandatory ("S")')
|
|
182
|
+
else error(field, %(address type must be "S" (or legacy "K"), got #{code.inspect}))
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def check_structured_lines(field, street, building_number, postal_code, town)
|
|
187
|
+
check_length(field[:street], street, 70, "street")
|
|
188
|
+
check_length(field[:building_number], building_number, 16, "building number")
|
|
189
|
+
error(field[:postal_code], "postal code is mandatory for a structured address") if postal_code.nil?
|
|
190
|
+
check_length(field[:postal_code], postal_code, 16, "postal code")
|
|
191
|
+
error(field[:town], "town is mandatory for a structured address") if town.nil?
|
|
192
|
+
check_length(field[:town], town, 35, "town")
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# "The postal code must be provided without a country" (IG 4.2.2): a
|
|
196
|
+
# country code or international vehicle code followed by "-" or a blank
|
|
197
|
+
# ("CH-8000", "D-78462", "FL 9490") is an error, except where the
|
|
198
|
+
# prefix is part of the country's official format ("LV-1050"). Other
|
|
199
|
+
# letter prefixes followed by "-" are only suspicious.
|
|
200
|
+
def check_postal_code_prefix(field, postal_code, country)
|
|
201
|
+
match = postal_code.match(/\A([A-Za-z]{1,3})([- ])(?=\d)/)
|
|
202
|
+
return unless match
|
|
203
|
+
|
|
204
|
+
prefix = match[1].upcase
|
|
205
|
+
separator = match[2]
|
|
206
|
+
return if prefix == country&.upcase && Countries::PREFIXED_POSTAL_CODES.include?(prefix)
|
|
207
|
+
|
|
208
|
+
if Countries.iso?(prefix) || Countries::VEHICLE_CODES.key?(prefix)
|
|
209
|
+
error(field, "postal code must be given without a country prefix (got #{postal_code.inspect})")
|
|
210
|
+
elsif separator == "-"
|
|
211
|
+
warning(field, "postal code looks like it has a country prefix (#{postal_code.inspect})")
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# IG 2.2 rules for address type "K".
|
|
216
|
+
def check_combined_lines(field, line1, line2, postal_code, town)
|
|
217
|
+
check_length(field[:address_line1], line1, 70, "address line 1")
|
|
218
|
+
error(field[:address_line2], "combined address (K) requires address line 2 (postal code and town)") if line2.nil?
|
|
219
|
+
check_length(field[:address_line2], line2, 70, "address line 2")
|
|
220
|
+
error(field[:postal_code], "combined address (K) must not fill postal code (it belongs in address line 2)") if postal_code
|
|
221
|
+
error(field[:town], "combined address (K) must not fill town (it belongs in address line 2)") if town
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def check_country(field, country)
|
|
225
|
+
if country.nil?
|
|
226
|
+
error(field, "country is mandatory")
|
|
227
|
+
elsif !Countries.iso?(country.upcase(:ascii))
|
|
228
|
+
error(field, "country must be a two-letter ISO 3166-1 code (got #{country.inspect})")
|
|
229
|
+
elsif country.match?(/[a-z]/)
|
|
230
|
+
error(field, "country code must be upper case (got #{country.inspect})")
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def build_ultimate_creditor(values)
|
|
235
|
+
return nil if values.all?(&:nil?)
|
|
236
|
+
|
|
237
|
+
error(:ultimate_creditor, "the ultimate creditor group is reserved for future use and must be empty")
|
|
238
|
+
Address.from_lines(values)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def parse_amount(value)
|
|
242
|
+
return nil if value.nil?
|
|
243
|
+
|
|
244
|
+
error(:amount, amount_problem(value)) unless value.match?(AMOUNT)
|
|
245
|
+
return nil unless value.match?(/\A-?\d+(?:[.,]\d+)?\z/)
|
|
246
|
+
|
|
247
|
+
BigDecimal(value.tr(",", "."))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def amount_problem(value)
|
|
251
|
+
case value
|
|
252
|
+
when /,/ then %(decimal separator must be "." (got #{value.inspect}))
|
|
253
|
+
when /\A-/ then "amount must not be negative (got #{value.inspect})"
|
|
254
|
+
when /\A0\d/ then "amount must not have leading zeroes (got #{value.inspect})"
|
|
255
|
+
when /\A\d+(?:\.\d?|\.\d{3,})?\z/ then "amount must have exactly two decimal places (got #{value.inspect})"
|
|
256
|
+
when /\A\d{10,}\.\d{2}\z/ then "amount above the maximum 999999999.99 (got #{value.inspect})"
|
|
257
|
+
else "amount must be a decimal such as 1949.75 (got #{value.inspect})"
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def check_notification(amount, message)
|
|
262
|
+
return unless amount&.zero?
|
|
263
|
+
return if NOTIFICATION_TEXTS.include?(message)
|
|
264
|
+
|
|
265
|
+
error(:amount, "amount must be at least 0.01; 0.00 is only permitted for a notification whose message is " \
|
|
266
|
+
"one of #{NOTIFICATION_TEXTS.map(&:inspect).join(", ")} (IG 4.4)")
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def check_currency(currency)
|
|
270
|
+
if currency.nil?
|
|
271
|
+
error(:currency, "currency is mandatory (CHF or EUR)")
|
|
272
|
+
elsif !CURRENCIES.include?(currency)
|
|
273
|
+
error(:currency, "only CHF and EUR are permitted (got #{currency})")
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def parse_reference_type(value)
|
|
278
|
+
return error(:reference_type, "reference type is mandatory (QRR, SCOR or NON)") if value.nil?
|
|
279
|
+
|
|
280
|
+
REFERENCE_TYPES.fetch(value) do
|
|
281
|
+
error(:reference_type, "unknown reference type #{value.inspect} (QRR, SCOR or NON)")
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# QR-IBAN requires QRR; an ordinary IBAN forbids it.
|
|
286
|
+
def check_iban_reference_type(iban, qr_iban, reference_type, currency)
|
|
287
|
+
return if reference_type.nil? || iban.nil? || !iban.match?(Iban::QR_BILL_PATTERN)
|
|
288
|
+
|
|
289
|
+
if qr_iban && reference_type != :qrr
|
|
290
|
+
error(:reference_type, "a QR-IBAN requires reference type QRR (got #{reference_type.to_s.upcase})")
|
|
291
|
+
elsif !qr_iban && reference_type == :qrr
|
|
292
|
+
error(:reference_type, "reference type QRR requires a QR-IBAN (institution id 30000-31999); " \
|
|
293
|
+
"#{iban} is an ordinary IBAN")
|
|
294
|
+
elsif reference_type == :qrr && currency == "EUR"
|
|
295
|
+
warning(:currency, "from IG 2.4 (in force 14 Nov 2026) a QR-IBAN with QR reference may only be used for CHF")
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def check_reference(type, reference)
|
|
300
|
+
case type
|
|
301
|
+
when :qrr then check_qrr(reference)
|
|
302
|
+
when :scor then check_scor(reference)
|
|
303
|
+
when :non
|
|
304
|
+
error(:reference, "reference type NON must have an empty reference (got #{reference.inspect})") if reference
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def check_qrr(reference)
|
|
309
|
+
return error(:reference, "reference type QRR requires a reference") if reference.nil?
|
|
310
|
+
return error(:reference, "QR reference must not contain spaces") if reference.match?(/[[:space:]]/)
|
|
311
|
+
return error(:reference, "QR reference must be numeric") unless reference.match?(/\A\d+\z/)
|
|
312
|
+
return error(:reference, "QR reference must be 27 digits (got #{reference.length})") if reference.length != 27
|
|
313
|
+
return error(:reference, "QR reference must not consist only of zeros") if reference.match?(/\A0+\z/)
|
|
314
|
+
|
|
315
|
+
expected = Checksum.mod10_recursive(reference[0, 26])
|
|
316
|
+
return if expected == reference[26].to_i
|
|
317
|
+
|
|
318
|
+
error(:reference, "QR reference check digit (modulo 10 recursive) is wrong: expected #{expected}, got #{reference[26]}")
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def check_scor(reference)
|
|
322
|
+
problem = scor_problem(reference)
|
|
323
|
+
error(:reference, problem) if problem
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def scor_problem(reference)
|
|
327
|
+
if reference.nil? then "reference type SCOR requires a creditor reference"
|
|
328
|
+
elsif reference.match?(/[[:space:]]/) then "creditor reference must not contain spaces"
|
|
329
|
+
elsif !reference.match?(/\A[A-Za-z0-9]+\z/) then "creditor reference may only contain letters and digits"
|
|
330
|
+
elsif !(5..25).cover?(reference.length) then "creditor reference must be 5 to 25 characters (got #{reference.length})"
|
|
331
|
+
elsif !reference.match?(/\ARF\d{2}/i) then 'creditor reference must start with "RF" and two check digits'
|
|
332
|
+
elsif Checksum.mod97(reference.upcase) != 1 then "creditor reference (ISO 11649) check digits are wrong"
|
|
333
|
+
end
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
def check_additional_information(message, billing_information)
|
|
337
|
+
if message && message.length > MAX_MESSAGE_LENGTH
|
|
338
|
+
check_length(:unstructured_message, message, MAX_MESSAGE_LENGTH, "unstructured message")
|
|
339
|
+
elsif message.to_s.length + billing_information.to_s.length > MAX_MESSAGE_LENGTH
|
|
340
|
+
error(:unstructured_message,
|
|
341
|
+
"unstructured message (#{message.to_s.length}) and billing information " \
|
|
342
|
+
"(#{billing_information.to_s.length}) together exceed #{MAX_MESSAGE_LENGTH} characters")
|
|
343
|
+
end
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def check_trailer(trailer)
|
|
347
|
+
return if trailer == TRAILER
|
|
348
|
+
|
|
349
|
+
detail = trailer.nil? ? "line 31 is empty" : "got #{trailer.inspect}"
|
|
350
|
+
error(:trailer, %(trailer must be "#{TRAILER}" (#{detail}); a line may be missing or extra))
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Billing information is status A: problems are warnings only.
|
|
354
|
+
def parse_billing_information(value)
|
|
355
|
+
return nil if value.nil?
|
|
356
|
+
|
|
357
|
+
unless value.start_with?("//")
|
|
358
|
+
warning(:billing_information, 'billing information should start with "//" and a syntax identifier such as "S1"')
|
|
359
|
+
end
|
|
360
|
+
return nil unless BillingInformation.s1?(value)
|
|
361
|
+
|
|
362
|
+
info = BillingInformation.parse(value)
|
|
363
|
+
@warnings.concat(info.warnings)
|
|
364
|
+
info
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
# Alternative procedures are status A: problems are warnings only.
|
|
368
|
+
def check_alternative_procedures(values)
|
|
369
|
+
values.each_with_index do |value, index|
|
|
370
|
+
if value.nil?
|
|
371
|
+
warning(:alternative_procedures, "alternative procedure #{index + 1} is empty")
|
|
372
|
+
elsif value.length > MAX_ALTERNATIVE_PROCEDURE_LENGTH
|
|
373
|
+
warning(:alternative_procedures,
|
|
374
|
+
"alternative procedure #{index + 1} longer than #{MAX_ALTERNATIVE_PROCEDURE_LENGTH} characters " \
|
|
375
|
+
"(#{value.length})")
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
380
|
+
end
|
data/lib/swiss_qr.rb
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "swiss_qr/version"
|
|
4
|
+
require_relative "swiss_qr/errors"
|
|
5
|
+
require_relative "swiss_qr/checksum"
|
|
6
|
+
require_relative "swiss_qr/iban"
|
|
7
|
+
require_relative "swiss_qr/countries"
|
|
8
|
+
require_relative "swiss_qr/reference"
|
|
9
|
+
require_relative "swiss_qr/address"
|
|
10
|
+
require_relative "swiss_qr/billing_information"
|
|
11
|
+
require_relative "swiss_qr/bill"
|
|
12
|
+
require_relative "swiss_qr/validation"
|
|
13
|
+
require_relative "swiss_qr/parser"
|
|
14
|
+
# SwissQr.read, .read_all and .reader; does not load the qr_reader gem.
|
|
15
|
+
require_relative "swiss_qr/reader"
|
|
16
|
+
|
|
17
|
+
# Parser for the payload of Swiss QR-bills ("QR-Rechnung", SIX Swiss
|
|
18
|
+
# Implementation Guidelines for the QR-bill: header "SPC", version "0200",
|
|
19
|
+
# coding type "1", trailer "EPD").
|
|
20
|
+
#
|
|
21
|
+
# bill = SwissQr.parse(payload)
|
|
22
|
+
# bill.iban # => "CH4431999123000889012"
|
|
23
|
+
# bill.amount # => 0.194975e4 (BigDecimal)
|
|
24
|
+
# bill.creditor.name # => "Max Muster & Söhne"
|
|
25
|
+
# bill.billing_information&.invoice_number
|
|
26
|
+
#
|
|
27
|
+
# SwissQr.read("invoice.pdf") # first QR-bill in an image or PDF (needs a reader,
|
|
28
|
+
# # by default the qr_reader gem)
|
|
29
|
+
module SwissQr
|
|
30
|
+
module_function
|
|
31
|
+
|
|
32
|
+
# Parses a QR-bill payload into a Bill.
|
|
33
|
+
#
|
|
34
|
+
# With <tt>strict: true</tt> (the default) any rule violation raises
|
|
35
|
+
# InvalidPayload, whose #errors lists every Problem. With
|
|
36
|
+
# <tt>strict: false</tt> the Bill is returned with its #errors filled in;
|
|
37
|
+
# InvalidPayload is still raised when the text is not structurally a
|
|
38
|
+
# QR-bill (QR type other than "SPC", fewer than 31 lines).
|
|
39
|
+
def parse(payload, strict: true)
|
|
40
|
+
Parser.parse(payload, strict: strict)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# True when +payload+ is a QR-bill without any rule violation (warnings
|
|
44
|
+
# are allowed).
|
|
45
|
+
def valid?(payload)
|
|
46
|
+
parse(payload, strict: false).valid?
|
|
47
|
+
rescue InvalidPayload, TypeError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Cheap check whether +text+ looks like a QR-bill payload at all, e.g. to
|
|
52
|
+
# skip other QR codes on the same page. Does not validate.
|
|
53
|
+
def looks_like_qr_bill?(text)
|
|
54
|
+
Parser.looks_like_qr_bill?(text)
|
|
55
|
+
end
|
|
56
|
+
end
|
data/sig/manifest.yaml
ADDED
data/sig/swiss_qr.rbs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
module SwissQr
|
|
2
|
+
VERSION: String
|
|
3
|
+
|
|
4
|
+
def self.parse: (String payload, ?strict: bool) -> Bill
|
|
5
|
+
def self.valid?: (untyped payload) -> bool
|
|
6
|
+
def self.looks_like_qr_bill?: (untyped text) -> bool
|
|
7
|
+
|
|
8
|
+
# Anything with decode_all(input) -> Array[String], or a callable returning
|
|
9
|
+
# a String, an Array of Strings (or objects with #text), or nil.
|
|
10
|
+
type reader = _DecodeAll | _CallReader
|
|
11
|
+
|
|
12
|
+
interface _DecodeAll
|
|
13
|
+
def decode_all: (untyped input) -> Array[untyped]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
interface _CallReader
|
|
17
|
+
def call: (untyped input) -> (String | Array[untyped] | nil)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.reader: () -> reader
|
|
21
|
+
def self.reader=: (reader? reader) -> reader?
|
|
22
|
+
def self.read: (untyped input, ?reader: reader?, ?strict: bool) -> Bill?
|
|
23
|
+
def self.read_all: (untyped input, ?reader: reader?, ?strict: bool) -> Array[Bill]
|
|
24
|
+
|
|
25
|
+
module Reader
|
|
26
|
+
INSTALL_HINT: String
|
|
27
|
+
|
|
28
|
+
class QrReaderAdapter
|
|
29
|
+
NO_IMAGES: String
|
|
30
|
+
|
|
31
|
+
attr_reader options: Hash[Symbol, untyped]
|
|
32
|
+
|
|
33
|
+
def initialize: (**untyped options) -> void
|
|
34
|
+
def decode_all: (untyped input) -> Array[String]
|
|
35
|
+
def each_text: (untyped input) { (String text) -> void } -> untyped
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
class Adapter
|
|
39
|
+
attr_reader reader: reader
|
|
40
|
+
|
|
41
|
+
def initialize: (reader reader) -> void
|
|
42
|
+
def decode_all: (untyped input) -> Array[String]
|
|
43
|
+
def each_text: (untyped input) { (String text) -> void } -> untyped
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def self.reader?: (untyped reader) -> bool
|
|
47
|
+
def self.adapt: (reader? reader) -> Adapter
|
|
48
|
+
def self.texts: (untyped result) -> Array[String]
|
|
49
|
+
def self.text: (untyped item) -> String?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class CLI
|
|
53
|
+
OK: Integer
|
|
54
|
+
INVALID: Integer
|
|
55
|
+
FAILURE: Integer
|
|
56
|
+
|
|
57
|
+
def initialize: (?stdin: IO | StringIO, ?stdout: IO | StringIO, ?stderr: IO | StringIO) -> void
|
|
58
|
+
def run: (Array[String] argv) -> Integer
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class Error < StandardError
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
class InvalidPayload < Error
|
|
65
|
+
attr_reader errors: Array[Problem]
|
|
66
|
+
attr_reader bill: Bill?
|
|
67
|
+
|
|
68
|
+
def initialize: (Array[Problem] errors, ?bill: Bill?) -> void
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
class ReaderUnavailable < Error
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
class Problem < Data
|
|
75
|
+
attr_reader field: Symbol
|
|
76
|
+
attr_reader message: String
|
|
77
|
+
|
|
78
|
+
def initialize: (field: Symbol | String, message: String) -> void
|
|
79
|
+
def about?: (Symbol | String name) -> bool
|
|
80
|
+
def as_json: (*untyped) -> Hash[String, String]
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
type address_type = :structured | :combined
|
|
84
|
+
type reference_type = :qrr | :scor | :non
|
|
85
|
+
|
|
86
|
+
class Address < Data
|
|
87
|
+
attr_reader type: address_type?
|
|
88
|
+
attr_reader name: String?
|
|
89
|
+
attr_reader street: String?
|
|
90
|
+
attr_reader building_number: String?
|
|
91
|
+
attr_reader postal_code: String?
|
|
92
|
+
attr_reader town: String?
|
|
93
|
+
attr_reader address_line1: String?
|
|
94
|
+
attr_reader address_line2: String?
|
|
95
|
+
attr_reader country: String?
|
|
96
|
+
|
|
97
|
+
def self.type_for: (String? code) -> address_type?
|
|
98
|
+
def self.from_lines: (Array[String?] lines) -> Address
|
|
99
|
+
def structured?: () -> bool
|
|
100
|
+
def combined?: () -> bool
|
|
101
|
+
def lines: () -> Array[String]
|
|
102
|
+
def as_json: (*untyped) -> Hash[String, String?]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
class BillingInformation < Data
|
|
106
|
+
class VatRate < Data
|
|
107
|
+
attr_reader rate: BigDecimal
|
|
108
|
+
attr_reader amount: BigDecimal?
|
|
109
|
+
|
|
110
|
+
def as_json: (*untyped) -> Hash[String, String?]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
class PaymentCondition < Data
|
|
114
|
+
attr_reader discount: BigDecimal
|
|
115
|
+
attr_reader days: Integer
|
|
116
|
+
|
|
117
|
+
def as_json: (*untyped) -> Hash[String, untyped]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
attr_reader raw: String
|
|
121
|
+
attr_reader invoice_number: String?
|
|
122
|
+
attr_reader invoice_date: Date?
|
|
123
|
+
attr_reader customer_reference: String?
|
|
124
|
+
attr_reader vat_number: String?
|
|
125
|
+
attr_reader vat_dates: Array[Date]?
|
|
126
|
+
attr_reader vat_details: Array[VatRate]?
|
|
127
|
+
attr_reader vat_import_taxes: Array[VatRate]?
|
|
128
|
+
attr_reader payment_conditions: Array[PaymentCondition]?
|
|
129
|
+
attr_reader unknown_tags: Hash[String, String]
|
|
130
|
+
attr_reader warnings: Array[Problem]
|
|
131
|
+
|
|
132
|
+
def self.prefix: () -> String
|
|
133
|
+
def self.s1?: (untyped text) -> bool
|
|
134
|
+
def self.parse: (String text) -> BillingInformation
|
|
135
|
+
def self.decimal_string: (BigDecimal? value) -> String?
|
|
136
|
+
def as_json: (*untyped) -> Hash[String, untyped]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
class Bill < Data
|
|
140
|
+
attr_reader version: String?
|
|
141
|
+
attr_reader coding_type: String?
|
|
142
|
+
attr_reader iban: String?
|
|
143
|
+
attr_reader qr_iban: bool
|
|
144
|
+
attr_reader creditor: Address?
|
|
145
|
+
attr_reader ultimate_creditor: Address?
|
|
146
|
+
attr_reader amount: BigDecimal?
|
|
147
|
+
attr_reader currency: String?
|
|
148
|
+
attr_reader debtor: Address?
|
|
149
|
+
attr_reader reference_type: reference_type?
|
|
150
|
+
attr_reader reference: String?
|
|
151
|
+
attr_reader unstructured_message: String?
|
|
152
|
+
attr_reader trailer: String?
|
|
153
|
+
attr_reader raw_billing_information: String?
|
|
154
|
+
attr_reader billing_information: BillingInformation?
|
|
155
|
+
attr_reader alternative_procedures: Array[String]
|
|
156
|
+
attr_reader errors: Array[Problem]
|
|
157
|
+
attr_reader warnings: Array[Problem]
|
|
158
|
+
attr_reader raw: String
|
|
159
|
+
|
|
160
|
+
def self.amount_string: (BigDecimal value) -> String
|
|
161
|
+
def qr_iban?: () -> bool
|
|
162
|
+
def valid?: () -> bool
|
|
163
|
+
def notification?: () -> bool
|
|
164
|
+
def open_amount?: () -> bool
|
|
165
|
+
def formatted_iban: () -> String?
|
|
166
|
+
def formatted_reference: () -> String?
|
|
167
|
+
def formatted_amount: () -> String?
|
|
168
|
+
def as_json: (*untyped) -> Hash[String, untyped]
|
|
169
|
+
def to_json: (*untyped) -> String
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
module Checksum
|
|
173
|
+
MOD10_TABLE: Array[Integer]
|
|
174
|
+
|
|
175
|
+
def self.mod10_recursive: (String digits) -> Integer
|
|
176
|
+
def self.mod97: (String value) -> Integer
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
module Iban
|
|
180
|
+
PATTERN: Regexp
|
|
181
|
+
QR_BILL_PATTERN: Regexp
|
|
182
|
+
QR_IID_RANGE: Range[Integer]
|
|
183
|
+
COUNTRIES: Array[String]
|
|
184
|
+
|
|
185
|
+
def self.normalize: (String? iban) -> String
|
|
186
|
+
def self.valid?: (String? iban) -> bool
|
|
187
|
+
def self.ch_or_li?: (String? iban) -> bool
|
|
188
|
+
def self.qr_iban?: (String? iban) -> bool
|
|
189
|
+
def self.qr_iid?: (String? iban) -> bool
|
|
190
|
+
def self.iid: (String? iban) -> String?
|
|
191
|
+
def self.format: (String? iban) -> String
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
module Countries
|
|
195
|
+
ISO_3166_1: Hash[String, true]
|
|
196
|
+
VEHICLE_CODES: Hash[String, String]
|
|
197
|
+
PREFIXED_POSTAL_CODES: Array[String]
|
|
198
|
+
|
|
199
|
+
def self.iso?: (String code) -> bool
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
module Reference
|
|
203
|
+
QRR_PATTERN: Regexp
|
|
204
|
+
SCOR_PATTERN: Regexp
|
|
205
|
+
|
|
206
|
+
def self.normalize: (String? reference) -> String
|
|
207
|
+
def self.valid_qrr?: (String? reference) -> bool
|
|
208
|
+
def self.valid_scor?: (String? reference) -> bool
|
|
209
|
+
def self.build_qrr: (String digits) -> String
|
|
210
|
+
def self.build_scor: (String payload) -> String
|
|
211
|
+
def self.format_qrr: (String reference) -> String
|
|
212
|
+
def self.format_scor: (String reference) -> String
|
|
213
|
+
def self.format: (String? reference, ?reference_type? type) -> String?
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
class Parser
|
|
217
|
+
BOM: String
|
|
218
|
+
MISTAGGED_ENCODINGS: Array[Encoding]
|
|
219
|
+
HEADER: Regexp
|
|
220
|
+
|
|
221
|
+
def self.parse: (String payload, ?strict: bool) -> Bill
|
|
222
|
+
def self.looks_like_qr_bill?: (untyped text) -> bool
|
|
223
|
+
def initialize: (String payload) -> void
|
|
224
|
+
def parse: (?strict: bool) -> Bill
|
|
225
|
+
end
|
|
226
|
+
end
|