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.
@@ -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(?:ü|&uuml;|\\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