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.
@@ -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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ VERSION = "0.1.0"
5
+ 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
@@ -0,0 +1,2 @@
1
+ dependencies:
2
+ - name: date
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