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,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
|