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,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+
5
+ module SwissQr
6
+ # A parsed Swiss QR-bill payload. Immutable.
7
+ #
8
+ # [version, coding_type] "0200" and "1"
9
+ # [iban] IBAN or QR-IBAN, without spaces, upper case
10
+ # [qr_iban] true when +iban+ is a QR-IBAN (also #qr_iban?)
11
+ # [creditor] Address
12
+ # [ultimate_creditor] Address or nil (reserved by SIX, must be empty)
13
+ # [amount] BigDecimal, or nil when the payer fills in the amount
14
+ # [currency] "CHF" or "EUR"
15
+ # [debtor] Address or nil
16
+ # [reference_type] :qrr, :scor or :non
17
+ # [reference] QR or creditor reference as given, or nil
18
+ # [unstructured_message] free text or nil
19
+ # [trailer] "EPD"
20
+ # [raw_billing_information] billing information as given (line 32) or nil
21
+ # [billing_information] the billing information parsed (BillingInformation)
22
+ # when it uses the Swico S1 syntax, else nil
23
+ # [alternative_procedures] up to two strings (e.g. "eBill/B/...")
24
+ # [errors, warnings] Arrays of Problem
25
+ # [raw] the payload as given
26
+ Bill = Data.define(
27
+ :version, :coding_type, :iban, :qr_iban, :creditor, :ultimate_creditor,
28
+ :amount, :currency, :debtor, :reference_type, :reference, :unstructured_message,
29
+ :trailer, :raw_billing_information, :billing_information, :alternative_procedures,
30
+ :errors, :warnings, :raw
31
+ ) do
32
+ def qr_iban? = qr_iban
33
+
34
+ # True when the payload satisfied every rule.
35
+ def valid? = errors.empty?
36
+
37
+ # True for a "DO NOT USE FOR PAYMENT" notification (amount 0.00).
38
+ def notification? = !amount.nil? && amount.zero?
39
+
40
+ # True when the payer has to fill in the amount.
41
+ def open_amount? = amount.nil?
42
+
43
+ def formatted_iban = iban && Iban.format(iban)
44
+
45
+ def formatted_reference = reference && Reference.format(reference, reference_type)
46
+
47
+ # Amount with two decimals ("1949.75"), or nil.
48
+ def formatted_amount = amount && Bill.amount_string(amount)
49
+
50
+ def self.amount_string(value)
51
+ units, decimals = value.round(2).to_s("F").split(".")
52
+ "#{units}.#{decimals.to_s.ljust(2, "0")}"
53
+ end
54
+
55
+ # Plain Ruby hash; nested value objects become hashes too.
56
+ def to_h(&)
57
+ hash = super().merge(
58
+ creditor: creditor&.to_h,
59
+ ultimate_creditor: ultimate_creditor&.to_h,
60
+ debtor: debtor&.to_h,
61
+ billing_information: billing_information&.to_h,
62
+ errors: errors.map(&:to_h),
63
+ warnings: warnings.map(&:to_h)
64
+ )
65
+ block_given? ? hash.to_h(&) : hash
66
+ end
67
+
68
+ # JSON-ready hash: only strings, numbers, booleans, nil, arrays and
69
+ # hashes (amount as "50.00", symbols and dates as strings).
70
+ def as_json(*)
71
+ to_h.transform_keys(&:to_s).merge(
72
+ "amount" => formatted_amount,
73
+ "creditor" => creditor&.as_json,
74
+ "ultimate_creditor" => ultimate_creditor&.as_json,
75
+ "debtor" => debtor&.as_json,
76
+ "reference_type" => reference_type&.to_s,
77
+ "billing_information" => billing_information&.as_json,
78
+ "errors" => errors.map(&:as_json),
79
+ "warnings" => warnings.map(&:as_json)
80
+ )
81
+ end
82
+
83
+ def to_json(*args)
84
+ require "json"
85
+ as_json.to_json(*args)
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bigdecimal"
4
+ require "date"
5
+
6
+ module SwissQr
7
+ # Structured billing information in the SIX/Swico "S1" syntax
8
+ # (QR-bill line 32, starting with "//S1/").
9
+ #
10
+ # //S1/10/10201409/11/190512/20/1400.000-53/30/106017086/31/180508/32/7.7/40/2:10;0:30
11
+ #
12
+ # Tags: /10/ invoice number, /11/ invoice date, /20/ customer reference,
13
+ # /30/ VAT number (UID without "CHE"), /31/ VAT date or period, /32/ VAT
14
+ # rate(s), /33/ VAT on imports, /40/ payment conditions. A literal "/" in
15
+ # a value is written "\/", a literal "\" as "\\". Unknown tags are kept
16
+ # in +unknown_tags+. Problems never make the bill invalid (billing
17
+ # information has status A in the IG); they are collected in +warnings+.
18
+ BillingInformation = Data.define(
19
+ :raw, :invoice_number, :invoice_date, :customer_reference, :vat_number,
20
+ :vat_dates, :vat_details, :vat_import_taxes, :payment_conditions,
21
+ :unknown_tags, :warnings
22
+ ) do
23
+ def self.prefix = "//S1/"
24
+
25
+ # True when +text+ uses the S1 syntax.
26
+ def self.s1?(text) = text.is_a?(String) && text.start_with?(prefix)
27
+
28
+ # Parses an S1 billing-information string. Raises ArgumentError unless
29
+ # it starts with "//S1/".
30
+ def self.parse(text)
31
+ raise ArgumentError, "S1 billing information must start with #{prefix.inspect}" unless s1?(text)
32
+
33
+ self::S1Parser.new(text).parse
34
+ end
35
+
36
+ # Decimal for JSON: "7.7", "100", "12.5".
37
+ def self.decimal_string(value)
38
+ return nil if value.nil?
39
+
40
+ string = value.to_s("F")
41
+ string.end_with?(".0") ? string[0..-3] : string
42
+ end
43
+
44
+ def to_h(&)
45
+ hash = super().merge(
46
+ vat_details: vat_details&.map(&:to_h),
47
+ vat_import_taxes: vat_import_taxes&.map(&:to_h),
48
+ payment_conditions: payment_conditions&.map(&:to_h),
49
+ warnings: warnings.map(&:to_h)
50
+ )
51
+ block_given? ? hash.to_h(&) : hash
52
+ end
53
+
54
+ def as_json(*)
55
+ {
56
+ "raw" => raw,
57
+ "invoice_number" => invoice_number,
58
+ "invoice_date" => invoice_date&.iso8601,
59
+ "customer_reference" => customer_reference,
60
+ "vat_number" => vat_number,
61
+ "vat_dates" => vat_dates&.map(&:iso8601),
62
+ "vat_details" => vat_details&.map(&:as_json),
63
+ "vat_import_taxes" => vat_import_taxes&.map(&:as_json),
64
+ "payment_conditions" => payment_conditions&.map(&:as_json),
65
+ "unknown_tags" => unknown_tags,
66
+ "warnings" => warnings.map(&:as_json)
67
+ }
68
+ end
69
+ end
70
+
71
+ class BillingInformation
72
+ # One VAT rate in percent and the amount it applies to (/32/: net
73
+ # amount, or nil when one rate covers the whole bill; /33/: VAT amount).
74
+ VatRate = Data.define(:rate, :amount) do
75
+ def as_json(*)
76
+ { "rate" => BillingInformation.decimal_string(rate), "amount" => BillingInformation.decimal_string(amount) }
77
+ end
78
+ end
79
+
80
+ # Discount in percent when paid within +days+ days of the invoice date
81
+ # (+discount+ 0 = net payment term).
82
+ PaymentCondition = Data.define(:discount, :days) do
83
+ def as_json(*)
84
+ { "discount" => BillingInformation.decimal_string(discount), "days" => days }
85
+ end
86
+ end
87
+
88
+ # Tokenizer and value parser behind BillingInformation.parse.
89
+ # @api private
90
+ class S1Parser
91
+ FIELD = :billing_information
92
+ KNOWN_TAGS = %w[10 11 20 30 31 32 33 40].freeze
93
+ DECIMAL = /\A\d+(?:\.\d+)?\z/
94
+
95
+ def initialize(text)
96
+ @text = text
97
+ @warnings = []
98
+ end
99
+
100
+ def parse
101
+ values = collect_tags
102
+ BillingInformation.new(
103
+ raw: @text.dup.freeze,
104
+ invoice_number: values["10"],
105
+ invoice_date: values["11"] && parse_dates(values["11"], "/11/ invoice date", max: 1)&.first,
106
+ customer_reference: values["20"],
107
+ vat_number: values["30"] && parse_uid(values["30"]),
108
+ vat_dates: values["31"] && parse_dates(values["31"], "/31/ VAT date", max: 2),
109
+ vat_details: values["32"] && parse_vat_details(values["32"]),
110
+ vat_import_taxes: values["33"] && parse_pairs(values["33"], "/33/ VAT on imports"),
111
+ payment_conditions: values["40"] && parse_conditions(values["40"]),
112
+ unknown_tags: values.except(*KNOWN_TAGS).freeze,
113
+ warnings: @warnings.freeze
114
+ )
115
+ end
116
+
117
+ private
118
+
119
+ def warn(message)
120
+ @warnings << Problem.new(field: FIELD, message: message)
121
+ nil
122
+ end
123
+
124
+ # Splits on unescaped "/" and unescapes "\/" and "\\".
125
+ def tokens
126
+ result = [+""]
127
+ chars = @text[(BillingInformation.prefix.length - 1)..].each_char
128
+ loop do
129
+ char = chars.next
130
+ if char == "\\"
131
+ following = begin
132
+ chars.next
133
+ rescue StopIteration
134
+ ""
135
+ end
136
+ result.last << (%w[/ \\].include?(following) ? following : "\\#{following}")
137
+ elsif char == "/"
138
+ result << +""
139
+ else
140
+ result.last << char
141
+ end
142
+ end
143
+ result.drop(1)
144
+ end
145
+
146
+ def collect_tags
147
+ values = {}
148
+ previous = nil
149
+ tokens.each_slice(2) do |tag, value|
150
+ next if tag.empty? && value.nil?
151
+ next unless valid_tag?(tag, previous)
152
+
153
+ previous = tag
154
+ if values.key?(tag)
155
+ warn("tag /#{tag}/ appears more than once; the first value is used")
156
+ elsif !value.nil? && !value.empty?
157
+ values[tag] = value.freeze
158
+ end
159
+ end
160
+ values
161
+ end
162
+
163
+ def valid_tag?(tag, previous)
164
+ unless tag.match?(/\A\d{2}\z/)
165
+ warn("#{tag.inspect} is not a two-digit S1 tag")
166
+ return false
167
+ end
168
+ warn("tag /#{tag}/ is not one of #{KNOWN_TAGS.join(", ")}; kept in unknown_tags") unless KNOWN_TAGS.include?(tag)
169
+ warn("tag /#{tag}/ after /#{previous}/: S1 tags must be in ascending order") if previous && tag <= previous
170
+ true
171
+ end
172
+
173
+ def parse_dates(value, label, max:)
174
+ unless value.match?(/\A(?:\d{6}){1,#{max}}\z/)
175
+ return warn("#{label} must be #{max == 1 ? "YYMMDD" : "YYMMDD or YYMMDDYYMMDD"} (got #{value.inspect})")
176
+ end
177
+
178
+ dates = value.scan(/\d{6}/).map do |yymmdd|
179
+ Date.new(2000 + yymmdd[0, 2].to_i, yymmdd[2, 2].to_i, yymmdd[4, 2].to_i)
180
+ rescue Date::Error
181
+ return warn("#{label} #{yymmdd} is not a calendar date")
182
+ end
183
+ dates.freeze
184
+ end
185
+
186
+ def parse_uid(value)
187
+ warn("/30/ VAT number should be the 9 UID digits without CHE (got #{value.inspect})") unless value.match?(/\A\d{9}\z/)
188
+ value
189
+ end
190
+
191
+ # "/32/7.7" (one rate for the whole amount) or "8:1000;2.5:51.80".
192
+ def parse_vat_details(value)
193
+ return [BillingInformation::VatRate.new(rate: BigDecimal(value), amount: nil)].freeze if value.match?(DECIMAL)
194
+
195
+ parse_pairs(value, "/32/ VAT details")
196
+ end
197
+
198
+ def parse_pairs(value, label)
199
+ pairs = split_pairs(value, label)
200
+ pairs&.map do |rate, amount|
201
+ return warn("#{label}: #{rate}:#{amount} is not rate:amount") unless rate.match?(DECIMAL) && amount.match?(DECIMAL)
202
+
203
+ BillingInformation::VatRate.new(rate: BigDecimal(rate), amount: BigDecimal(amount))
204
+ end&.freeze
205
+ end
206
+
207
+ def parse_conditions(value)
208
+ pairs = split_pairs(value, "/40/ payment conditions")
209
+ pairs&.map do |discount, days|
210
+ unless discount.match?(DECIMAL) && days.match?(/\A\d+\z/)
211
+ return warn("/40/ payment conditions: #{discount}:#{days} is not discount:days")
212
+ end
213
+
214
+ BillingInformation::PaymentCondition.new(discount: BigDecimal(discount), days: days.to_i)
215
+ end&.freeze
216
+ end
217
+
218
+ def split_pairs(value, label)
219
+ pairs = value.split(";").map { |item| item.split(":", -1) }
220
+ return pairs unless pairs.empty? || pairs.any? { |pair| pair.size != 2 }
221
+
222
+ warn("#{label} must be a list of a:b pairs separated by \";\" (got #{value.inspect})")
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ # Check-digit algorithms used by IBANs and payment references.
5
+ module Checksum
6
+ # Carry table of the modulo-10 recursive algorithm (IG QR-bill Annex B).
7
+ MOD10_TABLE = [0, 9, 4, 6, 8, 2, 7, 1, 3, 5].freeze
8
+
9
+ module_function
10
+
11
+ # Check digit (0-9) of a digit string, modulo 10 recursive.
12
+ def mod10_recursive(digits)
13
+ carry = digits.each_char.reduce(0) { |c, d| MOD10_TABLE[(c + d.to_i) % 10] }
14
+ (10 - carry) % 10
15
+ end
16
+
17
+ # ISO 7064 MOD 97-10 remainder as used by IBAN (ISO 13616) and the
18
+ # creditor reference (ISO 11649): the first four characters move to the
19
+ # end and letters become 10..35. The value is valid when the result is 1.
20
+ # +value+ must consist of A-Z and 0-9 only.
21
+ def mod97(value)
22
+ rearranged = value[4..] + value[0, 4]
23
+ rearranged.each_char.reduce(0) do |rem, char|
24
+ number = char.to_i(36)
25
+ ((rem * (number > 9 ? 100 : 10)) + number) % 97
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "optparse"
5
+ require_relative "../swiss_qr"
6
+
7
+ module SwissQr
8
+ # The +swiss_qr+ command: reads QR-bills from images/PDFs (or a payload
9
+ # from standard input) and prints them as JSON. Not loaded by
10
+ # <tt>require "swiss_qr"</tt>.
11
+ #
12
+ # Exit status: 0 when every input held at least one QR-bill and all of
13
+ # them were valid, 1 when an input held no QR-bill or an invalid one, 2 on
14
+ # usage errors, unreadable files or a missing QR code reader.
15
+ class CLI
16
+ OK = 0
17
+ INVALID = 1
18
+ FAILURE = 2
19
+
20
+ def initialize(stdin: $stdin, stdout: $stdout, stderr: $stderr)
21
+ @stdin = stdin
22
+ @stdout = stdout
23
+ @stderr = stderr
24
+ @strict = true
25
+ @text = false
26
+ @lines = false
27
+ @status = OK
28
+ end
29
+
30
+ # Runs the command with the given arguments; returns the exit status.
31
+ def run(argv)
32
+ catch(:exit) do
33
+ files = parser.parse(argv)
34
+ next usage_error("--text reads the payload from standard input; give no files") if @text && files.any?
35
+ next usage_error("no input given") if !@text && files.empty?
36
+
37
+ print_json(@text ? read_text : files.flat_map { |file| read_file(file) })
38
+ @status
39
+ end
40
+ rescue OptionParser::ParseError => e
41
+ usage_error(e.message)
42
+ rescue ReaderUnavailable => e
43
+ @stderr.puts "swiss_qr: #{e.message}"
44
+ FAILURE
45
+ end
46
+
47
+ private
48
+
49
+ def parser
50
+ OptionParser.new do |opts|
51
+ opts.banner = <<~BANNER
52
+ Usage: swiss_qr [options] FILE...
53
+ swiss_qr --text [options] < payload.txt
54
+
55
+ Prints the Swiss QR-bills found in images or PDFs (FILE "-" reads the
56
+ image from standard input) as a JSON array, one object per bill.
57
+ BANNER
58
+ opts.separator ""
59
+ opts.on("-t", "--text", "Parse a QR-bill payload (text) from standard input") { @text = true }
60
+ opts.on("--[no-]strict", "With --no-strict invalid bills are printed with their errors (exit status stays 1)") do |value|
61
+ @strict = value
62
+ end
63
+ opts.on("-l", "--lines", "Print one compact JSON object per line instead of an array") { @lines = true }
64
+ opts.on("-v", "--version", "Print the version") do
65
+ @stdout.puts "swiss_qr #{VERSION}"
66
+ throw :exit, OK
67
+ end
68
+ opts.on("-h", "--help", "Print this help") do
69
+ @stdout.puts opts
70
+ throw :exit, OK
71
+ end
72
+ end
73
+ end
74
+
75
+ def read_text
76
+ [parse("-", @stdin.binmode.read.force_encoding(Encoding::UTF_8))].compact
77
+ end
78
+
79
+ def read_file(file)
80
+ input = file == "-" ? @stdin.binmode : file
81
+ texts = Reader.adapt(SwissQr.reader).decode_all(input).select { |text| SwissQr.looks_like_qr_bill?(text) }
82
+ problem(INVALID, file, "no QR-bill found") if texts.empty?
83
+ texts.filter_map { |text| parse(file, text) }
84
+ rescue ReaderUnavailable
85
+ raise
86
+ rescue StandardError => e
87
+ problem(FAILURE, file, "#{e.message} (#{e.class})")
88
+ []
89
+ end
90
+
91
+ # The bill as a JSON-ready hash, or nil when it is not printed.
92
+ def parse(file, text)
93
+ bill = SwissQr.parse(text, strict: false)
94
+ unless bill.valid?
95
+ problem(INVALID, file, "invalid QR-bill: #{bill.errors.join("; ")}")
96
+ return if @strict
97
+ end
98
+ { "file" => file }.merge(bill.as_json)
99
+ rescue InvalidPayload => e
100
+ problem(INVALID, file, e.message)
101
+ nil
102
+ end
103
+
104
+ def print_json(bills)
105
+ if @lines
106
+ bills.each { |bill| @stdout.puts JSON.generate(bill) }
107
+ else
108
+ @stdout.puts JSON.pretty_generate(bills)
109
+ end
110
+ end
111
+
112
+ def problem(status, file, message)
113
+ @stderr.puts "swiss_qr: #{file}: #{message}"
114
+ @status = [@status, status].max
115
+ end
116
+
117
+ def usage_error(message)
118
+ @stderr.puts "swiss_qr: #{message}"
119
+ @stderr.puts parser.banner.lines.first(2).join
120
+ FAILURE
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ # Country codes for the address checks. Addresses carry a "two-digit
5
+ # country code according to ISO 3166-1" (IG 4.2.2, Ctry).
6
+ module Countries
7
+ # ISO 3166-1 alpha-2 (249 officially assigned codes, as in Debian's
8
+ # iso-codes 3166-1 list), plus XK (Kosovo): user-assigned, but used by
9
+ # SWIFT, the IBAN registry and the EU for Kosovo.
10
+ ISO_3166_1 = <<~CODES.split.to_h { |code| [code.freeze, true] }.freeze
11
+ AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE
12
+ BF BG BH BI BJ BL BM BN BO BQ BR BS BT BV BW BY BZ CA CC CD
13
+ CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM
14
+ DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF
15
+ GG GH GI GL GM GN GP GQ GR GS GT GU GW GY HK HM HN HR HT HU
16
+ ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN
17
+ KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME
18
+ MF MG MH MK ML MM MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA
19
+ NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM
20
+ PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI
21
+ SJ SK SL SM SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK
22
+ TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI
23
+ VN VU WF WS YE YT ZA ZM ZW
24
+ XK
25
+ CODES
26
+
27
+ # International vehicle codes that are not ISO alpha-2 codes but appear
28
+ # as postal-code prefixes ("D-78462", "FL-9490", "A-6900").
29
+ VEHICLE_CODES = {
30
+ "A" => "AT", "B" => "BE", "D" => "DE", "E" => "ES", "F" => "FR", "H" => "HU", "I" => "IT",
31
+ "L" => "LU", "M" => "MT", "N" => "NO", "P" => "PT", "S" => "SE", "V" => "VA",
32
+ "FL" => "LI", "UK" => "GB", "AND" => "AD", "BIH" => "BA", "CDN" => "CA", "EST" => "EE",
33
+ "FIN" => "FI", "IRL" => "IE", "MNE" => "ME", "NMK" => "MK", "RKS" => "XK", "RSM" => "SM",
34
+ "RUS" => "RU", "SLO" => "SI", "SRB" => "RS", "USA" => "US"
35
+ }.freeze
36
+
37
+ # Countries whose official postal code format starts with the country
38
+ # code ("LV-1050", "LT-01100", "MD-2001").
39
+ PREFIXED_POSTAL_CODES = %w[LT LV MD].freeze
40
+
41
+ module_function
42
+
43
+ # True for an (upper-case) ISO 3166-1 alpha-2 code.
44
+ def iso?(code)
45
+ ISO_3166_1.key?(code)
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ # Base class of every error raised by this gem.
5
+ class Error < StandardError; end
6
+
7
+ # One violation of the QR-bill rules.
8
+ #
9
+ # +field+ is a Symbol naming the offending element, e.g. +:iban+,
10
+ # +:"creditor.name"+, +:reference+ or +:payload+ (for problems with the
11
+ # payload as a whole: size, line count, separators, encoding).
12
+ Problem = Data.define(:field, :message) do
13
+ def initialize(field:, message:)
14
+ super(field: field.to_sym, message: message.to_s.dup.freeze)
15
+ end
16
+
17
+ # True when this problem concerns +name+ or an element nested in it
18
+ # (+problem.about?(:creditor)+ matches +:"creditor.name"+).
19
+ def about?(name)
20
+ name = name.to_s
21
+ field.to_s == name || field.to_s.start_with?("#{name}.")
22
+ end
23
+
24
+ def to_s = "#{field}: #{message}"
25
+
26
+ def as_json(*) = { "field" => field.to_s, "message" => message }
27
+ end
28
+
29
+ # Raised when a payload is not a valid Swiss QR-bill.
30
+ #
31
+ # With <tt>strict: true</tt> it is raised for any rule violation; with
32
+ # <tt>strict: false</tt> only when the payload is not even structurally a
33
+ # QR-bill (wrong QR type, fewer than 31 lines). #bill holds the partially
34
+ # parsed bill when there is one.
35
+ class InvalidPayload < Error
36
+ attr_reader :errors, :bill
37
+
38
+ def initialize(errors, bill: nil)
39
+ @errors = Array(errors).freeze
40
+ @bill = bill
41
+ super(build_message)
42
+ end
43
+
44
+ private
45
+
46
+ def build_message
47
+ return "invalid QR-bill payload" if errors.empty?
48
+
49
+ shown = errors.first(3).map(&:to_s)
50
+ shown << "#{errors.size - 3} more" if errors.size > 3
51
+ "invalid QR-bill payload: #{shown.join("; ")}"
52
+ end
53
+ end
54
+
55
+ # Raised when no QR code reader is configured and the default one, the
56
+ # +qr_reader+ gem, cannot be loaded.
57
+ class ReaderUnavailable < Error; end
58
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SwissQr
4
+ # IBAN helpers. A QR-bill account is a Swiss (CH) or Liechtenstein (LI)
5
+ # IBAN of exactly 21 characters; a QR-IBAN is one whose institution
6
+ # identification (positions 5-9) lies in 30000..31999.
7
+ module Iban
8
+ # Shape of any IBAN (country, check digits, 11-30 character BBAN).
9
+ PATTERN = /\A[A-Z]{2}\d{2}[A-Z0-9]{11,30}\z/
10
+ # Shape of a CH/LI IBAN: country, check digits, 5-digit IID, 12-character account.
11
+ QR_BILL_PATTERN = /\A(?:CH|LI)\d{2}\d{5}[A-Z0-9]{12}\z/
12
+ QR_IID_RANGE = (30_000..31_999)
13
+ COUNTRIES = %w[CH LI].freeze
14
+
15
+ module_function
16
+
17
+ # Removes all whitespace and upcases ASCII letters (only those: "ſ" must
18
+ # not turn into "S").
19
+ def normalize(iban)
20
+ iban.to_s.gsub(/[[:space:]]/, "").upcase(:ascii)
21
+ end
22
+
23
+ # True for any IBAN with a valid structure and MOD 97-10 check digits.
24
+ # Spaces and lower case are tolerated.
25
+ def valid?(iban)
26
+ value = normalize(iban)
27
+ value.match?(PATTERN) && Checksum.mod97(value) == 1
28
+ end
29
+
30
+ # True for a valid IBAN of the Swiss payment area (CH or LI), the only
31
+ # accounts a QR-bill may name (QR-IBAN or ordinary).
32
+ def ch_or_li?(iban)
33
+ value = normalize(iban)
34
+ value.match?(QR_BILL_PATTERN) && Checksum.mod97(value) == 1
35
+ end
36
+
37
+ # True for a valid CH/LI QR-IBAN (institution id 30000..31999).
38
+ def qr_iban?(iban)
39
+ ch_or_li?(iban) && qr_iid?(normalize(iban))
40
+ end
41
+
42
+ # True when a CH/LI-shaped IBAN carries a QR-IID; check digits are not
43
+ # verified.
44
+ def qr_iid?(iban)
45
+ value = normalize(iban)
46
+ value.match?(QR_BILL_PATTERN) && QR_IID_RANGE.cover?(value[4, 5].to_i)
47
+ end
48
+
49
+ # The institution identification (positions 5-9) of a CH/LI IBAN.
50
+ def iid(iban)
51
+ value = normalize(iban)
52
+ value[4, 5] if value.match?(QR_BILL_PATTERN)
53
+ end
54
+
55
+ # Groups of four for display: "CH44 3199 9123 0008 8901 2".
56
+ def format(iban)
57
+ normalize(iban).scan(/.{1,4}/).join(" ")
58
+ end
59
+ end
60
+ end