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,705 @@
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 "set"
14
+ require_relative "retvals"
15
+ require_relative "lut_file"
16
+
17
+ module KontoCheckRuby
18
+ # Parser for the "Bankleitzahlendatei" of the Deutsche Bundesbank (the
19
+ # fixed width text format, ISO-8859-1, 168 characters per record in the
20
+ # old format without IBAN rules or 174 characters with IBAN rules) and
21
+ # generator for LUT2 files from it.
22
+ #
23
+ # Record layout (0 based character positions):
24
+ #
25
+ # 0- 7 BLZ 8- 8 Merkmal (1 = Hauptstelle, 2 = Zweigstelle)
26
+ # 9-66 Bezeichnung 67-71 PLZ
27
+ # 72-106 Ort 107-133 Kurzbezeichnung
28
+ # 134-138 PAN 139-149 BIC
29
+ # 150-151 Prüfzifferberechnungsmethode
30
+ # 152-157 Datensatznummer 158 Änderungskennzeichen (A, D, M, U)
31
+ # 159 Bankleitzahllöschung 160-167 Nachfolge-BLZ
32
+ # 168-173 IBAN-Regel (Regel 4 Stellen + Version 2 Stellen)
33
+ module BlzFile
34
+ LINE_LEN_OLD = 168
35
+ LINE_LEN_NEW = 174
36
+
37
+ # One record of the Bundesbank file. All strings are UTF-8, numbers are
38
+ # Integers. hauptstelle is the character "1" or "2" (or "3" for test banks
39
+ # of old files), aenderung/loeschung single characters.
40
+ BankRecord = Struct.new(:blz, :hauptstelle, :name, :plz, :ort, :name_kurz, :pan, :bic, :pz,
41
+ :nr, :aenderung, :loeschung, :nachfolge_blz, :iban_regel, keyword_init: true) do
42
+ def hauptstelle?
43
+ hauptstelle == "1"
44
+ end
45
+
46
+ # IBAN rule number without version
47
+ def regel
48
+ iban_regel / 100
49
+ end
50
+
51
+ def regel_version
52
+ iban_regel % 100
53
+ end
54
+
55
+ # Formats the record as a line of the Bundesbank file (174 characters,
56
+ # without line terminator, UTF-8; convert to ISO-8859-1 for a real file).
57
+ def to_line(with_iban_rule: true)
58
+ s = format("%08d%1s%-58s%05d%-35s%-27s%5s%-11s%02s%6s%1s%1s%08d",
59
+ blz, hauptstelle, name[0, 58], plz, ort[0, 35], name_kurz[0, 27],
60
+ pan.zero? ? "" : format("%05d", pan), bic,
61
+ pz_string, nr.zero? ? "" : format("%06d", nr),
62
+ aenderung, loeschung, nachfolge_blz)
63
+ s += format("%06d", iban_regel) if with_iban_rule
64
+ s
65
+ end
66
+
67
+ def pz_string
68
+ BlzFile.pz_to_string(pz)
69
+ end
70
+ end
71
+
72
+ # The Bundesbank file does not contain the test banks used in the
73
+ # description of the check methods 52, 53 and B6; konto_check adds them.
74
+ TEST_BANK_LINES = [
75
+ "130511721Testbank Verfahren 52 57368Elsperhusen Testbank 52 Elsperhusen 13145TESTDEX987652130000U000000000000000",
76
+ "160520721Testbank Verfahren 53 57368Elsperhusen Testbank 53 Elsperhusen 13145TESTDEX987653130000U000000000000000",
77
+ "800537721Testbank Verfahren B6 57368Elsperhusen Testbank B6 Elsperhusen 13145TESTDEX9876B6130000U000000000000000",
78
+ "800537821Testbank Verfahren B6 57368Elsperhusen Testbank B6 Elsperhusen 13145TESTDEX9876B6130000U000000000000000"
79
+ ].freeze
80
+
81
+ # "00".."99", "A0".."Z9" -> 0..359 (bx2/bx1 tables in C)
82
+ def self.pz_from_string(s)
83
+ c1 = s[0]
84
+ c2 = s[1]
85
+ v1 = if c1 =~ /\d/ then c1.to_i elsif c1 =~ /[A-Za-z]/ then c1.upcase.ord - 65 + 10 else 0 end
86
+ v2 = c2 =~ /\d/ ? c2.to_i : 0
87
+ v1 * 10 + v2
88
+ end
89
+
90
+ def self.pz_to_string(pz)
91
+ pz < 100 ? format("%02d", pz) : ((pz / 10) - 10 + 65).chr + (pz % 10).to_s
92
+ end
93
+
94
+ # Parses the content of a Bundesbank file (binary/ISO-8859-1 String).
95
+ # Returns [code, records, file_format] where file_format is 1 (old format
96
+ # without IBAN rules), 2 (with IBAN rules) or 0 (unknown line length).
97
+ def self.parse_string(content, add_test_banks: true)
98
+ data = content.b
99
+ first_eol = data.index(/\r|\n/) || data.bytesize
100
+ case first_eol
101
+ when LINE_LEN_OLD then file_format = 1
102
+ when LINE_LEN_NEW then file_format = 2
103
+ else file_format = 0
104
+ end
105
+ records = []
106
+ data.each_line do |line|
107
+ line = line.chomp
108
+ next if line.empty? && records.any?
109
+ return [INVALID_BLZ_FILE, nil, file_format] if line.bytesize != first_eol
110
+ records << parse_line(line, file_format)
111
+ end
112
+ if add_test_banks
113
+ present = records.map(&:blz).to_set
114
+ TEST_BANK_LINES.each do |l|
115
+ l = l[0, LINE_LEN_OLD] if file_format == 1
116
+ rec = parse_line(l, file_format)
117
+ records << rec unless present.include?(rec.blz)
118
+ end
119
+ end
120
+ [OK, records, file_format]
121
+ end
122
+
123
+ def self.parse_file(filename, add_test_banks: true)
124
+ return [FILE_READ_ERROR, nil, 0] unless File.file?(filename)
125
+ parse_string(File.binread(filename), add_test_banks: add_test_banks)
126
+ rescue SystemCallError
127
+ [FILE_READ_ERROR, nil, 0]
128
+ end
129
+
130
+ # Parses one (binary, ISO-8859-1) line of the file
131
+ def self.parse_line(line, file_format = 2)
132
+ l = line.b
133
+ BankRecord.new(
134
+ blz: l[0, 8].to_i,
135
+ hauptstelle: l[8, 1],
136
+ name: iso(l[9, 58]),
137
+ plz: l[67, 5].to_i,
138
+ ort: iso(l[72, 35]),
139
+ name_kurz: iso(l[107, 27]),
140
+ pan: l[134, 5].strip.empty? ? 0 : l[134, 5].to_i,
141
+ bic: l[139, 11].rstrip,
142
+ pz: pz_from_string(l[150, 2]),
143
+ nr: l[152, 6].to_i,
144
+ aenderung: l[158, 1],
145
+ loeschung: l[159, 1],
146
+ nachfolge_blz: l[160, 8].to_i,
147
+ iban_regel: file_format == 2 && l.bytesize >= LINE_LEN_NEW ? l[168, 6].to_i : 0
148
+ )
149
+ end
150
+
151
+ def self.iso(s)
152
+ s.rstrip.force_encoding("ISO-8859-1").encode("UTF-8")
153
+ end
154
+
155
+ # Sorts the records like konto_check does: by BLZ, then main office
156
+ # before branches, then by original position (stable).
157
+ def self.sort_records(records)
158
+ records.each_with_index.sort_by { |r, i| [r.blz, r.hauptstelle, i] }.map(&:first)
159
+ end
160
+
161
+ # Field sets for the init levels 0..9 (lut_set_0 .. lut_set_9 in C)
162
+ LUT_SETS = [
163
+ [LUT2_BLZ, LUT2_PZ],
164
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_KURZ],
165
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_KURZ, LUT2_BIC],
166
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME, LUT2_PLZ, LUT2_ORT],
167
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME, LUT2_PLZ, LUT2_ORT, LUT2_IBAN_REGEL, LUT2_BIC],
168
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_NAME_KURZ, LUT2_PLZ, LUT2_ORT, LUT2_IBAN_REGEL, LUT2_BIC],
169
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_NAME_KURZ, LUT2_PLZ, LUT2_ORT, LUT2_IBAN_REGEL, LUT2_BIC, LUT2_NACHFOLGE_BLZ],
170
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_NAME_KURZ, LUT2_PLZ, LUT2_ORT, LUT2_IBAN_REGEL, LUT2_BIC, LUT2_NACHFOLGE_BLZ, LUT2_LOESCHUNG],
171
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_NAME_KURZ, LUT2_PLZ, LUT2_ORT, LUT2_IBAN_REGEL, LUT2_BIC, LUT2_NACHFOLGE_BLZ, LUT2_LOESCHUNG, LUT2_PAN],
172
+ [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_NAME_NAME_KURZ, LUT2_PLZ, LUT2_ORT, LUT2_IBAN_REGEL, LUT2_BIC, LUT2_NACHFOLGE_BLZ, LUT2_LOESCHUNG, LUT2_PAN, LUT2_NR]
173
+ ].freeze
174
+
175
+ # Blocks needed for the IBAN functions (lut_set_iban in C)
176
+ LUT_SET_IBAN = [LUT2_BLZ, LUT2_PZ, LUT2_AENDERUNG, LUT2_BIC, LUT2_NACHFOLGE_BLZ, LUT2_LOESCHUNG, LUT2_IBAN_REGEL].freeze
177
+
178
+ # Generates a LUT2 file from a Bundesbank file (generate_lut2 /
179
+ # generate_lut2_p in C).
180
+ #
181
+ # input Bundesbank text file
182
+ # output LUT file to write (created for set 0/1, appended for set 2)
183
+ # user_info free text stored in the prolog and the info block
184
+ # gueltigkeit validity "JJJJMMTT-JJJJMMTT" or nil
185
+ # felder 0..9 (field set, see LUT_SETS) or an Array of block types
186
+ # filialen true: include branches, false: main offices only
187
+ # slots number of directory slots (default 60)
188
+ # set 0 = new file, 1 = new file (primary set), 2 = append secondary set
189
+ # add_index write the sort index blocks (default true)
190
+ # iban_rules :default applies the built-in IBAN rule table to files
191
+ # without IBAN rule column (see default_iban_rules), false
192
+ # disables that, a Hash or a file name supplies own rules
193
+ #
194
+ # Returns a return code (OK or an error).
195
+ def self.generate_lut(input, output, user_info: "", gueltigkeit: nil, felder: 9, filialen: true,
196
+ slots: LutFile::DEFAULT_SLOTS, set: 0, add_index: true, now: Time.now, iban_rules: :default)
197
+ code, records, file_format, v1, v2 = parse_any(input)
198
+ return code unless code == OK
199
+ if file_format != 2 && (table = iban_rule_table(iban_rules))
200
+ file_format = 2 if apply_iban_rules!(records, table) > 0
201
+ end
202
+ gueltigkeit = format("%08d-%08d", v1, v2) if gueltigkeit.nil? && v1 != 0 && v2 != 0
203
+ generate_lut_from_records(records, output, input_name: input, file_format: file_format,
204
+ user_info: user_info, gueltigkeit: gueltigkeit,
205
+ felder: felder, filialen: filialen, slots: slots,
206
+ set: set, add_index: add_index, now: now)
207
+ end
208
+
209
+ def self.generate_lut_from_records(records, output, input_name: "blz.txt", file_format: 2, user_info: "",
210
+ gueltigkeit: nil, felder: 9, filialen: true,
211
+ slots: LutFile::DEFAULT_SLOTS, set: 0, add_index: true, now: Time.now)
212
+ ok = OK
213
+ g1 = g2 = 0
214
+ if gueltigkeit && !gueltigkeit.empty?
215
+ return LUT2_INVALID_GUELTIGKEIT unless gueltigkeit =~ /\A(\d{8})[ -](\d{8})\z/
216
+ g1 = Regexp.last_match(1).to_i
217
+ g2 = Regexp.last_match(2).to_i
218
+ return LUT2_GUELTIGKEIT_SWAPPED if g2 < g1
219
+ end
220
+ fields = if felder.is_a?(Array)
221
+ felder.dup
222
+ else
223
+ base = LUT_SETS[felder.to_i] || LUT_SETS[9]
224
+ f = [LUT2_BLZ, LUT2_PZ]
225
+ f += [LUT2_FILIALEN, LUT2_VOLLTEXT_TXT] if filialen
226
+ f + base
227
+ end
228
+ fields.uniq!
229
+ fields.delete(LUT2_IBAN_REGEL) unless file_format == 2
230
+ fields.reject! { |f| f < 1 || f > LAST_LUT_BLOCK || f == LUT2_OWN_IBAN }
231
+ slots = LutFile::DEFAULT_SLOTS if slots.nil? || slots == 0
232
+ if slots < LutFile::SLOT_CNT_MIN
233
+ slots = LutFile::SLOT_CNT_MIN
234
+ ok = OK_SLOT_CNT_MIN_USED
235
+ end
236
+ auch_filialen = fields.include?(LUT2_FILIALEN)
237
+ have_iban_rules = fields.include?(LUT2_IBAN_REGEL)
238
+ sorted = sort_records(records)
239
+ hs_cnt = sorted.count(&:hauptstelle?)
240
+ user_info = user_info.to_s.tr("\r\n", " ")
241
+
242
+ info = format("Gueltigkeit der Daten: %08d-%08d (%s Datensatz)\nEnthaltene Felder:", g1, g2, set < 2 ? "Erster" : "Zweiter")
243
+ info << " " << fields.map { |f| LutFile.block_name(f) + (add_index && LutFile::BLOCKS_WITH_INDEX.include?(f) ? "+" : "") }.join(", ")
244
+ info << "\n\n"
245
+ file_id = Array.new(8) { format("%04x", rand(32768)) }.join
246
+ prolog = format("BLZ Lookup Table/Format 2.0\nLUT-Datei generiert am %d.%d.%d, %d:%02d aus %s%s%s\n" \
247
+ "Anzahl Banken: %d, davon Hauptstellen: %d (inkl. %d Testbanken)\n" \
248
+ "dieser Datensatz enthaelt %s, %s und %sIBAN-Regeln\n" \
249
+ "Kompression: %s\nDatei-ID (zufaellig, fuer inkrementelle Initialisierung):\n%s\n",
250
+ now.day, now.month, now.year, now.hour, now.min, input_name,
251
+ user_info.empty? ? "" : "\\\n", user_info,
252
+ sorted.size, hs_cnt, TEST_BANK_LINES.size,
253
+ auch_filialen ? "auch die Filialen" : "nur die Hauptstellen",
254
+ add_index ? "sowie Indexblocks" : "keine Indexblocks",
255
+ have_iban_rules ? "" : "keine ", "zlib", file_id)
256
+ info << prolog
257
+ info = info.encode("ISO-8859-1", undef: :replace)
258
+ prolog_iso = prolog.chomp.encode("ISO-8859-1", undef: :replace)
259
+
260
+ set = 0 if set > 0 && !File.file?(output)
261
+ if set == 0
262
+ code = LutFile.create(output, prolog_iso, slots)
263
+ return code unless code == OK
264
+ end
265
+ offset = set == 2 ? LutFile::SET_OFFSET : 0
266
+ begin
267
+ File.open(output, "r+b") do |io|
268
+ code = LutFile.write_block_io(io, LUT2_INFO + offset, info)
269
+ return code unless code == OK
270
+ blocks = BlockBuilder.new(sorted, auch_filialen).build(fields, add_index)
271
+ blocks.each do |typ, data|
272
+ code = LutFile.write_block_io(io, typ + offset, data)
273
+ return code unless code == OK
274
+ end
275
+ end
276
+ rescue SystemCallError
277
+ return FILE_WRITE_ERROR
278
+ end
279
+ ok
280
+ end
281
+
282
+ # Encodes the sorted records into the binary LUT block formats
283
+ # (write_lutfile_entry_de in C).
284
+ class BlockBuilder
285
+ def initialize(sorted, auch_filialen)
286
+ @recs = sorted
287
+ @auch_filialen = auch_filialen
288
+ @sel = auch_filialen ? sorted : sorted.select(&:hauptstelle?)
289
+ end
290
+
291
+ def u16(v) = [v].pack("v")
292
+ def u24(v) = [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff].pack("C3")
293
+ def u32(v) = [v].pack("V")
294
+
295
+ def iso(s)
296
+ s.encode("ISO-8859-1", undef: :replace, invalid: :replace)
297
+ end
298
+
299
+ # returns Array of [typ, data]
300
+ def build(fields, add_index)
301
+ out = []
302
+ fields.each do |f|
303
+ case f
304
+ when LUT2_BLZ then out << [f, blz_block]
305
+ when LUT2_FILIALEN then out << [f, filialen_block] if @auch_filialen
306
+ when LUT2_NAME
307
+ names = @sel.map(&:name)
308
+ out << [f, name_block(names)]
309
+ out << [LUT2_NAME_SORT, sort_block_str(names)] if add_index
310
+ when LUT2_NAME_KURZ
311
+ v = @sel.map(&:name_kurz)
312
+ out << [f, string_block(v)]
313
+ out << [LUT2_NAME_KURZ_SORT, sort_block_str(v)] if add_index
314
+ when LUT2_NAME_NAME_KURZ
315
+ out << [f, name_name_kurz_block]
316
+ if add_index
317
+ out << [LUT2_NAME_SORT, sort_block_str(@sel.map(&:name))]
318
+ out << [LUT2_NAME_KURZ_SORT, sort_block_str(@sel.map(&:name_kurz))]
319
+ end
320
+ when LUT2_PLZ
321
+ v = @sel.map(&:plz)
322
+ out << [f, v.map { |x| u24(x) }.join]
323
+ out << [LUT2_PLZ_SORT, sort_block_int(v)] if add_index
324
+ when LUT2_ORT
325
+ v = @sel.map(&:ort)
326
+ out << [f, string_block(v)]
327
+ out << [LUT2_ORT_SORT, sort_block_str(v)] if add_index
328
+ when LUT2_PAN then out << [f, @sel.map { |r| u24(r.pan) }.join]
329
+ when LUT2_BIC
330
+ out << [f, bic_block]
331
+ if add_index
332
+ out << [LUT2_BIC_SORT, sort_block_str(@sel.map(&:bic))]
333
+ out << [LUT2_BIC_H_SORT, sort_block_str(bic_h_list)]
334
+ end
335
+ when LUT2_PZ
336
+ # one check method per BLZ (the C library writes one entry per
337
+ # main office record, which is the same for well-formed files)
338
+ v = pz_per_blz
339
+ out << [f, v.pack("C*")]
340
+ out << [LUT2_PZ_SORT, sort_block_int(v)] if add_index
341
+ when LUT2_NR then out << [f, @sel.map { |r| u24(r.nr) }.join]
342
+ when LUT2_AENDERUNG then out << [f, @sel.map(&:aenderung).join.b]
343
+ when LUT2_LOESCHUNG then out << [f, @sel.map(&:loeschung).join.b]
344
+ when LUT2_NACHFOLGE_BLZ then out << [f, @sel.map { |r| u32(r.nachfolge_blz) }.join]
345
+ when LUT2_IBAN_REGEL
346
+ v = @sel.map(&:iban_regel)
347
+ out << [f, v.map { |x| u24(x) }.join]
348
+ out << [LUT2_IBAN_REGEL_SORT, sort_block_int(v)] if add_index
349
+ when LUT2_VOLLTEXT_TXT
350
+ txt, idx = volltext_blocks
351
+ out << [f, txt]
352
+ out << [LUT2_VOLLTEXT_IDX, idx]
353
+ end
354
+ end
355
+ out
356
+ end
357
+
358
+ def blz_block
359
+ data = +"".b
360
+ prev = 0
361
+ cnt = 0
362
+ @recs.each do |r|
363
+ diff = r.blz - prev
364
+ prev = r.blz
365
+ next if diff == 0
366
+ if diff <= 253
367
+ data << diff.chr
368
+ elsif diff < 65536
369
+ data << 254.chr << u16(diff)
370
+ else
371
+ data << 255.chr << u32(r.blz)
372
+ end
373
+ cnt += 1
374
+ end
375
+ u16(cnt) + u16(@recs.size) + data
376
+ end
377
+
378
+ def pz_per_blz
379
+ out = []
380
+ last = nil
381
+ fixed = false
382
+ @recs.each do |r|
383
+ hs = r.hauptstelle == "1" || r.hauptstelle == "3"
384
+ if r.blz != last
385
+ out << r.pz
386
+ fixed = hs
387
+ last = r.blz
388
+ elsif hs && !fixed
389
+ out[-1] = r.pz
390
+ fixed = true
391
+ end
392
+ end
393
+ out
394
+ end
395
+
396
+ def filialen_block
397
+ counts = []
398
+ last = nil
399
+ @recs.each do |r|
400
+ if r.blz == last
401
+ counts[-1] += 1
402
+ else
403
+ counts << 1
404
+ last = r.blz
405
+ end
406
+ end
407
+ counts.map { |c| c & 255 }.pack("C*")
408
+ end
409
+
410
+ # Names: a main office name is prefixed with byte 1, a branch with the
411
+ # same name as its main office is stored as an empty string.
412
+ def name_block(names)
413
+ data = +"".b
414
+ hs_name = nil
415
+ @sel.each_with_index do |r, i|
416
+ if r.hauptstelle? && @auch_filialen
417
+ hs_name = names[i]
418
+ data << 1.chr << iso(names[i]) << 0.chr
419
+ elsif r.hauptstelle == "2" && hs_name == names[i]
420
+ data << 0.chr
421
+ else
422
+ data << iso(names[i]) << 0.chr
423
+ end
424
+ end
425
+ data
426
+ end
427
+
428
+ def name_name_kurz_block
429
+ data = +"".b
430
+ hs_name = nil
431
+ @sel.each do |r|
432
+ if r.hauptstelle? && @auch_filialen
433
+ hs_name = r.name
434
+ data << 1.chr << iso(r.name) << 0.chr
435
+ elsif r.hauptstelle == "2" && hs_name == r.name
436
+ data << 0.chr
437
+ else
438
+ data << iso(r.name) << 0.chr
439
+ end
440
+ data << iso(r.name_kurz) << 0.chr
441
+ end
442
+ data
443
+ end
444
+
445
+ def string_block(values)
446
+ values.map { |v| iso(v) + 0.chr }.join.b
447
+ end
448
+
449
+ # BIC: German BICs are stored without the "DE" at positions 5/6 (9 bytes),
450
+ # foreign BICs with a leading byte 1 (11 bytes), missing BICs as a 0 byte.
451
+ def bic_block
452
+ data = +"".b
453
+ @sel.each do |r|
454
+ b = r.bic
455
+ if b.empty?
456
+ data << 0.chr
457
+ elsif b[4, 2] == "DE" && b.length == 11
458
+ data << b[0, 4] << b[6, 5]
459
+ else
460
+ data << 1.chr << b.ljust(11)[0, 11]
461
+ end
462
+ end
463
+ data
464
+ end
465
+
466
+ def bic_h_list
467
+ hs_bic = ""
468
+ @sel.map do |r|
469
+ hs_bic = r.bic if r.hauptstelle?
470
+ r.hauptstelle? ? r.bic : hs_bic
471
+ end
472
+ end
473
+
474
+ def sort_block_int(values)
475
+ idx = (0...values.size).sort_by { |i| [values[i], i] }
476
+ u16(values.size) + idx.pack("v*")
477
+ end
478
+
479
+ def sort_block_str(values)
480
+ keys = values.map { |v| Collation.sort_key(v) }
481
+ idx = (0...values.size).sort_by { |i| [keys[i], i] }
482
+ u16(values.size) + idx.pack("v*")
483
+ end
484
+
485
+ # Full text index: all words of name, ort and name_kurz
486
+ def volltext_blocks
487
+ words = Hash.new { |h, k| h[k] = [] }
488
+ @sel.each_with_index do |r, j|
489
+ [r.name, r.ort, r.name_kurz].each do |field|
490
+ Collation.words(field).each do |w|
491
+ key = Collation.sort_key(w)
492
+ list = words[key]
493
+ list << [w, j] if list.empty? || list.last[1] != j
494
+ end
495
+ end
496
+ end
497
+ keys = words.keys.sort
498
+ txt = keys.map { |k| iso(words[k].first[0]) + 0.chr }.join.b
499
+ counts = keys.map { |k| words[k].size }
500
+ banks = keys.flat_map { |k| words[k].map { |_w, j| j } }
501
+ idx = u32(keys.size) + u32(banks.size) + counts.pack("v*") + banks.pack("v*")
502
+ [txt, idx]
503
+ end
504
+ end
505
+ end
506
+ end
507
+
508
+ module KontoCheckRuby
509
+ module BlzFile
510
+ # ------------------------------------------------- other Bundesbank formats
511
+
512
+ # Detects the format of a Bundesbank file by its content:
513
+ # :xml, :csv or :txt (fixed width).
514
+ def self.detect_format(content)
515
+ head = content.b[0, 4096].lstrip
516
+ return :xml if head.start_with?("<?xml") || head.start_with?("<Document")
517
+ return :csv if head.lines.first.to_s.include?(";")
518
+ :txt
519
+ end
520
+
521
+ # Parses a Bundesbank file in any of the published formats (fixed width
522
+ # TXT, CSV, XML). Returns [code, records, file_format, valid_from, valid_to]
523
+ # (validity only from XML files, else 0).
524
+ def self.parse_any(filename, add_test_banks: true)
525
+ return [FILE_READ_ERROR, nil, 0, 0, 0] unless File.file?(filename)
526
+ content = File.binread(filename)
527
+ case detect_format(content)
528
+ when :xml then parse_xml_string(content, add_test_banks: add_test_banks)
529
+ when :csv
530
+ code, records, fmt = parse_csv_string(content, add_test_banks: add_test_banks)
531
+ [code, records, fmt, 0, 0]
532
+ else
533
+ code, records, fmt = parse_string(content, add_test_banks: add_test_banks)
534
+ [code, records, fmt, 0, 0]
535
+ end
536
+ rescue SystemCallError
537
+ [FILE_READ_ERROR, nil, 0, 0, 0]
538
+ end
539
+
540
+ CSV_COLUMNS = %w[blz hauptstelle name plz ort name_kurz pan bic pz nr aenderung loeschung nachfolge_blz iban_regel].freeze
541
+
542
+ # Parses the CSV variant (semicolon separated, quoted fields, ISO-8859-1,
543
+ # first line is the header). Returns [code, records, file_format].
544
+ def self.parse_csv_string(content, add_test_banks: true)
545
+ text = content.b.force_encoding("ISO-8859-1").encode("UTF-8")
546
+ lines = text.lines.map(&:chomp).reject(&:empty?)
547
+ return [INVALID_BLZ_FILE, nil, 0] if lines.empty?
548
+ header = csv_fields(lines.shift)
549
+ has_rules = header.any? { |h| h =~ /IBAN/i }
550
+ records = []
551
+ lines.each do |line|
552
+ f = csv_fields(line)
553
+ return [INVALID_BLZ_FILE, nil, 0] if f.size < 13
554
+ records << BankRecord.new(
555
+ blz: f[0].to_i, hauptstelle: f[1], name: f[2].strip, plz: f[3].to_i, ort: f[4].strip,
556
+ name_kurz: f[5].strip, pan: f[6].strip.empty? ? 0 : f[6].to_i, bic: f[7].strip,
557
+ pz: pz_from_string(f[8].strip.ljust(2, "0")), nr: f[9].to_i, aenderung: f[10].strip[0, 1] || " ",
558
+ loeschung: f[11].strip[0, 1] || "0", nachfolge_blz: f[12].to_i,
559
+ iban_regel: has_rules && f[13] ? f[13].to_i : 0
560
+ )
561
+ end
562
+ add_test_bank_records(records, has_rules ? 2 : 1) if add_test_banks
563
+ [OK, records, has_rules ? 2 : 1]
564
+ end
565
+
566
+ def self.csv_fields(line)
567
+ fields = []
568
+ cur = +""
569
+ quoted = false
570
+ i = 0
571
+ while i < line.length
572
+ c = line[i]
573
+ if quoted
574
+ if c == '"'
575
+ if line[i + 1] == '"'
576
+ cur << '"'
577
+ i += 1
578
+ else
579
+ quoted = false
580
+ end
581
+ else
582
+ cur << c
583
+ end
584
+ elsif c == '"'
585
+ quoted = true
586
+ elsif c == ";"
587
+ fields << cur
588
+ cur = +""
589
+ else
590
+ cur << c
591
+ end
592
+ i += 1
593
+ end
594
+ fields << cur
595
+ fields
596
+ end
597
+
598
+ XML_FIELDS = {
599
+ "BLZ" => :blz, "Merkmal" => :hauptstelle, "Bezeichnung" => :name, "PLZ" => :plz, "Ort" => :ort,
600
+ "Kurzbez" => :name_kurz, "PAN" => :pan, "BIC" => :bic, "PruefZiffMeth" => :pz, "DsNr" => :nr,
601
+ "Aenderungskennz" => :aenderung, "BLZLoesch" => :loeschung, "NachfolgeBLZ" => :nachfolge_blz,
602
+ "IBANRegel" => :iban_regel
603
+ }.freeze
604
+
605
+ # Parses the XML variant (UTF-8). Returns [code, records, file_format, valid_from, valid_to].
606
+ def self.parse_xml_string(content, add_test_banks: true)
607
+ xml = content.b.force_encoding("UTF-8")
608
+ xml = xml.encode("UTF-8", "ISO-8859-1") unless xml.valid_encoding?
609
+ v1 = xml[%r{<ValidFrom>(\d{4})-(\d{2})-(\d{2})</ValidFrom>}] ? ($1 + $2 + $3).to_i : 0
610
+ v2 = xml[%r{<ValidTill>(\d{4})-(\d{2})-(\d{2})</ValidTill>}] ? ($1 + $2 + $3).to_i : 0
611
+ records = []
612
+ has_rules = false
613
+ xml.scan(%r{<BLZEintrag>(.*?)</BLZEintrag>}m) do |(entry)|
614
+ h = {}
615
+ entry.scan(%r{<([A-Za-z]+)>(.*?)</\1>}m) do |tag, value|
616
+ key = XML_FIELDS[tag]
617
+ h[key] = xml_unescape(value) if key
618
+ end
619
+ has_rules = true if h.key?(:iban_regel)
620
+ records << BankRecord.new(
621
+ blz: h[:blz].to_i, hauptstelle: h[:hauptstelle].to_s, name: h[:name].to_s.strip, plz: h[:plz].to_i,
622
+ ort: h[:ort].to_s.strip, name_kurz: h[:name_kurz].to_s.strip, pan: h[:pan].to_i, bic: h[:bic].to_s.strip,
623
+ pz: pz_from_string(h[:pz].to_s.ljust(2, "0")), nr: h[:nr].to_i, aenderung: h[:aenderung].to_s[0, 1] || " ",
624
+ loeschung: h[:loeschung].to_s[0, 1] || "0", nachfolge_blz: h[:nachfolge_blz].to_i,
625
+ iban_regel: h[:iban_regel].to_i
626
+ )
627
+ end
628
+ return [INVALID_BLZ_FILE, nil, 0, 0, 0] if records.empty?
629
+ add_test_bank_records(records, has_rules ? 2 : 1) if add_test_banks
630
+ [OK, records, has_rules ? 2 : 1, v1, v2]
631
+ end
632
+
633
+ def self.xml_unescape(s)
634
+ s.gsub(/&(amp|lt|gt|quot|apos|#\d+|#x[0-9a-fA-F]+);/) do
635
+ case Regexp.last_match(1)
636
+ when "amp" then "&"
637
+ when "lt" then "<"
638
+ when "gt" then ">"
639
+ when "quot" then '"'
640
+ when "apos" then "'"
641
+ when /\A#x(.*)/ then Regexp.last_match(1).to_i(16).chr(Encoding::UTF_8)
642
+ when /\A#(.*)/ then Regexp.last_match(1).to_i.chr(Encoding::UTF_8)
643
+ end
644
+ end
645
+ end
646
+
647
+ def self.add_test_bank_records(records, file_format)
648
+ present = records.map(&:blz).to_set
649
+ TEST_BANK_LINES.each do |l|
650
+ l = l[0, LINE_LEN_OLD] if file_format == 1
651
+ rec = parse_line(l, file_format)
652
+ records << rec unless present.include?(rec.blz)
653
+ end
654
+ end
655
+
656
+ # ------------------------------------------------------ IBAN rule table
657
+
658
+ # The current Bundesbank files do not contain the IBAN rule column any
659
+ # more. This table (data/iban_regeln.txt, BLZ -> rule*100+version) was
660
+ # extracted from the LUT file shipped with konto_check 6.15 and can be
661
+ # applied to records that carry no IBAN rule.
662
+ def self.default_iban_rules
663
+ @default_iban_rules ||= load_iban_rules(File.join(__dir__, "..", "..", "data", "iban_regeln.txt"))
664
+ end
665
+
666
+ # Loads a rule table from a text file with lines "BLZ;regel_version"
667
+ # (or "BLZ regel_version"); "#" starts a comment.
668
+ def self.load_iban_rules(filename)
669
+ table = {}
670
+ File.foreach(filename) do |line|
671
+ next if line.start_with?("#")
672
+ blz, rule = line.split(/[;\s]+/)
673
+ next unless blz && rule
674
+ table[blz.to_i] = rule.to_i
675
+ end
676
+ table
677
+ end
678
+
679
+ # Sets the IBAN rule of all records without a rule from the table
680
+ # (Hash BLZ -> rule*100+version). Returns the number of records changed.
681
+ def self.apply_iban_rules!(records, table)
682
+ changed = 0
683
+ records.each do |r|
684
+ next unless r.iban_regel == 0
685
+ v = table[r.blz]
686
+ next unless v && v != 0
687
+ r.iban_regel = v
688
+ changed += 1
689
+ end
690
+ changed
691
+ end
692
+
693
+ # Resolves the iban_rules option (:default, true, false/nil, a Hash or a
694
+ # file name) to a Hash or nil.
695
+ def self.iban_rule_table(option)
696
+ case option
697
+ when nil, false then nil
698
+ when true, :default then default_iban_rules
699
+ when Hash then option
700
+ when String then load_iban_rules(option)
701
+ else raise ArgumentError, "invalid iban_rules option #{option.inspect}"
702
+ end
703
+ end
704
+ end
705
+ end