konto_check_ruby 1.0.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 +26 -0
- data/COPYRIGHT +22 -0
- data/LICENSE +504 -0
- data/README.md +295 -0
- data/bin/konto_check_ruby +202 -0
- data/data/README.md +17 -0
- data/data/blz.lut2f +0 -0
- data/data/iban_regeln.txt +1260 -0
- data/lib/konto_check_ruby/bank_data.rb +421 -0
- data/lib/konto_check_ruby/blz_file.rb +705 -0
- data/lib/konto_check_ruby/check_methods/part1.rb +1634 -0
- data/lib/konto_check_ruby/check_methods/part2.rb +1604 -0
- data/lib/konto_check_ruby/check_methods/part3.rb +1905 -0
- data/lib/konto_check_ruby/check_methods/part4.rb +1738 -0
- data/lib/konto_check_ruby/check_methods/part5.rb +1407 -0
- data/lib/konto_check_ruby/check_methods.rb +147 -0
- data/lib/konto_check_ruby/collation.rb +52 -0
- data/lib/konto_check_ruby/engine.rb +1006 -0
- data/lib/konto_check_ruby/iban_rules.rb +1060 -0
- data/lib/konto_check_ruby/konto_check.rb +1578 -0
- data/lib/konto_check_ruby/lut_file.rb +457 -0
- data/lib/konto_check_ruby/raw.rb +614 -0
- data/lib/konto_check_ruby/retvals.rb +826 -0
- data/lib/konto_check_ruby/search.rb +440 -0
- data/lib/konto_check_ruby/update.rb +176 -0
- data/lib/konto_check_ruby/version.rb +18 -0
- data/lib/konto_check_ruby.rb +42 -0
- metadata +112 -0
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file is part of konto_check_ruby, a Ruby port of the C library
|
|
4
|
+
# konto_check, Copyright (C) 2002-2023 Michael Plugge <konto_check@yahoo.com>.
|
|
5
|
+
# Ruby port Copyright (C) 2026 tickettoaster GmbH <https://tickettoaster.de>.
|
|
6
|
+
#
|
|
7
|
+
# konto_check_ruby is free software; you can redistribute it and/or modify it
|
|
8
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
9
|
+
# the Free Software Foundation; either version 2.1 of the License, or (at
|
|
10
|
+
# your option) any later version. It is distributed WITHOUT ANY WARRANTY; see
|
|
11
|
+
# the file LICENSE for details.
|
|
12
|
+
|
|
13
|
+
require_relative "retvals"
|
|
14
|
+
require_relative "collation"
|
|
15
|
+
|
|
16
|
+
module KontoCheckRuby
|
|
17
|
+
# Search functions of the C library (lut_suche_*): search banks by BIC,
|
|
18
|
+
# name, short name, place, BLZ range, postal code range, check method and
|
|
19
|
+
# IBAN rule, full text search and combined searches.
|
|
20
|
+
#
|
|
21
|
+
# All entries (main offices and branches) are addressed by a "flat index"
|
|
22
|
+
# j in 0...cnt (the index into the per-branch arrays of BankData). Results
|
|
23
|
+
# are returned as [code, hits] where hits is an Array of flat indexes in
|
|
24
|
+
# the natural sort order of the search key (like the C library, whose
|
|
25
|
+
# results are slices of the sort index arrays).
|
|
26
|
+
module Search
|
|
27
|
+
LUT_SUCHE_VOLLTEXT = 1
|
|
28
|
+
LUT_SUCHE_BIC = 2
|
|
29
|
+
LUT_SUCHE_NAMEN = 3
|
|
30
|
+
LUT_SUCHE_NAMEN_KURZ = 4
|
|
31
|
+
LUT_SUCHE_ORT = 5
|
|
32
|
+
LUT_SUCHE_BLZ = 6
|
|
33
|
+
LUT_SUCHE_PLZ = 7
|
|
34
|
+
LUT_SUCHE_PZ = 8
|
|
35
|
+
LUT_SUCHE_REGEL = 9
|
|
36
|
+
LUT_SUCHE_BIC_H = 10
|
|
37
|
+
|
|
38
|
+
VOLLTEXT_INVALID = ["-", "'", ",", ".", "+", "/", "&", "(", ")"].freeze
|
|
39
|
+
|
|
40
|
+
# BLZ of the flat index j
|
|
41
|
+
def blz_f(j)
|
|
42
|
+
d = @data
|
|
43
|
+
d.blz[d.startidx_r[j]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# branch number of the flat index j
|
|
47
|
+
def zweigstelle_f(j)
|
|
48
|
+
@data.startidx_r ? j - @data.startidx[@data.startidx_r[j]] : 0
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# check method of the flat index j
|
|
52
|
+
def pz_f(j)
|
|
53
|
+
@data.pz[@data.startidx_r[j]]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# [blz, zweigstelle, retval] for a flat index (konto_check_idx2blz in C)
|
|
57
|
+
def idx2blz(j)
|
|
58
|
+
return [0, 0, LUT2_NOT_INITIALIZED] unless @data
|
|
59
|
+
return [0, 0, ARRAY_INDEX_OUT_OF_RANGE] if j < 0 || j >= @data.cnt
|
|
60
|
+
[blz_f(j), zweigstelle_f(j), OK]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def lut_suche_bic(str)
|
|
64
|
+
suche_str(str, :bic, LUT2_BIC_NOT_INITIALIZED)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def lut_suche_bic_h(str)
|
|
68
|
+
suche_str(str, :bic_h, LUT2_BIC_NOT_INITIALIZED)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def lut_suche_namen(str)
|
|
72
|
+
suche_str(str, :name, LUT2_NAME_NOT_INITIALIZED)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def lut_suche_namen_kurz(str)
|
|
76
|
+
suche_str(str, :name_kurz, LUT2_NAME_KURZ_NOT_INITIALIZED)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def lut_suche_ort(str)
|
|
80
|
+
suche_str(str, :ort, LUT2_ORT_NOT_INITIALIZED)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def lut_suche_blz(a1, a2 = 0)
|
|
84
|
+
suche_int(a1, a2, :blz_f_values, nil)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def lut_suche_plz(a1, a2 = 0)
|
|
88
|
+
return [LUT2_PLZ_NOT_INITIALIZED, []] if @data && @data.plz.nil?
|
|
89
|
+
suche_int(a1, a2, :plz, LUT2_PLZ_NOT_INITIALIZED)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def lut_suche_pz(a1, a2 = 0)
|
|
93
|
+
suche_int(a1, a2, :pz_f_values, nil)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# IBAN rule (without version)
|
|
97
|
+
def lut_suche_regel(a1, a2 = 0)
|
|
98
|
+
return [LUT2_NOT_INITIALIZED, []] unless @data
|
|
99
|
+
return [LUT2_IBAN_REGEL_NOT_INITIALIZED, []] unless @data.iban_regel
|
|
100
|
+
a2 = a1 if a2 == 0
|
|
101
|
+
return [INVALID_SEARCH_RANGE, []] if a1 > a2
|
|
102
|
+
suche_int(a1 * 100, a2 * 100 + 99, :iban_regel, LUT2_IBAN_REGEL_NOT_INITIALIZED)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Full text search for one word in name, short name and place.
|
|
106
|
+
# Returns [code, hits, words] where words are the matching index words.
|
|
107
|
+
def lut_suche_volltext(word)
|
|
108
|
+
return [LUT2_NOT_INITIALIZED, [], []] unless @data
|
|
109
|
+
return [LUT2_NAME_NOT_INITIALIZED, [], []] if @data.name.nil? || @data.ort.nil? || @data.name_kurz.nil?
|
|
110
|
+
w = word.to_s.strip
|
|
111
|
+
return [LUT2_VOLLTEXT_INVALID_CHAR, [], []] if VOLLTEXT_INVALID.any? { |c| w.include?(c) }
|
|
112
|
+
exact = w.start_with?("!")
|
|
113
|
+
w = w[1..] if exact
|
|
114
|
+
return [LUT2_VOLLTEXT_SINGLE_WORD_ONLY, [], []] unless w =~ /\A[[:alnum:]]+\z/
|
|
115
|
+
key = Collation.sort_key(w)
|
|
116
|
+
idx = volltext_index
|
|
117
|
+
words = idx.keys.select { |k| exact ? k == key : k.start_with?(key) }.sort
|
|
118
|
+
return [KEY_NOT_FOUND, [], []] if words.empty?
|
|
119
|
+
hits = words.flat_map { |k| idx[k][:banks] }
|
|
120
|
+
[OK, hits, words.map { |k| idx[k][:word] }]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Combined search (lut_suche_multiple in C). such_str contains one or
|
|
124
|
+
# more search terms separated by blanks, each optionally with an index
|
|
125
|
+
# letter ("a:"), a leading "!" for exact matching, a range ("1000-2000")
|
|
126
|
+
# and a field ("@bic", "@blz", "@name", "@kurz", "@ort", "@plz", "@pz",
|
|
127
|
+
# "@regel", "@volltext"; default is the full text search). such_cmd
|
|
128
|
+
# combines the terms: letters name the terms, "+" is OR, "-" is NOT,
|
|
129
|
+
# e.g. "a b+c-d" = (a AND b) OR c AND NOT d. Without such_cmd all terms
|
|
130
|
+
# are combined with AND.
|
|
131
|
+
# Returns [code, Array of [blz, zweigstelle]] sorted by BLZ; with uniq
|
|
132
|
+
# only one entry per BLZ.
|
|
133
|
+
def lut_suche_multiple(such_str, uniq = false, such_cmd = nil)
|
|
134
|
+
return [LUT2_NOT_INITIALIZED, []] unless @data
|
|
135
|
+
s = such_str.to_s.strip
|
|
136
|
+
return [OK, []] if s.empty?
|
|
137
|
+
terms = {}
|
|
138
|
+
key_not_found = false
|
|
139
|
+
letter = "a"
|
|
140
|
+
s.split(/\s+/).each do |term|
|
|
141
|
+
break if letter > "z"
|
|
142
|
+
exact = false
|
|
143
|
+
if term.start_with?("!")
|
|
144
|
+
exact = true
|
|
145
|
+
term = term[1..]
|
|
146
|
+
end
|
|
147
|
+
idx = letter
|
|
148
|
+
if term =~ /\A([A-Za-z]):(.*)\z/
|
|
149
|
+
idx = Regexp.last_match(1).downcase
|
|
150
|
+
term = Regexp.last_match(2)
|
|
151
|
+
end
|
|
152
|
+
field = "v"
|
|
153
|
+
if term =~ /\A(.*)@([A-Za-z]+)\z/
|
|
154
|
+
term = Regexp.last_match(1)
|
|
155
|
+
field = Regexp.last_match(2).downcase
|
|
156
|
+
end
|
|
157
|
+
typ = case field
|
|
158
|
+
when /\Abl/ then LUT_SUCHE_BLZ
|
|
159
|
+
when /\Abi/ then LUT_SUCHE_BIC
|
|
160
|
+
when /\Abh/ then LUT_SUCHE_BIC_H
|
|
161
|
+
when /\Ak/ then LUT_SUCHE_NAMEN_KURZ
|
|
162
|
+
when /\An/ then LUT_SUCHE_NAMEN
|
|
163
|
+
when /\Ao/ then LUT_SUCHE_ORT
|
|
164
|
+
when /\Apl/ then LUT_SUCHE_PLZ
|
|
165
|
+
when /\Ap[zr]/ then LUT_SUCHE_PZ
|
|
166
|
+
when /\Ar/ then LUT_SUCHE_REGEL
|
|
167
|
+
else LUT_SUCHE_VOLLTEXT
|
|
168
|
+
end
|
|
169
|
+
i1, i2 = term.split("-", 2).map(&:to_i)
|
|
170
|
+
i2 ||= 0
|
|
171
|
+
txt = (exact ? "!" : "") + term.split("-", 2).first.to_s
|
|
172
|
+
code, hits = case typ
|
|
173
|
+
when LUT_SUCHE_VOLLTEXT then lut_suche_volltext(txt).first(2)
|
|
174
|
+
when LUT_SUCHE_BIC then lut_suche_bic(txt)
|
|
175
|
+
when LUT_SUCHE_BIC_H then lut_suche_bic_h(txt)
|
|
176
|
+
when LUT_SUCHE_NAMEN then lut_suche_namen(txt)
|
|
177
|
+
when LUT_SUCHE_NAMEN_KURZ then lut_suche_namen_kurz(txt)
|
|
178
|
+
when LUT_SUCHE_ORT then lut_suche_ort(txt)
|
|
179
|
+
when LUT_SUCHE_BLZ then lut_suche_blz(i1, i2)
|
|
180
|
+
when LUT_SUCHE_PLZ then lut_suche_plz(i1, i2)
|
|
181
|
+
when LUT_SUCHE_PZ then lut_suche_pz(i1, i2)
|
|
182
|
+
when LUT_SUCHE_REGEL then lut_suche_regel(i1, i2)
|
|
183
|
+
end
|
|
184
|
+
return [code, []] if code < 0 && code != KEY_NOT_FOUND
|
|
185
|
+
key_not_found = true if code == KEY_NOT_FOUND
|
|
186
|
+
terms[idx] = hits.to_a
|
|
187
|
+
letter = letter.succ
|
|
188
|
+
end
|
|
189
|
+
cmd = such_cmd.to_s.strip
|
|
190
|
+
cmd = terms.keys.join if cmd.empty?
|
|
191
|
+
result = nil
|
|
192
|
+
group = nil
|
|
193
|
+
negate = false
|
|
194
|
+
apply = lambda do
|
|
195
|
+
group ||= []
|
|
196
|
+
result = if result.nil?
|
|
197
|
+
negate ? (all_indexes - group) : group
|
|
198
|
+
elsif negate
|
|
199
|
+
result - group
|
|
200
|
+
else
|
|
201
|
+
result | group
|
|
202
|
+
end
|
|
203
|
+
group = nil
|
|
204
|
+
end
|
|
205
|
+
cmd.each_char do |c|
|
|
206
|
+
case c
|
|
207
|
+
when "+"
|
|
208
|
+
apply.call
|
|
209
|
+
negate = false
|
|
210
|
+
when "-"
|
|
211
|
+
apply.call
|
|
212
|
+
negate = true
|
|
213
|
+
when " "
|
|
214
|
+
next
|
|
215
|
+
else
|
|
216
|
+
return [LUT_SUCHE_INVALID_CMD, []] unless c =~ /[A-Za-z]/
|
|
217
|
+
hits = terms[c.downcase]
|
|
218
|
+
return [LUT_SUCHE_INVALID_CMD, []] if hits.nil?
|
|
219
|
+
group = group.nil? ? hits.dup : (group & hits)
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
apply.call
|
|
223
|
+
result = result.to_a.sort
|
|
224
|
+
if uniq
|
|
225
|
+
seen = {}
|
|
226
|
+
result = result.reject { |j| b = blz_f(j); seen.key?(b) ? true : (seen[b] = true; false) }
|
|
227
|
+
end
|
|
228
|
+
out = result.map { |j| [blz_f(j), zweigstelle_f(j)] }
|
|
229
|
+
[key_not_found ? SOME_KEYS_NOT_FOUND : OK, out]
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# Banks with a given BIC (bic_info in C). mode 0: main office BICs first,
|
|
233
|
+
# then all BICs, then with branch code "XXX"; 1: all BICs; 2: main office
|
|
234
|
+
# BICs only. Returns [retval, count, start_idx] where start_idx is an
|
|
235
|
+
# opaque handle for the biq_* functions (positive: main office BIC index,
|
|
236
|
+
# negative: BIC index; 0 = nothing found).
|
|
237
|
+
def bic_info(bic, mode = 0)
|
|
238
|
+
s = bic.to_s
|
|
239
|
+
case mode
|
|
240
|
+
when 1
|
|
241
|
+
code, hits = lut_suche_bic(s)
|
|
242
|
+
return [code, 0, 0] if hits.empty?
|
|
243
|
+
[code, hits.size, -(bic_sort_position(:bic, hits.first) + 1)]
|
|
244
|
+
when 2
|
|
245
|
+
code, hits = lut_suche_bic_h(s)
|
|
246
|
+
return [code, 0, 0] if hits.empty?
|
|
247
|
+
[code, hits.size, bic_sort_position(:bic_h, hits.first) + 1]
|
|
248
|
+
else
|
|
249
|
+
code, hits = lut_suche_bic_h(s)
|
|
250
|
+
return [code, hits.size, bic_sort_position(:bic_h, hits.first) + 1] unless hits.empty?
|
|
251
|
+
code, hits = lut_suche_bic(s)
|
|
252
|
+
return [code, hits.size, -(bic_sort_position(:bic, hits.first) + 1)] unless hits.empty?
|
|
253
|
+
code, hits = lut_suche_bic(s[0, 8].to_s + "XXX")
|
|
254
|
+
return [code, 0, 0] if hits.empty?
|
|
255
|
+
[code >= OK ? OK_SHORT_BIC_USED : code, hits.size, -(bic_sort_position(:bic, hits.first) + 1)]
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Flat index for a biq handle (see bic_info) or a negative error code
|
|
260
|
+
def biq_index(idx)
|
|
261
|
+
return LUT2_NOT_INITIALIZED unless @data
|
|
262
|
+
return INVALID_BIQ_INDEX if idx == 0
|
|
263
|
+
if idx > 0
|
|
264
|
+
sorted = sort_index(:bic_h)
|
|
265
|
+
return LUT2_INDEX_OUT_OF_RANGE if sorted.nil? || idx - 1 >= sorted.size
|
|
266
|
+
sorted[idx - 1]
|
|
267
|
+
else
|
|
268
|
+
sorted = sort_index(:bic)
|
|
269
|
+
return LUT2_INDEX_OUT_OF_RANGE if sorted.nil? || -idx - 1 >= sorted.size
|
|
270
|
+
sorted[-idx - 1]
|
|
271
|
+
end
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# Field of a bank addressed by biq handle: [value, retval]
|
|
275
|
+
def biq_field(field, idx)
|
|
276
|
+
return [nil, LUT2_NOT_INITIALIZED] unless @data
|
|
277
|
+
j = biq_index(idx)
|
|
278
|
+
return [nil, j] if j < 0
|
|
279
|
+
value = case field
|
|
280
|
+
when :pz then @data.pz[@data.startidx_r[j]]
|
|
281
|
+
when :blz then blz_f(j)
|
|
282
|
+
else
|
|
283
|
+
arr = @data.public_send(field)
|
|
284
|
+
return [nil, field_error(field)] unless arr
|
|
285
|
+
arr[j]
|
|
286
|
+
end
|
|
287
|
+
[value, OK]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# Field of a bank addressed by BIC: [value, retval]
|
|
291
|
+
def bic_field(field, bic, mode = 0, filiale = 0)
|
|
292
|
+
return [nil, LUT2_NOT_INITIALIZED] unless @data
|
|
293
|
+
return [nil, field_error(field)] if field != :pz && field != :blz && @data.public_send(field).nil?
|
|
294
|
+
ret1, cnt, start = bic_info(bic, mode)
|
|
295
|
+
return [nil, ret1] if ret1 < 0
|
|
296
|
+
return [nil, LUT2_INDEX_OUT_OF_RANGE] if filiale >= cnt
|
|
297
|
+
handle = start > 0 ? start + filiale : start - filiale
|
|
298
|
+
value, ret2 = biq_field(field, handle)
|
|
299
|
+
[value, ret2 < 0 ? ret2 : ret1]
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def reset_search_cache
|
|
303
|
+
@sort_cache = {}
|
|
304
|
+
@volltext_index = nil
|
|
305
|
+
@blz_f_values = nil
|
|
306
|
+
@pz_f_values = nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
private
|
|
310
|
+
|
|
311
|
+
def field_error(field)
|
|
312
|
+
{ name: LUT2_NAME_NOT_INITIALIZED, name_kurz: LUT2_NAME_KURZ_NOT_INITIALIZED, ort: LUT2_ORT_NOT_INITIALIZED,
|
|
313
|
+
plz: LUT2_PLZ_NOT_INITIALIZED, pan: LUT2_PAN_NOT_INITIALIZED, bic: LUT2_BIC_NOT_INITIALIZED,
|
|
314
|
+
bic_h: LUT2_BIC_NOT_INITIALIZED, nr: LUT2_NR_NOT_INITIALIZED, aenderung: LUT2_AENDERUNG_NOT_INITIALIZED,
|
|
315
|
+
loeschung: LUT2_LOESCHUNG_NOT_INITIALIZED, nachfolge_blz: LUT2_NACHFOLGE_BLZ_NOT_INITIALIZED,
|
|
316
|
+
iban_regel: LUT2_IBAN_REGEL_NOT_INITIALIZED }[field] || LUT2_NOT_INITIALIZED
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def all_indexes
|
|
320
|
+
(0...@data.cnt).to_a
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def blz_f_values
|
|
324
|
+
@blz_f_values ||= (0...@data.cnt).map { |j| blz_f(j) }
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
def pz_f_values
|
|
328
|
+
@pz_f_values ||= (0...@data.cnt).map { |j| pz_f(j) }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def field_values(field)
|
|
332
|
+
case field
|
|
333
|
+
when :blz_f_values then blz_f_values
|
|
334
|
+
when :pz_f_values then pz_f_values
|
|
335
|
+
else @data.public_send(field)
|
|
336
|
+
end
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# flat indexes sorted by the (folded) field value, stable
|
|
340
|
+
def sort_index(field)
|
|
341
|
+
@sort_cache[field] ||= begin
|
|
342
|
+
values = field_values(field)
|
|
343
|
+
return nil if values.nil?
|
|
344
|
+
if values.first.is_a?(String)
|
|
345
|
+
keys = values.map { |v| Collation.sort_key(v) }
|
|
346
|
+
(0...values.size).sort_by { |i| [keys[i], i] }
|
|
347
|
+
else
|
|
348
|
+
(0...values.size).sort_by { |i| [values[i], i] }
|
|
349
|
+
end
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def sort_keys(field)
|
|
354
|
+
@sort_cache[[:keys, field]] ||= field_values(field).map { |v| Collation.sort_key(v) }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def bic_sort_position(field, j)
|
|
358
|
+
sort_index(field).index(j)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
# Prefix search in a string field. A leading "!" requests an exact match.
|
|
362
|
+
def suche_str(str, field, error)
|
|
363
|
+
return [LUT2_NOT_INITIALIZED, []] unless @data
|
|
364
|
+
return [error, []] if @data.public_send(field).nil?
|
|
365
|
+
s = str.to_s.lstrip
|
|
366
|
+
exact = false
|
|
367
|
+
if s.start_with?("!")
|
|
368
|
+
exact = true
|
|
369
|
+
s = s[1..]
|
|
370
|
+
end
|
|
371
|
+
key = Collation.sort_key(s)
|
|
372
|
+
keys = sort_keys(field)
|
|
373
|
+
sorted = sort_index(field)
|
|
374
|
+
# binary search for the first key >= search key, then collect the range
|
|
375
|
+
lo = 0
|
|
376
|
+
hi = sorted.size
|
|
377
|
+
while lo < hi
|
|
378
|
+
mid = (lo + hi) / 2
|
|
379
|
+
if keys[sorted[mid]] < key then lo = mid + 1 else hi = mid end
|
|
380
|
+
end
|
|
381
|
+
hits = []
|
|
382
|
+
i = lo
|
|
383
|
+
while i < sorted.size
|
|
384
|
+
k = keys[sorted[i]]
|
|
385
|
+
break unless exact ? k == key : k.start_with?(key)
|
|
386
|
+
hits << sorted[i]
|
|
387
|
+
i += 1
|
|
388
|
+
end
|
|
389
|
+
return [KEY_NOT_FOUND, []] if hits.empty?
|
|
390
|
+
[OK, hits]
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# Range search in an integer field
|
|
394
|
+
def suche_int(a1, a2, field, error)
|
|
395
|
+
return [LUT2_NOT_INITIALIZED, []] unless @data
|
|
396
|
+
a1 = a1.to_i
|
|
397
|
+
a2 = a2.to_i
|
|
398
|
+
a2 = a1 if a2 == 0
|
|
399
|
+
return [INVALID_SEARCH_RANGE, []] if a1 > a2
|
|
400
|
+
values = field_values(field)
|
|
401
|
+
return [error, []] if values.nil?
|
|
402
|
+
sorted = sort_index(field)
|
|
403
|
+
lo = 0
|
|
404
|
+
hi = sorted.size
|
|
405
|
+
while lo < hi
|
|
406
|
+
mid = (lo + hi) / 2
|
|
407
|
+
if values[sorted[mid]] < a1 then lo = mid + 1 else hi = mid end
|
|
408
|
+
end
|
|
409
|
+
hits = []
|
|
410
|
+
i = lo
|
|
411
|
+
while i < sorted.size && values[sorted[i]] <= a2
|
|
412
|
+
hits << sorted[i]
|
|
413
|
+
i += 1
|
|
414
|
+
end
|
|
415
|
+
return [KEY_NOT_FOUND, []] if hits.empty?
|
|
416
|
+
[OK, hits]
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# word key -> { word:, banks: [flat indexes] }
|
|
420
|
+
def volltext_index
|
|
421
|
+
@volltext_index ||= begin
|
|
422
|
+
idx = {}
|
|
423
|
+
d = @data
|
|
424
|
+
d.cnt.times do |j|
|
|
425
|
+
seen = {}
|
|
426
|
+
[d.name[j], d.ort[j], d.name_kurz[j]].each do |field|
|
|
427
|
+
Collation.words(field).each do |w|
|
|
428
|
+
k = Collation.sort_key(w)
|
|
429
|
+
next if seen[k]
|
|
430
|
+
seen[k] = true
|
|
431
|
+
e = (idx[k] ||= { word: w, banks: [] })
|
|
432
|
+
e[:banks] << j
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
idx
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
440
|
+
end
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file is part of konto_check_ruby, a Ruby port of the C library
|
|
4
|
+
# konto_check, Copyright (C) 2002-2023 Michael Plugge <konto_check@yahoo.com>.
|
|
5
|
+
# Ruby port Copyright (C) 2026 tickettoaster GmbH <https://tickettoaster.de>.
|
|
6
|
+
#
|
|
7
|
+
# konto_check_ruby is free software; you can redistribute it and/or modify it
|
|
8
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
9
|
+
# the Free Software Foundation; either version 2.1 of the License, or (at
|
|
10
|
+
# your option) any later version. It is distributed WITHOUT ANY WARRANTY; see
|
|
11
|
+
# the file LICENSE for details.
|
|
12
|
+
|
|
13
|
+
require "net/http"
|
|
14
|
+
require "uri"
|
|
15
|
+
require "fileutils"
|
|
16
|
+
require "json"
|
|
17
|
+
require_relative "retvals"
|
|
18
|
+
require_relative "blz_file"
|
|
19
|
+
|
|
20
|
+
module KontoCheckRuby
|
|
21
|
+
# Self update of the bank data: fetches the current bank code file
|
|
22
|
+
# ("Bankleitzahlendatei") from the download page of the Deutsche
|
|
23
|
+
# Bundesbank, keeps it in a local cache directory and optionally writes a
|
|
24
|
+
# LUT file from it.
|
|
25
|
+
#
|
|
26
|
+
# result = KontoCheckRuby::Update.update # download if a new file is available
|
|
27
|
+
# result.path # => "~/.cache/konto_check_ruby/blz-20260907-20261206.xml"
|
|
28
|
+
# engine.load_current # cached current file, downloaded on demand
|
|
29
|
+
#
|
|
30
|
+
# The Bundesbank publishes a new file every quarter (validity periods
|
|
31
|
+
# start in March, June, September and December); the download links are
|
|
32
|
+
# content addressed (a hash in the URL, immutable), so a changed link means
|
|
33
|
+
# a new file. The XML variant is used by default because it carries its
|
|
34
|
+
# validity period; TXT and CSV are supported as well (the validity is then
|
|
35
|
+
# taken from the download page).
|
|
36
|
+
module Update
|
|
37
|
+
DOWNLOAD_PAGE = "https://www.bundesbank.de/de/aufgaben/unbarer-zahlungsverkehr/serviceangebot/bankleitzahlen/download-bankleitzahlen-602592"
|
|
38
|
+
FORMATS = { xml: "blz-aktuell-xml-data.xml", txt: "blz-aktuell-txt-data.txt", csv: "blz-aktuell-csv-data.csv" }.freeze
|
|
39
|
+
USER_AGENT = "konto_check_ruby/#{VERSION} (+https://rubygems.org/gems/konto_check_ruby)"
|
|
40
|
+
MAX_REDIRECTS = 5
|
|
41
|
+
|
|
42
|
+
class Error < StandardError; end
|
|
43
|
+
|
|
44
|
+
# Description of the current file on the download page
|
|
45
|
+
Source = Struct.new(:url, :hash, :format, :valid_from, :valid_to, keyword_init: true) do
|
|
46
|
+
# local file name: blz-<valid_from>-<valid_to>.<format>, or with the hash
|
|
47
|
+
# if the validity is unknown
|
|
48
|
+
def filename
|
|
49
|
+
if valid_from && valid_to && valid_from != 0
|
|
50
|
+
Kernel.format("blz-%08d-%08d.%s", valid_from, valid_to, self.format)
|
|
51
|
+
else
|
|
52
|
+
"blz-#{hash}.#{self.format}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Result of an update: status is :downloaded, :current (cached file is
|
|
58
|
+
# up to date) or :offline (network failed, cached file used); path is the
|
|
59
|
+
# local file, lut the generated LUT file (if requested).
|
|
60
|
+
Result = Struct.new(:status, :path, :valid_from, :valid_to, :lut, :message, keyword_init: true)
|
|
61
|
+
|
|
62
|
+
class << self
|
|
63
|
+
# Cache directory: $KONTO_CHECK_RUBY_CACHE, else $XDG_CACHE_HOME/konto_check_ruby,
|
|
64
|
+
# else ~/.cache/konto_check_ruby
|
|
65
|
+
def cache_dir
|
|
66
|
+
ENV["KONTO_CHECK_RUBY_CACHE"] ||
|
|
67
|
+
File.join(ENV["XDG_CACHE_HOME"] || File.join(Dir.home, ".cache"), "konto_check_ruby")
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Parses the download page and returns the Source of the current file
|
|
71
|
+
# of the given format. fetcher is a callable url -> body (for tests).
|
|
72
|
+
def current_source(format: :xml, fetcher: method(:fetch))
|
|
73
|
+
name = FORMATS.fetch(format.to_sym) { raise ArgumentError, "format must be one of #{FORMATS.keys.join(', ')}" }
|
|
74
|
+
page = fetcher.call(DOWNLOAD_PAGE)
|
|
75
|
+
parse_page(page, name, format.to_sym)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Extracts link and validity of a file from the page HTML
|
|
79
|
+
def parse_page(html, filename, format)
|
|
80
|
+
html = html.dup.force_encoding("UTF-8")
|
|
81
|
+
html = html.encode("UTF-8", "ISO-8859-1") unless html.valid_encoding?
|
|
82
|
+
m = html.match(%r{href="(/resource/blob/(\d+)/([0-9a-f]{32})/[^"]*/#{Regexp.escape(filename)})"})
|
|
83
|
+
raise Error, "download link for #{filename} not found on #{DOWNLOAD_PAGE}" unless m
|
|
84
|
+
url = URI.join(DOWNLOAD_PAGE, m[1]).to_s
|
|
85
|
+
v1 = v2 = 0
|
|
86
|
+
if (vm = html.match(/g(?:ü|ü|\\u00fc)ltig vom\s+(\d{2})\.(\d{2})\.(\d{4})\s+bis\s+(\d{2})\.(\d{2})\.(\d{4})/))
|
|
87
|
+
v1 = (vm[3] + vm[2] + vm[1]).to_i
|
|
88
|
+
v2 = (vm[6] + vm[5] + vm[4]).to_i
|
|
89
|
+
end
|
|
90
|
+
Source.new(url: url, hash: m[3], format: format, valid_from: v1, valid_to: v2)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Downloads the current file into dir (default: cache_dir) unless it
|
|
94
|
+
# is already there. With lut: PATH a LUT file is generated from it
|
|
95
|
+
# (validity and IBAN rules included). force: true downloads again.
|
|
96
|
+
# Raises Update::Error (network problems, unexpected page layout).
|
|
97
|
+
def update(dir: cache_dir, format: :xml, force: false, lut: nil, iban_rules: :default, fetcher: method(:fetch))
|
|
98
|
+
src = current_source(format: format, fetcher: fetcher)
|
|
99
|
+
FileUtils.mkdir_p(dir)
|
|
100
|
+
path = File.join(dir, src.filename)
|
|
101
|
+
status = :current
|
|
102
|
+
unless File.file?(path) && !force
|
|
103
|
+
body = fetcher.call(src.url)
|
|
104
|
+
if format.to_sym == :xml
|
|
105
|
+
v1, v2 = xml_validity(body)
|
|
106
|
+
if v1 != 0
|
|
107
|
+
src.valid_from = v1
|
|
108
|
+
src.valid_to = v2
|
|
109
|
+
path = File.join(dir, src.filename)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
tmp = "#{path}.part"
|
|
113
|
+
File.binwrite(tmp, body)
|
|
114
|
+
File.rename(tmp, path)
|
|
115
|
+
File.write(File.join(dir, "current.json"), JSON.pretty_generate(url: src.url, hash: src.hash, file: File.basename(path),
|
|
116
|
+
valid_from: src.valid_from, valid_to: src.valid_to,
|
|
117
|
+
downloaded: Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")))
|
|
118
|
+
status = :downloaded
|
|
119
|
+
end
|
|
120
|
+
lut_path = nil
|
|
121
|
+
if lut
|
|
122
|
+
code = BlzFile.generate_lut(path, lut, gueltigkeit: validity_string(src), filialen: true, felder: 9,
|
|
123
|
+
iban_rules: iban_rules,
|
|
124
|
+
user_info: "Bundesbank #{File.basename(path)}, konto_check_ruby #{VERSION}")
|
|
125
|
+
raise Error, "LUT file could not be written: #{RETVAL_SHORT[code]}" if code < 0
|
|
126
|
+
lut_path = lut
|
|
127
|
+
end
|
|
128
|
+
Result.new(status: status, path: path, valid_from: src.valid_from, valid_to: src.valid_to, lut: lut_path)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# The cached file with the latest validity (or nil). current_date
|
|
132
|
+
# (JJJJMMTT) restricts the result to files valid on that date.
|
|
133
|
+
def cached_file(dir: cache_dir, current_date: nil)
|
|
134
|
+
files = Dir.glob(File.join(dir, "blz-*.{xml,txt,csv}")).map do |f|
|
|
135
|
+
v1, v2 = File.basename(f).scan(/\d{8}/).first(2).map(&:to_i)
|
|
136
|
+
[f, v1 || 0, v2 || 0]
|
|
137
|
+
end
|
|
138
|
+
files = files.select { |_f, v1, v2| v1 <= current_date && current_date <= v2 } if current_date
|
|
139
|
+
best = files.max_by { |_f, v1, _v2| v1 }
|
|
140
|
+
best && best.first
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Fetches an URL (following redirects). Returns the body as binary String.
|
|
144
|
+
def fetch(url, limit = MAX_REDIRECTS)
|
|
145
|
+
raise Error, "too many redirects for #{url}" if limit <= 0
|
|
146
|
+
uri = URI.parse(url)
|
|
147
|
+
req = Net::HTTP::Get.new(uri)
|
|
148
|
+
req["User-Agent"] = USER_AGENT
|
|
149
|
+
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 20, read_timeout: 120) do |http|
|
|
150
|
+
http.request(req)
|
|
151
|
+
end
|
|
152
|
+
case res
|
|
153
|
+
when Net::HTTPSuccess then res.body.b
|
|
154
|
+
when Net::HTTPRedirection then fetch(URI.join(url, res["location"]).to_s, limit - 1)
|
|
155
|
+
else raise Error, "download of #{url} failed: #{res.code} #{res.message}"
|
|
156
|
+
end
|
|
157
|
+
rescue SocketError, SystemCallError, Timeout::Error, IOError, OpenSSL::SSL::SSLError => e
|
|
158
|
+
raise Error, "download of #{url} failed: #{e.class}: #{e.message}"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def xml_validity(body)
|
|
164
|
+
head = body.b[0, 4096]
|
|
165
|
+
v1 = head[%r{<ValidFrom>(\d{4})-(\d{2})-(\d{2})</ValidFrom>}] ? ($1 + $2 + $3).to_i : 0
|
|
166
|
+
v2 = head[%r{<ValidTill>(\d{4})-(\d{2})-(\d{2})</ValidTill>}] ? ($1 + $2 + $3).to_i : 0
|
|
167
|
+
[v1, v2]
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def validity_string(src)
|
|
171
|
+
return nil if src.valid_from.to_i == 0 || src.valid_to.to_i == 0
|
|
172
|
+
format("%08d-%08d", src.valid_from, src.valid_to)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file is part of konto_check_ruby, a Ruby port of the C library
|
|
4
|
+
# konto_check, Copyright (C) 2002-2023 Michael Plugge <konto_check@yahoo.com>.
|
|
5
|
+
# Ruby port Copyright (C) 2026 tickettoaster GmbH <https://tickettoaster.de>.
|
|
6
|
+
#
|
|
7
|
+
# konto_check_ruby is free software; you can redistribute it and/or modify it
|
|
8
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
9
|
+
# the Free Software Foundation; either version 2.1 of the License, or (at
|
|
10
|
+
# your option) any later version. It is distributed WITHOUT ANY WARRANTY; see
|
|
11
|
+
# the file LICENSE for details.
|
|
12
|
+
|
|
13
|
+
module KontoCheckRuby
|
|
14
|
+
VERSION = "1.0.0"
|
|
15
|
+
VERSION_DATE = "11.09.2026"
|
|
16
|
+
# version of the ported C library konto_check
|
|
17
|
+
C_LIBRARY_VERSION = "6.15.0"
|
|
18
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# This file is part of konto_check_ruby, a Ruby port of the C library
|
|
4
|
+
# konto_check, Copyright (C) 2002-2023 Michael Plugge <konto_check@yahoo.com>.
|
|
5
|
+
# Ruby port Copyright (C) 2026 tickettoaster GmbH <https://tickettoaster.de>.
|
|
6
|
+
#
|
|
7
|
+
# konto_check_ruby is free software; you can redistribute it and/or modify it
|
|
8
|
+
# under the terms of the GNU Lesser General Public License as published by
|
|
9
|
+
# the Free Software Foundation; either version 2.1 of the License, or (at
|
|
10
|
+
# your option) any later version. It is distributed WITHOUT ANY WARRANTY; see
|
|
11
|
+
# the file LICENSE for details.
|
|
12
|
+
|
|
13
|
+
# konto_check_ruby - pure Ruby port of the C library konto_check
|
|
14
|
+
# (check digit validation of German bank accounts, IBAN generation and
|
|
15
|
+
# validation, bank directory lookups) including a Ruby port of the LUT2
|
|
16
|
+
# file format and a parser for the Bundesbank bank code files.
|
|
17
|
+
#
|
|
18
|
+
# The module KontoCheckRuby contains the implementation (Engine, BankData,
|
|
19
|
+
# LutFile, BlzFile, CheckMethods); the modules KontoCheckRaw and KontoCheck
|
|
20
|
+
# provide the interface of the original konto_check gem.
|
|
21
|
+
|
|
22
|
+
require_relative "konto_check_ruby/version"
|
|
23
|
+
require_relative "konto_check_ruby/retvals"
|
|
24
|
+
require_relative "konto_check_ruby/lut_file"
|
|
25
|
+
require_relative "konto_check_ruby/blz_file"
|
|
26
|
+
require_relative "konto_check_ruby/bank_data"
|
|
27
|
+
require_relative "konto_check_ruby/check_methods"
|
|
28
|
+
require_relative "konto_check_ruby/engine"
|
|
29
|
+
require_relative "konto_check_ruby/update"
|
|
30
|
+
require_relative "konto_check_ruby/raw"
|
|
31
|
+
require_relative "konto_check_ruby/konto_check"
|
|
32
|
+
|
|
33
|
+
module KontoCheckRuby
|
|
34
|
+
class << self
|
|
35
|
+
# The engine used by the module level functions of KontoCheckRaw / KontoCheck
|
|
36
|
+
def engine
|
|
37
|
+
@engine ||= Engine.new
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
attr_writer :engine
|
|
41
|
+
end
|
|
42
|
+
end
|