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,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
|
data/lib/swiss_qr/cli.rb
ADDED
|
@@ -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
|