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,421 @@
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 "lut_file"
15
+ require_relative "blz_file"
16
+
17
+ module KontoCheckRuby
18
+ # In-memory representation of the bank directory: the arrays of the C
19
+ # library (blz, pz_methoden, filialen, startidx, name, ort, ...) plus a hash
20
+ # for the BLZ lookup. The data can be loaded either from a LUT2 file (block
21
+ # by block, like kto_check_init() in C) or directly from the records of a
22
+ # Bundesbank file (BlzFile.parse_file).
23
+ #
24
+ # Indexing follows the C library: the "main office" arrays (blz, pz,
25
+ # filialen, startidx) are indexed 0...cnt_hs, the per-branch arrays (name,
26
+ # name_kurz, plz, ort, pan, bic, nr, aenderung, loeschung, nachfolge_blz,
27
+ # iban_regel) are indexed startidx[idx] + zweigstelle (0...cnt).
28
+ class BankData
29
+ # main office arrays
30
+ attr_reader :cnt_hs, :cnt, :blz, :pz, :filialen, :startidx
31
+ # per branch arrays (nil if the block was not loaded)
32
+ attr_reader :name, :name_kurz, :plz, :ort, :pan, :bic, :bic_h, :nr, :aenderung, :loeschung,
33
+ :nachfolge_blz, :iban_regel, :hs_idx, :startidx_r
34
+ # IBAN blacklist (Array of BLZ Integers, first element 2718281 marks the list) or nil
35
+ attr_reader :own_iban
36
+ # meta data
37
+ attr_reader :info, :valid_from, :valid_to, :lut_id, :block_status, :source, :set
38
+ attr_accessor :level
39
+
40
+ EMPTY_BIC = " " # 11 blanks, like the C library for a missing BIC
41
+
42
+ def initialize
43
+ @cnt_hs = 0
44
+ @cnt = 0
45
+ @blz = []
46
+ @pz = nil
47
+ @filialen = nil
48
+ @startidx = []
49
+ @hs_idx = []
50
+ @startidx_r = []
51
+ @block_status = {}
52
+ @info = nil
53
+ @valid_from = 0
54
+ @valid_to = 0
55
+ @lut_id = ""
56
+ @source = nil
57
+ @set = 0
58
+ @level = -1
59
+ @index = {}
60
+ end
61
+
62
+ # ---------------------------------------------------------------- lookup
63
+
64
+ # Index of a BLZ (Integer) in the main office arrays, or nil
65
+ def index(blz_int)
66
+ @index[blz_int]
67
+ end
68
+
69
+ def loaded?(typ)
70
+ @block_status[typ] == OK
71
+ end
72
+
73
+ def branches?
74
+ !@filialen.nil?
75
+ end
76
+
77
+ # Number of branches of the bank at idx (1 if the file has no branches)
78
+ def filialen_at(idx)
79
+ @filialen ? @filialen[idx] : 1
80
+ end
81
+
82
+ # Validity of the loaded data set for the given date (Integer JJJJMMTT)
83
+ def valid(current_date)
84
+ return LUT2_NO_VALID_DATE if @valid_from == 0 || @valid_to == 0
85
+ return LUT2_VALID if current_date >= @valid_from && current_date <= @valid_to
86
+ return LUT2_NOT_YET_VALID if current_date < @valid_from
87
+ LUT2_NO_LONGER_VALID
88
+ end
89
+
90
+ # Info text of the loaded set plus the list of loaded blocks (lut_info in C)
91
+ def info_text
92
+ return nil unless @info
93
+ loaded = (1..LutFile::SET_OFFSET - 1).select { |t| @block_status[t] == OK }.map { |t| LutFile.block_name(t) }
94
+ "#{@info}\nin den Speicher geladene Blocks:\n #{loaded.join(', ')}\n"
95
+ end
96
+
97
+ # ------------------------------------------------------ loading from LUT
98
+
99
+ # Loads the requested blocks from a LUT2 file. required is an Array of
100
+ # block types (the mandatory blocks BLZ, FILIALEN, PZ and AENDERUNG are
101
+ # always added), set 0 selects the currently valid data set.
102
+ # Returns [code, BankData]; code is OK, LUT2_PARTIAL_OK (some requested
103
+ # blocks are missing) or an error code.
104
+ def self.from_lut(filename, required: BlzFile::LUT_SETS[5], set: 0, current_date: LutFile.today_int)
105
+ data = new
106
+ code = data.load_lut(filename, required: required, set: set, current_date: current_date)
107
+ [code, data]
108
+ end
109
+
110
+ def load_lut(filename, required:, set: 0, current_date: LutFile.today_int)
111
+ return FILE_READ_ERROR unless File.file?(filename)
112
+ code, info1, info2, v1, v2 = LutFile.info(filename, current_date)
113
+ return code if [FILE_READ_ERROR, INVALID_LUT_FILE, LUT1_FILE_USED].include?(code)
114
+ if code == OK
115
+ if set == 0
116
+ # the valid set; if none is valid the younger one (the C library
117
+ # always falls back to the first set)
118
+ set = if v1 == LUT2_VALID then 1
119
+ elsif v2 == LUT2_VALID then 2
120
+ elsif v1 == LUT2_NO_VALID_DATE then 1
121
+ elsif v2 == LUT2_NO_VALID_DATE then 2
122
+ elsif v2 == LUT2_NO_LONGER_VALID_BETTER then 2
123
+ else 1
124
+ end
125
+ end
126
+ @info = set == 1 ? info1 : info2
127
+ return LUT2_BLOCK_NOT_IN_FILE unless @info
128
+ @valid_from, @valid_to = LutFile.parse_validity(@info)
129
+ @lut_id = LutFile.file_id(@info) || ""
130
+ else
131
+ set = 1 if set == 0
132
+ @info = nil
133
+ @valid_from = @valid_to = 0
134
+ @lut_id = ""
135
+ end
136
+ @source = filename
137
+ @set = set
138
+ load_blocks(required)
139
+ end
140
+
141
+ # Loads further blocks from the LUT file the data came from (incremental
142
+ # initialisation). Returns OK, LUT2_PARTIAL_OK or an error code.
143
+ def load_blocks(required)
144
+ return LUT2_NOT_INITIALIZED unless @source && File.file?(@source)
145
+ offset = @set == 2 ? LutFile::SET_OFFSET : 0
146
+ wanted = [LUT2_BLZ, LUT2_FILIALEN, LUT2_PZ, LUT2_AENDERUNG]
147
+ required.each { |t| wanted << (t > LutFile::SET_OFFSET ? t - LutFile::SET_OFFSET : t) }
148
+ wanted.uniq!
149
+ alles_ok = true
150
+ File.open(@source, "rb") do |io|
151
+ code, dir = LutFile.read_directory(io)
152
+ return code unless code == OK
153
+ queue = wanted.dup
154
+ until queue.empty?
155
+ typ1 = queue.shift
156
+ next if @block_status[typ1] == OK
157
+ typ = typ1 + offset
158
+ code, data = LutFile.read_block_io(io, dir, typ: typ)
159
+ case code
160
+ when OK
161
+ @block_status[typ] = @block_status[typ1] = OK
162
+ parse_block(typ1, data)
163
+ when LUT2_BLOCK_NOT_IN_FILE
164
+ if typ1 == LUT2_NAME_NAME_KURZ
165
+ # name and short name may be stored in separate blocks
166
+ queue.unshift(LUT2_NAME_KURZ)
167
+ c2, d2 = LutFile.read_block_io(io, dir, typ: LUT2_NAME + offset)
168
+ if c2 == OK
169
+ @block_status[LUT2_NAME + offset] = @block_status[LUT2_NAME] = OK
170
+ parse_block(LUT2_NAME, d2)
171
+ next
172
+ end
173
+ elsif typ1 == LUT2_NAME || typ1 == LUT2_NAME_KURZ
174
+ c2, d2 = LutFile.read_block_io(io, dir, typ: LUT2_NAME_NAME_KURZ + offset)
175
+ if c2 == OK
176
+ @block_status[LUT2_NAME_NAME_KURZ + offset] = @block_status[LUT2_NAME_NAME_KURZ] = OK
177
+ parse_block(LUT2_NAME_NAME_KURZ, d2)
178
+ next
179
+ end
180
+ elsif typ1 == LUT2_OWN_IBAN
181
+ # the blacklist may be stored in the other set
182
+ other = typ == LUT2_OWN_IBAN ? LUT2_2_OWN_IBAN : LUT2_OWN_IBAN
183
+ c2, d2 = LutFile.read_block_io(io, dir, typ: other)
184
+ if c2 == OK
185
+ @block_status[typ] = @block_status[typ1] = OK
186
+ parse_block(LUT2_OWN_IBAN, d2)
187
+ next
188
+ end
189
+ end
190
+ @block_status[typ] = @block_status[typ1] = code
191
+ # missing branch counts or blacklist are not an error
192
+ alles_ok = false unless [LUT2_FILIALEN, LUT2_OWN_IBAN].include?(typ1)
193
+ when LUT2_FILE_CORRUPTED, KTO_CHECK_UNSUPPORTED_COMPRESSION
194
+ @block_status[typ] = @block_status[typ1] = code
195
+ return code
196
+ else
197
+ @block_status[typ] = @block_status[typ1] = code
198
+ alles_ok = false
199
+ end
200
+ end
201
+ end
202
+ return LUT2_NOT_INITIALIZED if @blz.empty? || @pz.nil?
203
+ finish_indexes
204
+ return OK if alles_ok
205
+ case valid(LutFile.today_int)
206
+ when LUT2_NO_LONGER_VALID then LUT2_NO_LONGER_VALID_PARTIAL_OK
207
+ when LUT2_NOT_YET_VALID then LUT2_NOT_YET_VALID_PARTIAL_OK
208
+ else LUT2_PARTIAL_OK
209
+ end
210
+ rescue SystemCallError
211
+ FILE_READ_ERROR
212
+ end
213
+
214
+ def parse_block(typ, data)
215
+ case typ
216
+ when LUT2_BLZ then parse_blz(data)
217
+ when LUT2_FILIALEN then parse_filialen(data)
218
+ when LUT2_PZ then @pz = data.unpack("C*")
219
+ when LUT2_NAME
220
+ strings = split_strings(data)
221
+ @name = resolve_hs_names(strings)
222
+ when LUT2_NAME_KURZ then @name_kurz = split_strings(data).map { |s| iso(s) }
223
+ when LUT2_NAME_NAME_KURZ
224
+ strings = split_strings(data)
225
+ names = []
226
+ kurz = []
227
+ strings.each_slice(2) do |n, k|
228
+ names << n
229
+ kurz << (k || "")
230
+ end
231
+ @name = resolve_hs_names(names)
232
+ @name_kurz = kurz.map { |s| iso(s) }
233
+ @block_status[LUT2_NAME] = OK
234
+ @block_status[LUT2_NAME_KURZ] = OK
235
+ when LUT2_PLZ then @plz = unpack_u24(data)
236
+ when LUT2_ORT then @ort = split_strings(data).map { |s| iso(s) }
237
+ when LUT2_IBAN_REGEL then @iban_regel = unpack_u24(data)
238
+ when LUT2_PAN then @pan = unpack_u24(data)
239
+ when LUT2_BIC then parse_bic(data)
240
+ when LUT2_NR then @nr = unpack_u24(data)
241
+ when LUT2_AENDERUNG then @aenderung = data.chars.map { |c| c.force_encoding("UTF-8") }
242
+ when LUT2_LOESCHUNG then @loeschung = data.chars.map { |c| c.force_encoding("UTF-8") }
243
+ when LUT2_NACHFOLGE_BLZ then @nachfolge_blz = data.unpack("V*")
244
+ when LUT2_OWN_IBAN
245
+ cnt = data.unpack1("V")
246
+ @own_iban = data.byteslice(4, cnt * 4).unpack("V*")
247
+ end
248
+ end
249
+
250
+ private
251
+
252
+ def iso(s)
253
+ s.force_encoding("ISO-8859-1").encode("UTF-8")
254
+ end
255
+
256
+ def split_strings(data)
257
+ # strings are NUL terminated; a trailing NUL produces no extra element
258
+ parts = data.split("\0", -1)
259
+ parts.pop if parts.last == ""
260
+ parts
261
+ end
262
+
263
+ # Byte 1 at the start marks a main office name, an empty string means
264
+ # "same name as the main office".
265
+ def resolve_hs_names(strings)
266
+ hs = ""
267
+ strings.map do |s|
268
+ if s.getbyte(0) == 1
269
+ hs = iso(s.byteslice(1..))
270
+ hs
271
+ elsif s.empty?
272
+ hs
273
+ else
274
+ iso(s)
275
+ end
276
+ end
277
+ end
278
+
279
+ def unpack_u24(data)
280
+ out = []
281
+ i = 0
282
+ n = data.bytesize
283
+ while i + 2 < n
284
+ out << (data.getbyte(i) | (data.getbyte(i + 1) << 8) | (data.getbyte(i + 2) << 16))
285
+ i += 3
286
+ end
287
+ out
288
+ end
289
+
290
+ def parse_blz(data)
291
+ @cnt_hs, @cnt = data.unpack("vv")
292
+ @blz = []
293
+ pos = 4
294
+ prev = 0
295
+ while @blz.size < @cnt_hs && pos < data.bytesize
296
+ j = data.getbyte(pos)
297
+ pos += 1
298
+ case j
299
+ when 254
300
+ prev += data.byteslice(pos, 2).unpack1("v")
301
+ pos += 2
302
+ when 255
303
+ prev = data.byteslice(pos, 4).unpack1("V")
304
+ pos += 4
305
+ else
306
+ prev += j
307
+ end
308
+ @blz << prev
309
+ end
310
+ @startidx = (0...@cnt_hs).to_a
311
+ @index = {}
312
+ @blz.each_with_index { |b, i| @index[b] = i }
313
+ end
314
+
315
+ def parse_filialen(data)
316
+ @filialen = data.unpack("C*")
317
+ j = 0
318
+ @filialen.each_with_index do |f, i|
319
+ @startidx[i] = i + j
320
+ j += f - 1
321
+ end
322
+ end
323
+
324
+ def parse_bic(data)
325
+ @bic = []
326
+ pos = 0
327
+ n = data.bytesize
328
+ while pos < n && @bic.size < [@cnt, 1].max
329
+ c = data.getbyte(pos)
330
+ if c == 0
331
+ @bic << EMPTY_BIC
332
+ pos += 1
333
+ elsif c == 1
334
+ @bic << data.byteslice(pos + 1, 11).force_encoding("UTF-8")
335
+ pos += 12
336
+ else
337
+ s = data.byteslice(pos, 9)
338
+ @bic << (s[0, 4] + "DE" + s[4, 5]).force_encoding("UTF-8")
339
+ pos += 9
340
+ end
341
+ end
342
+ end
343
+
344
+ # Builds the reciprocal indexes (hs_idx, startidx_r) and bic_h.
345
+ def finish_indexes
346
+ # without the FILIALEN block only the main offices are in the arrays
347
+ @cnt = @cnt_hs if @filialen.nil?
348
+ @hs_idx = Array.new(@cnt, 0)
349
+ @startidx_r = Array.new(@cnt, 0)
350
+ @cnt_hs.times do |i|
351
+ f = filialen_at(i)
352
+ s = @startidx[i]
353
+ f.times do |k|
354
+ @hs_idx[s + k] = s
355
+ @startidx_r[s + k] = i
356
+ end
357
+ end
358
+ @bic_h = @bic ? @bic.each_index.map { |j| @bic[@hs_idx[j] || j] || EMPTY_BIC } : nil
359
+ end
360
+
361
+ public
362
+
363
+ # ------------------------------------------- loading from Bundesbank data
364
+
365
+ # Builds the data set directly from BankRecords (see BlzFile). All blocks
366
+ # are available afterwards.
367
+ def self.from_records(records, source: nil, info: nil, valid_from: 0, valid_to: 0)
368
+ data = new
369
+ data.load_records(records, source: source, info: info, valid_from: valid_from, valid_to: valid_to)
370
+ data
371
+ end
372
+
373
+ def load_records(records, source: nil, info: nil, valid_from: 0, valid_to: 0)
374
+ sorted = BlzFile.sort_records(records)
375
+ @source = source
376
+ @set = 1
377
+ @cnt = sorted.size
378
+ @blz = []
379
+ @pz = []
380
+ @filialen = []
381
+ @startidx = []
382
+ @index = {}
383
+ sorted.each_with_index do |r, i|
384
+ if @blz.last == r.blz
385
+ @filialen[-1] += 1
386
+ @pz[-1] = r.pz if @pz_from_branch && (r.hauptstelle == "1" || r.hauptstelle == "3")
387
+ else
388
+ @index[r.blz] = @blz.size
389
+ @blz << r.blz
390
+ @pz << r.pz
391
+ @pz_from_branch = !(r.hauptstelle == "1" || r.hauptstelle == "3")
392
+ @filialen << 1
393
+ @startidx << i
394
+ end
395
+ end
396
+ @pz_from_branch = nil
397
+ @cnt_hs = @blz.size
398
+ @name = sorted.map(&:name)
399
+ @name_kurz = sorted.map(&:name_kurz)
400
+ @plz = sorted.map(&:plz)
401
+ @ort = sorted.map(&:ort)
402
+ @pan = sorted.map(&:pan)
403
+ @bic = sorted.map { |r| r.bic.empty? ? EMPTY_BIC : r.bic.ljust(11) }
404
+ @nr = sorted.map(&:nr)
405
+ @aenderung = sorted.map(&:aenderung)
406
+ @loeschung = sorted.map(&:loeschung)
407
+ @nachfolge_blz = sorted.map(&:nachfolge_blz)
408
+ @iban_regel = sorted.map(&:iban_regel)
409
+ [LUT2_BLZ, LUT2_FILIALEN, LUT2_PZ, LUT2_NAME, LUT2_NAME_KURZ, LUT2_NAME_NAME_KURZ, LUT2_PLZ, LUT2_ORT,
410
+ LUT2_PAN, LUT2_BIC, LUT2_NR, LUT2_AENDERUNG, LUT2_LOESCHUNG, LUT2_NACHFOLGE_BLZ,
411
+ LUT2_IBAN_REGEL].each { |t| @block_status[t] = OK }
412
+ @valid_from = valid_from
413
+ @valid_to = valid_to
414
+ @info = info || format("Gueltigkeit der Daten: %08d-%08d (Erster Datensatz)\nEnthaltene Felder: BLZ, PZ, FILIALEN, AENDERUNG, NAME_NAME_KURZ, PLZ, ORT, IBAN_REGEL, BIC, NACHFOLGE_BLZ, LOESCHUNG, PAN, NR\n\nDatensatz aus %s\nAnzahl Banken: %d, davon Hauptstellen: %d\n",
415
+ valid_from, valid_to, source || "Bundesbank-Datei", @cnt, @cnt_hs)
416
+ @lut_id = ""
417
+ finish_indexes
418
+ OK
419
+ end
420
+ end
421
+ end